fix(core): 测试清单 行24/28-39/46 — 流水线脚本&资产/消息页 + 单镜重跑后端接口

前端流水线(pipeline.tsx):脚本助手三选项指引(AI全生/一句话/自带脚本)+来源风格推荐;
风格/人物设定卡并持久化到 project.metadata;Enter发送/Shift+Enter换行;长文折叠10行;
生成进度流提示;分镜人物/场景标签可编辑并持久化+点击添加分镜;单镜重跑/删除即时反馈;
脚本助手记录按项目 localStorage 持久化;Stage2 基础资产补替换/重跑/可编辑提示词;
三视图采用弹窗+缺三视图气泡。
消息页(messages):复用全站 .search-inline 搜索框、去掉小标题背景方块。
视频项目(projects):列表按创建时间倒序,新建置顶。
性能:action 改后台 hydrate,消除创建/确认后白等(行29/37)。

后端(projects/ai):新增单镜 AI 重跑接口 POST /projects/{id}/rerun-script-segment/
(regenerate_script_segment 服务,复用 AITask+计费 reserve/charge/release;+2 单测)。

前端 build 通过;后端 check 0/无新迁移/apps.projects 14 测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-16 16:51:32 +08:00
co-authored by Claude Opus 4.8
parent f7a403ead3
commit 9be2042520
11 changed files with 703 additions and 60 deletions
+5 -3
View File
@@ -183,9 +183,11 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
</button>
))}
</div>
<div className="msg-search">
<Search />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
<div className="msg-search-wrap">
<div className="search-inline">
<Search />
<input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
</div>
</div>
<div className="msg-list" ref={listRef} onScroll={onScroll}>
{items.length === 0 && !loading ? (
+414 -32
View File
@@ -234,6 +234,118 @@ const STAGE_STEPS: Array<{ n: number; label: string }> = [
{ n: 5, label: "拼接导出" }
];
// 行34 · 行内「添加标签」:点 + 展开输入框,回车 / 失焦提交(空则收起)
function AddTagInline({ onAdd, placeholder, ariaLabel }: { onAdd?: (value: string) => void; placeholder?: string; ariaLabel?: string }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { if (editing) inputRef.current?.focus(); }, [editing]);
const commit = () => {
const v = value.trim();
if (v) onAdd?.(v);
setValue("");
setEditing(false);
};
if (!editing) {
return <button className="tag-add" type="button" aria-label={ariaLabel} onClick={() => setEditing(true)}>+</button>;
}
return (
<input
ref={inputRef}
className="tag-add-input"
type="text"
value={value}
placeholder={placeholder}
aria-label={ariaLabel}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "Escape") { setValue(""); setEditing(false); } }}
onBlur={commit}
/>
);
}
// 行34 · 「添加分镜」插入的本地可编辑空白卡片:输入旁白/画面,提交后落库
function DraftShotCard({ draft, onCommit, onCancel }: {
draft: { id: string; afterId: string | null; narration: string; visual: string };
onCommit?: (draft: { id: string; afterId: string | null; narration: string; visual: string }) => void;
onCancel?: (id: string) => void;
}) {
const [narration, setNarration] = useState(draft.narration);
const [visual, setVisual] = useState(draft.visual);
const naRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => { naRef.current?.focus(); }, []);
const commit = () => onCommit?.({ ...draft, narration: narration.trim(), visual: visual.trim() });
return (
<div className="shot-card draft-shot-card">
<div className="shot-n">+</div>
<div className="shot-main">
<div className="shot-meta-row">
<div className="shot-meta">// 新分镜 · 待填写</div>
<div className="shot-actions">
<button className="icon-mini-btn" type="button" title="确认新增" onClick={commit}>✓</button>
<button className="icon-mini-btn" type="button" title="取消" onClick={() => onCancel?.(draft.id)}>×</button>
</div>
</div>
<div className="shot-row">
<span className="shot-k">旁白</span>
<textarea ref={naRef} className="draft-shot-input" rows={1} placeholder="输入这一镜的旁白…" value={narration}
onChange={(e) => setNarration(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</div>
<div className="shot-row">
<span className="shot-k">画面</span>
<textarea className="draft-shot-input" rows={1} placeholder="输入这一镜的画面描述…" value={visual}
onChange={(e) => setVisual(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); commit(); } else if (e.key === "Escape") { onCancel?.(draft.id); } }} />
</div>
</div>
</div>
);
}
// 行33 · 进度提示流气泡:steps 逐条滚动;done 后折叠成一行「已完成」结果
function ProgressStream({ steps, done }: { steps?: string[]; done?: boolean }) {
const list = steps ?? [];
if (done) {
return (
<div className="progress-stream done">
<span className="ps-check" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</span>
已完成 {list.length} 步思考 · 镜头脚本已生成
</div>
);
}
return (
<div className="progress-stream">
{list.map((step, i) => {
const isLast = i === list.length - 1;
return (
<div className={`ps-row${isLast ? " active" : " past"}`} key={i}>
<span className="ps-dot" aria-hidden="true"></span>
<span className="ps-text">{step}</span>
</div>
);
})}
</div>
);
}
// 行32 · 长文本折叠:超过 maxLines 行先折叠,点「查看更多」展开
function CollapsibleText({ text, maxLines = 10 }: { text: string; maxLines?: number }) {
const [expanded, setExpanded] = useState(false);
const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
// 长文本不一定有换行(整段粘贴),按字符量也判一次溢出
const overflow = lineCount > maxLines || text.length > maxLines * 40;
if (!overflow) return <>{text}</>;
return (
<>
<div className={expanded ? "" : "clamp-lines"} style={expanded ? undefined : ({ ["--clamp-lines"]: String(maxLines) } as CSSProperties)}>{text}</div>
<button type="button" className="clamp-toggle" onClick={() => setExpanded((v) => !v)}>{expanded ? "收起" : "查看更多"}</button>
</>
);
}
export function PipelinePage(props: {
project: Project;
loading: boolean;
@@ -254,6 +366,9 @@ export function PipelinePage(props: {
onUpdateShot: (payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) => Promise<unknown>;
onAddShot: (afterSegmentId: string) => Promise<unknown>;
onDeleteShot: (segmentId: string) => Promise<unknown>;
// 行35 · 单条分镜重跑(后端只重写该 segment);行28/34 · 把设定/标签合并进 project.metadata 持久化
onRerunShot?: (segmentId: string, instruction?: string) => Promise<unknown>;
onSaveProjectMeta?: (meta: Record<string, unknown>) => Promise<unknown>;
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
@@ -273,7 +388,7 @@ export function PipelinePage(props: {
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onAdoptVideoVersion, onGenerateVoiceover,
scriptModelName, onGenerateScript, onAdoptScript, onUpdateShot, onAddShot, onDeleteShot, onRerunShot, onSaveProjectMeta, onAdoptVideoVersion, onGenerateVoiceover,
onGenerateBaseAsset, onAdoptBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport, onRefreshProject,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
@@ -303,6 +418,19 @@ export function PipelinePage(props: {
null;
const scriptAdopted = Boolean(currentScript?.is_adopted);
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
// 行34 · 脚本里的「人物 / 场景」标签:持久化进 project.metadata.cast / .scenes。
// 初值从 metadata 读,刷新/重进后仍在;增删后 onSaveProjectMeta 合并落库。
const [castTags, setCastTags] = useState<string[]>(() => project.metadata?.cast ?? []);
const [sceneTags, setSceneTags] = useState<string[]>(() => project.metadata?.scenes ?? []);
// 后台刷新(action 后 refreshProjectDetail)回填的 metadata 同步到本地态,保证多端/重进一致
useEffect(() => { setCastTags(project.metadata?.cast ?? []); }, [project.metadata?.cast]);
useEffect(() => { setSceneTags(project.metadata?.scenes ?? []); }, [project.metadata?.scenes]);
// 标签增删:先本地更新(即时反馈)再合并落库
const saveCastTags = (next: string[]) => { setCastTags(next); void onSaveProjectMeta?.({ cast: next }); };
const saveSceneTags = (next: string[]) => { setSceneTags(next); void onSaveProjectMeta?.({ scenes: next }); };
// 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot)
type DraftShot = { id: string; afterId: string | null; narration: string; visual: string };
const [draftShots, setDraftShots] = useState<DraftShot[]>([]);
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
// 后端 BaseAssetGroup 无 Meta.ordering(UUID 主键顺序≈随机),必须按 created_at 排稳,
@@ -311,6 +439,11 @@ export function PipelinePage(props: {
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
// 行38 · 资产卡可编辑提示词(本地草稿,按 group id 覆盖原 prompt;重跑/替换时带上)
const [assetPromptDraft, setAssetPromptDraft] = useState<Record<string, string>>({});
// 行39 · AI 生成三视图弹窗:打开后从该商品组选择采用哪个候选三视图(没有也可跑视频)
const [triViewOpen, setTriViewOpen] = useState(false);
useBodyScrollLock(triViewOpen);
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
@@ -392,20 +525,73 @@ export function PipelinePage(props: {
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
// ── Stage 1 · 生成前「来源 & 风格 & 人物」设定(行28/行30):向导那边已删,这里补上 ──
// setupOpen:三选项之一被选中后,展示风格/人物下拉 + 确认/重新推荐;确认后才真正发起生成。
const SETUP_STYLE_KEYS = Object.keys(WIZ_STYLE_LABEL);
const SETUP_PERSONA_KEYS = Object.keys(WIZ_PERSONA_LABEL);
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");
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const chatFileRef = useRef<HTMLInputElement | null>(null);
const chatBodyRef = useRef<HTMLDivElement | null>(null);
// 对话记录(本地会话态):生成动作可追溯,不再是「点了按钮、对话区永远空着」
type ChatMsg = { role: "ai" | "user"; text: string; time: string };
// kind=progress:进度提示流(行33),steps 逐条滚动出现,done 后折叠成一行结果
type ChatMsg = { id: number; role: "ai" | "user"; text: string; time: string; kind?: "progress"; steps?: string[]; done?: boolean };
const nowHm = () => new Date().toTimeString().slice(0, 5);
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() =>
currentScript
? [{ role: "ai", text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
: []
);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { role, text, time: nowHm() }]);
const msgIdRef = useRef(1);
const nextMsgId = () => msgIdRef.current++;
// 脚本助手记录按项目持久化:退出项目→重进仍能看到历史对话(行36)
const chatKey = `airshelf:pipeline:chat:${project.id}`;
const [chatMsgs, setChatMsgs] = useState<ChatMsg[]>(() => {
try {
const raw = localStorage.getItem(chatKey);
if (raw) {
const saved = JSON.parse(raw) as ChatMsg[];
if (Array.isArray(saved) && saved.length) {
msgIdRef.current = Math.max(...saved.map((m) => m.id || 0)) + 1;
return saved;
}
}
} catch { /* 解析失败则回退默认 */ }
return currentScript
? [{ id: nextMsgId(), role: "ai", text: `当前已有 ${currentScript.segments?.length ?? 0} 镜脚本(${currentScript.is_adopted ? "已采用" : "待采用"})。直接输入修改意见可整体重写。`, time: nowHm() }]
: [];
});
// 落盘:丢掉未完成的 progress 流(那是临时态),只保留最近 60 条
useEffect(() => {
try {
const slim = chatMsgs.filter((m) => m.kind !== "progress" || m.done).slice(-60);
localStorage.setItem(chatKey, JSON.stringify(slim));
} catch { /* localStorage 不可用则忽略 */ }
}, [chatKey, chatMsgs]);
const pushMsg = (role: "ai" | "user", text: string) => setChatMsgs((list) => [...list, { id: nextMsgId(), role, text, time: nowHm() }]);
// 删除分镜的两步确认(行内变红,3 秒不二次点击自动复位;不用原生 confirm)
const [armedDelete, setArmedDelete] = useState<string | null>(null);
// 行35 · 单条分镜重跑 / 删除的即时反馈:正在处理的 shot id(按钮转「处理中」并禁用)
const [busyShot, setBusyShot] = useState<string | null>(null);
// 行35 · 单条分镜重跑:调后端 rerun-script-segment 只重写该镜(instruction 带本镜要点微调),给即时反馈
async function rerunShot(shotId: string, _index: number, hint: string) {
if (busyShot) return;
setBusyShot(shotId);
try {
await onRerunShot?.(shotId, hint || undefined);
} finally {
setBusyShot(null);
}
}
// 单条分镜删除:立即反馈(置 busy)再落库
async function deleteShot(shotId: string) {
if (busyShot) return;
setBusyShot(shotId);
setArmedDelete(null);
try {
await onDeleteShot(shotId);
} finally {
setBusyShot(null);
}
}
useEffect(() => {
if (!armedDelete) return;
const timer = window.setTimeout(() => setArmedDelete(null), 3000);
@@ -414,20 +600,75 @@ export function PipelinePage(props: {
useEffect(() => {
const el = chatBodyRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [chatMsgs.length]);
// 统一的脚本生成对话回合:用户消息 → 生成中 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
}, [chatMsgs]);
// 行33 · 进度提示流:前端模拟 AI 分步思考(逐条滚动),不需要真后端分步。
// 生成期间逐条往同一条 progress 消息追加 step;onGenerateScript 返回后置 done 折叠收起。
const PROGRESS_STEPS = [
"收到脚本,正在解析商品卖点与创作方向…",
"提取关键卖点 · 锁定目标人群画像…",
"匹配创作风格与镜头节奏…",
"编排分镜 · 旁白与画面逐镜成稿…",
"校对时长与转化点,整理输出…"
];
// 统一的脚本生成对话回合:用户消息 → 进度流 → 成功/失败回执(onGenerateScript 失败被 App 兜住返回 null)
async function runScriptGeneration(prompt: string, userLabel?: string, source?: string) {
pushMsg("user", userLabel || prompt);
pushMsg("ai", "正在解析商品卖点与创作方向,生成镜头脚本…");
const progressId = nextMsgId();
setChatMsgs((list) => [...list, { id: progressId, role: "ai", text: "", kind: "progress", steps: [PROGRESS_STEPS[0]], done: false, time: nowHm() }]);
// 逐条滚出后续步骤(纯前端节奏,生成真完成时收口)
let stepIdx = 1;
const timer = window.setInterval(() => {
if (stepIdx >= PROGRESS_STEPS.length) { window.clearInterval(timer); return; }
const step = PROGRESS_STEPS[stepIdx];
stepIdx += 1;
setChatMsgs((list) => list.map((m) => (m.id === progressId && !m.done ? { ...m, steps: [...(m.steps ?? []), step] } : m)));
}, 900);
const res = await onGenerateScript(prompt, source ?? chatMode);
window.clearInterval(timer);
// 收口:把 progress 折叠成一行结果,并补一条结果文本
setChatMsgs((list) => list.map((m) => (m.id === progressId ? { ...m, done: true } : m)));
pushMsg("ai", res ? "镜头脚本已生成,左侧已刷新。可继续输入修改意见整体重写,或点底部「确认脚本」进入下一步。" : "生成没有成功,请查看提示后重试。");
}
// 行28/行30 · 确认设定后真正发起生成:把所选「风格 / 人物」并进提示词(后端从 prompt 推断)
async function runScriptWithSetup() {
const styleLabel = WIZ_STYLE_LABEL[setupStyle] || setupStyle;
const personaLabel = WIZ_PERSONA_LABEL[setupPersona] || setupPersona;
setSetupOpen(false);
// 行28 · 持久化所选风格/人物到 metadata.wizard(合并现有 wizard,不冲掉 duration/selling_point_ids);
// 顶部「风格/人物」brief pill 读 metadata.wizard,刷新后即显已确认值。
// 先 await 落库再发起生成:action 有 in-flight 互斥锁,并发会被「操作进行中」挡掉
await onSaveProjectMeta?.({ wizard: { ...(project.metadata?.wizard ?? {}), script_style: setupStyle, persona: setupPersona } });
const sourceLabel = SOURCE_LABEL[setupSource] || "AI 全生";
if (setupSource === "theme") {
const theme = chatText.trim();
if (!theme) { focusThemeMode(); return; }
setChatText("");
await runScriptGeneration(`一句话主题:${theme}。风格:${styleLabel},目标人群:${personaLabel}。生成镜头脚本,突出商品卖点,适合短视频投放`, `一句话主题:${theme} · ${styleLabel} · ${personaLabel}`, "theme");
return;
}
if (setupSource === "manual") {
const base = chatText.trim();
setChatText("");
await runScriptGeneration(`${base ? `${base}\n` : ""}据此整理成镜头脚本。风格:${styleLabel},目标人群:${personaLabel}`, `自带脚本 · ${styleLabel} · ${personaLabel}`, "manual");
return;
}
await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai");
}
function clearChat() {
setChatText("");
setChatAttachments([]);
setChatMode("ai");
setSetupOpen(false);
setChatMsgs([]);
}
// 行30 · 三选项指引:选了某种生成方式 → 打开「来源 & 风格 & 人物」设定卡(确认后再生成)
function openSetup(source: "ai" | "theme" | "manual") {
setSetupSource(source);
setChatMode(source);
setSetupOpen(true);
if (source === "theme") { focusThemeMode(); }
else if (source === "manual") { pickScriptMode(); }
}
function pickScriptMode() {
setChatMode("manual");
chatFileRef.current?.click();
@@ -1391,14 +1632,27 @@ export function PipelinePage(props: {
<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>
</div>
{/* 行34 · 该脚本的人物 / 场景标签:可编辑(增删 chip);出现分镜后才展示 */}
<div className="script-tags" id="script-tags">
<div className="tag-group" data-kind="char">
<span className="tg-lbl">// 人物</span>
<button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
{castTags.map((tag, i) => (
<span className="script-chip" key={`cast-${i}-${tag}`}>
{tag}
<button type="button" className="chip-x" aria-label={`移除人物 ${tag}`} onClick={() => saveCastTags(castTags.filter((_, j) => j !== i))}>×</button>
</span>
))}
<AddTagInline placeholder="人物名" onAdd={(v) => { if (!castTags.includes(v)) saveCastTags([...castTags, v]); }} ariaLabel="添加人物" />
</div>
<div className="tag-group" data-kind="scene">
<span className="tg-lbl">// 场景</span>
<button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
{sceneTags.map((tag, i) => (
<span className="script-chip" key={`scene-${i}-${tag}`}>
{tag}
<button type="button" className="chip-x" aria-label={`移除场景 ${tag}`} onClick={() => saveSceneTags(sceneTags.filter((_, j) => j !== i))}>×</button>
</span>
))}
<AddTagInline placeholder="场景名" onAdd={(v) => { if (!sceneTags.includes(v)) saveSceneTags([...sceneTags, v]); }} ariaLabel="添加场景" />
</div>
</div>
<span className="spacer"></span>
@@ -1422,16 +1676,16 @@ export function PipelinePage(props: {
<div className="shot-meta-row">
<div className="shot-meta">// 场 {index + 1} · {start}-{cum}s</div>
<div className="shot-actions">
<button className="icon-mini-btn" type="button" title="重写本场(把修改意见发给脚本助手)" disabled={loading} onClick={() => { focusThemeMode(); setChatText(`修改第 ${index + 1} 镜:`); }}>↻</button>
{/* 行35 · 单条重跑:即时反馈(转「…」并禁用) */}
<button className="icon-mini-btn" type="button" title="重跑本场(只重写这一镜)" disabled={loading || busyShot === shot.id} onClick={() => void rerunShot(shot.id, index, (visualShown || narration))}>{busyShot === shot.id ? "…" : "↻"}</button>
<button
className={`icon-mini-btn${armedDelete === shot.id ? " armed" : ""}`}
type="button"
title={armedDelete === shot.id ? "再点一次确认删除" : "删除本场"}
disabled={loading || shots.length <= 1}
disabled={loading || shots.length <= 1 || busyShot === shot.id}
onClick={() => {
if (armedDelete === shot.id) {
setArmedDelete(null);
void onDeleteShot(shot.id);
void deleteShot(shot.id);
} else {
setArmedDelete(shot.id);
}
@@ -1449,6 +1703,7 @@ export function PipelinePage(props: {
data-placeholder="(旁白)点击编辑"
data-empty={narration ? undefined : "true"}
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
onBlur={(event) => {
const text = (event.currentTarget.textContent || "").trim();
if (!text) event.currentTarget.setAttribute("data-empty", "true");
@@ -1466,6 +1721,7 @@ export function PipelinePage(props: {
data-placeholder="(画面描述)点击编辑"
data-empty={visualShown ? undefined : "true"}
onFocus={(event) => { event.currentTarget.removeAttribute("data-empty"); }}
onKeyDown={(event) => { if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); event.currentTarget.blur(); } }}
onBlur={(event) => {
const text = (event.currentTarget.textContent || "").trim();
if (!text) event.currentTarget.setAttribute("data-empty", "true");
@@ -1475,8 +1731,14 @@ export function PipelinePage(props: {
</div>
</div>
</div>
{/* 行34 · 该卡之后挂着的本地草稿分镜(点「添加分镜」插入,可编辑,有内容失焦才落库) */}
{draftShots.filter((d) => d.afterId === shot.id).map((draft) => (
<DraftShotCard key={draft.id} draft={draft}
onCommit={(d) => { setDraftShots((list) => list.filter((x) => x.id !== d.id)); if ((d.narration || d.visual).trim()) void onAddShot(d.afterId || shot.id); }}
onCancel={(id) => setDraftShots((list) => list.filter((x) => x.id !== id))} />
))}
<div className="shot-insert-gap">
<button className="add-shot-btn" type="button" disabled={loading} onClick={() => void onAddShot(shot.id)}>
<button className="add-shot-btn" type="button" disabled={loading} onClick={() => setDraftShots((list) => [...list, { id: `draft-${Date.now()}`, afterId: shot.id, narration: "", visual: "" }])}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>添加分镜
</button>
</div>
@@ -1504,19 +1766,58 @@ export function PipelinePage(props: {
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0 && chatMsgs.length === 0} onClick={clearChat}>清空对话</button>
</div>
<div className="chat-body" id="chat-body" ref={chatBodyRef}>
{chatMsgs.length ? chatMsgs.map((msg, index) => (
<div className={`msg ${msg.role}`} key={index}>
<div className="bubble">{msg.text}</div>
<div className="time">{msg.time}</div>
</div>
)) : (
{chatMsgs.length || setupOpen ? (
<>
{chatMsgs.map((msg) => (
<div className={`msg ${msg.role}`} key={msg.id}>
<div className="bubble">
{msg.kind === "progress"
? <ProgressStream steps={msg.steps} done={msg.done} />
: <CollapsibleText text={msg.text} maxLines={10} />}
</div>
<div className="time">{msg.time}</div>
</div>
))}
{/* 行28/行30 · 选定生成方式后的「来源 & 风格 & 人物」设定卡(确认后再生成) */}
{setupOpen && (
<div className="msg ai">
<div className="bubble setup-card">
<div className="setup-lead">{setupSource === "manual" ? "已选「自带脚本」。" : setupSource === "theme" ? "已选「一句话主题」。" : "我会根据商品信息直接生成第一版。"}先确认创作方向。</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>)}
</select>
</label>
<div className="setup-rec">根据商品信息推荐</div>
<label className="setup-field">
<span className="sf-k">人物</span>
<select className="setup-select" value={setupPersona} onChange={(e) => setSetupPersona(e.target.value)}>
{SETUP_PERSONA_KEYS.map((k) => <option key={k} value={k}>{WIZ_PERSONA_LABEL[k]}</option>)}
</select>
</label>
<div className="setup-rec">根据商品目标人群推荐</div>
<div className="setup-foot">
<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>
<button type="button" className="btn btn-primary btn-sm" disabled={loading} onClick={() => void runScriptWithSetup()}>确定</button>
</div>
</div>
<div className="time">{nowHm()}</div>
</div>
)}
</>
) : (
<div className="chat-empty">
<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={() => { setChatMode("ai"); void runScriptGeneration("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放", "AI 全生:根据商品信息直接生成第一版", "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={focusThemeMode}><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={pickScriptMode}><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>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>
</div>
</div>
)}
@@ -1532,14 +1833,27 @@ export function PipelinePage(props: {
</span>
))}
</div>
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<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)}
onKeyDown={(event) => {
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (loading) return;
if (setupOpen) { void runScriptWithSetup(); return; }
const text = chatText.trim();
if (!text) return;
setChatText("");
setChatAttachments([]);
void runScriptGeneration(text);
}
}}></textarea>
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
<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>
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { const text = chatText.trim(); setChatText(""); setChatAttachments([]); void runScriptGeneration(text); }}>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim())} onClick={() => { if (setupOpen) { void runScriptWithSetup(); return; } const text = chatText.trim(); if (!text) return; setChatText(""); setChatAttachments([]); void runScriptGeneration(text); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
@@ -1613,7 +1927,8 @@ export function PipelinePage(props: {
<div className="prod-date">{(project.created_at || "").slice(0, 10)} 创建</div>
</div>
<div className="prod-action" id="asset-prod-action">
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => onGenerateBaseAsset("product", `${productName} 三视图`)}>
{/* 行39 · 点开弹窗:可选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
<button className="btn-aigen" type="button" data-stop id="asset-prod-aigen-btn" disabled={loading} onClick={() => setTriViewOpen(true)}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
AI 生成三视图
</button>
@@ -1651,6 +1966,8 @@ export function PipelinePage(props: {
{list.map((group, gi) => {
const mainUrl = groupMainUrl(group);
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
// 行38 · 可编辑提示词:草稿优先,落空回退原 prompt;重跑/替换都带它
const promptValue = assetPromptDraft[group.id] ?? group.prompt ?? "";
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
@@ -1664,8 +1981,14 @@ export function PipelinePage(props: {
? <span className="pill ok"><span className="dot"></span>已采用</span>
: <span className="pill neutral"><span className="dot"></span>待采用</span>}
</div>
{/* 提示词只读展示:此前是 contentEditable 但编辑结果无人消费,纯欺骗性交互 */}
<div className="prompt-box">{group.prompt || "(暂无提示词)"}</div>
{/* 行38 · 可编辑提示词:改完点重跑/替换据此生成 */}
<textarea
className="asset-prompt-edit"
rows={3}
placeholder="描述这个素材的提示词…"
value={promptValue}
onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [group.id]: e.target.value }))}
/>
{cands.length > 0 && (
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
{cands.map((id) => (
@@ -1673,6 +1996,15 @@ export function PipelinePage(props: {
))}
</div>
)}
{/* 行38 · 重跑 / 替换:重跑=据当前提示词重新生成;替换=有候选先采下一张,无候选回退重生成 */}
<div className="asset-card-actions">
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt))}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
重跑
</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => { if (cands.length) { void onAdoptBaseAsset(group.id, cands[0]); } else { onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt)); } }}>替换</button>
</div>
</div>
</div>
);
@@ -1696,6 +2028,56 @@ export function PipelinePage(props: {
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(3)}>确认资产,进入故事板 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
{/* 行39 · AI 生成三视图弹窗:选择采用哪个三视图版本(建议生成,但没有也可跑视频) */}
{triViewOpen && (
<div className="asset-modal-bg" onClick={() => setTriViewOpen(false)}>
<div className="asset-modal tri-modal" onClick={(e) => e.stopPropagation()}>
<div className="asset-modal-h">
<h2>AI 生成三视图</h2>
<button className="x" type="button" aria-label="关闭" onClick={() => setTriViewOpen(false)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div className="asset-modal-body">
<div className="tri-modal-tip">
建议先生成 <b>正 / 侧 / 背</b> 三视图,后续生成的角色一致性与姿态稳定性更好;但没有三视图也可直接跑视频。
</div>
{productCandidates.length ? (
<>
<div className="vd-history-h">// 候选三视图 · 选择采用哪个版本</div>
<div className="tri-cand-grid">
{productCandidates.map((id, i) => {
const u = candUrl(productGroup, id);
return (
<div className={`tri-cand-card${productGroup?.adopted_asset === id ? " adopted" : ""}`} key={id} role="button" tabIndex={0}
onClick={() => { if (productGroup) { void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && productGroup) { e.preventDefault(); void onAdoptBaseAsset(productGroup.id, id); setTriViewOpen(false); } }}>
<div className={`placeholder tri-cand-img${u ? " has-mock-media" : ""}`} style={u ? mediaStyle(u) : undefined}><span className="ph-frame">版本 {i + 1}</span></div>
<div className="tri-cand-foot"><span className="mono">// V{i + 1}</span><span className="btn btn-ghost btn-sm">采用此版本</span></div>
</div>
);
})}
</div>
</>
) : (
<div className="tri-empty">
<div className="placeholder tri-cand-img" style={{ maxWidth: 220, margin: "0 auto" }}><span className="ph-frame">// 暂无三视图候选</span></div>
<p className="tri-empty-hint">还没有候选三视图。点下方「生成三视图」让 AI 出多角度参考图,生成后回此处选择采用。</p>
</div>
)}
</div>
<div className="asset-modal-f">
<button className="btn" type="button" onClick={() => setTriViewOpen(false)}>暂不生成(直接跑视频)</button>
<span className="spacer" style={{ flex: 1 }}></span>
<button className="btn-aigen" type="button" disabled={loading} onClick={() => { onGenerateBaseAsset("product", `${productName} 三视图`); setTriViewOpen(false); }}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /></svg>
生成三视图
</button>
</div>
</div>
</div>
)}
</section>
);
})()}
+5
View File
@@ -528,6 +528,11 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
if (days > Number(timeFilter)) return false;
}
return true;
}).sort((a, b) => {
// 倒序排列:新创建的项目排最前;created_at 缺失时回退 updated_at,再回退 id 字典序
const ta = a.created_at || a.updated_at || a.id;
const tb = b.created_at || b.updated_at || b.id;
return ta < tb ? 1 : ta > tb ? -1 : 0;
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);