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:
@@ -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) => {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user