大量修改二期功能清单内容
This commit is contained in:
@@ -3,6 +3,7 @@ import { Gauge, Wallet, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminLedger, AdminQuotaPolicy, AdminTeam } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
@@ -175,7 +176,7 @@ export function AdminLedgersPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>加载中…</h3><p>// fetching ledgers</p></div>
|
||||
<SystemLoading title="正在加载计费数据" description="正在同步最新数据,请稍候。" icon="creditCard" />
|
||||
) : ledgers.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="creditCard" size={24} /></div><h3>暂无流水</h3><p>// no ledgers</p></div>
|
||||
) : (
|
||||
@@ -331,7 +332,7 @@ export function AdminQuotaPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>加载中…</h3><p>// fetching policies</p></div>
|
||||
<SystemLoading title="正在加载额度配置" description="正在同步最新数据,请稍候。" icon="gauge" />
|
||||
) : policies.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="gauge" size={24} /></div><h3>暂无额度策略</h3><p>// 默认仅余额管控 · 点右上新建</p></div>
|
||||
) : (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminIntegrity, AdminProjectRow } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -107,7 +108,7 @@ export function AdminGovernancePage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>加载中…</h3><p>// fetching projects</p></div>
|
||||
<SystemLoading title="正在加载项目" description="正在同步最新数据,请稍候。" icon="clapperboard" />
|
||||
) : projects.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="clapperboard" size={24} /></div><h3>暂无项目</h3><p>// no projects</p></div>
|
||||
) : (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Ticket, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { Invitation } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -129,7 +130,7 @@ export function AdminInvitesPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div><h3>加载中…</h3><p>// fetching invitations</p></div>
|
||||
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="ticket" />
|
||||
) : invites.length === 0 ? (
|
||||
<div className="empty-state show">
|
||||
<div className="ic-empty"><IconKitSvg name="ticket" size={24} /></div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Server, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminModel, AdminProvider } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
@@ -128,7 +128,7 @@ export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="server" size={24} /></div><h3>加载中…</h3><p>// fetching providers</p></div>
|
||||
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="server" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-detail-subhead">供应商</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminPromptTemplate } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -68,7 +68,7 @@ export function AdminPromptsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching prompt templates</p></div>
|
||||
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
|
||||
) : (
|
||||
<div className="pt-list">
|
||||
{rows.map((r) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminQualityWord } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -84,7 +84,7 @@ export function AdminQualityPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3>加载中…</h3><p>// fetching quality words</p></div>
|
||||
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="type" />
|
||||
) : (
|
||||
<div className="qw-stages">
|
||||
{STAGES.map((s) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { RefreshCw } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import { MediaLightbox } from "../../components/overlays";
|
||||
import type { AdminReviewAsset } from "../../types";
|
||||
|
||||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -32,6 +34,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
const [tab, setTab] = useState("");
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -113,7 +116,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>加载中…</h3><p>// fetching review queue</p></div>
|
||||
<SystemLoading title="正在加载审核数据" description="正在同步最新数据,请稍候。" icon="shield" />
|
||||
) : assets.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="shield" size={24} /></div><h3>暂无资产</h3><p>// no person assets</p></div>
|
||||
) : (
|
||||
@@ -135,7 +138,17 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
|
||||
<td>
|
||||
{a.preview_url
|
||||
? <img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-thumb-btn"
|
||||
title="点击放大"
|
||||
aria-label={`放大预览 ${a.name || "资产"}`}
|
||||
onClick={() => setPreview({ src: a.preview_url, name: a.name || "预览" })}
|
||||
>
|
||||
<img className="admin-thumb" src={a.preview_url} alt={a.name} loading="lazy" />
|
||||
</button>
|
||||
)
|
||||
: <span className="admin-thumb admin-thumb-empty" />}
|
||||
</td>
|
||||
<td>{a.team_name || <span className="muted">—</span>}</td>
|
||||
@@ -160,12 +173,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="bulk-bar show">
|
||||
<span className="bulk-count">已选 {selected.size} 个</span>
|
||||
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
|
||||
<span className="admin-bulk-count">已选 {selected.size} 个</span>
|
||||
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}>取消</button>
|
||||
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}>批量送审</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Activity, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminTask, AdminTaskDetail } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
@@ -127,7 +128,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>加载中…</h3><p>// fetching tasks</p></div>
|
||||
<SystemLoading title="正在加载任务" description="正在同步最新数据,请稍候。" icon="activity" />
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="activity" size={24} /></div><h3>暂无任务</h3><p>// no tasks</p></div>
|
||||
) : (
|
||||
@@ -174,7 +175,7 @@ export function AdminTasksPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
<div className="drawer-b">
|
||||
{detailLoading || !detail ? (
|
||||
<p className="admin-modal-desc">加载中…</p>
|
||||
<SystemLoading variant="inline" title="正在加载详情" />
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Building, KeyRound, Percent, X } from "lucide-react";
|
||||
import { adminApi } from "../../api";
|
||||
import { Pager } from "../../components/pager";
|
||||
import { IconKitSvg } from "../../components/IconKitSvg";
|
||||
import { SystemLoading } from "../../components/loading";
|
||||
import type { AdminTeam, AdminTeamDetail, AdminUser } from "../../types";
|
||||
import { pts } from "../stage-config";
|
||||
|
||||
@@ -128,7 +129,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>加载中…</h3><p>// fetching teams</p></div>
|
||||
<SystemLoading title="正在加载团队数据" description="正在同步最新数据,请稍候。" icon="building" />
|
||||
) : teams.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="building" size={24} /></div><h3>暂无团队</h3><p>// no teams</p></div>
|
||||
) : (
|
||||
@@ -198,7 +199,7 @@ export function AdminTeamsPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
{detailLoading || !detail ? (
|
||||
<p className="admin-modal-desc">加载中…</p>
|
||||
<SystemLoading variant="inline" title="正在加载详情" icon="building" />
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-detail-meta">
|
||||
@@ -311,7 +312,7 @@ export function AdminUsersPage({ notify }: { notify: Notify }) {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>加载中…</h3><p>// fetching users</p></div>
|
||||
<SystemLoading title="正在加载用户数据" description="正在同步最新数据,请稍候。" icon="users" />
|
||||
) : users.length === 0 ? (
|
||||
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="users" size={24} /></div><h3>暂无用户</h3><p>// no users</p></div>
|
||||
) : (
|
||||
|
||||
@@ -31,7 +31,7 @@ import type { AITask, Asset, ImageConversation, ImageConversationTask, ModelConf
|
||||
import { api } from "../api";
|
||||
import { imageModelPickerOptions } from "../model-display";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { SkeletonRows } from "../components/loading";
|
||||
import { SkeletonRows, SystemLoading } from "../components/loading";
|
||||
import { ConfirmModal, MediaLightbox } from "../components/overlays";
|
||||
import { Pager } from "../components/pager";
|
||||
import { useViewMode } from "../components/use-view-mode";
|
||||
@@ -461,7 +461,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
|
||||
</div>
|
||||
<div className="modal-b">
|
||||
{batchLoading ? (
|
||||
<div className="task-empty"><div className="mono">// LOADING…</div></div>
|
||||
<SystemLoading variant="inline" title="正在加载详情" icon="images" />
|
||||
) : batchImgs.length === 0 ? (
|
||||
<div className="task-empty"><div className="mono">// NO IMAGE</div><div>这批没有可显示的图片</div></div>
|
||||
) : (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 任务流(滚动加载)+ 底部输入条(全能参考/首尾帧 + @mention)+ 渐进轮询(10/30/60s 打后端
|
||||
// poll 端点,web 进程内查火山,不依赖 worker)+ 平滑进度动画(sessionStorage 续)+ 全屏播放器。
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Users } from "lucide-react";
|
||||
import { Images, Users } from "lucide-react";
|
||||
import { api, ApiError } from "../api";
|
||||
import type { FreeVideoRef, FreeVideoTask, ModelConfig } from "../types";
|
||||
import {
|
||||
@@ -23,7 +23,9 @@ import { FreeInputBar } from "../components/free-create/input-bar";
|
||||
import { GenerationCard } from "../components/free-create/generation-card";
|
||||
import { VideoDetailModal } from "../components/free-create/video-detail-modal";
|
||||
import { AssetLibraryModal } from "../components/free-create/asset-library-modal";
|
||||
import { PlatformLibraryModal } from "../components/free-create/platform-library-modal";
|
||||
import type { PromptInputHandle } from "../components/free-create/prompt-input";
|
||||
import { SystemLoading } from "../components/loading";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -96,6 +98,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
// —— 弹窗 ——
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [libraryOpen, setLibraryOpen] = useState(false);
|
||||
const [platformOpen, setPlatformOpen] = useState(false);
|
||||
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
|
||||
|
||||
@@ -468,6 +471,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
setLibraryTargetRole(null);
|
||||
}, []);
|
||||
|
||||
// 三库(资产库/模特库/商品库)引用:与人物素材库共用槽位逻辑,只是数据源不同。
|
||||
// 首尾帧模式没指定槽位时按拖拽同一规则落位:首帧空就填首帧,否则填尾帧。
|
||||
const openPlatformLibrary = useCallback((role?: "first_frame" | "last_frame") => {
|
||||
const target = mode === "keyframe"
|
||||
? role || (refs.some((r) => r.role === "first_frame") ? "last_frame" : "first_frame")
|
||||
: null;
|
||||
setLibraryTargetRole(target);
|
||||
setPlatformOpen(true);
|
||||
}, [mode, refs]);
|
||||
|
||||
const closePlatformLibrary = useCallback(() => {
|
||||
setPlatformOpen(false);
|
||||
setLibraryTargetRole(null);
|
||||
}, []);
|
||||
|
||||
// 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位
|
||||
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
|
||||
if (mode === "keyframe") {
|
||||
@@ -483,8 +501,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
...ref,
|
||||
key: nextRefKey(),
|
||||
role: libraryTargetRole,
|
||||
label: undefined,
|
||||
source: "library"
|
||||
label: undefined
|
||||
};
|
||||
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
|
||||
setLibraryTargetRole(null);
|
||||
@@ -517,6 +534,9 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn" onClick={() => openPlatformLibrary()}>
|
||||
<Images size={14} /> 引用平台素材
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => openLibrary()}>
|
||||
<Users size={14} /> 人物素材库
|
||||
</button>
|
||||
@@ -525,7 +545,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
|
||||
<div className="fc-feed-wrap">
|
||||
{loading ? (
|
||||
<div className="fc-loading mono">// 加载中…</div>
|
||||
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="empty-state show fc-empty">
|
||||
<span className="ic-empty"><Users size={22} strokeWidth={1.5} /></span>
|
||||
@@ -572,6 +592,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
onDurationChange={setDuration}
|
||||
onSeedChange={setSeed}
|
||||
onOpenLibrary={openLibrary}
|
||||
onOpenPlatformLibrary={openPlatformLibrary}
|
||||
onClear={clearInput}
|
||||
onSend={() => void handleSend()}
|
||||
/>
|
||||
@@ -593,6 +614,14 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled }: {
|
||||
|
||||
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
|
||||
|
||||
<PlatformLibraryModal
|
||||
open={platformOpen}
|
||||
imageOnly={mode === "keyframe"}
|
||||
onClose={closePlatformLibrary}
|
||||
onPick={handleLibraryPick}
|
||||
notify={notify}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={deleteTarget !== null}
|
||||
title="删除视频"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RefreshCw, Upload, User, X } from "lucide-react";
|
||||
import { api } from "../api";
|
||||
import { generationErrorText } from "../generation-error";
|
||||
import type { ModelEntity } from "../types";
|
||||
import { SystemLoading } from "../components/loading";
|
||||
import { MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays";
|
||||
import "../models-page.css";
|
||||
|
||||
@@ -351,7 +352,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="placeholder" style={{ minHeight: 200 }}><span className="ph-frame">// 加载中…</span></div>
|
||||
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="model" />
|
||||
) : items.length === 0 ? (
|
||||
<div className="placeholder models-empty">
|
||||
<span className="ph-frame">// 还没有模特</span>
|
||||
|
||||
@@ -12,13 +12,32 @@ import { MediaLightbox, useBodyScrollLock } from "../components/overlays";
|
||||
import { ModelLibrary } from "../components/model-library";
|
||||
import { ReviewBadge, type ReviewStatus } from "../components/review-badge";
|
||||
import { IconKitSvg } from "../components/IconKitSvg";
|
||||
import {
|
||||
allowedStructures,
|
||||
clampDuration,
|
||||
DURATION_OPTIONS,
|
||||
durationWarning,
|
||||
isForbidden,
|
||||
PRESENTATION_FORMATS,
|
||||
PRESENTATION_HINT,
|
||||
PRESENTATION_KEYS,
|
||||
recommendDuration,
|
||||
recommendSetup,
|
||||
SEGMENT_DURATION_MAX,
|
||||
STRUCTURE_HINT,
|
||||
TOTAL_DURATION_MIN,
|
||||
VIDEO_STRUCTURES,
|
||||
type PresentationFormat,
|
||||
type VideoStructure,
|
||||
} from "../script-setup";
|
||||
|
||||
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角)
|
||||
const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties);
|
||||
// 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
|
||||
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
|
||||
// 脚本来源 → 「来源」brief pill 文案
|
||||
const SOURCE_LABEL: Record<string, string> = { ai: "AI 全生", theme: "一句话主题", manual: "自带脚本" };
|
||||
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
||||
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||
const VO_VOICES = [
|
||||
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
||||
@@ -28,9 +47,8 @@ const VO_VOICES = [
|
||||
{ key: "BV102_streaming", label: "儒雅青年 · 解说男声" },
|
||||
{ key: "BV002_streaming", label: "通用男声" },
|
||||
];
|
||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS)
|
||||
// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示)
|
||||
const WIZ_STYLE_LABEL: Record<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
|
||||
// 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_PERSONAS)
|
||||
// 一期的「风格」(真实测评/痛点种草/…)已被二期的「视频结构」取代,见 script-setup.ts
|
||||
const WIZ_PERSONA_LABEL: Record<string, string> = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" };
|
||||
// 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral)
|
||||
function statusLabel(status: string): string {
|
||||
@@ -570,6 +588,9 @@ export function PipelinePage(props: {
|
||||
try { return new Set<string>(JSON.parse(localStorage.getItem(`airshelf:pending-del:${project.id}`) || "[]")); } catch { return new Set(); }
|
||||
});
|
||||
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).filter((s) => !pendingDeletes.has(s.id));
|
||||
// 单镜时长自二期起是变长的(4–15s),缺值时按上限兜底,跟后端 script_agent 一致
|
||||
const shotSeconds = (s: { duration_seconds?: number | null }) => s.duration_seconds || SEGMENT_DURATION_MAX;
|
||||
const scriptTotalSeconds = shots.reduce((sum, s) => sum + shotSeconds(s), 0);
|
||||
// 对白 speaker(entity id)→ 角色名;null = 旁白。entities 在 ScriptVersion.metadata
|
||||
const scriptEntities = ((currentScript?.metadata as { entities?: Array<{ id: string; name: string }> } | undefined)?.entities) ?? [];
|
||||
const entityName = (id: string | null | undefined) => (id ? (scriptEntities.find((e) => e.id === id)?.name || id) : "旁白");
|
||||
@@ -661,9 +682,9 @@ export function PipelinePage(props: {
|
||||
// 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group
|
||||
const [triPanelOpen, setTriPanelOpen] = useState(false);
|
||||
const [triPreviewId, setTriPreviewId] = useState<string | null>(null);
|
||||
// 通用「选已有素材替换」选图层。商品三视图(取 product_image、按商品过滤)与场景(取 scene、团队全量)共用一套。
|
||||
// 角色不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
|
||||
// 旧 bug:场景卡「替换」误用了模特选择器 → 列的是角色数据。现在场景改走本选图层。
|
||||
// 通用「选已有素材替换」选图层。当前由场景卡使用(取 scene、团队全量);按商品过滤的 product_image 分支留给后续复用。
|
||||
// 商品三视图不走这里——它已改成卡片上的独立上传框 + AI 生成两条通道(见 uploadTriViewFromFile)。
|
||||
// 角色也不走这里——角色有专门的模特选择器(含模特库/角色三视图工作台),对角色是对的。
|
||||
type AssetPick = {
|
||||
title: string;
|
||||
category: "product_image" | "scene";
|
||||
@@ -731,6 +752,8 @@ export function PipelinePage(props: {
|
||||
setAssetPickBusy(false);
|
||||
}
|
||||
}
|
||||
const triFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [triUploading, setTriUploading] = useState(false);
|
||||
function jumpAssetSection(kind: "product" | "person" | "scene") {
|
||||
setAssetTab(kind);
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -1150,7 +1173,7 @@ export function PipelinePage(props: {
|
||||
const timeline = project.timeline;
|
||||
const tlTotalMs = (timeline?.duration_seconds || 0) * 1000;
|
||||
const tlClips = [...(timeline?.clips ?? [])].sort((a, b) => a.sort_order - b.sort_order);
|
||||
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || 15000;
|
||||
const tlRulerMs = tlTotalMs > 0 ? tlTotalMs : tlClips.reduce((m, c) => Math.max(m, c.start_ms + c.duration_ms), 0) || scriptTotalSeconds * 1000 || TOTAL_DURATION_MIN * 1000;
|
||||
const ruler = buildRuler(tlRulerMs / 1000);
|
||||
const subtitleTrack = (timeline?.subtitle_tracks ?? []).find((t) => t.enabled) || (timeline?.subtitle_tracks ?? [])[0] || null;
|
||||
const subtitleCues = [...(subtitleTrack?.content ?? [])].sort((a, b) => a.start_ms - b.start_ms);
|
||||
@@ -1234,24 +1257,72 @@ export function PipelinePage(props: {
|
||||
const [chatText, setChatText] = useState("");
|
||||
// 媒体预览灯箱(视频片段播放 / 故事板分镜放大)
|
||||
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
|
||||
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
|
||||
const [chatMode, setChatMode] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
|
||||
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
|
||||
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
|
||||
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
|
||||
// ── Stage 1 · 生成前「表现形式 × 视频结构 × 人物 × 时长」设定(1.5–1.9):向导那边已删,这里补上 ──
|
||||
// setupOpen:两个入口之一被选中后,展示四栏选择 + 确认/重新推荐;确认后才真正发起生成。
|
||||
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
|
||||
// 2.3 按商品品类/人群推荐一组默认值;用户随时可改,推荐只是省掉「从零开始选」
|
||||
const setupProduct = products.find((item) => item.id === project.product);
|
||||
const recommended = useMemo(
|
||||
() => recommendSetup({
|
||||
category: setupProduct?.category,
|
||||
targetAudience: setupProduct?.target_audience,
|
||||
title: setupProduct?.title,
|
||||
}),
|
||||
[setupProduct?.category, setupProduct?.target_audience, setupProduct?.title]
|
||||
);
|
||||
const wizard = project.metadata?.wizard;
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setupSource, setSetupSource] = useState<"ai" | "theme" | "manual">("ai");
|
||||
const [setupStyle, setSetupStyle] = useState<string>(project.metadata?.wizard?.script_style || SETUP_STYLE_KEYS[0] || "pain");
|
||||
const [setupPersona, setSetupPersona] = useState<string>(project.metadata?.wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
||||
// 向导选的成片时长档("0-30"→30 秒)传给脚本 agent —— 脚本按它定镜数,别再写死 60(否则选 30s 也按 60s 出)
|
||||
const wizardTotalDuration = (() => {
|
||||
const raw = project.metadata?.wizard?.duration;
|
||||
const m = /(\d+)\s*$/.exec(typeof raw === "string" ? raw : "");
|
||||
return m ? Number(m[1]) : 60;
|
||||
})();
|
||||
const [setupSource, setSetupSource] = useState<"ai" | "manual" | "video">("ai");
|
||||
const [setupFormat, setSetupFormat] = useState<PresentationFormat>(
|
||||
(wizard?.presentation_format as PresentationFormat) || recommended.format
|
||||
);
|
||||
const [setupStructure, setSetupStructure] = useState<VideoStructure>(
|
||||
(wizard?.video_structure as VideoStructure) || recommended.structure
|
||||
);
|
||||
const [setupPersona, setSetupPersona] = useState<string>(wizard?.persona || SETUP_PERSONA_KEYS[0] || "urban");
|
||||
const [setupDuration, setSetupDuration] = useState<number>(() => {
|
||||
if (typeof wizard?.total_duration === "number") return clampDuration(wizard.total_duration);
|
||||
// 兼容一期向导存的字符串档位("0-30" → 30)
|
||||
const legacy = /(\d+)\s*$/.exec(typeof wizard?.duration === "string" ? wizard.duration : "");
|
||||
return legacy ? clampDuration(Number(legacy[1])) : recommended.duration;
|
||||
});
|
||||
// 商品是异步拉回来的,首帧 setupProduct 还是 undefined → 上面的初值只能拿到兜底组合。
|
||||
// 等商品到位后补一次推荐,但只在「用户没动过 + 项目也没存过设定」时才覆盖。
|
||||
const setupTouched = useRef(false);
|
||||
const wizardHasCombo = Boolean(wizard?.presentation_format && wizard?.video_structure);
|
||||
const wizardHasDuration = typeof wizard?.total_duration === "number" || Boolean(wizard?.duration);
|
||||
useEffect(() => {
|
||||
if (setupTouched.current || wizardHasCombo) return;
|
||||
setSetupFormat(recommended.format);
|
||||
setSetupStructure(recommended.structure);
|
||||
if (!wizardHasDuration) setSetupDuration(recommended.duration);
|
||||
}, [recommended.format, recommended.structure, recommended.duration, wizardHasCombo, wizardHasDuration]);
|
||||
// 1.8 组合联动:表现形式为主,视频结构按它筛;当前选中的若被筛掉就自动落到第一个合法项
|
||||
const structureOptions = useMemo(() => allowedStructures(setupFormat), [setupFormat]);
|
||||
function pickFormat(next: PresentationFormat) {
|
||||
setupTouched.current = true;
|
||||
setSetupFormat(next);
|
||||
if (isForbidden(next, setupStructure)) setSetupStructure(allowedStructures(next)[0]);
|
||||
}
|
||||
const durationHint = durationWarning(setupStructure, setupDuration);
|
||||
// 建议时长跟着**当前选中**的组合走,不是跟着推荐组合走(否则选了短剧还提示口播的 30 秒)
|
||||
const durationSuggest = recommendDuration(setupFormat, setupStructure);
|
||||
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
// 输入框随内容长高(封顶 260px 后内部滚动)。上传脚本 / 上传视频提炼灌进来的是整篇稿子,
|
||||
// 固定两行的框没法逐镜校对 —— 而「可人工逐镜编辑」正是这两个入口的硬要求。
|
||||
useEffect(() => {
|
||||
const el = chatTextareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 260)}px`;
|
||||
}, [chatText]);
|
||||
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
|
||||
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
|
||||
const videoFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
|
||||
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
|
||||
@@ -1432,8 +1503,12 @@ export function PipelinePage(props: {
|
||||
model_config_id: activeScriptModelId || undefined,
|
||||
base_version_id: baseVersionId,
|
||||
aspect_ratio: "9:16",
|
||||
total_duration: wizardTotalDuration,
|
||||
target_index: targetIndex
|
||||
// 设定卡的三个参数一路传到后端:后端据此挑套路提示词 + 按节奏切镜(不再每镜写死 15 秒)
|
||||
total_duration: setupDuration,
|
||||
presentation_format: setupFormat,
|
||||
video_structure: setupStructure,
|
||||
target_index: targetIndex,
|
||||
source: source === "manual" || source === "video" ? source : undefined
|
||||
},
|
||||
(evt) => {
|
||||
receivedEvent = true;
|
||||
@@ -1517,20 +1592,40 @@ export function PipelinePage(props: {
|
||||
pushMsg("ai", summaryText || "镜头脚本已生成,左侧已刷新。可继续输入修改意见,或点「确认脚本」进入下一步。");
|
||||
}
|
||||
}
|
||||
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
|
||||
// 1.5 · 确认设定后真正发起生成。表现形式/结构/时长走结构化参数(后端挑套路),
|
||||
// 人物没有对应的后端字段,仍并进 prompt 让模型读。
|
||||
async function runScriptWithSetup() {
|
||||
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
|
||||
const formatLabel = PRESENTATION_FORMATS[setupFormat];
|
||||
const structureLabel = VIDEO_STRUCTURES[setupStructure];
|
||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||
// 行28 · 持久化所选风格/人物到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回三个菜单。
|
||||
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
|
||||
// 持久化到 metadata.wizard。先 await 落库(设定卡仍开着、确定 disabled),
|
||||
// 存完再「关卡」一帧切换,不出现空窗 → 不闪回入口菜单。
|
||||
await onSaveProjectMeta?.({
|
||||
wizard: {
|
||||
...(project.metadata?.wizard ?? {}),
|
||||
presentation_format: setupFormat,
|
||||
video_structure: setupStructure,
|
||||
total_duration: setupDuration,
|
||||
persona: setupPersona,
|
||||
},
|
||||
});
|
||||
setSetupOpen(false);
|
||||
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
|
||||
if (setupSource === "theme") {
|
||||
// 确定 = 确认风格/人物;主题交给模型来问 → 用户输入后由 submitChat 的 theme 分支带风格/人物生成
|
||||
setChatMode("theme");
|
||||
pushMsg("ai", `好,${styleLabel} · ${personaLabel} 定了。用一句话说说这条视频的主题(5-30字),例如「熬夜党的早八续命面膜」,发我就生成。`);
|
||||
chatTextareaRef.current?.focus();
|
||||
const combo = `${formatLabel} · ${structureLabel} · ${setupDuration}s · ${personaLabel}`;
|
||||
if (setupSource === "video") {
|
||||
const base = chatText.trim();
|
||||
if (!base) {
|
||||
chatTextareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
setChatText("");
|
||||
// 上传视频提炼:参考的是**结构与节奏**,画面里的商品是别人的,必须换成用户自己的商品重写
|
||||
await runScriptGeneration(
|
||||
`${base}\n以上是我拆解一条参考视频得到的分镜稿。请照搬它的叙事结构、镜头节奏和每镜的作用顺序,`
|
||||
+ `把内容整体换成我自己的商品重写一遍;参考视频里出现的商品、品牌、人物一律不要保留。`
|
||||
+ `原稿里标注「看不清 / 缺失 / 存疑」的地方,由你按我的商品补全。目标人群:${personaLabel}`,
|
||||
`上传视频提炼 · ${combo}`,
|
||||
"video",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (setupSource === "manual") {
|
||||
@@ -1540,10 +1635,21 @@ export function PipelinePage(props: {
|
||||
return;
|
||||
}
|
||||
setChatText("");
|
||||
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
|
||||
// 上传脚本:先识别原稿结构,再补齐钩子 / 卖点证明 / 转化,而不是照抄一遍
|
||||
await runScriptGeneration(
|
||||
`${base}\n以上是我已有的脚本。请先识别它的结构与叙述顺序,尽量保留原意与原有表达,再据此整理成镜头脚本;`
|
||||
+ `其中开头钩子、卖点的证明方式、结尾转化引导如有缺失或偏弱,请补齐补强。目标人群:${personaLabel}`,
|
||||
`上传脚本 · ${combo}`,
|
||||
"manual",
|
||||
);
|
||||
return;
|
||||
}
|
||||
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
|
||||
const sourceLabel = SOURCE_LABEL[setupSource] || "脚本辅助生成";
|
||||
await runScriptGeneration(
|
||||
`目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`,
|
||||
`${sourceLabel}:${combo}`,
|
||||
"ai",
|
||||
);
|
||||
}
|
||||
// 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签
|
||||
function tagEditsInstruction() {
|
||||
@@ -1558,15 +1664,6 @@ export function PipelinePage(props: {
|
||||
const text = chatText.trim();
|
||||
const tagInstr = tagEditsInstruction();
|
||||
if (!text && !tagInstr) return;
|
||||
// 一句话主题:确定后模型已问主题,这次输入就是主题 → 带上已确认的风格/人物生成(仅首次出稿前)
|
||||
if (chatMode === "theme" && !currentScript && text) {
|
||||
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
|
||||
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
void runScriptGeneration(`一句话主题:${text}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${text}`, "theme");
|
||||
return;
|
||||
}
|
||||
const prompt = [tagInstr, text].filter(Boolean).join("\n");
|
||||
const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · ");
|
||||
setChatText("");
|
||||
@@ -1594,40 +1691,86 @@ export function PipelinePage(props: {
|
||||
setSetupOpen(false);
|
||||
setChatMsgs([]);
|
||||
}
|
||||
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
|
||||
function openSetup(source: "ai" | "theme" | "manual") {
|
||||
// 选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
|
||||
function openSetup(source: "ai" | "manual" | "video") {
|
||||
setSetupSource(source);
|
||||
setChatMode(source);
|
||||
setSetupOpen(true);
|
||||
// 一句话主题:开卡时不再 push 提示(否则 chatMsgs 非空,点返回就回不到三选项);
|
||||
// 主题改成在「确定」(确认风格/人物)之后由模型来问,更像对话流。
|
||||
if (source === "manual") {
|
||||
if (source === "manual" || source === "video") {
|
||||
setChatText("");
|
||||
setChatAttachments([]);
|
||||
// 上传脚本 / 上传视频:选中即拉起选文件(用户手势内触发,不会被浏览器拦);想改成粘贴的话直接在输入框打字即可
|
||||
(source === "video" ? videoFileRef : chatFileRef).current?.click();
|
||||
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
|
||||
}
|
||||
// 文件上传入口先保留,当前「自带脚本」改为粘贴文本。
|
||||
// if (source === "manual") { pickScriptMode(); }
|
||||
}
|
||||
function pickScriptMode() {
|
||||
setChatMode("manual");
|
||||
chatFileRef.current?.click();
|
||||
}
|
||||
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
|
||||
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
|
||||
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const text = String(reader.result || "").trim();
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(file.name)) {
|
||||
onNotify?.("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||
return;
|
||||
}
|
||||
setVideoDigestBusy(true);
|
||||
pushMsg("user", `已上传视频《${file.name}》`);
|
||||
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const digest = await api.extractScriptVideo(project.id, fd);
|
||||
const text = digest.text.trim();
|
||||
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||||
setChatMode("video");
|
||||
pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,`
|
||||
+ "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。");
|
||||
chatTextareaRef.current?.focus();
|
||||
} catch (error) {
|
||||
pushMsg("ai", "这条视频没能拆开,可以换一条再试。");
|
||||
onNotify?.("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
setVideoDigestBusy(false);
|
||||
}
|
||||
}
|
||||
// 上传脚本仅收 docx / txt。txt 前端直读;docx 交后端拆(zip+xml),前端不引解析库。
|
||||
async function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const name = file.name.toLowerCase();
|
||||
const isDocx = name.endsWith(".docx");
|
||||
if (!isDocx && !name.endsWith(".txt")) {
|
||||
onNotify?.("error", "只支持 docx 和 txt 两种脚本文件");
|
||||
return;
|
||||
}
|
||||
setScriptFileBusy(true);
|
||||
try {
|
||||
let text = "";
|
||||
if (isDocx) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
text = (await api.extractScriptFile(project.id, fd)).text.trim();
|
||||
} else {
|
||||
text = (await file.text()).trim();
|
||||
}
|
||||
if (!text) {
|
||||
onNotify?.("error", "没能从这个文件里读到文字,请换一份");
|
||||
return;
|
||||
}
|
||||
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||||
setChatMode("manual");
|
||||
pushMsg("user", `已上传脚本附件《${file.name}》(${text.length} 字)`);
|
||||
pushMsg("ai", "已读入你的脚本。可在输入框补充修改意见,点发送据此生成镜头脚本。");
|
||||
pushMsg("user", `已上传脚本《${file.name}》(${text.length} 字)`);
|
||||
pushMsg("ai", "已读入你的脚本。确认风格与人物后点「确定」,我会保留你的原意,并把钩子、卖点证明、转化引导补齐。");
|
||||
chatTextareaRef.current?.focus();
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
onNotify?.("error", error instanceof Error && error.message ? error.message : "读取脚本文件失败,请重试");
|
||||
} finally {
|
||||
setScriptFileBusy(false);
|
||||
}
|
||||
}
|
||||
// Stage 1 · 脚本助手宽度拖拽(对齐设计稿:clamp [380, 680],写 --chat-w)
|
||||
const scriptGridRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -2504,6 +2647,24 @@ export function PipelinePage(props: {
|
||||
const productName = productRecord?.title || "透真补水面膜";
|
||||
const productCover = productRecord?.cover_asset || productRecord?.images?.find((img) => img.is_primary)?.asset || productRecord?.images?.[0]?.asset || null;
|
||||
|
||||
// 三视图的「上传」通道:独立上传框,不再借「替换」弹窗绕。AI 生成通道原样保留,两条路并存。
|
||||
async function uploadTriViewFromFile(file: File) {
|
||||
setTriUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("asset_type", "image");
|
||||
fd.append("category", "tri_view");
|
||||
fd.append("name", `${productName}·三视图`);
|
||||
const asset = await api.uploadAsset(fd); // 落 TOS + 建团队资产
|
||||
await onAttachBaseAsset({ kind: "product" }, asset.id); // 直接采用为商品三视图
|
||||
} catch (e) {
|
||||
onNotify?.("error", e instanceof Error && e.message ? e.message : "上传失败,请重试");
|
||||
} finally {
|
||||
setTriUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 进资产趴【不再自动生成基础资产】——原先这里会自动 genPersonPortrait/genBaseAsset 出图、直接扣用户费,
|
||||
// 用户有"被掏口袋"感。改为:进资产趴盖半透明蒙版 + 三按钮(只提取 / 提取+生成角色场景 / 提取+全套),
|
||||
// 由用户点了才提取/生成(见下方 STAGE 2 的 extract gate 与 runExtract)。
|
||||
@@ -2632,10 +2793,23 @@ export function PipelinePage(props: {
|
||||
</span>
|
||||
</div>
|
||||
<div className="script-brief-summary" aria-label="当前创作方向">
|
||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),风格/人物=新建向导里选的(metadata.wizard) */}
|
||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "AI 全生") : chatMode !== "ai" ? SOURCE_LABEL[chatMode] : "未选择"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">风格</span><span className="v" id="brief-style">{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
{/* 真实创作方向:来源=已有脚本的 source(无脚本时跟随所选模式),其余=设定卡确认时存进 metadata.wizard 的 */}
|
||||
<span className="pill neutral script-brief-pill"><span className="k">来源</span><span className="v" id="brief-source">{currentScript ? (SOURCE_LABEL[currentScript.source || "ai"] || "脚本辅助生成") : setupOpen ? SOURCE_LABEL[setupSource] : "未选择"}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">形式</span><span className="v" id="brief-format">{(() => {
|
||||
const k = wizard?.presentation_format as PresentationFormat | undefined;
|
||||
// 旧项目只有一期的「风格」,没有表现形式 —— 显示待确认,别硬套
|
||||
return k && PRESENTATION_FORMATS[k] ? PRESENTATION_FORMATS[k] : "待确认";
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">结构</span><span className="v" id="brief-structure">{(() => {
|
||||
const k = wizard?.video_structure as VideoStructure | undefined;
|
||||
return k && VIDEO_STRUCTURES[k] ? VIDEO_STRUCTURES[k] : "待确认";
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">时长</span><span className="v" id="brief-duration">{(() => {
|
||||
// 有脚本时按各镜真实秒数加总(镜可以不等长了);没脚本就显示设定卡里选的
|
||||
const scripted = (currentScript?.segments || []).reduce((sum, s) => sum + (s.duration_seconds || 0), 0);
|
||||
return `${scripted || setupDuration} 秒`;
|
||||
})()}</span></span>
|
||||
<span className="pill neutral script-brief-pill"><span className="k">人物</span><span className="v" id="brief-persona">{(() => { const k = wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
|
||||
</div>
|
||||
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
|
||||
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
|
||||
@@ -2687,7 +2861,7 @@ export function PipelinePage(props: {
|
||||
let cum = 0;
|
||||
return shots.map((shot, index) => {
|
||||
const start = cum;
|
||||
cum += shot.duration_seconds || 15;
|
||||
cum += shotSeconds(shot);
|
||||
const narration = (shot.narration || "").trim();
|
||||
const visual = (shot.visual_prompt || "").trim();
|
||||
// 旁白/画面历史数据可能同文(结构化解析前生成),画面框置空避免重复编辑同一段
|
||||
@@ -2812,18 +2986,26 @@ export function PipelinePage(props: {
|
||||
<div className="time">{msg.time}</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
|
||||
{/* 1.5 · 选定生成方式后的「表现形式 × 视频结构 × 人物 × 时长」设定卡(确认后再生成) */}
|
||||
{setupOpen && (
|
||||
<div className="msg ai">
|
||||
<div className="bubble setup-card">
|
||||
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||
<div className="setup-lead">{setupSource === "manual" ? "已选「上传脚本」。" : setupSource === "video" ? "已选「上传视频提炼」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</div>
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">风格</span>
|
||||
<select className="setup-select" value={setupStyle} onChange={(e) => setSetupStyle(e.target.value)}>
|
||||
{SETUP_STYLE_KEYS.map((k) => <option key={k} value={k}>{WIZ_STYLE_LABEL[k]}</option>)}
|
||||
<span className="sf-k">表现形式</span>
|
||||
<select className="setup-select" value={setupFormat} onChange={(e) => pickFormat(e.target.value as PresentationFormat)}>
|
||||
{PRESENTATION_KEYS.map((k) => <option key={k} value={k}>{PRESENTATION_FORMATS[k]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">根据商品信息推荐</div>
|
||||
<div className="setup-rec">{PRESENTATION_HINT[setupFormat]}</div>
|
||||
{/* 1.8 组合联动:短剧下拉里没有「测评验证」—— 演出来的实测没有可信度 */}
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">视频结构</span>
|
||||
<select className="setup-select" value={setupStructure} onChange={(e) => { setupTouched.current = true; setSetupStructure(e.target.value as VideoStructure); }}>
|
||||
{structureOptions.map((k) => <option key={k} value={k}>{VIDEO_STRUCTURES[k]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">{STRUCTURE_HINT[setupStructure]}</div>
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">人物</span>
|
||||
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
|
||||
@@ -2831,15 +3013,25 @@ export function PipelinePage(props: {
|
||||
</select>
|
||||
</label>
|
||||
<div className="setup-rec">根据商品目标人群推荐</div>
|
||||
{/* 1.9 时长自由化:5–60 秒、5 秒一档;每镜切多长由后端按表现形式的节奏决定 */}
|
||||
<label className="setup-field">
|
||||
<span className="sf-k">时长</span>
|
||||
<select className="setup-select" value={setupDuration} onChange={(e) => { setupTouched.current = true; setSetupDuration(clampDuration(Number(e.target.value))); }}>
|
||||
{DURATION_OPTIONS.map((s) => <option key={s} value={s}>{s} 秒</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className={`setup-rec${durationHint ? " warn" : ""}`}>{durationHint || `${PRESENTATION_FORMATS[setupFormat]} × ${VIDEO_STRUCTURES[setupStructure]}建议 ${durationSuggest} 秒左右`}</div>
|
||||
<div className="setup-foot">
|
||||
{/* ← 返回:收起设定卡,回到「AI全生/一句话主题/自带脚本」三选项页 */}
|
||||
{/* ← 返回:收起设定卡,回到「脚本辅助生成 / 上传脚本」入口页 */}
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setSetupOpen(false)}>← 返回</button>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => {
|
||||
// 重新推荐:随机换一组,模拟「再推荐」(纯前端,后端会从 prompt 推断)
|
||||
setSetupStyle(SETUP_STYLE_KEYS[Math.floor(Math.random() * SETUP_STYLE_KEYS.length)] || setupStyle);
|
||||
setSetupPersona(SETUP_PERSONA_KEYS[Math.floor(Math.random() * SETUP_PERSONA_KEYS.length)] || setupPersona);
|
||||
<button type="button" className="btn btn-ghost btn-sm" title={recommended.reason} onClick={() => {
|
||||
// 重新推荐:回到按商品信息算出的那一组(不是随机换,随机换等于没推荐)
|
||||
setupTouched.current = false;
|
||||
setSetupFormat(recommended.format);
|
||||
setSetupStructure(recommended.structure);
|
||||
setSetupDuration(recommended.duration);
|
||||
}}>重新推荐</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading || (setupSource === "manual" && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={loading || scriptFileBusy || videoDigestBusy || ((setupSource === "manual" || setupSource === "video") && !chatText.trim())} onClick={() => void runScriptWithSetup()}>确定</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="time">{nowHm()}</div>
|
||||
@@ -2851,9 +3043,10 @@ export function PipelinePage(props: {
|
||||
<div className="ce-title">选择一种生成方式开始</div>
|
||||
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
|
||||
<div className="chat-modes">
|
||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI 全生</button>
|
||||
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={() => openSetup("theme")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg>一句话主题</button>
|
||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>自带脚本</button>
|
||||
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => openSetup("ai")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>脚本辅助生成</button>
|
||||
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("manual")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>上传脚本</button>
|
||||
{/* 1.11 上传视频提炼:拆一条参考视频的分镜,改成自己的商品重拍 */}
|
||||
<button className={`chat-mode${chatMode === "video" ? " primary" : ""}`} type="button" data-mode="video" disabled={loading || scriptFileBusy || videoDigestBusy} onClick={() => openSetup("video")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>上传视频提炼</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -2878,7 +3071,7 @@ export function PipelinePage(props: {
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜 ·(Enter 发送 / Shift+Enter 换行)" : chatMode === "manual" ? "粘贴你的脚本,AI 将据此生成镜头脚本 ·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
|
||||
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "manual" ? "上传 docx / txt,或直接把脚本粘进来 ·(Enter 发送)" : chatMode === "video" ? "拆解稿会填在这里,逐镜改完再点「确定」·(Enter 发送)" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党 ·(Enter 发送)"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
@@ -2886,15 +3079,20 @@ export function PipelinePage(props: {
|
||||
submitChat();
|
||||
}
|
||||
}}></textarea>
|
||||
{/* 文件上传入口先保留,当前自带脚本改为粘贴文本。
|
||||
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
|
||||
*/}
|
||||
<input ref={chatFileRef} type="file" accept=".docx,.txt" style={{ display: "none" }} onChange={(event) => void onPickScriptFile(event)} />
|
||||
<input ref={videoFileRef} type="file" accept=".mp4,.mov,.m4v,.webm" style={{ display: "none" }} onChange={(event) => void onPickVideoFile(event)} />
|
||||
<div className="chat-input-foot">
|
||||
{/* 文件上传按钮先保留,当前自带脚本改为粘贴文本。
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本(docx / txt)" aria-label="上传脚本(docx / txt)" disabled={scriptFileBusy} onClick={() => chatFileRef.current?.click()}>
|
||||
{scriptFileBusy
|
||||
? <span className="spinner" aria-hidden="true"></span>
|
||||
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>}
|
||||
</button>
|
||||
{/* 1.11 · 聊天已经开起来之后仍能提炼参考视频(入口菜单只在空态出现) */}
|
||||
<button className="chat-icon-btn" id="chat-video-btn" type="button" title="上传视频提炼(mp4 / mov)" aria-label="上传视频提炼(mp4 / mov)" disabled={videoDigestBusy} onClick={() => videoFileRef.current?.click()}>
|
||||
{videoDigestBusy
|
||||
? <span className="spinner" aria-hidden="true"></span>
|
||||
: <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m22 8-6 4 6 4V8Z" /><rect x="2" y="6" width="14" height="12" rx="2" /></svg>}
|
||||
</button>
|
||||
*/}
|
||||
{/* 模型选择小按钮(输入框下方,对齐 ChatGPT/Lovart)· 复用 restraint chip 下拉,向上展开 */}
|
||||
{textModels && textModels.length > 0 ? (
|
||||
<div className={`chip-wrap chat-model-pick${modelMenuOpen ? " open" : ""}`}>
|
||||
@@ -3051,27 +3249,33 @@ export function PipelinePage(props: {
|
||||
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
|
||||
</div>
|
||||
<div className="prod-action" id="asset-prod-action">
|
||||
{/* 与人物/场景卡页脚一致:hstack 左「使用已有素材」(ghost) + spacer + 右「AI 生成」(primary) */}
|
||||
{/* 双通道:左「上传三视图」(ghost,用户自带图) + 右「AI 生成三视图」(primary,平台生成) */}
|
||||
<div className="hstack">
|
||||
{/* 问题5(a):不想用平台生成 → 直接用已有素材(选商品图 / 上传本地图)当三视图 */}
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || assetPickBusy} onClick={() => setAssetPick({ title: "使用已有素材作三视图", category: "product_image", product: project.product, target: { kind: "product" }, uploadCategory: "tri_view", uploadName: `${productName || "商品"}·三视图` })}>替换</button>
|
||||
<button className="btn btn-ghost btn-sm" type="button" data-stop disabled={triGenerating || triUploading} onClick={() => triFileRef.current?.click()}>{triUploading ? "上传中…" : "上传三视图"}</button>
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||
<button className="btn btn-primary btn-sm" type="button" data-stop id="asset-prod-aigen-btn" disabled={triGenerating || triUploading} onClick={runProductTri}>{triGenerating ? "生成中…" : (hasTriView ? "AI 重新生成三视图" : "AI 生成三视图")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input ref={triFileRef} type="file" accept="image/*" hidden onChange={(e) => { const f = e.target.files?.[0]; if (f) void uploadTriViewFromFile(f); e.target.value = ""; }} />
|
||||
{/* 行39 · 三视图预览面板(对齐原版 prod-preview):生成中转圈 → 主图(可放大)+ 重跑/采用 + 历史版本切换 */}
|
||||
<div className={`prod-preview${triPanelShow ? " show" : ""}`} id="asset-prod-preview">
|
||||
<div className="prod-preview-h">// 三视图预览 · <span id="prod-preview-status">{triGenerating ? "生成中 · 约 12s" : previewTriAsset ? (previewTriAsset === adoptedTriAsset ? "已采用,不满意可重跑" : "预览中(未采用)") : "待生成"}</span></div>
|
||||
{(() => {
|
||||
const u = candUrl(productGroup, previewTriAsset);
|
||||
if (triGenerating && !u) {
|
||||
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">生成中</span></span></div>;
|
||||
if ((triGenerating || triUploading) && !u) {
|
||||
return <div className="placeholder prod-preview-img"><span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span className="mono">{triUploading ? "上传中" : "生成中"}</span></span></div>;
|
||||
}
|
||||
// 空态即上传框:点一下直接选本地图当三视图(不占用右侧 AI 生成那条路)
|
||||
if (!u) {
|
||||
return (
|
||||
<div className="placeholder prod-preview-img prod-tri-drop" id="prod-preview-img" role="button" tabIndex={0} title="上传本地图片作三视图" onClick={() => triFileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); triFileRef.current?.click(); } }}>
|
||||
<span className="ph-frame">+ 上传三视图</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`placeholder prod-preview-img${u ? " has-mock-media is-zoomable" : ""}`} id="prod-preview-img" role={u ? "button" : undefined} tabIndex={u ? 0 : undefined} title={u ? "点击放大查看" : undefined} style={u ? mediaStyle(u) : undefined} onClick={u ? () => setPreview({ src: u, kind: "image", name: `三视图` }) : undefined} onKeyDown={u ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } } : undefined}>
|
||||
{!u && <span className="ph-frame">// 暂无三视图</span>}
|
||||
</div>
|
||||
<div className="placeholder prod-preview-img has-mock-media is-zoomable" id="prod-preview-img" role="button" tabIndex={0} title="点击放大查看" style={mediaStyle(u)} onClick={() => setPreview({ src: u, kind: "image", name: "三视图" })} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setPreview({ src: u, kind: "image", name: "三视图" }); } }} />
|
||||
);
|
||||
})()}
|
||||
{previewTriAsset && (
|
||||
@@ -3302,7 +3506,7 @@ export function PipelinePage(props: {
|
||||
{viewStage === 3 && (() => {
|
||||
// 每场时间区间(累加脚本镜时长 → 「0~5s」)
|
||||
let cum = 0;
|
||||
const sceneTimes = shots.map((s) => { const st = cum; cum += s.duration_seconds || 15; return `${st}~${cum}s`; });
|
||||
const sceneTimes = shots.map((s) => { const st = cum; cum += shotSeconds(s); return `${st}~${cum}s`; });
|
||||
const shotImg = (s?: StoryboardShot | null) => frameUrl(s ? { asset: s.adopted_asset ?? "", asset_url: s.adopted_asset_url } : null);
|
||||
const shotBusy = (s: StoryboardShot) => sbGenerating || sbBusyShots.has(s.id) || ["queued", "running"].includes(s.status);
|
||||
const cardCount = sbShots.length || sbExpectedShots;
|
||||
@@ -3471,6 +3675,15 @@ export function PipelinePage(props: {
|
||||
{viewStage === 4 && (() => {
|
||||
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
|
||||
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
|
||||
const segSeconds = segments.map((s) => s.target_duration_seconds).filter((n) => n > 0);
|
||||
const segMin = segSeconds.length ? Math.min(...segSeconds) : 0;
|
||||
const segMax = segSeconds.length ? Math.max(...segSeconds) : 0;
|
||||
const segTotal = segSeconds.reduce((sum, n) => sum + n, 0);
|
||||
const paceText = !segSeconds.length
|
||||
? "每场按脚本时长出片"
|
||||
: segMin === segMax
|
||||
? `每场 Seedance ${segMin} 秒 · 全片 ${segTotal} 秒`
|
||||
: `每场 Seedance ${segMin}–${segMax} 秒 · 全片 ${segTotal} 秒`;
|
||||
const statusText = !segments.length
|
||||
? "暂无片段"
|
||||
: segDone === segments.length
|
||||
@@ -3483,7 +3696,7 @@ export function PipelinePage(props: {
|
||||
<div className="queue-bar">
|
||||
<div>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600 }}>视频生成 · {segDone} / {segments.length} 完成</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 约 15 秒 · {statusText}</div>
|
||||
<div className="muted-2 mono" style={{ fontSize: "12px", marginTop: "3px", letterSpacing: ".02em" }}>// {paceText} · {statusText}</div>
|
||||
</div>
|
||||
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
|
||||
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { api } from "../api";
|
||||
import { SystemLoading } from "../components/loading";
|
||||
import { ConfirmModal } from "../components/overlays";
|
||||
import type { NavigateFn } from "./route-config";
|
||||
import type { Asset, FreeVideoTask, ImageConversationTrash, ImageExceptionBatchTrash, ModelEntity, Product, Project } from "../types";
|
||||
@@ -326,7 +327,7 @@ export function TrashPage({ onRestore, onPurge, onRestoreProducts, onPurgeProduc
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="placeholder" style={{ minHeight: 160 }}><span className="ph-frame">// 加载中...</span></div>
|
||||
<SystemLoading title="正在加载数据" description="正在同步最新数据,请稍候。" icon="trash" />
|
||||
) : isEmpty ? (
|
||||
<div className="placeholder trash-empty"><span className="ph-frame">// 垃圾桶是空的</span></div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user