Files
yingqing/core/frontend/src/App.tsx
T
zycandClaude Opus 4.8 608c8aeb7f refactor(core): 基础资产改对逻辑+对齐设计稿(一组多版本候选 + 详情弹窗 asset-detail-grid)
逻辑(后端):用 BaseAssetGroup 本来的设计,不再每次生成新建组
- generate_base_asset 复用同实体的组(商品=唯一商品组;人物/场景按 label):追加 candidate + 采用最新
- 版本 = candidate_assets,采用/当前 = adopted_asset;采用/切换走真 adopt-base-asset(下游只认 adopted)
- 人物三视图绑定到「某一版立绘 asset」:metadata.triview_of=<立绘 asset id>,同立绘多次=同组追加候选
- generate-triview 端点改取 portrait_asset_id;attach-base-asset 坏参数 404 加固

设计(前端):
- 人物/场景卡对齐设计稿(.prompt-box 可编辑 + 重跑/替换 + 转圈态,区头 N 个 + 克制「+ 新增」)
- 详情弹窗整套换成设计稿 asset-detail-grid:左大立绘(3:4)+版本缩略条;右三视图 section(16:9 + 重跑/下载 + 单张大图 + 暂无 tip + v1/v2 版本条)+ 提示词 section;脚部 下载立绘 + 使用该资产;场景无三视图
- 商品三视图内联面板版本=candidate_assets、采用走真 adopt
- 修:.thumb/.v-thumb/.h-thumb 非 .placeholder 导致缩略图不显图 → 接 --mock-media-url

本地 playwright 截图实测:基础资产卡 + 详情弹窗均按设计稿渲染。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 14:01:15 +08:00

