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, FreeCreatePage, 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 { AdminApp } from "./routes/admin/admin-app"; import { TrashPage } from "./routes/trash"; import { ModelsPage } from "./routes/models"; import { money } from "./routes/stage-config"; const crumbLabels: Partial> = { 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; 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 不可用时静默降级,不影响生成 */ } } export function App() { const [route, setRoute] = useState(() => resolveRoute()); const page = route.page; const [authMode, setAuthMode] = useState(route.authMode); const [authed, setAuthed] = useState(() => Boolean(getToken())); const [booting, setBooting] = useState(() => Boolean(getToken())); const [user, setUser] = useState(null); const [team, setTeam] = useState(null); // 当前用户在该团队的角色(owner/admin/member)。主账号(owner=超管)才看得到「团队」「消费」页(PMC#3)。 const [role, setRole] = useState(""); const isOwner = role === "owner"; const [products, setProducts] = useState([]); const [productTotal, setProductTotal] = useState(0); // 后端真实总数(分页 count),侧栏/仪表盘徽标用 const [projects, setProjects] = useState([]); const [projectTotal, setProjectTotal] = useState(0); const [modelConfigs, setModelConfigs] = useState([]); const [billing, setBilling] = useState(null); const [unreadCount, setUnreadCount] = useState(0); // YYX#row22:未读生成任务 —— 导航「图片生成」总数 + 每个商品的未读分数(商品角标) const [aiUnread, setAiUnread] = useState(0); const [aiUnreadByProduct, setAiUnreadByProduct] = useState>({}); const [dataLoaded, setDataLoaded] = useState(false); // 全局数据(商品/项目)首次加载完成前,列表显示加载态而非空态 const [projectDetail, setProjectDetail] = useState(null); const [projectDetailError, setProjectDetailError] = useState(false); // 进管线拉详情失败:从「永久 loading」改为可重试,避免卡死进不来 const [detailRetry, setDetailRetry] = useState(0); // 手动重试计数,变化即重新触发详情 useEffect const [exportResult, setExportResult] = useState(null); const [preferences, setPreferences] = useState(null); const [sessions, setSessions] = useState([]); const [activeProductId, setActiveProductId] = useState(route.productId || ""); const [activeProjectId, setActiveProjectId] = useState(route.projectId || ""); const [notice, setNotice] = useState(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 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) { 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); }, []); // 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> | 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(); 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); }, []); // 平台后台 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 为空时(身份尚未带回角色)不拦,避免误纠超管。 useEffect(() => { if (booting || !user || !role) return; if (!isOwner && (page === "team" || page === "account")) { 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]); // 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。 // 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行 // ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。 const projectDetailRef = useRef(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)); } }, [activeProjectId]); // 静默轮询故事板分镜生成(对标 pollVideosQuiet):有 queued/running 的场就驱动后端出图并刷新,不占全局 loading、不弹 toast。 // 这样单场重跑不会把别的场按钮也锁住——生成在后台跑,UI 只按每场自身状态转圈。 const pollStoryboardQuiet = 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.storyboard_shots ?? []).filter((s) => ["queued", "running"].includes(s.status)); if (active.length === 0) return; await api.pollStoryboard(activeProjectId).catch(() => undefined); const next = await api.project(activeProjectId).catch(() => null); if (next && next.id === activeProjectIdRef.current) { setProjectDetail((prev) => (prev && JSON.stringify(prev) === JSON.stringify(next) ? prev : 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, 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"; window.history[method](null, "", path); } window.scrollTo({ top: 0, behavior: "auto" }); } // 平台超管后台导航:section="" → /admin(概览),否则 /admin/
。 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(work: () => Promise, successText: string, opts?: { liteRefresh?: boolean; concurrent?: boolean }): Promise { // 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 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; 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 = 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 = t.error_message; } } return { assets, error }; } // 基础资产/三视图异步出图统一入口:提交(秒回任务)→ 轮询出图 → 刷新项目 → 返回新资产 id 供链式(立绘→三视图)。 async function submitAndPollAsset(submit: () => Promise<{ task: { id: string; status: string } } | null>, okText: string): Promise { 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 大重渲染,所有背景图被重新赋值 → "每次生成全页图重载"。 // 余额受出图扣费影响,单独轻量刷新一下即可,不必连带把 products/projects/全部 assets/通知都重拉。 await refreshProjectDetail(); api.billingSummary().then((b) => b && setBilling(b)).catch(() => {}); if (assets.length === 0) { setNotice({ type: "error", text: error || "生成超时,稍后可在资产里查看" }); return null; } 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 ( { setAuthMode(next); window.history.pushState(null, "", next === "register" ? "/register" : "/login"); }} onAuthed={onAuthed} /> ); } // 平台超管后台:独立外壳,不依赖团队(超管可无团队),须在 !team 守卫之前分流。 if (!booting && user && route.admin !== undefined && user.is_platform_admin) { return ( ); } if (booting || !user || !team) { return (

正在进入工作台…

// 正在拉取团队数据
); } const currentUser: User = user; const currentTeam: Team = team; function renderPage() { switch (page) { case "dashboard": return action(() => api.createProduct(payload), "")} />; case "products": return ( 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 ( 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 navigate("productDetail", { productId, hash: tab === "videos" ? "videos" : undefined })} onCreate={(payload) => action(() => api.createProduct(payload), "")} onDelete={(productId) => action(() => api.deleteProduct(productId), "已移至垃圾桶")} />; return ( 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 ( action(() => api.createProject(payload), "项目已创建")} openPipeline={(projectId) => navigate("pipeline", { projectId })} onDelete={async (projectId) => { const ok = await action(() => api.deleteProject(projectId), "项目已删除"); // 删的是当前激活项目就清掉残留 id/详情,否则后续新建/进管线会拿着已删 id 去拉 → 404 / 卡 loading if (ok !== null && projectId === activeProjectIdRef.current) { setActiveProjectId(""); setProjectDetail(null); } }} /> ); case "projectWizard": return ( 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), "")} onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} /> ); case "pipeline": // 有项目时由下方 full-screen 特例渲染;这里只兜底「暂无项目」 return (

暂无项目

// 先创建一个视频项目
); case "models": return setNotice({ type, text })} />; case "library": return action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />; case "trash": return ( action(() => api.restoreProduct(id), "已恢复到商品库")} onPurge={(id) => action(() => api.purgeProduct(id), "已彻底删除")} onChanged={() => { void loadData(); }} /> ); case "account": return ( action(() => api.recharge({ amount, bonus_points: bonusPoints }), "充值成功")} /> ); case "team": return ( 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 ( ); case "assetFactory": return ; case "freeCreate": return setNotice({ type, text })} />; case "imageOptimize": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhoto": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "platformCover": return navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} />; case "modelPhotoDemoA": return navigate("modelPhoto")} navigate={navigate} />; case "modelPhotoDemoB": return navigate("modelPhoto")} navigate={navigate} />; case "settings": return setNotice({ type: "success", text })} onLogout={logout} />; case "settingsNotify": return setNotice({ type: "success", text })} onLogout={logout} />; default: return action(() => api.createProduct(payload), "")} />; } } const avatarChar = (currentTeam.name || currentUser.username || "A").slice(0, 1).toUpperCase(); const here = crumbLabels[page] || routeLabels[page] || "工作台"; // 生产管线 · 全屏 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) { return (

{projectDetailError ? "项目加载失败" : "加载中…"}

{projectDetailError ? "// 网络异常或服务繁忙" : "// 正在拉取项目数据"}
{projectDetailError && (
)}
); } if (page === "pipeline" && pipelineProject) { const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text"); return ( m.capability === "text" && m.status === "active")} loading={loading} navigate={navigate} 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) => { // 异步:提交→轮询出图→刷新;返回新资产 id 供「立绘→三视图」链式生成 // referenceAssetId:角色重跑时传当前立绘 → 后端走 image_edit 参考它,保持人物一致 const assetId = await submitAndPollAsset(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label, reference_asset_id: referenceAssetId }), "基础资产已生成"); return assetId ? { adopted_asset: assetId } : null; }} onGenerateStoryboard={(prompt) => // 只「提交」(秒回);出图由后台 pollStoryboardQuiet 驱动 —— 不占全局 loading,不锁其它场按钮(对标视频「开始生成」) action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已开始生成") } onRerunStoryboardShot={(shotId, prompt) => // 单场重跑也只「提交」(秒回);后台轮询出图。不阻塞 → 可同时重跑别的场 action(() => api.rerunStoryboardShot(pipelineProject.id, shotId, prompt), "本场已开始重跑") } onAdoptStoryboardShotVersion={(shotId, versionId) => action(() => api.adoptStoryboardShotVersion(pipelineProject.id, shotId, versionId), "已采用该版本")} onPollStoryboardQuiet={pollStoryboardQuiet} 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; }} 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"); // 返回上传后的 Asset(供添加人物工作台进右侧栏做三视图/命名) return action(() => api.uploadAsset(fd), ""); }} // 流程步骤4 · 添加人物工作台命名:把名字写回该人物资产 onRenameActor={(assetId, name) => action(() => api.updateAsset(assetId, { name }), "")} onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")} 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} 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 (
navigateAdmin("")} />
{page === "dashboard" ? ( 工作台 ) : ( <> { event.preventDefault(); navigate("dashboard"); }}>工作台 / {here} )}
navigate("account")}> 余额 {money(billing?.account.balance)}
{currentUser.avatar_url ? 头像 : {avatarChar}}
{notice && } {renderPage()}
); }