Files
yingqing/core/frontend/src/App.tsx
T
2026-08-28 14:45:38 +08:00

1293 lines
66 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, 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,
TimelineSavePayload,
User,
UserPreference
} from "./types";
import { publicModelDisplayName } from "./model-display";
import { generationErrorText } from "./generation-error";
import { isQuickCreateBusy, lockedQuickCreateProject, rememberQuickCreateJob, withQuickCreateStatus } from "./quick-create-lock";
import { AccountMenu, CornerMarks, Decorations, ModeTabs, openCommandPalette, Sidebar, ToastLike, topModuleForPage } from "./components/app-shell";
import { SystemLoading } from "./components/loading";
import {
AccountPage,
AssetFactoryPage,
AuthScreen,
Dashboard,
FreeCreatePage,
QuickCreatePage,
VideoRemixPage,
VideoReplacePage,
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 { isOwnerOnlyPage, isPage, parentPage, pathForPage, resolveRoute, routeLabels } from "./routes/route-config";
import { AdminApp } from "./routes/admin/admin-app";
import { TrashPage } from "./routes/trash";
import { ModelsPage } from "./routes/models";
import { money } from "./routes/stage-config";
import type { ProductBatchResult } from "./routes/products";
/* 图片生成工作台·跨刷新持久化:把在跑的任务 id + 已出结果按 mode 存本地,
刷新后可恢复"生成中"占位并继续轮询(worker 在后台出图,任务永不丢)。 */
const imgwbKey = (mode?: string) => `airshelf:imgwb:${mode || "image"}`;
type ImgwbSaved = { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string };
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 不可用时静默降级,不影响生成 */
}
}
type NavHistoryState = {
airshelf: 1;
scrollY: number;
tab?: string;
from?: {
page: Page;
productId?: string;
projectId?: string;
tab?: string;
hash?: string;
scrollY: number;
};
};
function readNavState(raw: unknown): NavHistoryState | null {
if (!raw || typeof raw !== "object" || !("airshelf" in raw)) return null;
return raw as NavHistoryState;
}
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);
// 当前用户在该团队的角色(owner/admin/member)。主账号(owner=超管)才看得到「团队」「消费」页(PMC#3)。
const [role, setRole] = useState<string>("");
const isOwner = role === "owner";
const [products, setProducts] = useState<Product[]>([]);
const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用
const [projects, setProjects] = useState<Project[]>([]);
const [projectTotal, setProjectTotal] = useState(0);
const [modelConfigs, setModelConfigs] = useState<ModelConfig[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
// YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标)
const [aiUnread, setAiUnread] = useState(0);
const [aiUnreadByProduct, setAiUnreadByProduct] = useState<Record<string, number>>({});
const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来
const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
// 合成成片单飞:合成不占全局 loading,自己守一把锁,防止连点起两轮轮询
const exportingRef = useRef(false);
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);
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
// 返回上一页时待恢复的滚动位置;null = 前进导航,滚到顶部
const pendingScrollRef = useRef<number | null>(null);
// 右上角 toast 自动消失:成功/信息 3s,错误 5s(此前 notice 不会自己清,导致「提示一直挂着」)
useEffect(() => {
if (!notice) return;
const timer = setTimeout(() => setNotice(null), notice.type === "error" ? 5000 : 3000);
return () => clearTimeout(timer);
}, [notice]);
const activeProduct = useMemo(
() => products.find((product) => product.id === activeProductId) || products[0],
[products, activeProductId]
);
const loadData = useCallback(async () => {
// bootstrap 只拉「每页 shell 都要」的全局数据:商品/项目(侧栏+导航+active 解析)、余额(顶栏)、
// 模型配置(多生成页+pipeline 共用)、未读数(侧栏徽标,page_size=1 轻量)。
// 资产/流水/趋势/成员/任务/通知列表均改各页按需懒加载,不再全局取全。
const [productData, projectData, billingData, modelData, badgeData] =
await Promise.all([
api.products(),
api.projects(),
api.billingSummary().catch(() => null),
api.modelConfigs().catch(() => null),
api.notificationsBadge().catch(() => null)
]);
setProducts(productData.results);
setProductTotal(productData.count ?? productData.results.length);
setProjects(projectData.results);
setProjectTotal(projectData.count ?? projectData.results.length);
setModelConfigs(modelData?.results || []);
if (billingData) setBilling(billingData);
if (badgeData) setUnreadCount(badgeData.unread_count);
setActiveProjectId((current) => current || projectData.results[0]?.id || "");
setActiveProductId((current) => current || productData.results[0]?.id || "");
setDataLoaded(true);
}, []);
// 首登水合带重试:loadData 里 products/projects/allAssets 没有 .catch,任一瞬时失败会让整个
// Promise.all 直接 reject、一个 setter 都不跑 → 页面卡在全 0。boot 路径有 api.me 重试兜底,
// 故刷新就好;首登(onAuthed)以前是 void loadData().catch(log) 静默吞掉 → 数据全错。这里统一重试。
const loadDataWithRetry = useCallback(async (attempts = 3) => {
for (let attempt = 0; attempt < attempts; attempt += 1) {
try {
await loadData();
return;
} catch (error) {
if (attempt === attempts - 1) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [loadData]);
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
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) {
// 后端旋转 token 真正吊销目标设备(单 token 体系),回传新 token 给当前设备 —— 必须存下,
// 否则当前设备旧 token 已失效会把自己也踢下线
const res = await action(() => api.revokeSession(id), "设备已下线");
if (res?.token) setToken(res.token);
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.notificationsBadge().catch(() => null);
if (data) setUnreadCount(data.unread_count);
}, []);
// 仅同步顶栏余额:异步 AI 任务在成功结算后调用,避免为一个数字重拉整站数据。
const refreshBilling = useCallback(async () => {
const summary = await api.billingSummary().catch(() => null);
if (summary) setBilling(summary);
}, []);
// 自由创作自行管理长视频轮询;任务终态后只需同步顶栏依赖的两项全局数据,
// 不必像通用 action 一样重拉商品、项目和素材列表。
const refreshFreeCreateShell = useCallback(() => {
void api.billingSummary().then((summary) => setBilling(summary)).catch(() => {});
void reloadNotifications();
}, [reloadNotifications]);
// YYX#row22:拉未读生成任务汇总(导航胶囊 + 商品角标)
const reloadAiUnread = useCallback(async () => {
const data = await api.aiTasksUnread().catch(() => null);
if (data) { setAiUnread(data.total); setAiUnreadByProduct(data.by_product || {}); }
}, []);
// 标记已读(全部 / 按商品)→ 乐观清零本地角标,再刷一次真值
const markAiRead = useCallback(async (productId?: string) => {
if (productId) setAiUnreadByProduct((prev) => { const n = { ...prev }; delete n[productId]; return n; });
else { setAiUnread(0); setAiUnreadByProduct({}); }
await api.markAiTasksRead(productId ? { product_id: productId } : undefined).catch(() => undefined);
void reloadAiUnread();
}, [reloadAiUnread]);
// Boot: validate token, hydrate identity + data.
useEffect(() => {
if (!getToken()) {
setBooting(false);
return;
}
let cancelled = false;
(async () => {
// 提到 try 外,便于身份就绪后按 identity.team 决定是否拉团队级数据
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
try {
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
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);
setRole(identity.role || "");
} catch (bootError) {
// 仅「身份校验失败」才登出;me() 成功后的数据加载失败不在此处
console.error("[boot] identity failed:", bootError);
setToken(null);
if (!cancelled) setAuthed(false);
if (!cancelled) setBooting(false);
return;
}
// ★ 身份就绪即渲染外壳,不等全局数据 —— 商品/项目/余额/未读 后台并行填充,页面骨架先出来。
if (!cancelled) setBooting(false);
// 全局数据后台加载;失败只记录(token 已验证有效,不踢登录、不阻塞渲染)。
// 无团队的平台超管跳过(团队级接口会报错),其只用 /admin 后台。
if (identity?.team) {
loadDataWithRetry().catch((dataError) => console.error("[boot] data load failed:", dataError));
}
})();
return () => {
cancelled = true;
};
}, [loadDataWithRetry]);
// Keep route in sync with browser navigation.
useEffect(() => {
function syncRouteFromHistory() {
const next = resolveRoute();
const state = readNavState(window.history.state);
pendingScrollRef.current = state?.scrollY ?? 0;
setRoute({ ...next, tab: state?.tab ?? next.tab });
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);
}, []);
useLayoutEffect(() => {
const y = pendingScrollRef.current;
if (y == null) return;
const restore = () => window.scrollTo({ top: y, behavior: "auto" });
restore();
const frame = window.requestAnimationFrame(restore);
const later = window.setTimeout(() => {
restore();
pendingScrollRef.current = null;
}, 120);
return () => {
window.cancelAnimationFrame(frame);
window.clearTimeout(later);
};
}, [page, route.projectId, route.productId]);
// 平台后台 gating(身份就绪后):
// - 平台超管且无团队:任何非 admin 路由都送进 /admin(否则普通外壳因 !team 永久卡 loading)
// - 非超管访问 /admin/*:纠回工作台
useEffect(() => {
if (booting || !user) return;
if (user.is_platform_admin && !team && route.admin === undefined) {
navigateAdmin("", { replace: true });
} else if (!user.is_platform_admin && route.admin !== undefined) {
navigate("dashboard", { replace: true });
}
// navigate/navigateAdmin 为组件内函数,故意不入依赖避免每次渲染重跑
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, team, route.admin]);
// 子账号(非主账号)纠回:团队 / 消费 页仅主账号(owner=超管)可见,子账号直接拉回工作台(PMC#3)。
// role 为空时(身份尚未带回角色)不拦,避免误纠超管。
useLayoutEffect(() => {
if (booting || !user || !role) return;
if (!isOwner && isOwnerOnlyPage(page)) {
navigate("dashboard", { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, user, role, isOwner, page]);
// Load preferences + sessions when entering settings.
useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData();
}, [authed, page, loadSettingsData]);
// YYX#row22:登录后拉一次未读生成任务,并每 30s 静默轮询(生成是慢任务,出图后角标自动亮)
useEffect(() => {
if (!authed || !user) return;
void reloadAiUnread();
const timer = window.setInterval(() => { void reloadAiUnread(); }, 30000);
return () => window.clearInterval(timer);
}, [authed, user, reloadAiUnread]);
// YYX#row22:进入「图片生成」任务中心即把全部生成任务标记已读 → 清零导航胶囊
useEffect(() => {
if (!authed || page !== "assetFactory") return;
void markAiRead();
}, [authed, page, markAiRead]);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
if (page !== "pipeline") setProjectDetail(null);
return;
}
// 已持有当前项目的完整详情(刚创建即落了全量数据 / 仍停在本项目):跳过这次拉取,
// 否则会再 setProjectDetail 触发重型 PipelinePage 重渲染(背景图重载),用户看到「刷新了一下」。
if (projectDetail && projectDetail.id === activeProjectId) return;
let cancelled = false;
setExportResult(null); // 切项目/进管线时清空上个项目的导出态
setProjectDetailError(false);
// 失败重试:网络抖动 / 偶发 5xx 时别永久卡 loading。退避重试几次,仍失败才落错误态(下方给重试按钮)。
const fetchDetail = (attempt: number) => {
api
.project(activeProjectId)
.then((detail) => {
if (!cancelled) setProjectDetail(detail);
})
.catch((error) => {
if (cancelled) return;
// 项目不存在(被删/无权限):重试也没用,直接落错误态给「返回项目列表」,别空转
const status = error instanceof ApiError ? error.status : 0;
if (status === 404 || status === 403) {
setProjectDetailError(true);
return;
}
if (attempt < 3) {
setTimeout(() => { if (!cancelled) fetchDetail(attempt + 1); }, 800 * (attempt + 1));
} else {
setProjectDetailError(true);
}
});
};
fetchDetail(0);
return () => {
cancelled = true;
};
}, [authed, page, activeProjectId, detailRetry]);
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) return;
const listed = projects.find((item) => item.id === activeProjectId);
const detailed = projectDetail && projectDetail.id === activeProjectId ? projectDetail : null;
const locked = lockedQuickCreateProject(listed, detailed);
if (!locked) return;
rememberQuickCreateJob(locked.quick_create_job_id);
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
navigate("quickCreate", { productId: locked.product, replace: true });
}, [authed, page, activeProjectId, projects, projectDetail]);
// 静默轮询运行中的视频段(本机无 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]);
// 始终拿到「当前激活项目 id」的最新值(异步回写前用它校验,避免旧请求把刚切换/新建的项目详情冲掉)
const activeProjectIdRef = useRef(activeProjectId);
useEffect(() => {
activeProjectIdRef.current = activeProjectId;
}, [activeProjectId]);
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 && next.id === activeProjectIdRef.current) {
// 视频生成动辄数分钟,多数 5s 轮次状态没变 —— 内容一致时跳过 setState,避免整棵管线每 5 秒空重渲染。
setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
// 仅在本轮在途片段实际完成时同步余额;失败不产生真实扣费,不刷新。
const charged = active.some((segment) => next.video_segments.find((item) => item.id === segment.id)?.status === "succeeded");
if (charged) void refreshBilling();
}
}, [activeProjectId, refreshBilling]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => {
if (!activeProjectId) return;
const res = await api.pollExport(activeProjectId).catch(() => null);
if (res) setExportResult(res);
}, [activeProjectId]);
// 合成成片(把各场视频用 ffmpeg 拼成一条):**不走 action()** —— 拼接要几十秒到几分钟,
// 占住全局 loading + 单飞锁会把整条流水线按钮全禁掉(期间用户还想播放/重跑单场)。
// 进度靠 exportResult 内联展示;后端对在跑的任务会复用,连点不会拼两遍。
// payload 给定(V2 剪辑台)则先落盘编辑态,成片即所见;V1 视频阶段直接合成不传。
const submitExport = useCallback(async (payload?: TimelineSavePayload) => {
const projectId = activeProjectId;
if (!projectId || exportingRef.current) return;
exportingRef.current = true;
setNotice(null);
setExportResult({ status: "running", progress: 0, output_asset: null, output_url: "", error_message: "" });
try {
if (payload) await api.saveTimeline(projectId, payload);
await api.submitExport(projectId);
// 后端在后台线程跑 ffmpeg;这里轮询 poll-export 直到成片/失败,实时回填进度。
// 上限 ~8 分钟:后端 ffmpeg 自身 15 分钟超时,轮询窗口耗尽只是停止盯着,任务仍在跑。
for (let i = 0; i < 240; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 2000));
const res = await api.pollExport(projectId);
setExportResult(res);
if (res.status === "succeeded") {
setNotice({ type: "success", text: "成片已合成,可直接播放" });
// 列表页的播放按钮读 final_video_url,合成完要刷一次才拿到
void loadData();
void refreshProjectDetail();
return;
}
if (res.status === "failed") throw new Error(res.error_message || "合成失败,请重试");
}
throw new Error("合成用时超出预期,后台可能仍在拼接,稍后回到本页查看");
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "合成失败" });
await refreshExport();
} finally {
exportingRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeProjectId, refreshExport]);
function applyQuickCreateStatus(projectId: string, status: string) {
setProjects((items) => withQuickCreateStatus(items, projectId, status));
setProjectDetail((current) => (
current && current.id === projectId ? { ...current, quick_create_status: status } : current
));
}
function navigate(next: Page, options: NavigateOptions = {}) {
// 已知为子账号时在发起导航前拦截;与直接访问 URL 的 layout guard 共用同一规则。
if (role && !isOwner && isOwnerOnlyPage(next)) {
setNotice({ type: "info", text: "当前账号暂无访问权限" });
return;
}
if (next === "pipeline") {
const targetId = options.projectId ?? activeProjectId;
const listed = projects.find((item) => item.id === targetId);
const detailed = projectDetail && projectDetail.id === targetId ? projectDetail : null;
const released = options.quickCreateStatus || "failed";
const canForce = Boolean(options.forcePipeline && targetId && !isQuickCreateBusy({ quick_create_status: released }));
if (canForce && targetId) {
applyQuickCreateStatus(targetId, released);
}
const locked = canForce ? null : lockedQuickCreateProject(listed, detailed);
if (locked) {
rememberQuickCreateJob(locked.quick_create_job_id);
setNotice({ type: "info", text: "该项目正在一键成片中,请在一键成片页查看进度" });
next = "quickCreate";
options = { ...options, productId: locked.product, replace: options.replace };
}
}
// 图片创作 / 一键成片只有显式入口才携带商品;从工作台或视频创作进入时不能继承全局当前商品。
const productId = next === "imageOptimize" || next === "quickCreate" ? options.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(/^#/, "");
const currentPath = `${window.location.pathname}${window.location.hash}`;
const path = `${pathForPage(next, { productId, projectId })}${hash ? `#${hash}` : ""}`;
const prevState = readNavState(window.history.state);
const leaving: NavHistoryState = {
airshelf: 1,
scrollY: window.scrollY || document.documentElement.scrollTop || 0,
tab: route.tab,
from: prevState?.from,
};
if (!options.replace) {
window.history.replaceState(leaving, "", currentPath);
}
setRoute({ page: next, authMode, productId, projectId, hash, tab: options.tab });
const arriving: NavHistoryState = {
airshelf: 1,
scrollY: 0,
tab: options.tab,
from: options.replace
? prevState?.from
: {
page,
productId: route.productId,
projectId: route.projectId,
tab: route.tab,
hash: route.hash,
scrollY: leaving.scrollY,
},
};
if (`${window.location.pathname}${window.location.hash}` !== path || window.location.search) {
const method = options.replace ? "replaceState" : "pushState";
window.history[method](arriving, "", path);
} else {
window.history.replaceState(arriving, "", path);
}
pendingScrollRef.current = null;
window.scrollTo({ top: 0, behavior: "auto" });
}
function entryOrigin(fallback: Page = parentPage(page)): Page {
const fromPage = readNavState(window.history.state)?.from?.page;
return fromPage && isPage(fromPage) ? fromPage : fallback;
}
function goBack(fallback: Page = parentPage(page)) {
const from = readNavState(window.history.state)?.from;
if (from?.page && isPage(from.page)) {
window.history.back();
return;
}
navigate(fallback, { replace: true });
}
// 平台超管后台导航:section="" → /admin(概览),否则 /admin/<section>。
function navigateAdmin(section: string, options: { replace?: boolean } = {}) {
const path = section ? `/admin/${section}` : "/admin";
setRoute({ page: "dashboard", authMode, admin: section });
if (`${window.location.pathname}` !== path || window.location.search) {
window.history[options.replace ? "replaceState" : "pushState"](null, "", path);
}
window.scrollTo({ top: 0, behavior: "auto" });
}
async function refreshProjectDetail() {
// 取调用时的 id 去拉,但回写前再用 ref 校验「现在」激活的还是不是它 —— 否则新建/切项目后,
// 这个晚到的旧项目详情会把刚渲染的新项目详情冲掉,导致 projectDetail.id ≠ activeProjectId、
// pipelineProject 变 null、页面永久卡 loading(接口全 200 也卡)。
const targetId = activeProjectIdRef.current;
if (!targetId) return;
const detail = await api.project(targetId).catch(() => null);
if (detail && detail.id === activeProjectIdRef.current) setProjectDetail(detail);
}
// 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
const actionInFlightRef = useRef(false);
async function action<T>(work: () => Promise<T>, successText: string, opts?: { liteRefresh?: boolean; concurrent?: boolean }): Promise<T | null> {
// concurrent=true:可并行的长任务(图片生成/重跑——工作台明确支持多批并行),不参与全局单飞锁,
// 否则「正在生成时点另一张重跑」会被单飞锁拒成 null → 该批被标「失败」(PMC#8)。
if (!opts?.concurrent) {
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,操作立即返回。
// liteRefresh(改/删/加分镜等只动脚本、不动商品/资产的轻操作):跳过全量 loadData,只刷项目详情 → 快很多。
if (!opts?.liteRefresh) void loadData();
void refreshProjectDetail();
return result;
} catch (error) {
setNotice({ type: "error", text: error instanceof Error ? error.message : "操作失败" });
return null;
} finally {
setLoading(false);
if (!opts?.concurrent) actionInFlightRef.current = false;
}
}
async function deleteProjectAction(projectId: string) {
const ok = await action(() => api.deleteProject(projectId), "项目已删除");
if (ok !== null && projectId === activeProjectIdRef.current) {
setActiveProjectId("");
setProjectDetail(null);
}
}
async function runProductBatch<T>(ids: string[], work: (id: string) => Promise<T>, successText: string): Promise<ProductBatchResult> {
const uniqueIds = Array.from(new Set(ids));
if (!uniqueIds.length) return { succeededIds: [], failedIds: [] };
if (actionInFlightRef.current) {
setNotice({ type: "error", text: "操作进行中,请稍候…" });
return { succeededIds: [], failedIds: uniqueIds };
}
actionInFlightRef.current = true;
setLoading(true);
setNotice(null);
try {
const results = await Promise.allSettled(uniqueIds.map(work));
const succeededIds = uniqueIds.filter((_, index) => results[index].status === "fulfilled");
const failedIds = uniqueIds.filter((_, index) => results[index].status === "rejected");
if (succeededIds.length && !failedIds.length) {
setNotice({ type: "success", text: `${successText} ${succeededIds.length} 项` });
} else if (succeededIds.length) {
setNotice({ type: "error", text: `${successText} ${succeededIds.length} 项,失败 ${failedIds.length} 项` });
} else {
const firstFailure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
setNotice({ type: "error", text: firstFailure?.reason instanceof Error ? firstFailure.reason.message : "操作失败" });
}
if (succeededIds.length) {
void loadData();
void refreshProjectDetail();
}
return { succeededIds, failedIds };
} 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 archiveNotification(id: string) {
await api.archiveNotification(id).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; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; platform_id?: string; conversation_id?: string; reference_image_ids?: string[]; batch_id?: string; retry_of_task_id?: string; onSubmitted?: (taskIds: string[], batchId?: string) => void }) {
// 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑——
// Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网,
// worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。
const { onSubmitted, ...apiPayload } = payload; // onSubmitted 是本地回调,不发后端
return action(async () => {
const { tasks, conversation_id, batch_id } = await api.submitGenerateImage(apiPayload);
const ids = tasks.map((t) => t.id);
if (ids.length === 0) throw new Error("未能提交生成任务");
// 把刚提交的任务 id + 后端 batch_id 回传给工作台:pending 按批落盘续轮询(PMC#5/#10);
// batch_id 必须在提交时就落(不能等出图)——整批全失败时轮询会抛错,等不到结果,重跑就丢了归属
onSubmitted?.(ids, batch_id);
// 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢)
// 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名)
const batchProduct = products.find((p) => p.id === payload.product_id);
saveImgwb(payload.mode, { pending: ids, results: [], count: payload.count, productId: payload.product_id, productTitle: batchProduct?.title });
const res = await pollImageTasks(payload.mode, ids);
// 回传后端归属/新建的对话 id + 本批 batch_id(重跑时带回原批次用),供工作台登记
return { ...res, conversation_id, batch_id };
// successText 留空:工作台的批次卡已就地显示出图结果(生成中→已完成),全局右下角「图片已生成」toast 多余,
// 且会在「生成中删掉该批次」后才弹出来,让人以为删了又生成(PMC#23)。靠批次卡反馈即可。
// concurrent:生图支持多批并行,不占全局单飞锁(否则并发重跑被拒成 null→标失败,PMC#8)
}, "", { concurrent: true });
}
// 轮询一批已提交的生图任务直到全部出图/超时;每出一张就回写本地,刷新后可恢复。
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 = generationErrorText(t.error, 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);
}
// 轮询一批 AITask(基础资产/三视图异步出图)直到终态;返回成功任务产出的 assets 与最后错误。
// 轮询期间 Web 层是空闲的(只发轻量 status 请求),整站不卡;出图后调用方刷新项目即见新资产。
async function pollAiTasks(ids: string[]): Promise<{ assets: Asset[]; error: string }> {
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 error = "";
const deadline = Date.now() + 6 * 60 * 1000; // 兜底:6 分钟还没好就停轮询(图仍会在后台出完,刷新可见)
while (pending.size > 0 && Date.now() < deadline) {
await sleep(2500);
const res = await api.generateImageStatus([...pending]).catch(() => null);
if (!res) continue;
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) error = generationErrorText(t.error, t.error_message);
}
}
return { assets, error };
}
// 基础资产/三视图异步出图统一入口:提交(秒回任务)→ 轮询出图 → 刷新项目 → 返回新资产 id 供链式(立绘→三视图)。
async function submitAndPollAsset(submit: () => Promise<{ task: { id: string; status: string } } | null>, okText: string): Promise<string | null> {
const submitted = await submit().catch((e) => { setNotice({ type: "error", text: e instanceof Error ? e.message : "提交失败" }); return null; });
const taskId = submitted?.task?.id;
if (!taskId) return null; // 提交失败(余额不足/无 worker 等),错误已由 submit 抛出处理
const { assets, error } = await pollAiTasks([taskId]);
// 只刷新当前项目详情(新出的图就在 base_asset_groups 里),不再整页全量 loadData ——
// 后者十几个 setState 触发全 PipelinePage 大重渲染,所有背景图被重新赋值 → "每次生成全页图重载"。
// 成功出图已完成真实扣费,单独轻量同步顶栏余额即可,不必连带重拉其他全局数据。
await refreshProjectDetail();
if (assets.length === 0) {
setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" });
return null;
}
await refreshBilling();
if (okText) setNotice({ type: "success", text: okText });
return assets[0].id;
}
async function onAuthed(payload: { token: string; user: User; team: Team; role?: string; remember?: boolean }) {
setToken(payload.token, payload.remember ?? true);
setUser(payload.user);
setTeam(payload.team);
setRole(payload.role || "");
setBooting(false);
// 平台超管且无团队:直落后台,不拉团队级数据(否则 products/projects 等接口因无团队报错)
if (payload.user.is_platform_admin && !payload.team) {
setAuthed(true);
navigateAdmin("", { replace: true });
return;
}
navigate("dashboard", { replace: true });
// 先把工作台必需数据拉好,这期间「登录页」保持「登录成功,正在进入工作台…」提示(authed 仍 false → AuthScreen 不卸载),
// 数据就绪后才揭开外壳直接进有数据的工作台 —— 避免中间闪一个空白「加载中…」页(PMC#7)。
try {
await loadDataWithRetry();
} catch (error) {
console.error("[login] data hydrate failed:", error);
setNotice({ type: "error", text: "数据加载失败,请刷新页面重试" });
} finally {
setAuthed(true);
}
}
async function logout() {
await api.logout().catch(() => undefined);
setToken(null);
setAuthed(false);
setUser(null);
setTeam(null);
setRole("");
setAuthMode("login");
window.history.replaceState(null, "", "/login");
}
// ---- Auth gate ----
if (!authed) {
return (
<AuthScreen
initialMode={authMode}
onModeChange={(next) => {
setAuthMode(next);
window.history.pushState(null, "", next === "register" ? "/register" : "/login");
}}
onAuthed={onAuthed}
/>
);
}
// 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。
if (!booting && user && route.admin !== undefined && user.is_platform_admin) {
return (
<AdminApp
section={route.admin}
user={user}
team={team}
navigateAdmin={navigateAdmin}
navigate={navigate}
logout={logout}
/>
);
}
if (booting || !user || !team) {
return (
<SystemLoading
variant="fullscreen"
title="正在进入工作台"
description="正在同步账号与团队数据,请稍候。"
icon="layoutDashboard"
/>
);
}
const currentUser: User = user;
const currentTeam: Team = team;
function renderPage() {
switch (page) {
case "dashboard":
return <Dashboard products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
case "products":
return (
<ProductsPage
products={products}
loading={!dataLoaded}
projects={projects}
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), "已移至垃圾桶")}
onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
// 创建成功后由 ProductsPage 弹「继续创建商品 / 一键成片 / 专业创作」选择弹窗,不再自动跳详情
return (
<ProductsPage
products={products}
loading={!dataLoaded}
projects={projects}
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), "已移至垃圾桶")}
onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} loading={!dataLoaded} 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), "已移至垃圾桶")} onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteProduct(id), "已移至垃圾桶")} />;
return (
<ProductDetailPage
product={activeProduct}
projects={projects.filter((project) => project.product === activeProduct.id)}
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}
loading={!dataLoaded}
navigate={navigate}
initialTab={route.tab}
onCreate={(payload) => action(() => api.createProject(payload), "项目已创建")}
openPipeline={(projectId) => navigate("pipeline", { projectId })}
onDelete={deleteProjectAction}
/>
);
case "projectWizard":
return (
<ProjectWizardPage
products={products}
projects={projects}
preselectProductId={activeProductId}
onBack={() => goBack("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, replace: true });
}
}}
onCreateProduct={(payload) => action(() => api.createProduct(payload), "")}
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
/>
);
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 "models":
return (
<ModelsPage
onNotify={(type, text) => setNotice({ type, text })}
onBillingChanged={() => { api.billingSummary().then((summary) => setBilling(summary)).catch(() => {}); }}
/>
);
case "library":
return <LibraryPage onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDeleteMany={(ids) => runProductBatch(ids, (id) => api.deleteAsset(id), "已移至垃圾桶")} />;
case "trash":
return (
<TrashPage
navigate={navigate}
onRestore={(id) => action(() => api.restoreProduct(id), "已恢复到商品库")}
onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")}
onRestoreProducts={(ids) => runProductBatch(ids, (id) => api.restoreProduct(id), "已恢复到商品库")}
onPurgeProducts={(ids) => runProductBatch(ids, (id) => api.purgeProduct(id), "已彻底删除")}
onChanged={() => { void loadData(); }}
/>
);
case "account":
return (
<AccountPage
billing={billing}
projects={projects}
team={currentTeam}
navigate={navigate}
onRecharge={(amount, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
onNotify={(type, text) => setNotice({ type, text })}
/>
);
case "team":
return (
<TeamPage
team={currentTeam}
user={currentUser}
billing={billing}
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, bonusPoints) => action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")}
/>
);
case "messages":
return (
<MessagesPage
unreadCount={unreadCount}
onMarkRead={markNotificationRead}
onMarkAllRead={markAllNotificationsRead}
onArchive={archiveNotification}
navigate={navigate}
/>
);
case "assetFactory":
return <AssetFactoryPage navigate={navigate} />;
case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
case "quickCreate":
return (
<QuickCreatePage
onBack={() => goBack(entryOrigin("projects"))}
backLabel={`返回${routeLabels[entryOrigin("projects")]}`}
initialProductId={route.productId}
navigate={navigate}
modelConfigs={modelConfigs}
onNotify={(type, text) => setNotice({ type, text })}
onProjectCreated={() => { void loadData(); }}
onQuickCreateStatus={applyQuickCreateStatus}
/>
);
case "videoRemix":
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
case "videoReplace":
return (
<VideoReplacePage
products={products}
modelConfigs={modelConfigs}
onNotify={(type, text) => setNotice({ type, text })}
onBack={() => goBack(entryOrigin("projects"))}
onTaskSettled={refreshFreeCreateShell}
navigate={navigate}
/>
);
case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto":
return <ImageWorkbenchPage mode="model" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} modelConfigs={modelConfigs} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => goBack("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => goBack("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} productTotal={productTotal} projectTotal={projectTotal} billing={billing} userName={currentUser.username} loading={!dataLoaded} navigate={navigate} onDelete={deleteProjectAction} />;
}
}
const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase();
const topModule = topModuleForPage(page);
const searchKbd = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl K";
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
// 这里只认「当前激活项目的完整详情」(含 stages/timeline/script_versions/metadata 等嵌套字段)。
// 不能回退到列表轻量项目(activeProject):ProjectListSerializer 不带这些嵌套字段,PipelinePage 直接读会白屏 ——
// 这正是「站内点进去白屏、刷新就正常」的根因(刷新走 booting 等详情拉好才渲染,站内导航却拿轻量数据先渲染)。
const pipelineProject = projectDetail && projectDetail.id === activeProjectId ? projectDetail : null;
// 详情还在拉取(刚切项目 / 首进管线):显示全屏加载占位,等完整详情到位再渲染,而不是拿轻量数据去崩。
// 拉取多次仍失败则给重试 + 返回,避免「永久卡 loading 进不来」。
if (page === "pipeline" && activeProjectId && !pipelineProject) {
const projectRef = activeProjectId.slice(0, 8).toUpperCase();
return (
<SystemLoading
variant="fullscreen"
state={projectDetailError ? "error" : "loading"}
title={projectDetailError ? "项目加载失败" : "正在打开项目工作台"}
description={projectDetailError ? "暂时无法连接服务,请重试或返回项目列表。" : "正在同步项目数据,请稍候。"}
icon={projectDetailError ? "activity" : "clapperboard"}
reference={projectRef}
actions={projectDetailError ? (
<>
<button className="btn btn-primary" type="button" onClick={() => setDetailRetry((n) => n + 1)}>重试</button>
<button className="btn btn-ghost" type="button" onClick={() => goBack("projects")}>返回</button>
</>
) : undefined}
/>
);
}
const pipelineTextModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
const pipelinePage = page === "pipeline" && pipelineProject ? (
<PipelinePage
key={pipelineProject.id}
project={pipelineProject}
scriptModelName={publicModelDisplayName(pipelineTextModel, "AI")}
textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")}
videoModels={modelConfigs.filter((m) => m.capability === "video" && m.status === "active")}
loading={loading}
navigate={navigate}
onBack={() => goBack("projects")}
user={currentUser}
team={currentTeam}
products={products}
projects={projects}
assets={[]}
billing={billing}
notice={notice}
unreadCount={unreadCount}
avatarChar={avatarChar}
logout={logout}
onNotify={(type, text) => setNotice({ type, text })}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新", { liteRefresh: true })}
onAddShot={(afterSegmentId, content) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId, ...content }), "分镜已添加", { liteRefresh: true })}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除", { liteRefresh: true })}
onRerunShot={(segmentId, instruction) => action(() => api.rerunScriptSegment(pipelineProject.id, { segment_id: segmentId, instruction }), "分镜已重跑", { liteRefresh: true })}
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={async (kind, prompt, label, referenceAssetId) => {
// 异步:提交→轮询出图→刷新。角色三视图由后端在立绘落库后自动接力,
// 避免页面刷新/离开时漏掉,也避免这里重复创建第二个三视图任务。
// referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致
const assetId = await submitAndPollAsset(
() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }),
kind === "person" ? "" : "基础资产已生成",
);
if (!assetId) return null;
return { adopted_asset: assetId };
}}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSetAdoptState={(groupId, state) => action(() => api.setBaseAssetAdopt(pipelineProject.id, { group_id: groupId, state }), state === "adopted" ? "已采用" : "已标为未采用")}
onDeleteBaseAsset={(groupId) => action(() => api.deleteBaseAsset(pipelineProject.id, groupId), "已删除")}
onAttachBaseAsset={(target, assetId) => action(() => api.attachBaseAsset(pipelineProject.id, { ...target, asset_id: assetId }), "已采用所选素材")}
onGenerateTriview={async (portraitAssetId) => {
// 异步:image_edit 慢出图在 worker 跑,轮询期间整站不卡;出图后刷新即见三视图
const assetId = await submitAndPollAsset(() => api.generateTriview(pipelineProject.id, { portrait_asset_id: portraitAssetId }), "三视图已据立绘生成");
return assetId ? { id: assetId } : null;
}}
onGenerateModel={(prompt) => generateImages({ prompt, mode: "model", count: 1 })}
onUploadModel={(file) => {
const fd = new FormData();
fd.append("file", file);
fd.append("name", file.name);
fd.append("asset_type", "image");
fd.append("category", "model_portrait");
// 返回上传后的模特形象 Asset,保存模特时复用它创建 Model。
return action(() => api.uploadAsset(fd), "");
}}
// 流程步骤4 · 添加模特工作台命名:同步写回形象资产名称。
onRenameModel={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onSubmitAllVideos={(prompt) =>
action(async () => {
// 「全部重跑」要把已完成的也重跑(否则全成功的项目点了等于没点);只跳过在途(running/queued)避免重复提交
const targets = pipelineProject.video_segments.filter((segment) => !["running", "queued"].includes(segment.status));
// ★ 并发提交,且单段失败绝不拖累其余段。旧实现用 for+await 串行,任一段抛错(如人脸需走素材库
// 的 502、中转限流)整个循环就 break → 后面的段永不提交,前端出现「4 个框只有 2 个在跑」。
// 改 allSettled:每段各自独立提交,失败只标该段,成功的照常进生成。
const results = await Promise.allSettled(
targets.map((segment) =>
api.submitVideo(pipelineProject.id, {
video_segment_id: segment.id,
prompt: `${prompt}${segment.sort_order + 1} 段,时长 ${segment.target_duration_seconds} 秒`,
})
)
);
const failed = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
if (failed.length) {
// 部分失败也要刷新,让已成功提交的段立刻进「生成中」(否则 action 走 catch 分支会跳过刷新)
await refreshProjectDetail();
const firstErr = failed[0].reason;
const detail = firstErr instanceof Error ? firstErr.message : "提交失败";
throw new Error(`${targets.length - failed.length}/${targets.length} 段已提交,${failed.length} 段失败:${detail}(可对失败的段单独点重跑)`);
}
return targets.length;
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
exportResult={exportResult}
onRefreshExport={refreshExport}
onRefreshProject={refreshProjectDetail}
onRefreshBilling={refreshBilling}
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={submitExport}
/>
) : null;
return (
<div className="app">
<Sidebar page={page} navigate={navigate} user={currentUser} team={currentTeam} canManageBilling={isOwner} products={products} projects={projects} productTotal={productTotal} projectTotal={projectTotal} aiUnread={aiUnread} onOpenAdmin={() => navigateAdmin("")} accountOpen={accountAnchor !== null} onOpenAccount={(rect) => setAccountAnchor((prev) => (prev ? null : rect))} />
<header className="topbar">
<ModeTabs active={topModule} navigate={navigate} />
<div className="right">
<button className="top-search" type="button" onClick={() => openCommandPalette()} aria-label="搜索">
<IconKitSvg name="search" />
<span>搜索</span>
<span className="kbd">{searchKbd}</span>
</button>
<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>
</header>
<main>
<Decorations />
<div className="content" id="page-content" key={page}>
<CornerMarks />
{notice && <ToastLike notice={notice} />}
{pipelinePage || renderPage()}
</div>
</main>
<AccountMenu
open={accountAnchor !== null}
anchorRect={accountAnchor}
onClose={() => setAccountAnchor(null)}
navigate={navigate}
logout={logout}
user={currentUser}
team={currentTeam}
canManageBilling={isOwner}
/>
</div>
);
}