806 lines
37 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, ApiError, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
Asset,
BillingSummary,
BillingTrend,
ExportPoll,
Ledger,
LoginSession,
ModelConfig,
Notification,
Product,
Project,
Team,
TeamMember,
User,
UserPreference
} from "./types";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
AccountPage,
AssetFactoryPage,
AuthScreen,
Dashboard,
ImageWorkbenchPage,
LibraryPage,
MessagesPage,
ModelPhotoDemoPage,
PipelinePage,
ProductCreateUploadPage,
ProductDetailPage,
ProductsPage,
ProjectWizardPage,
ProjectsPage,
SettingsPage,
TeamPage
} from "./routes";
import type { AuthMode, NavigateOptions, Notice, Page, ResolvedRoute } from "./routes/route-config";
import { pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { money } from "./routes/stage-config";
const crumbLabels: Partial<Record<Page, string>> = {
dashboard: "工作台",
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "商品库",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
library: "资产库",
account: "消费",
team: "团队",
messages: "消息中心",
assetFactory: "图片生成",
imageOptimize: "图片创作",
modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A",
modelPhotoDemoB: "模特图方案 B",
platformCover: "平台套图",
settings: "设置",
settingsNotify: "设置"
};
/* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地,
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number };
function loadImgwb(mode?: string): ImgwbSaved | null {
try {
const raw = localStorage.getItem(imgwbKey(mode));
if (!raw) return null;
const saved = JSON.parse(raw) as ImgwbSaved;
// 过期保护:1 小时前的残留不再恢复,避免显示陈旧"生成中"
if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) {
localStorage.removeItem(imgwbKey(mode));
return null;
}
return saved;
} catch {
return null;
}
}
function saveImgwb(mode: string | undefined, patch: ImgwbSaved) {
try {
const prev = loadImgwb(mode) || {};
localStorage.setItem(imgwbKey(mode), JSON.stringify({ ...prev, ...patch, ts: Date.now() }));
} catch {
/* localStorage 不可用时静默降级,不影响生成 */
}
}
export function App() {
const [route, setRoute] = useState<ResolvedRoute>(() => resolveRoute());
const page = route.page;
const [authMode, setAuthMode] = useState<AuthMode>(route.authMode);
const [authed, setAuthed] = useState<boolean>(() => Boolean(getToken()));
const [booting, setBooting] = useState<boolean>(() => Boolean(getToken()));
const [user, setUser] = useState<User | null>(null);
const [team, setTeam] = useState<Team | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
const [activeProductId, setActiveProductId] = useState(route.productId || "");
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
const [notice, setNotice] = useState<Notice>(null);
const [loading, setLoading] = useState(false);
// 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」)
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), notice.type === "error" ? 5000 : 3000);
return () => clearTimeout(timer);
}, [notice]);
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId) || projects[0],
[projects, activeProjectId]
);
const activeProduct = useMemo(
() => products.find((product) => product.id === activeProductId) || products[0],
[products, activeProductId]
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
await Promise.all([
api.products(),
api.projects(),
api.allAssets(),
api.billingSummary().catch(() => null),
api.ledgers(1, 10).catch(() => ({ count: 0, page: 1, page_size: 10, results: [] as Ledger[] })),
api.billingTrend().catch(() => null),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null),
api.allNotifications().catch(() => null)
]);
setProducts(productData.results);
setProjects(projectData.results);
setAssets(assetData);
setTeamMembers(memberData);
setModelConfigs(modelData?.results || []);
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData.results);
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
setUnreadCount(notificationData.unread_count);
}
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
setActiveProductId((current) => current || productData.results[0]?.id || "");
}, []);
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
const loadSettingsData = useCallback(async () => {
const [pref, sess] = await Promise.all([
api.preferences().catch(() => null),
api.loginSessions().catch(() => [])
]);
if (pref) setPreferences(pref);
setSessions(sess);
}, []);
async function savePreferences(payload: Partial<UserPreference>) {
const next = await api.updatePreferences(payload).catch(() => null);
if (next) setPreferences(next);
return next;
}
async function revokeSession(id: string) {
await action(() => api.revokeSession(id), "设备已下线");
setSessions(await api.loginSessions().catch(() => []));
}
async function revokeOtherSessions() {
const res = await action(() => api.revokeOtherSessions(), "其他设备已全部下线");
if (res?.token) setToken(res.token);
setSessions(await api.loginSessions().catch(() => []));
}
const reloadNotifications = useCallback(async () => {
const data = await api.allNotifications().catch(() => null);
if (data) {
setNotifications(data.results);
setUnreadCount(data.unread_count);
}
}, []);
// Boot: validate token, hydrate identity + data.
useEffect(() => {
if (!getToken()) {
setBooting(false);
return;
}
let cancelled = false;
(async () => {
try {
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
identity = await api.me();
break;
} catch (error) {
const status = error instanceof ApiError ? error.status : 0;
if (status === 401 || status === 403 || attempt === 2) throw error;
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
if (cancelled || !identity) return;
setUser(identity.user);
setTeam(identity.team);
await loadData();
} catch (bootError) {
console.error("[boot] failed:", bootError);
setToken(null);
if (!cancelled) setAuthed(false);
} finally {
if (!cancelled) setBooting(false);
}
})();
return () => {
cancelled = true;
};
}, [loadData]);
// Keep route in sync with browser navigation.
useEffect(() => {
function syncRouteFromHistory() {
const next = resolveRoute();
setRoute(next);
setAuthMode(next.authMode);
if (next.productId !== undefined) setActiveProductId(next.productId);
if (next.projectId !== undefined) setActiveProjectId(next.projectId);
}
window.addEventListener("popstate", syncRouteFromHistory);
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
// Load preferences + sessions when entering settings.
useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData();
}, [authed, page, loadSettingsData]);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
if (page !== "pipeline") setProjectDetail(null);
return;
}
let cancelled = false;
setExportResult(null); // 切项目/进管线时清空上个项目的导出态
api
.project(activeProjectId)
.then((detail) => {
if (!cancelled) setProjectDetail(detail);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
// 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行
// ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。
const projectDetailRef = useRef<Project | null>(null);
useEffect(() => {
projectDetailRef.current = projectDetail;
}, [projectDetail]);
const pollVideosQuiet = useCallback(async () => {
if (!activeProjectId) return;
let detail = projectDetailRef.current;
if (!detail || detail.id !== activeProjectId) {
detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
setProjectDetail(detail);
}
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) return;
await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined)));
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => {
if (!activeProjectId) return;
const res = await api.pollExport(activeProjectId).catch(() => null);
if (res) setExportResult(res);
}, [activeProjectId]);
function navigate(next: Page, options: NavigateOptions = {}) {
const productId = options.productId ?? activeProductId;
const projectId = options.projectId ?? activeProjectId;
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 });
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";
window.history[method](null, "", path);
}
window.scrollTo({ top: 0, behavior: "auto" });
}
async function refreshProjectDetail() {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
// 写进 projectDetail;渲染处 pipelineProject 会校验 id 是否仍是当前激活项目,故后台旧请求写回也不会串台
if (detail) setProjectDetail(detail);
}
// 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
const actionInFlightRef = useRef(false);
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
if (actionInFlightRef.current) {
setNotice({ type: "error", text: "操作进行中,请稍候…" });
return null;
}
actionInFlightRef.current = true;
setLoading(true);
setNotice(null);
try {
const result = await work();
// successText 为空 → 不弹 toast(交给调用方自定义反馈,如成功弹窗)
if (successText) setNotice({ type: "success", text: successText });
// 后台刷新,不阻塞操作返回:全量 loadData 会分页拉全部 assets 很重,await 它会让
// 「项目已创建/确认脚本」后白等很久(行29/37)。改为后台 hydrate,操作立即返回。
void loadData();
void refreshProjectDetail();
return result;
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" });
return null;
} finally {
setLoading(false);
actionInFlightRef.current = false;
}
}
async function markNotificationRead(id: string) {
await api.markNotificationRead(id).catch(() => undefined);
await reloadNotifications();
}
async function markAllNotificationsRead() {
await api.markAllNotificationsRead().catch(() => undefined);
await reloadNotifications();
}
async function saveProfile(payload: { name?: string; phone?: string; email?: string }) {
const res = await action(() => api.updateProfile(payload), "资料已保存");
if (res) {
setUser(res.user);
setTeam(res.team);
}
}
async function changeOwnPassword(payload: { old_password: string; new_password: string }) {
const res = await action(() => api.changePassword(payload), "密码已修改");
if (res?.token) setToken(res.token);
}
async function uploadOwnAvatar(formData: FormData) {
const res = await action(() => api.uploadAvatar(formData), "头像已更新");
if (res) setUser(res);
}
async function resetOwnAvatar() {
const res = await action(() => api.resetAvatar(), "已恢复默认头像");
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
return action(async () => {
const { tasks } = await api.submitGenerateImage(payload);
const ids = tasks.map((t) => t.id);
if (ids.length === 0) throw new Error("未能提交生成任务");
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count });
return pollImageTasks(payload.mode, ids);
}, "图片已生成");
}
// 轮询一批已提交的生图任务直到全部出图/超时;每出一张就回写本地,刷新后可恢复。
async function pollImageTasks(mode: string | undefined, ids: string[]): Promise<{ assets: Asset[] }> {
const pending = new Set(ids);
const TERMINAL = new Set(["succeeded", "failed", "cancelled", "compensating"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const assets: Asset[] = [];
let lastErr = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没全好就停止轮询(图仍会在后台出完)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]);
for (const t of res.tasks) {
if (!TERMINAL.has(t.status)) continue;
pending.delete(t.id);
if (t.status === "succeeded") assets.push(...(t.assets || []));
else if (t.error_message) lastErr = t.error_message;
}
// 进度回写:保留还在跑的 id + 已出结果,供刷新后恢复
saveImgwb(mode, { pending: [...pending], results: assets });
}
if (assets.length === 0) {
// 没出任何图:清掉本地残留,避免刷新后卡在空"生成中"
if (pending.size === 0) saveImgwb(mode, { pending: [], results: [] });
throw new Error(lastErr || (pending.size > 0 ? "生成超时,图片仍在后台生成,稍后可在素材库查看" : "未生成任何图片"));
}
saveImgwb(mode, { pending: [...pending], results: assets });
return { assets };
}
// 刷新后恢复:对本地残留的 pending 任务继续轮询(不再重复提交、不再重复扣费)。
function resumeImages(mode: string | undefined, ids: string[]) {
return pollImageTasks(mode, ids).catch(() => null);
}
async function onAuthed(payload: { token: string; user: User; team: Team; remember?: boolean }) {
setToken(payload.token, payload.remember ?? true);
setUser(payload.user);
setTeam(payload.team);
setBooting(false);
setAuthed(true);
navigate("dashboard", { replace: true });
void loadData().catch((error) => {
console.error("[login] data hydrate failed:", error);
});
}
async function logout() {
await api.logout().catch(() => undefined);
setToken(null);
setAuthed(false);
setUser(null);
setTeam(null);
setAuthMode("login");
window.history.replaceState(null, "", "/login");
}
// ---- Auth gate ----
if (!authed) {
return (
<AuthScreen
initialMode={authMode}
onModeChange={(next) => {
setAuthMode(next);
window.history.pushState(null, "", "/login");
}}
onAuthed={onAuthed}
/>
);
}
if (booting || !user || !team) {
return (
<div className="app">
<main>
<div className="content">
<div className="page-head">
<div>
<h1>加载中…</h1>
<div className="sub">
<span className="mono">// 正在拉取团队数据</span>
</div>
</div>
</div>
</div>
</main>
</div>
);
}
const currentUser: User = user;
const currentTeam: Team = team;
function renderPage() {
switch (page) {
case "dashboard":
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} userName={currentUser.username} navigate={navigate} />;
case "products":
return (
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
// 创建成功后由 ProductsPage 弹「继续创建商品 / 去新建项目」选择弹窗,不再自动跳详情
return (
<ProductsPage
products={products}
projects={projects}
assets={assets}
navigate={navigate}
openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })}
onCreate={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} assets={assets} navigate={navigate} openProduct={(productId, tab) => navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
assets={assets}
initialTab={route.hash === "videos" ? "videos" : "assets"}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")}
onDeleteImage={(imageId) => action(() => api.deleteProductImage(activeProduct.id, imageId), "商品图已移除")}
onGenerateImages={generateImages}
onAdoptTriView={(asset) => action(() => api.updateProduct(activeProduct.id, { cover_asset: asset.id }), "三视图已采用为商品图")}
/>
);
case "projects":
return (
<ProjectsPage
products={products}
projects={projects}
navigate={navigate}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
onDelete={(projectId) => action(() => api.deleteProject(projectId), "项目已删除")}
/>
);
case "projectWizard":
return (
<ProjectWizardPage
products={products}
projects={projects}
preselectProductId={activeProductId}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
if (created) {
// 立刻把新项目落到 detail/列表,避免后台 hydrate 期间 pipeline 先闪出旧项目数据
// (旧项目的脚本会污染新项目的脚本助手 chat)
setProjectDetail(created);
setProjects((prev) => (prev.some((p) => p.id === created.id) ? prev : [created, ...prev]));
navigate("pipeline", { projectId: created.id });
}
}}
onCreateProduct={(payload) => action(() => api.createProduct(payload), "")}
/>
);
case "pipeline":
// 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」
return (
<div className="page-head">
<div>
<h1>暂无项目</h1>
<div className="sub">
<span className="mono">// 先创建一个视频项目</span>
</div>
</div>
<div className="actions">
<button className="btn btn-primary" type="button" onClick={() => navigate("projectWizard")}>
新建视频项目
</button>
</div>
</div>
);
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "account":
return (
<AccountPage
billing={billing}
ledgers={ledgers}
trend={billingTrend}
projects={projects}
teamMembers={teamMembers}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
/>
);
case "team":
return (
<TeamPage
team={currentTeam}
user={currentUser}
members={teamMembers}
billing={billing}
notifications={notifications}
navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
onRemoveMember={(id) => action(() => api.removeTeamMember(id), "成员已移除")}
onResetPassword={(id, password) => action(() => api.resetMemberPassword(id, password), "密码已重置")}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
/>
);
case "messages":
return (
<MessagesPage
unreadCount={unreadCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
navigate={navigate}
/>
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} aiTasks={aiTasks} assets={assets} />;
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} initialProductId={activeProductId} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
}
const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase();
const here = crumbLabels[page] || routeLabels[page] || "工作台";
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
// projectDetail 只有在确实是当前激活项目时才用;否则回退 activeProject。
// 否则后台 refreshProjectDetail 拿旧 id 的结果会把刚进入的新项目串成上一个项目(实测发现)。
const pipelineProject = projectDetail && projectDetail.id === activeProjectId ? projectDetail : activeProject;
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (
<PipelinePage
key={pipelineProject.id}
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
loading={loading}
navigate={navigate}
user={currentUser}
team={currentTeam}
products={products}
projects={projects}
assets={assets}
billing={billing}
notice={notice}
unreadCount={unreadCount}
avatarChar={avatarChar}
logout={logout}
onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑")}
onSaveProjectMeta={(meta) =>
// metadata 是整体替换:合并现有 project.metadata 后再 PATCH,别把别的 key(wizard 等)冲掉
action(() => api.updateProject(pipelineProject.id, { metadata: { ...(pipelineProject.metadata ?? {}), ...meta } }), "已保存")
}
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={(kind, prompt, label) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
await api.generateStoryboard(pipelineProject.id, { prompt });
for (let i = 0; i < 60; i += 1) {
const res = await api.pollStoryboard(pipelineProject.id);
if (res.status === "succeeded") return true;
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
throw new Error("故事板生成超时,请稍后刷新查看或重试");
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onAttachBaseAsset={(groupId, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已替换为所选演员")}
onGenerateTriview={(portraitAssetId) => action(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成")}
onGenerateActor={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
onUploadActor={(file) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "person");
return action(() => api.uploadAsset(fd), "演员已保存到演员库");
}}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onSubmitAllVideos={(prompt) =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
for (const segment of targets) {
await api.submitVideo(pipelineProject.id, {
video_segment_id: segment.id,
prompt: `${prompt}${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`
});
}
return targets.length;
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
exportResult={exportResult}
onRefreshExport={refreshExport}
onRefreshProject={refreshProjectDetail}
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
onSubmitExport={(payload) =>
action(async () => {
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
if (payload) await api.saveTimeline(pipelineProject.id, payload);
await api.submitExport(pipelineProject.id);
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
for (let i = 0; i < 160; i += 1) {
const res = await api.pollExport(pipelineProject.id);
setExportResult(res);
if (res.status === "succeeded") return res;
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
}, "成片已导出")
}
/>
);
}
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} products={products} projects={projects} />
<main>
<Decorations />
<header className="topbar">
<div className="crumbs">
{page === "dashboard" ? (
<span className="here">工作台</span>
) : (
<>
<a href="/dashboard" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>工作台</a>
<span className="sep">/</span>
<span className="here">{here}</span>
</>
)}
</div>
<div className="right">
<span className="balance-chip" onClick={() => navigate("account")}>
<IconKitSvg name="creditCard" />
余额 <strong>{money(billing?.account.balance)}</strong>
</span>
<button className="icon-btn" type="button" onClick={() => navigate("messages")} title="消息中心">
<IconKitSvg name="bell" />
{unreadCount > 0 && <span className="count-noti">{unreadCount}</span>}
</button>
<div className="topbar-avatar" onDoubleClick={logout} title="账户(双击退出)">
<span>{avatarChar}</span>
</div>
</div>
</header>
<div className="content" id="page-content">
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{renderPage()}
</div>
</main>
</div>
);
}