Files
omniai-web/src/App.tsx
T

1359 lines
49 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
import {
BarChartOutlined,
BranchesOutlined,
CustomerServiceOutlined,
DeleteOutlined,
FolderOpenOutlined,
GlobalOutlined,
HeartOutlined,
HomeOutlined,
LayoutOutlined,
RobotOutlined,
ShoppingOutlined,
SwapOutlined,
ToolOutlined,
WalletOutlined,
} from "@ant-design/icons";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
import ErrorBoundary from "./components/ErrorBoundary";
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { notificationClient } from "./api/notificationClient";
import {
SERVER_SESSION_REPLACED_EVENT,
SERVER_SESSION_EXPIRED_EVENT,
checkServerHealth,
getErrorMessage,
type ServerSessionReplacedDetail,
} from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError";
import AppShell from "./components/AppShell";
import { cloneWorkflow, createBlankWorkflow } from "./data/workflows";
const AgentPage = lazy(() => import("./features/agent/AgentPage"));
const AssetsPage = lazy(() => import("./features/assets/AssetsPage"));
const CanvasPage = lazy(() => import("./features/canvas/CanvasPage"));
const CharacterMixPage = lazy(() => import("./features/character-mix/CharacterMixPage"));
const CommunityPage = lazy(() => import("./features/community/CommunityPage"));
const CommunityCaseAddPage = lazy(() => import("./features/community-review/CommunityCaseAddPage"));
const CommunityReviewPage = lazy(() => import("./features/community-review/CommunityReviewPage"));
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
import type { TemplateCase } from "./features/ecommerce/ecommerceTemplates";
const HomePage = lazy(() => import("./features/home/HomePage"));
const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage"));
const MorePage = lazy(() => import("./features/more/MorePage"));
const ReportPage = lazy(() => import("./features/report/ReportPage"));
const ProfilePage = lazy(() => import("./features/profile/ProfilePage"));
const ProviderHealthPage = lazy(() => import("./features/provider-health/ProviderHealthPage"));
const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/ResolutionUpscalePage"));
const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage"));
const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage"));
const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage"));
const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage"));
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage"));
import type { WorkbenchResultActionPayload } from "./features/workbench/WorkbenchPage";
import {
useSessionStore,
useProjectStore,
useTaskStore,
useAppStore,
type PendingAction,
} from "./stores";
import type {
WebCanvasWorkflow,
WebGenerationPreviewTask,
WebImageWorkbenchTool,
WebNavItem,
WebNotification,
WebProjectSummary,
WebUsageSummary,
WebUserSession,
WebViewKey,
} from "./types";
type SaveCanvasWorkflowOptions = {
silent?: boolean;
reason?: "manual" | "autosave" | "publish";
};
const emptyUsageSummary: WebUsageSummary = {
balanceCents: 0,
imageUsed: 0,
videoUsed: 0,
textUsed: 0,
source: "preview",
};
const VIEW_KEYS = new Set<WebViewKey>([
"home",
"workbench",
"community",
"login",
"agent",
"canvas",
"assets",
"ecommerceHub",
"ecommerce",
"ecommerceTemplates",
"scriptTokens",
"tokenUsage",
"settings",
"imageWorkbench",
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"digitalHuman",
"avatarConsole",
"characterMix",
"more",
"sizeTemplate",
"communityReview",
"communityCaseAdd",
"report",
"providerHealth",
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "ecommerceTemplates", "sizeTemplate"]);
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
? "login"
: rawView === "ecommerceHub"
? "ecommerce"
: rawView === "community-review"
? "communityReview"
: rawView === "community-case-add"
? "communityCaseAdd"
: rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "home";
}
function readViewFromHash(): WebViewKey {
return normalizeViewKey(window.location.hash.replace(/^#\/?/, ""));
}
function isWorkspaceView(view: WebViewKey): boolean {
return (
view !== "workbench" &&
view !== "home" &&
view !== "community" &&
view !== "assets" &&
view !== "ecommerceHub" &&
view !== "ecommerce" &&
view !== "scriptTokens" &&
view !== "login"
);
}
function isAdminAccount(session: WebUserSession | null): boolean {
if (!session) return false;
const role = String(session.user.role || "").trim().toLowerCase();
const enterpriseRole = String(session.user.enterpriseRole || "").trim().toLowerCase();
return role === "admin" || enterpriseRole === "admin";
}
function createWorkflowFromResult(payload: WorkbenchResultActionPayload): WebCanvasWorkflow {
const now = Date.now();
const resultNodeKind = payload.resultType === "video" ? "video" : "image";
return {
id: `workflow-result-${now}`,
version: 1,
title: `继续编辑:${payload.title}`,
description: payload.prompt || "从生成结果进入画布继续创作。",
source: "blank",
settings: {
model: payload.resultType === "video" ? "Seedance 2.0" : "Nano Banana Pro",
ratio: payload.resultType === "video" ? "16:9" : "1:1",
duration: payload.resultType === "video" ? "6s" : "0s",
resolution: payload.resultType === "video" ? "720p" : "2K",
},
nodes: [
{
id: "prompt",
kind: "prompt",
label: "原始提示词",
detail: payload.prompt || "继续补充提示词",
position: { x: 80, y: 120 },
},
{
id: "generated-result",
kind: resultNodeKind,
label: payload.resultType === "video" ? "生成视频" : "生成图片",
detail: payload.title,
previewUrl: payload.resultUrl,
position: { x: 410, y: 80 },
},
{
id: "next-edit",
kind: "model",
label: "二次编辑",
detail: "可继续扩图、图生图、视频化或拆分镜头",
position: { x: 760, y: 160 },
},
{
id: "output",
kind: "output",
label: "新版输出",
detail: "保存资产或提交社区",
position: { x: 1080, y: 110 },
},
],
edges: [
{ id: "prompt-generated-result", source: "prompt", target: "generated-result", label: "生成", animated: true },
{ id: "generated-result-next-edit", source: "generated-result", target: "next-edit", label: "继续", animated: true },
{ id: "next-edit-output", source: "next-edit", target: "output", label: "输出", animated: true },
],
};
}
function App() {
const initialView = readViewFromHash();
const lastNonAuthViewRef = useRef<WebViewKey>(initialView === "login" ? "workbench" : initialView);
const canvasAutoOpenedRecentRef = useRef(false);
// Session store
const session = useSessionStore((s) => s.session);
const loginPromptOpen = useSessionStore((s) => s.loginPromptOpen);
const pendingAction = useSessionStore((s) => s.pendingAction);
const sessionReplacedOpen = useSessionStore((s) => s.sessionReplacedOpen);
const sessionReplacedMessage = useSessionStore((s) => s.sessionReplacedMessage);
const setSession = useSessionStore((s) => s.setSession);
const openLoginPrompt = useSessionStore((s) => s.openLoginPrompt);
const closeLoginPrompt = useSessionStore((s) => s.closeLoginPrompt);
const showSessionReplaced = useSessionStore((s) => s.showSessionReplaced);
const hideSessionReplaced = useSessionStore((s) => s.hideSessionReplaced);
const clearSessionState = useSessionStore((s) => s.clearSession);
// Project store
const projects = useProjectStore((s) => s.projects);
const projectsLoaded = useProjectStore((s) => s.projectsLoaded);
const canvasWorkflow = useProjectStore((s) => s.canvasWorkflow);
const currentCanvasProjectId = useProjectStore((s) => s.currentCanvasProjectId);
const pendingDeleteProject = useProjectStore((s) => s.pendingDeleteProject);
const deleteProjectSubmitting = useProjectStore((s) => s.deleteProjectSubmitting);
const setProjects = useProjectStore((s) => s.setProjects);
const setProjectsLoaded = useProjectStore((s) => s.setProjectsLoaded);
const setCanvasWorkflow = useProjectStore((s) => s.setCanvasWorkflow);
const setCurrentCanvasProjectId = useProjectStore((s) => s.setCurrentCanvasProjectId);
const openDeleteProjectModal = useProjectStore((s) => s.openDeleteProject);
const closeDeleteProjectModal = useProjectStore((s) => s.closeDeleteProject);
const setDeleteProjectSubmitting = useProjectStore((s) => s.setDeleteProjectSubmitting);
const clearProjectState = useProjectStore((s) => s.clearProjectState);
// Task store
const tasks = useTaskStore((s) => s.tasks);
const appendTask = useTaskStore((s) => s.appendTask);
const mergeServerTasks = useTaskStore((s) => s.mergeServerTasks);
const clearTasks = useTaskStore((s) => s.clearTasks);
// App store
const usage = useAppStore((s) => s.usage);
const runtimeNotifications = useAppStore((s) => s.runtimeNotifications);
const serverNotifications = useAppStore((s) => s.serverNotifications);
const activeView = useAppStore((s) => s.activeView);
const workspaceExpanded = useAppStore((s) => s.workspaceExpanded);
const imageWorkbenchTool = useAppStore((s) => s.imageWorkbenchTool);
const pendingEcommerceTemplate = useAppStore((s) => s.pendingEcommerceTemplate);
const backendHealth = useAppStore((s) => s.backendHealth);
const setUsage = useAppStore((s) => s.setUsage);
const pushNotification = useAppStore((s) => s.pushNotification);
const setRuntimeNotifications = useAppStore((s) => s.setRuntimeNotifications);
const setServerNotifications = useAppStore((s) => s.setServerNotifications);
const setView = useAppStore((s) => s.setView);
const setWorkspaceExpanded = useAppStore((s) => s.setWorkspaceExpanded);
const setImageWorkbenchTool = useAppStore((s) => s.setImageWorkbenchTool);
const setPendingEcommerceTemplate = useAppStore((s) => s.setPendingEcommerceTemplate);
const setBackendHealth = useAppStore((s) => s.setBackendHealth);
const markNotificationRead = useAppStore((s) => s.markNotificationRead);
const markAllNotificationsRead = useAppStore((s) => s.markAllNotificationsRead);
const clearAppState = useAppStore((s) => s.clearAppState);
// Dismiss boot splash after first render
useEffect(() => {
const splash = document.getElementById("app-boot-splash");
if (splash) {
splash.style.opacity = "0";
const timer = setTimeout(() => splash.remove(), 350);
return () => clearTimeout(timer);
}
}, []);
// Initialize canvasWorkflow if null
useEffect(() => {
if (!canvasWorkflow) {
setCanvasWorkflow(createBlankWorkflow());
}
}, [canvasWorkflow, setCanvasWorkflow]);
// Initialize activeView from hash
useEffect(() => {
setView(initialView);
if (isWorkspaceView(initialView)) {
setWorkspaceExpanded(true);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const navItems = useMemo<WebNavItem[]>(
() => [
{ key: "home", label: "首页", hint: "项目入口", icon: <HomeOutlined /> },
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <RobotOutlined /> },
{
key: "ecommerce",
label: "电商生成",
hint: "AI创作与海报生成",
icon: <ShoppingOutlined />,
},
{
key: "sizeTemplate",
label: "示例模板",
hint: "平台比例与导出尺寸",
icon: <LayoutOutlined />,
},
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <BranchesOutlined /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <GlobalOutlined /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <BarChartOutlined /> },
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <WalletOutlined /> },
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <HeartOutlined /> },
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <FolderOpenOutlined /> },
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <RobotOutlined /> },
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <CustomerServiceOutlined /> },
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <SwapOutlined /> },
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ToolOutlined /> },
],
[],
);
const handleSetView = useCallback((view: WebViewKey) => {
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
lastNonAuthViewRef.current = view;
}
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [setView, setWorkspaceExpanded]);
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
keyServerClient.clearSession();
clearSessionState();
setProjects([]);
setProjectsLoaded(true);
setUsage(emptyUsageSummary);
clearTasks();
setRuntimeNotifications([]);
setServerNotifications([]);
setCanvasWorkflow(createBlankWorkflow());
setCurrentCanvasProjectId(null);
canvasAutoOpenedRecentRef.current = false;
setWorkspaceExpanded(false);
if (options?.resetView) {
handleSetView("login");
2026-06-02 12:38:01 +08:00
}
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
const showSessionReplacedModal = useCallback((message?: string) => {
clearAuthenticatedState({ resetView: true });
2026-06-02 12:38:01 +08:00
showSessionReplaced(message);
}, [clearAuthenticatedState, showSessionReplaced]);
useEffect(() => {
const handleSessionReplaced = (event: Event) => {
const detail = (event as CustomEvent<ServerSessionReplacedDetail>).detail;
showSessionReplacedModal(detail?.message);
};
window.addEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced);
window.addEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced);
return () => {
window.removeEventListener(SERVER_SESSION_REPLACED_EVENT, handleSessionReplaced);
window.removeEventListener(SERVER_SESSION_EXPIRED_EVENT, handleSessionReplaced);
};
}, [showSessionReplacedModal]);
const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}, [setPendingEcommerceTemplate, handleSetView]);
const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => {
setProjectsLoaded(false);
if (!nextSession) {
setProjects([]);
setUsage(emptyUsageSummary);
clearTasks();
setProjectsLoaded(true);
return;
}
const [projectResult, usageResult, taskResult] = await Promise.allSettled([
keyServerClient.listProjects(),
keyServerClient.getUsageSummary(),
aiGenerationClient.listTasks({ limit: 100 }),
]);
const loadedProjects = projectResult.status === "fulfilled" ? projectResult.value : [];
setProjects(loadedProjects);
setProjectsLoaded(projectResult.status === "fulfilled");
setUsage(
usageResult.status === "fulfilled"
? usageResult.value
: {
...emptyUsageSummary,
source: "server",
errorMessage: usageResult.reason instanceof Error ? usageResult.reason.message : "用量服务暂时不可用",
},
);
if (taskResult.status === "fulfilled") {
mergeServerTasks(taskResult.value);
setRuntimeNotifications(useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error"));
} else {
setRuntimeNotifications([
{
id: "server-task-history-sync-error",
type: "info" as const,
title: "任务历史同步失败",
description: getErrorMessage(taskResult.reason),
createdAt: new Date().toISOString(),
isRead: false,
targetView: "login" as const,
},
...useAppStore.getState().runtimeNotifications.filter((item: WebNotification) => item.id !== "server-task-history-sync-error"),
].slice(0, 30));
}
}, [setProjects, setProjectsLoaded, setUsage, clearTasks, mergeServerTasks, setRuntimeNotifications]);
useEffect(() => {
if (!window.location.hash) {
window.history.replaceState(null, "", "#/home");
}
const handleHashChange = () => {
const nextView = readViewFromHash();
setView(nextView);
if (nextView !== "login") {
lastNonAuthViewRef.current = nextView;
}
if (isWorkspaceView(nextView)) {
setWorkspaceExpanded(true);
}
};
window.addEventListener("hashchange", handleHashChange);
return () => window.removeEventListener("hashchange", handleHashChange);
}, [setView, setWorkspaceExpanded]);
useEffect(() => {
let cancelled = false;
const loadSession = async () => {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
setSession(nextSession);
await hydrateAccountData(nextSession);
};
void loadSession();
return () => {
cancelled = true;
};
}, [hydrateAccountData, setSession]);
const refreshUsage = useCallback(async () => {
try {
const fresh = await keyServerClient.getUsageSummary();
setUsage(fresh);
} catch {
// silent — balance display will update on next refreshUsage call
}
}, [setUsage]);
useEffect(() => {
if (!session || sessionReplacedOpen) return undefined;
let cancelled = false;
let checking = false;
const verifySession = async () => {
if (checking) return;
checking = true;
try {
const nextSession = await keyServerClient.getCurrentSession();
if (cancelled) return;
if (nextSession) {
setSession(nextSession);
} else {
clearAuthenticatedState({ resetView: true });
2026-06-02 12:38:01 +08:00
}
} finally {
checking = false;
}
};
const handleFocusCheck = () => void verifySession();
const handleVisibilityCheck = () => {
if (document.visibilityState === "visible") {
void verifySession();
}
};
const intervalId = window.setInterval(() => void verifySession(), 60_000);
window.addEventListener("focus", handleFocusCheck);
document.addEventListener("visibilitychange", handleVisibilityCheck);
return () => {
cancelled = true;
window.clearInterval(intervalId);
window.removeEventListener("focus", handleFocusCheck);
document.removeEventListener("visibilitychange", handleVisibilityCheck);
};
}, [clearAuthenticatedState, session, sessionReplacedOpen, setSession]);
useEffect(() => {
let cancelled = false;
const refreshBackendHealth = async () => {
const health = await checkServerHealth();
if (!cancelled) {
setBackendHealth(health);
}
};
void refreshBackendHealth();
const intervalId = window.setInterval(() => void refreshBackendHealth(), 60_000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [setBackendHealth]);
const refreshServerNotifications = useCallback(async () => {
if (!keyServerClient.getStoredSession()) {
setServerNotifications([]);
return;
}
try {
const items = await notificationClient.list();
setServerNotifications(items);
} catch {
setServerNotifications([]);
}
}, [setServerNotifications]);
useEffect(() => {
if (!session) {
setServerNotifications([]);
return;
}
void refreshServerNotifications();
const intervalId = window.setInterval(() => void refreshServerNotifications(), 45_000);
return () => window.clearInterval(intervalId);
}, [refreshServerNotifications, session, setServerNotifications]);
const queueLoginGate = useCallback((action: PendingAction) => {
openLoginPrompt(action);
}, [openLoginPrompt]);
const openWorkflowProject = useCallback(
async (workflow: WebCanvasWorkflow) => {
const nextWorkflow = cloneWorkflow(workflow);
setCanvasWorkflow(nextWorkflow);
const project = await keyServerClient.createProjectSpace(nextWorkflow);
let savedProject = project;
try {
savedProject = await keyServerClient.saveProjectContent(project.id, nextWorkflow);
} catch (error) {
pushNotification({
type: "info",
title: "项目已创建,内容暂未保存",
description: error instanceof Error ? error.message : String(error),
targetView: "canvas",
targetId: project.id,
});
}
setCurrentCanvasProjectId(project.id);
setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== savedProject.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
},
[pushNotification, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
const appendPreviewTask = useCallback(async (input: CreatePreviewTaskInput): Promise<WebGenerationPreviewTask> => {
const task = await webGenerationGateway.createPreviewTask(input);
appendTask(task);
return task;
}, [appendTask]);
const handleStartCreate = useCallback(() => {
const workflow = createBlankWorkflow("新建项目");
if (!session) {
queueLoginGate({
kind: "project",
label: "新建项目",
description: "登录或注册后,我们会为你创建专属项目空间,并以画布方式打开。",
workflow,
});
return;
}
void openWorkflowProject(workflow);
}, [openWorkflowProject, queueLoginGate, session]);
const handleStartTemplateCanvasCreate = useCallback(() => {
const workflow = createBlankWorkflow("新建项目");
canvasAutoOpenedRecentRef.current = true;
setCanvasWorkflow(cloneWorkflow(workflow));
setCurrentCanvasProjectId(null);
setWorkspaceExpanded(true);
handleSetView("canvas");
}, [handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded]);
const handleImportWorkflow = useCallback(
(workflow: WebCanvasWorkflow) => {
const nextWorkflow = cloneWorkflow(workflow);
if (!session) {
queueLoginGate({
kind: "project",
label: "导入社区案例",
description: "登录或注册后,案例会写入你的项目空间,并以画布方式打开。",
workflow: nextWorkflow,
});
return;
}
void openWorkflowProject(nextWorkflow);
},
[openWorkflowProject, queueLoginGate, session],
);
const handleOpenProject = useCallback(
async (project: WebProjectSummary) => {
if (!session) {
openLoginPrompt();
return;
}
try {
const workflow = await keyServerClient.getProjectContent(project.id);
setCanvasWorkflow(workflow);
setCurrentCanvasProjectId(project.id);
setProjects([project, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
} catch (error) {
pushNotification({
type: "info",
title: "项目打开失败",
description: error instanceof Error ? error.message : String(error),
targetView: "ecommerce",
targetId: project.id,
});
}
},
[pushNotification, session, handleSetView, openLoginPrompt, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
useEffect(() => {
const shouldAutoOpenRecentProject =
activeView === "canvas" &&
Boolean(session) &&
projectsLoaded &&
projects.length > 0 &&
!currentCanvasProjectId &&
canvasWorkflow &&
canvasWorkflow.source === "blank" &&
canvasWorkflow.nodes.length === 0 &&
!canvasAutoOpenedRecentRef.current;
if (!shouldAutoOpenRecentProject) {
return;
}
canvasAutoOpenedRecentRef.current = true;
void handleOpenProject(projects[0]);
}, [
activeView,
canvasWorkflow?.nodes.length,
canvasWorkflow?.source,
currentCanvasProjectId,
handleOpenProject,
projects,
projectsLoaded,
session,
]);
const handleSaveCanvasWorkflow = useCallback(
async (workflow: WebCanvasWorkflow, options?: SaveCanvasWorkflowOptions) => {
if (!session) {
openLoginPrompt();
throw new Error("请先登录后再保存画布。");
}
const nextWorkflow = cloneWorkflow(workflow);
let projectId = currentCanvasProjectId;
if (!projectId) {
const project = await keyServerClient.createProjectSpace(nextWorkflow);
projectId = project.id;
setCurrentCanvasProjectId(projectId);
}
const savedProject = await keyServerClient.saveProjectContent(projectId, nextWorkflow);
setProjects([savedProject, ...useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== projectId)]);
if (options?.reason !== "autosave") {
setCanvasWorkflow(nextWorkflow);
}
if (!options?.silent) {
pushNotification({
type: "info",
title: "画布已保存",
description: savedProject.name || nextWorkflow.title,
targetView: "canvas",
targetId: projectId,
});
}
return savedProject;
},
[currentCanvasProjectId, pushNotification, session, openLoginPrompt, setCurrentCanvasProjectId, setProjects, setCanvasWorkflow],
);
const handleDeleteProject = useCallback(
(project: WebProjectSummary) => {
if (!session) {
openLoginPrompt();
return;
}
openDeleteProjectModal(project);
},
[session, openLoginPrompt, openDeleteProjectModal],
);
const closeDeleteProject = useCallback(() => {
if (deleteProjectSubmitting) return;
closeDeleteProjectModal();
}, [deleteProjectSubmitting, closeDeleteProjectModal]);
const confirmDeleteProject = useCallback(async () => {
if (!pendingDeleteProject || deleteProjectSubmitting) return;
const project = pendingDeleteProject;
setDeleteProjectSubmitting(true);
try {
await keyServerClient.deleteProject(project.id, { cleanupUserData: true });
setProjects(useProjectStore.getState().projects.filter((item: WebProjectSummary) => item.id !== project.id));
if (currentCanvasProjectId === project.id) {
setCurrentCanvasProjectId(null);
}
pushNotification({
type: "info",
title: "项目已删除",
description: project.name,
targetView: "community",
targetId: project.id,
});
closeDeleteProjectModal();
} catch (error) {
pushNotification({
type: "info",
title: "项目删除失败",
description: error instanceof Error ? error.message : String(error),
targetView: "community",
targetId: project.id,
});
} finally {
setDeleteProjectSubmitting(false);
}
}, [currentCanvasProjectId, deleteProjectSubmitting, pendingDeleteProject, pushNotification, setCurrentCanvasProjectId, setProjects, setDeleteProjectSubmitting, closeDeleteProjectModal]);
const handleCreateTask = useCallback(
async (input: CreatePreviewTaskInput) => {
if (!session) {
queueLoginGate({
kind: "task",
label: input.title || "创建生成任务",
description: "登录或注册后,将继续发送当前输入并同步生成任务记录。",
input,
});
throw new Error("需要先登录后继续");
}
const task = await appendPreviewTask(input);
if (task.status === "failed") {
throw new Error(translateTaskError(task.errorMessage));
}
return task;
},
[appendPreviewTask, queueLoginGate, session],
);
const handleRequireTaskLogin = useCallback(
(input: CreatePreviewTaskInput) => {
queueLoginGate({
kind: "task",
label: input.title || "创建生成任务",
description: "登录或注册后,将继续发送当前输入并同步生成任务记录。",
input,
});
},
[queueLoginGate],
);
const executePendingAction = useCallback(
async (action: PendingAction) => {
if (action.kind === "project") {
await openWorkflowProject(action.workflow!);
return;
}
await appendPreviewTask(action.input!);
handleSetView(lastNonAuthViewRef.current || "workbench");
},
[appendPreviewTask, openWorkflowProject, handleSetView],
);
const completeAuth = useCallback(
async (nextSession: WebUserSession) => {
hideSessionReplaced();
setSession(nextSession);
await hydrateAccountData(nextSession);
const action = pendingAction;
closeLoginPrompt();
if (action) {
await executePendingAction(action);
return;
}
const redirectTarget = sessionStorage.getItem("omniai:redirect-after-login") as WebViewKey | null;
sessionStorage.removeItem("omniai:redirect-after-login");
handleSetView(redirectTarget || lastNonAuthViewRef.current || "workbench");
},
[executePendingAction, hydrateAccountData, pendingAction, handleSetView, hideSessionReplaced, setSession, closeLoginPrompt],
);
const handleLogin = useCallback(
async (username: string, password: string) => {
const nextSession = await keyServerClient.login({ username, password });
await completeAuth(nextSession);
},
[completeAuth],
);
const handleRegister = useCallback(
async (username: string, password: string, betaCode: string) => {
const nextSession = await keyServerClient.register({ username, password, betaCode });
await completeAuth(nextSession);
},
[completeAuth],
);
const handleLogout = useCallback(() => {
hideSessionReplaced();
clearAuthenticatedState({ resetView: true });
}, [clearAuthenticatedState, hideSessionReplaced]);
const handleOpenResultInCanvas = useCallback(
async (payload: WorkbenchResultActionPayload) => {
const recentProject = projects?.[0];
// If user has recent projects, append result to the latest one
if (recentProject && session) {
try {
const workflow = await keyServerClient.getProjectContent(recentProject.id);
const nodeId = `img-${Date.now()}`;
const maxY = (workflow.nodes || []).reduce((m, n) => Math.max(m, n.position?.y || 0), 0);
workflow.nodes = [
...(workflow.nodes || []),
{
id: nodeId,
kind: payload.resultType === "video" ? "video" : "image",
label: payload.title || "生成结果",
detail: payload.prompt || "",
position: { x: 280, y: maxY + 60 },
previewUrl: payload.resultUrl,
params: payload.resultType === "video"
? { model: "Kling V3 Omni", aspectRatio: "16:9", resolution: "720p", duration: "6s", videoMode: "text-to-video" }
: { model: "Nano Banana Pro", aspectRatio: "1:1", imageSize: "2K" },
assetRef: payload.resultOssKey ? { url: payload.resultUrl, ossKey: payload.resultOssKey, mediaType: payload.resultType === "video" ? "video/mp4" : "image/png", sourceTaskId: payload.taskId } : undefined,
},
];
await keyServerClient.saveProjectContent(recentProject.id, workflow);
setCanvasWorkflow(workflow);
setCurrentCanvasProjectId(recentProject.id);
setProjects([recentProject, ...useProjectStore.getState().projects.filter((p: WebProjectSummary) => p.id !== recentProject.id)]);
setWorkspaceExpanded(true);
handleSetView("canvas");
return;
} catch (err) {
console.warn("[App] failed to append result to recent project, creating new:", err instanceof Error ? err.message : err);
}
}
// Fallback: create new canvas workflow
setCanvasWorkflow(createWorkflowFromResult(payload));
setCurrentCanvasProjectId(null);
setWorkspaceExpanded(true);
handleSetView("canvas");
},
[projects, session, handleSetView, setCanvasWorkflow, setCurrentCanvasProjectId, setProjects, setWorkspaceExpanded],
);
const notifications = useMemo<WebNotification[]>(() => {
const creditsNotification: WebNotification[] =
session && usage.balanceCents > 0 && usage.balanceCents < 10_000
? [
{
id: "credits-low",
type: "credits_low",
title: "积分余额偏低",
description: `当前剩余 ${(usage.balanceCents / 100).toFixed(2)} 积分,请留意生成消耗。`,
createdAt: new Date().toISOString(),
isRead: false,
targetView: "login",
},
]
: [];
return [...runtimeNotifications, ...serverNotifications, ...creditsNotification].slice(0, 30);
}, [runtimeNotifications, serverNotifications, session, usage.balanceCents]);
const handleMarkNotificationRead = useCallback((id: string, isRead = true) => {
markNotificationRead(id, isRead);
notificationClient.markRead(id, isRead).catch((err) => {
console.warn("[notification] markRead failed:", err?.message || err);
});
}, [markNotificationRead]);
const handleMarkAllNotificationsRead = useCallback(() => {
markAllNotificationsRead();
notificationClient.markAllRead().catch((err) => {
console.warn("[notification] markAllRead failed:", err?.message || err);
});
}, [markAllNotificationsRead]);
const handleOpenLogin = useCallback(() => {
closeLoginPrompt();
handleSetView("login");
}, [handleSetView, closeLoginPrompt]);
const handleOpenImageWorkbenchTool = useCallback(
(tool: WebImageWorkbenchTool) => {
setImageWorkbenchTool(tool);
handleSetView("imageWorkbench");
},
[handleSetView, setImageWorkbenchTool],
);
const renderAdminOnlyPage = useCallback(
(content: React.ReactNode) => {
if (isAdminAccount(session)) return content;
return (
<div className="feature-access-gate">
<div className="feature-access-gate__content" aria-hidden="true">
{content}
</div>
<div className="feature-access-gate__overlay" role="dialog" aria-modal="true" aria-labelledby="feature-access-title">
<section className="feature-access-gate__panel panel-surface">
<span className="feature-access-gate__eyebrow"></span>
<h2 id="feature-access-title"></h2>
<p></p>
</section>
</div>
</div>
);
},
[session],
);
const PUBLIC_VIEWS = PUBLIC_VIEW_SET;
useEffect(() => {
if (!session && !PUBLIC_VIEWS.has(activeView)) {
sessionStorage.setItem("omniai:redirect-after-login", activeView);
}
}, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps
const activePage = (() => {
if (!session && !PUBLIC_VIEWS.has(activeView)) {
return (
<ProfilePage
session={session}
usage={usage}
projects={projects}
tasks={tasks}
pendingActionLabel={pendingAction?.label ?? null}
onLogin={handleLogin}
onRegister={handleRegister}
onAuthComplete={completeAuth}
onSessionChange={setSession}
onLogout={handleLogout}
onOpenWorkbench={() => handleSetView("workbench")}
onOpenCommunity={() => handleSetView("community")}
onDeleteProject={handleDeleteProject}
/>
);
}
switch (activeView) {
case "login":
return (
<ProfilePage
session={session}
usage={usage}
projects={projects}
tasks={tasks}
pendingActionLabel={pendingAction?.label ?? null}
onLogin={handleLogin}
onRegister={handleRegister}
onAuthComplete={completeAuth}
onSessionChange={setSession}
onLogout={handleLogout}
onOpenWorkbench={() => handleSetView("workbench")}
onOpenCommunity={() => handleSetView("community")}
onDeleteProject={handleDeleteProject}
/>
);
case "community":
return (
<CommunityPage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
/>
);
case "agent":
return (
<AgentPage
tasks={tasks}
isAuthenticated={Boolean(session)}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
onOpenLogin={handleOpenLogin}
/>
);
case "canvas":
return (
<CanvasPage
workflow={canvasWorkflow!}
projectId={currentCanvasProjectId}
projects={projects}
projectsLoaded={projectsLoaded}
onOpenCommunity={() => handleSetView("community")}
onOpenProject={handleOpenProject}
onStartCreate={handleStartCreate}
isAuthenticated={Boolean(session)}
session={session}
onOpenLogin={handleOpenLogin}
onSaveWorkflow={handleSaveCanvasWorkflow}
onCreateTask={handleCreateTask}
/>
);
case "assets":
return <AssetsPage isAuthenticated={Boolean(session)} onOpenLogin={handleOpenLogin} />;
case "ecommerce":
case "ecommerceHub":
return (
<EcommercePage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/>
);
case "ecommerceTemplates":
return (
<EcommerceTemplatesPage
projects={projects}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectTemplate={handleOpenEcommerceTemplate}
onStartCreate={handleStartTemplateCanvasCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
/>
);
case "digitalHuman":
return (
<DigitalHumanPage
isAuthenticated={Boolean(session)}
isAdmin={isAdminAccount(session)}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "avatarConsole":
return <AvatarConsolePage onOpenMore={() => handleSetView("more")} onSelectView={handleSetView} />;
case "characterMix":
return (
<CharacterMixPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "more":
return <MorePage onSelectView={handleSetView} onOpenImageTool={handleOpenImageWorkbenchTool} />;
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
case "scriptTokens":
return <ScriptTokensPage />;
case "tokenUsage":
return (
<TokenUsagePage
session={session}
usage={usage}
loadEnterpriseUsage={() => keyServerClient.getEnterpriseUsageSummary()}
loadPersonalUsage={() => keyServerClient.getPersonalUsageSummary()}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "settings":
return <SettingsPage />;
case "imageWorkbench":
return (
<ImageWorkbenchPage
initialTool={imageWorkbenchTool}
onOpenMore={() => handleSetView("more")}
onSelectView={handleSetView}
/>
);
case "resolutionUpscale":
return (
<ResolutionUpscalePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "watermarkRemoval":
return (
<WatermarkRemovalPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "subtitleRemoval":
return (
<SubtitleRemovalPage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenImageTool={handleOpenImageWorkbenchTool}
onSelectView={handleSetView}
/>
);
case "report":
return <ReportPage />;
case "providerHealth":
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
case "communityReview":
return (
<CommunityReviewPage
session={session}
onOpenLogin={handleOpenLogin}
onOpenReport={() => handleSetView("report")}
onOpenCaseAdd={() => handleSetView("communityCaseAdd")}
/>
);
case "communityCaseAdd":
return (
<CommunityCaseAddPage
session={session}
onOpenLogin={handleOpenLogin}
onOpenReview={() => handleSetView("communityReview")}
/>
);
case "workbench":
return (
<WorkbenchPage
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
/>
);
case "home":
default:
return (
<HomePage
onOpenGenerate={() => handleSetView("workbench")}
onOpenCanvas={() => handleSetView("canvas")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onOpenScriptReview={() => handleSetView("scriptTokens")}
onOpenTokenMonitor={() => handleSetView("tokenUsage")}
onSelectView={handleSetView}
onOpenImageTool={handleOpenImageWorkbenchTool}
2026-06-02 12:38:01 +08:00
/>
);
}
})();
return (
<AppShell
activeView={activeView}
navItems={navItems}
session={session}
usage={usage}
notifications={notifications}
backendHealth={backendHealth}
workspaceExpanded={workspaceExpanded}
onSelectView={handleSetView}
onLogout={handleLogout}
onOpenLogin={handleOpenLogin}
onMarkNotificationRead={handleMarkNotificationRead}
onMarkAllNotificationsRead={handleMarkAllNotificationsRead}
>
<ErrorBoundary>
<Suspense fallback={
<div className="page-loading-center">
<div className="page-loading-spinner" />
<span className="page-loading-center__text">...</span>
</div>
}>
<PageTransition viewKey={activeView}>
{activePage}
</PageTransition>
</Suspense>
</ErrorBoundary>
{loginPromptOpen && pendingAction ? (
<div className="login-gate-modal" role="dialog" aria-modal="true" aria-labelledby="login-gate-title">
<button
type="button"
className="login-gate-modal__scrim"
aria-label="关闭登录提示"
onClick={closeLoginPrompt}
/>
<section className="login-gate-modal__panel panel-surface">
<span className="login-gate-modal__eyebrow"></span>
<h2 id="login-gate-title">{pendingAction.label}</h2>
<p>{pendingAction.description}</p>
<div className="login-gate-modal__actions">
<button type="button" className="login-gate-modal__primary" onClick={handleOpenLogin}>
/
</button>
<button
type="button"
className="login-gate-modal__secondary"
onClick={() => {
closeLoginPrompt();
handleSetView("community");
}}
>
</button>
</div>
</section>
</div>
) : null}
{sessionReplacedOpen ? (
<div className="session-replaced-modal" role="dialog" aria-modal="true" aria-labelledby="session-replaced-title">
<div className="session-replaced-modal__scrim" />
<section className="session-replaced-modal__panel" role="status">
<span className="session-replaced-modal__eyebrow"></span>
<h2 id="session-replaced-title"></h2>
<p>{sessionReplacedMessage}</p>
<div className="session-replaced-modal__actions">
<button
type="button"
className="session-replaced-modal__primary"
onClick={() => {
hideSessionReplaced();
handleSetView("login");
}}
>
</button>
<button
type="button"
className="session-replaced-modal__secondary"
onClick={hideSessionReplaced}
>
</button>
</div>
</section>
</div>
) : null}
{pendingDeleteProject ? (
<div className="project-delete-modal" role="dialog" aria-modal="true" aria-labelledby="project-delete-title">
<button
type="button"
className="project-delete-modal__scrim"
aria-label="关闭删除确认"
onClick={closeDeleteProject}
disabled={deleteProjectSubmitting}
/>
<section className="project-delete-modal__panel">
<span className="project-delete-modal__icon">
<DeleteOutlined />
</span>
<h2 id="project-delete-title"></h2>
<p>{pendingDeleteProject.name}</p>
<div className="project-delete-modal__actions">
<button
type="button"
className="project-delete-modal__secondary"
onClick={closeDeleteProject}
disabled={deleteProjectSubmitting}
>
</button>
<button
type="button"
className="project-delete-modal__danger"
onClick={() => void confirmDeleteProject()}
disabled={deleteProjectSubmitting}
>
{deleteProjectSubmitting ? "删除中..." : "确认删除"}
</button>
</div>
</section>
</div>
) : null}
<ToastContainer />
</AppShell>
);
}
export default App;