Files
omniai-web/src/App.tsx
T
stringadmin fd71b2b18e fix: redirect to login page after logout instead of workbench
Logout and session expiry previously redirected to "workbench" which
requires authentication, causing 401 errors and a frozen page state.
Now correctly redirects to "login" page immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:47:00 +08:00

1357 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
}
}, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]);
const showSessionReplacedModal = useCallback((message?: string) => {
clearAuthenticatedState();
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 });
}
} 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")}
/>
);
}
})();
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;