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:
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})()}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user