fix: 测试清单(8) 新一轮 — ZWQ/PMC/设置/团队 多项修复

视频/工作台/商品库(ZWQ):
- row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx)
- row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App)
- row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx)
- row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts)
- row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx)

图片生成/设置(PMC):
- row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts)
- row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署)
- row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx)
- row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx)

平台套图(YYX):
- row3 图片预览铺满占位 (ai-tools-page.css)

团队:
- PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx)

含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交)
构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK
注: row29 头像/后端项需部署生效

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-29 16:45:29 +08:00
co-authored by Claude Opus 4.8
parent f3b89f3d49
commit ffada7caab
15 changed files with 292 additions and 82 deletions
+3 -2
View File
@@ -433,7 +433,7 @@ export function App() {
if (options.productId !== undefined) setActiveProductId(options.productId);
if (options.projectId !== undefined) setActiveProjectId(options.projectId);
const hash = options.hash?.replace(/^#/, "");
setRoute({ page: next, authMode, productId, projectId, hash });
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
const method = options.replace ? "replaceState" : "pushState";
@@ -535,7 +535,7 @@ export function App() {
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
@@ -776,6 +776,7 @@ export function App() {
projects={projects}
loading={!dataLoaded}
navigate={navigate}
initialTab={route.tab}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
onDelete={async (projectId) => {
+1 -1
View File
@@ -1914,7 +1914,7 @@
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
/* 任务结果图:有真实生成图时铺满占位框,去掉斜纹底与编号 */
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; }
.asset-factory .placeholder.has-img { overflow: hidden; position: relative; padding: 0; }
.asset-factory .placeholder.has-img::after { display: none; }
.asset-factory .placeholder.has-img .ph-frame { display: none; }
.asset-factory .placeholder.has-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
+1 -1
View File
@@ -601,7 +601,7 @@ export const api = {
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果。
// 带 conversation_id 则归属该对话;不带则后端自动开一条新对话并回传其 id。
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) {
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[] }) {
return request<{ conversation_id: string; tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
// 图片创作对话 CRUD —— 左栏会话列表 / 新对话 / 切换 / 重命名 / 删除
@@ -0,0 +1,24 @@
import { useState } from "react";
// 网格/列表(或任意字面量)视图模式持久化 hook。
// 按页面独立 key 存 localStorage,切走再切回恢复上次选择。
// SSR / 无 localStorage / 读写抛错全部容错 —— 失败时退回 defaultVal,绝不报错。
export function useViewMode<T extends string>(key: string, defaultVal: T): [T, (value: T) => void] {
const [view, setViewState] = useState<T>(() => {
try {
const saved = window.localStorage.getItem(key);
return saved ? (saved as T) : defaultVal;
} catch {
return defaultVal;
}
});
const setView = (value: T) => {
setViewState(value);
try {
window.localStorage.setItem(key, value);
} catch {
// 无 localStorage / 隐私模式写满 —— 静默,内存态仍生效本次会话
}
};
return [view, setView];
}
+21 -10
View File
@@ -33,6 +33,7 @@ import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import { useViewMode } from "../components/use-view-mode";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import { productMockCoverUrl } from "./products";
@@ -182,7 +183,7 @@ export function AssetFactoryPage({ navigate }: { navigate: (page: Page) => void
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [view, setView] = useViewMode<"grid" | "list">("viewmode:tasks", "grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
@@ -514,10 +515,11 @@ const MODE_META: Record<
cover: {
title: "平台套图",
tag: "[ PLATFORM · KIT ]",
desc: "选择平台模板,一键生成电商平台头图 / 主图。",
ratio: "4:5",
desc: "选择平台模板,一键生成平台商品头图。",
// 优化版:商品上架主图默认 1:1(原 4:5 会 fallback 成方图致比例错乱);竖图按平台/类目再选
ratio: "1:1",
// YYX#4:只出平台头图,去掉「详情排版」
promptTemplate: (title) => `${title}电商平台头图,统一视觉,主图特写`
promptTemplate: (title) => `${title},平台商品头图,统一视觉,商品主体清晰`
}
};
@@ -565,6 +567,12 @@ const PLATFORM_OPTIONS = [
{ id: "al", name: "1688", logo: "阿", img: "/assets/svg/icon-platform-1688.svg" }
];
/* 前端平台 id(dy/tb…) → 后端规范化 platform_id(优化版:后端按此 key 注入平台版式块)。 */
const PLATFORM_ID_MAP: Record<string, string> = {
dy: "douyin", tb: "taobao", tm: "tmall", jd: "jd", pdd: "pdd",
xhs: "xhs", ks: "kuaishou", sph: "wechat", amz: "amazon", al: "1688"
};
/* YYX#row10: logo logo (public/assets/svg),退+
className p-logo(/), cg-logo */
function PlatformLogo({ p, className }: { p: { id: string; name: string; logo: string; img?: string }; className?: string }) {
@@ -623,7 +631,7 @@ export function ImageWorkbenchPage({
modelConfigs: ModelConfig[];
onBack: () => void;
navigate?: (page: Page) => void;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>;
onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>;
/** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */
initialProductId?: string;
@@ -963,6 +971,8 @@ export function ImageWorkbenchPage({
modelId?: string;
modelName?: string;
platformIds?: string[];
/** 优化版:规范化平台 id(douyin/taobao…),透传给后端注入平台版式块 */
platformId?: string;
/** 图片创作:本批要参考的上传图(含 file 用于上传;已是 asset 的可只给 id) */
refs?: { name: string; url: string; file?: File; assetId?: string }[];
/** PMC#25:原地重跑——复位这张已有批次卡(清旧结果、重新生成),不新开任务卡 */
@@ -1027,7 +1037,7 @@ export function ImageWorkbenchPage({
}
// 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值
// onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10)
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, platform_id: opts.platformId, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds,
onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) });
// 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数
const convId = result?.conversation_id;
@@ -1083,10 +1093,9 @@ export function ImageWorkbenchPage({
};
if (mode === "cover") {
// P0③:每个选中平台各起一批 → 右侧自然形成多平台分组 section;
// 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达)
// 优化版:不再把平台名拼进 prompt,改传规范化 platform_id 给后端注入平台版式块(平台调性/版式/负面约束)
for (const pid of pickedIds) {
const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name;
void startBatch({ ...base, prompt: platName ? `${base.prompt}${platName}平台头图版式` : base.prompt, platformIds: [pid] });
void startBatch({ ...base, platformId: PLATFORM_ID_MAP[pid], platformIds: [pid] });
}
return;
}
@@ -1116,6 +1125,7 @@ export function ImageWorkbenchPage({
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
reuseBatchId: src.id
});
}
@@ -1196,6 +1206,7 @@ export function ImageWorkbenchPage({
modelId: src.modelId,
modelName: src.modelName,
platformIds: src.platformIds,
platformId: src.platformIds?.[0] ? PLATFORM_ID_MAP[src.platformIds[0]] : undefined,
appendToBatchId: src.id
});
}
@@ -2114,7 +2125,7 @@ export function ImageWorkbenchPage({
for (const pid of plDraft.slice(1)) {
const p = products.find((x) => x.id === pid);
if (mode === "cover") {
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformIds: [platId] });
for (const platId of pickedIds) void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, platformId: PLATFORM_ID_MAP[platId], platformIds: [platId] });
} else {
void startBatch({ prompt: prompt.trim(), ratio, count: candidateCount, productId: pid, productTitle: p?.title, modelId: mode === "model" ? pickedIds[0] : undefined, modelName: mode === "model" ? pickedModelName : undefined });
}
+9 -6
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import type { BillingSummary, Product, Project } from "../types";
import type { Page } from "./route-config";
import type { NavigateFn } from "./route-config";
import { api } from "../api";
import { SkeletonRows } from "../components/loading";
import { money, stageMeta, statusPill } from "./stage-config";
@@ -38,7 +38,7 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
billing: BillingSummary | null;
userName?: string;
loading?: boolean;
navigate: (page: Page) => void;
navigate: NavigateFn;
// YYX#11:工作台点「新建商品」直接弹抽屉(复用商品库同一组件),而非跳商品库
onCreateProduct?: (payload: ProductCreatePayload) => Promise<Product | null | undefined> | void;
}) {
@@ -95,8 +95,8 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
</button>
</div>
</div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} /><KpiStat label="进行中" badge="WIP" value={running} /><KpiStat label="成片" badge="DONE" value={completed} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline")}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
<div className="stats with-corners"><span className="corner-tr">+</span><span className="corner-bl">+</span><KpiStat label="总项目" badge="ALL" value={projCount} delta={totalDelta} onClick={() => navigate("projects", { tab: "all" })} /><KpiStat label="进行中" badge="WIP" value={running} onClick={() => navigate("projects", { tab: "wip" })} /><KpiStat label="成片" badge="DONE" value={completed} onClick={() => navigate("projects", { tab: "done" })} /><button className="stat" type="button" onClick={() => navigate("account")}><div className="lbl"> <span className="badge">¥</span></div><div className="v">{money(billing?.account.balance)}</div><div className="bar"><span style={{ width: `${reservedPct}%` }} /></div><div className="sub"> {money(reservedNum)} / {money(fundsTotal)}</div></button></div>
<div className="dash-grid"><div><div className="section-h"><h2></h2><button className="more" type="button" onClick={() => navigate("projects")}>[ ALL · {projCount} ] </button></div><div className="card-hard">{loading && projects.length === 0 ? <SkeletonRows count={4} /> : projects.slice(0, 6).map((project) => <button className="recent-row" key={project.id} type="button" onClick={() => navigate("pipeline", { projectId: project.id })}><div className="placeholder thumb"><span className="ph-frame">9:16</span></div><div className="recent-meta"><div className="name">{project.name}</div><div className="sub">{[productTitle(project.product), "AI 全生", project.video_segment_count ? `${project.video_segment_count}` : null].filter(Boolean).join(" / ")}</div></div><DashProgress project={project} /><span className={`pill ${statusPill(project.status)}`}><span className="dot" />{stageMeta[project.current_stage]?.label || project.current_stage}</span><span className="btn btn-sm"></span></button>)}</div></div><div style={{ display: "flex", flexDirection: "column", gap: 24 }}><div><div className="section-h"><h2></h2><span className="more">[ /shortcuts ]</span></div><div className="shortcuts"><Shortcut name="package" title="" desc={`${prodCount} SKU`} onClick={() => navigate("products")} /><Shortcut name="images" title="" desc={`${assetCount ?? ""} `} onClick={() => navigate("library")} /><Shortcut name="creditCard" title="" desc={money(billing?.account.balance)} onClick={() => navigate("account")} /><Shortcut name="clapperboard" title="" desc={`${projCount} `} onClick={() => navigate("projects")} /></div></div><div><div className="section-h"><h2></h2><span className="more">[ FAQ ]</span></div><div className="tip"><strong></strong> <span className="mono">[ ]</span> token </div></div></div></div>
{/* YYX#11:新建商品抽屉 · 复用商品库同一组件,创建成功后跳商品库看新商品 */}
{onCreateProduct && (
<ProductCreateDrawer
@@ -110,8 +110,11 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill
);
}
export function KpiStat({ label, badge, value, delta }: { label: string; badge: string; value: string | number; delta?: string }) {
return <div className="stat"><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</div>;
export function KpiStat({ label, badge, value, delta, onClick }: { label: string; badge: string; value: string | number; delta?: string; onClick?: () => void }) {
const inner = <><div className="lbl">{label} <span className="badge">{badge}</span></div><div className="v">{value}</div>{delta ? <div className="delta up">{delta}</div> : null}</>;
// 可点的统计块复用现有 .stat 类(与余额块一致),不新造样式/色值。
if (onClick) return <button className="stat" type="button" onClick={onClick}>{inner}</button>;
return <div className="stat">{inner}</div>;
}
export function Shortcut({ name, title, desc, onClick }: { name: string; title: string; desc: string; onClick: () => void }) {
+12 -19
View File
@@ -1615,8 +1615,6 @@ export function PipelinePage(props: {
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
const edHydratedRef = useRef(false);
const bgmFileRef = useRef<HTMLInputElement | null>(null);
const videoUploadRef = useRef<HTMLInputElement | null>(null);
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
// 提交一个新编辑态(进 undo 栈)
const commitEdit = useCallback((next: EditorState) => {
setEdHistory((h) => [...h.slice(-49), edState]);
@@ -2350,16 +2348,6 @@ export function PipelinePage(props: {
// (旧的转场黑闪提示层已删:双缓冲槽位的 opacity 过渡就是真转场预览)
// Stage 4 / 5 文件上传
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
setUploadTargetSeg(null);
}
function triggerVideoUpload(segmentId: string) {
setUploadTargetSeg(segmentId);
videoUploadRef.current?.click();
}
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
const [exporting, setExporting] = useState(false);
const [exportErr, setExportErr] = useState("");
@@ -3355,18 +3343,12 @@ export function PipelinePage(props: {
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => guardVideoGen(shots, null, submitAllVideosOptimistic)}>{loading ? <><span className="spinner btn-spin" aria-hidden="true" /></> : anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button>
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
{exporting ? "导出中…" : exportErr ? "导出失败" : "导出全部"}
</button>
</div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
@@ -3963,6 +3945,17 @@ export function PipelinePage(props: {
const triVersions = triGroup?.candidate_assets ?? [];
const viewTriAsset = (adTriId && triVersions.includes(adTriId)) ? adTriId : (triGroup?.adopted_asset || triVersions.at(-1) || "");
const triUrl = candUrl(triGroup, viewTriAsset);
// 已有三视图判定(ZWQ-row24):优先看 BaseGroup(流水线内生成的三视图组);其次看资产 metadata
// 沿用 library.tsx 同款判定:tri_view/triview/has_triview/three_view/tri_views 任一为真即已有;
// 模特库正面图(metadata.view==="frontal")本身与三视图同批生成,直接算已就绪。
const assetAlreadyHasTri: boolean = (() => {
if (triUrl) return true;
const m: Record<string, unknown> = (viewPortraitAsset ? byId.get(viewPortraitAsset)?.metadata : null) || {};
const has = m.tri_view ?? m.triview ?? m.has_triview ?? m.three_view ?? m.tri_views;
if (has === true || (Array.isArray(has) && has.length > 0) || (typeof has === "string" && (has as string).trim())) return true;
if (m.view === "frontal") return true; // 模特库正面图:generate_model 同批出了三视图
return false;
})();
const pBK = `addet-portrait:${entity.key}`;
// 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈
const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
@@ -4037,7 +4030,7 @@ export function PipelinePage(props: {
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!triUrl && !busyTri && (
{!assetAlreadyHasTri && !busyTri && (
<div className="asset-detail-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"}</span>
+11 -2
View File
@@ -6,6 +6,7 @@ import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { ProductCreateDrawer } from "../components/product-create-drawer";
import { Pager } from "../components/pager";
import { SkeletonRows } from "../components/loading";
import { useViewMode } from "../components/use-view-mode";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
@@ -416,7 +417,7 @@ const PROJ_TABS: Array<{ filter: "all" | "wip" | "done" | "fail"; label: string
{ filter: "all", label: "全部" }, { filter: "wip", label: "进行中" }, { filter: "done", label: "已完成" }, { filter: "fail", label: "失败" }
];
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete }: {
export function ProjectsPage({ products, projects, loading = false, navigate, openPipeline, onDelete, initialTab }: {
products: Product[];
projects: Project[];
loading?: boolean;
@@ -424,9 +425,17 @@ export function ProjectsPage({ products, projects, loading = false, navigate, op
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
openPipeline: (projectId: string) => void;
onDelete: (projectId: string) => Promise<unknown> | void;
// 工作台统计块点击带入的初始 tab(all/wip/done/fail);变化时覆盖当前 tab。
initialTab?: string;
}) {
const [view, setView] = useState<"list" | "grid">("list");
const [view, setView] = useViewMode<"list" | "grid">("viewmode:projects", "list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
// initialTab 从工作台统计块带入时同步到本地 tab(仅识别合法值)。
useEffect(() => {
if (initialTab && ["all", "wip", "done", "fail"].includes(initialTab)) {
setTab(initialTab as "all" | "wip" | "done" | "fail");
}
}, [initialTab]);
const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
+4
View File
@@ -43,6 +43,8 @@ export type ResolvedRoute = {
productId?: string;
projectId?: string;
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):由 navigate 透传,刷新/前进后退不持久(回默认 all)。
tab?: string;
// 平台超管后台:/admin → ""(概览),/admin/<section> → section slug。undefined = 非 admin 路由。
admin?: string;
};
@@ -51,6 +53,8 @@ export type NavigateOptions = {
projectId?: string;
replace?: boolean;
hash?: string;
// 视频项目页初始 tab(all/wip/done/fail):仅经 route state 透传给 ProjectsPage,不进 URL path。
tab?: string;
};
export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
export type Notice = { type: "success" | "error" | "info"; text: string } | null;
+6 -12
View File
@@ -4,9 +4,7 @@ import {
Bell,
KeyRound,
LogOut,
Monitor,
ShieldCheck,
Sliders,
Upload,
User as UserIcon,
} from "lucide-react";
@@ -15,7 +13,8 @@ import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "pref", "display"];
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
{
@@ -26,13 +25,7 @@ const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string;
{ key: "notify", label: "通知", icon: <Bell /> },
],
},
{
group: "偏好",
items: [
{ key: "pref", label: "创作默认", icon: <Sliders /> },
{ key: "display", label: "显示", icon: <Monitor /> },
],
},
// 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。
];
const TEMPLATE_CHOICES = [
@@ -177,7 +170,8 @@ export function SettingsPage({
const cd = preferences?.creation_defaults;
const dp = preferences?.display;
return {
name: user.username || "",
// 显示名称存在后端 first_name(UserSerializer 暴露),username 不随保存变;优先读 first_name 回退 username
name: (user as { first_name?: string }).first_name || user.username || "",
email: user.email || "",
phone: "",
template: cd?.template ?? DEFAULT_PREFS.template,
@@ -191,7 +185,7 @@ export function SettingsPage({
language: dp?.language ?? DEFAULT_PREFS.language,
density: dp?.density ?? DEFAULT_PREFS.density,
};
}, [user.username, user.email, preferences]);
}, [user, preferences]);
// baseline = 已保存值;draft = 当前编辑值。diff(draft, baseline) = 脏字段。
const [baseline, setBaseline] = useState<TrackedState>(baselineFromProps);
+1 -1
View File
@@ -17,6 +17,6 @@ export function money(value: string | number | undefined) {
export function statusPill(status: string) {
if (["succeeded", "completed", "done", "ok", "skipped"].includes(status)) return "ok";
if (["failed", "error"].includes(status)) return "err";
if (["running", "queued", "needs_review", "polling"].includes(status)) return "info";
if (["running", "queued", "needs_review", "polling", "created", "reserved", "submitted", "postprocessing", "compensating"].includes(status)) return "info";
return "neutral";
}
+3
View File
@@ -466,6 +466,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 生成邀请码 · 新成员凭码注册即加入本团队"
icon={<UserPlus size={16} />}
close={() => setModal("")}
dismissable={false}
footer={inviteResult
? <button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>
: <button className="btn btn-primary" type="button" disabled={inviteBusy} onClick={() => {
@@ -516,6 +517,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 自然月重置 · 仅超管可改"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={() => setModal("")}></button>}
>
<div className="limit-modal">
@@ -646,6 +648,7 @@ export function TeamPage({ team, user, billing, navigate, onCreateMember: onCrea
subtitle="// 充值后立即到账 · 仅超管可操作"
icon={<CircleDollarSign size={16} />}
close={() => setModal("")}
dismissable={false}
footer={<button className="btn btn-primary" type="button" onClick={submitRecharge}></button>}
>
<div className="field"><label className="field-label"> ¥</label><input className="input num-input" type="number" value={rechargeAmt} onChange={(e) => setRechargeAmt(e.target.value)} placeholder="最低 ¥50" /></div>