Files
omniai-web/src/App.tsx
T

1498 lines
53 KiB
TypeScript
Raw Normal View History

import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
2026-06-02 12:38:01 +08:00
import ErrorBoundary from "./components/ErrorBoundary";
import { reportError } from "./utils/errorReporting";
import { initNotificationPermission } from "./utils/generationNotifier";
2026-06-02 12:38:01 +08:00
import PageTransition from "./components/PageTransition";
import ToastContainer from "./components/toast/ToastContainer";
import { toast } from "./components/toast/toastStore";
2026-06-02 12:38:01 +08:00
import { aiGenerationClient } from "./api/aiGenerationClient";
import { keyServerClient } from "./api/keyServerClient";
import { setUserMaxConcurrency } from "./api/generationConcurrency";
2026-06-02 12:38:01 +08:00
import { notificationClient } from "./api/notificationClient";
import {
SERVER_SESSION_REPLACED_EVENT,
SERVER_SESSION_EXPIRED_EVENT,
checkServerHealth,
clearAllUserStorage,
2026-06-02 12:38:01 +08:00
getErrorMessage,
type ServerSessionReplacedDetail,
} from "./api/serverConnection";
import { webGenerationGateway, type CreatePreviewTaskInput } from "./api/webGenerationGateway";
import { translateTaskError } from "./utils/translateTaskError";
import { recoverAndResumeTasks } from "./services/backgroundTaskRunner";
2026-06-02 12:38:01 +08:00
import AppShell from "./components/AppShell";
2026-06-05 20:42:34 +08:00
import { ShellIcon } from "./components/ShellIcon";
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
const CompliancePage = lazy(() => import("./features/compliance/CompliancePage"));
2026-06-02 12:38:01 +08:00
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"));
2026-06-08 15:23:13 +08:00
const BetaApplicationsPage = lazy(() => import("./features/beta-applications/BetaApplicationsPage"));
2026-06-02 12:38:01 +08:00
const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage"));
const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage"));
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
2026-06-02 12:38:01 +08:00
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage"));
2026-06-02 12:38:01 +08:00
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"));
2026-06-02 12:38:01 +08:00
const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage"));
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",
"sizeTemplate",
2026-06-02 12:38:01 +08:00
"scriptTokens",
"tokenUsage",
"imageWorkbench",
"resolutionUpscale",
"watermarkRemoval",
"subtitleRemoval",
"dialogGenerator",
2026-06-02 12:38:01 +08:00
"digitalHuman",
"avatarConsole",
"characterMix",
"more",
"communityReview",
"communityCaseAdd",
2026-06-08 15:23:13 +08:00
"betaApplications",
2026-06-02 12:38:01 +08:00
"report",
"providerHealth",
"userAgreement",
"privacyPolicy",
"not-found",
2026-06-02 12:38:01 +08:00
]);
const PUBLIC_VIEW_SET = new Set<WebViewKey>(["home", "login", "community", "more", "dialogGenerator", "userAgreement", "privacyPolicy", "not-found"]);
2026-06-05 17:35:54 +08:00
const LEGACY_PAGE_STYLE_VIEWS = new Set<WebViewKey>([
"login",
"workbench",
"canvas",
"community",
"communityReview",
"communityCaseAdd",
2026-06-08 15:23:13 +08:00
"betaApplications",
2026-06-05 17:35:54 +08:00
"assets",
"ecommerce",
"ecommerceHub",
"ecommerceTemplates",
"sizeTemplate",
2026-06-05 17:35:54 +08:00
"digitalHuman",
"characterMix",
"more",
]);
let legacyPageStylesPromise: Promise<unknown> | null = null;
function loadLegacyPageStyles(): Promise<unknown> {
legacyPageStylesPromise ??= import("./styles/pages/legacy-pages.css");
return legacyPageStylesPromise;
}
2026-06-02 12:38:01 +08:00
function normalizeViewKey(rawView: string): WebViewKey {
const normalized =
rawView === "profile" || rawView === "auth"
? "login"
: rawView === "ecommerceHub"
? "ecommerce"
2026-06-08 13:54:45 +08:00
: rawView === "bug-feedback" || rawView === "feedback"
? "report"
: rawView === "terms" || rawView === "agreement" || rawView === "user-agreement"
? "userAgreement"
: rawView === "privacy" || rawView === "privacy-policy"
? "privacyPolicy"
2026-06-02 12:38:01 +08:00
: rawView === "community-review"
? "communityReview"
: rawView === "community-case-add"
? "communityCaseAdd"
2026-06-08 15:23:13 +08:00
: rawView === "beta-applications" || rawView === "beta-application-review"
? "betaApplications"
2026-06-02 12:38:01 +08:00
: rawView;
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
2026-06-02 12:38:01 +08:00
}
function readViewFromHash(): WebViewKey {
const raw = window.location.hash.replace(/^#\/?/, "");
if (!raw) return "home";
return normalizeViewKey(raw);
2026-06-02 12:38:01 +08:00
}
function isWorkspaceView(view: WebViewKey): boolean {
return (
view !== "workbench" &&
view !== "home" &&
view !== "community" &&
view !== "assets" &&
view !== "ecommerceHub" &&
view !== "ecommerce" &&
view !== "scriptTokens" &&
view !== "login" &&
view !== "not-found"
2026-06-02 12:38:01 +08:00
);
}
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" : "omni-水果 Pro",
2026-06-02 12:38:01 +08:00
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,
loginPromptOpen,
pendingAction,
sessionReplacedOpen,
sessionReplacedMessage,
setSession,
openLoginPrompt,
closeLoginPrompt,
showSessionReplaced,
hideSessionReplaced,
clearSession: clearSessionState,
} = useSessionStore(useShallow((s) => ({
session: s.session,
loginPromptOpen: s.loginPromptOpen,
pendingAction: s.pendingAction,
sessionReplacedOpen: s.sessionReplacedOpen,
sessionReplacedMessage: s.sessionReplacedMessage,
setSession: s.setSession,
openLoginPrompt: s.openLoginPrompt,
closeLoginPrompt: s.closeLoginPrompt,
showSessionReplaced: s.showSessionReplaced,
hideSessionReplaced: s.hideSessionReplaced,
clearSession: s.clearSession,
})));
2026-06-02 12:38:01 +08:00
// Project store
const {
projects,
projectsLoaded,
canvasWorkflow,
currentCanvasProjectId,
pendingDeleteProject,
deleteProjectSubmitting,
setProjects,
setProjectsLoaded,
setCanvasWorkflow,
setCurrentCanvasProjectId,
openDeleteProject: openDeleteProjectModal,
closeDeleteProject: closeDeleteProjectModal,
setDeleteProjectSubmitting,
clearProjectState,
} = useProjectStore(useShallow((s) => ({
projects: s.projects,
projectsLoaded: s.projectsLoaded,
canvasWorkflow: s.canvasWorkflow,
currentCanvasProjectId: s.currentCanvasProjectId,
pendingDeleteProject: s.pendingDeleteProject,
deleteProjectSubmitting: s.deleteProjectSubmitting,
setProjects: s.setProjects,
setProjectsLoaded: s.setProjectsLoaded,
setCanvasWorkflow: s.setCanvasWorkflow,
setCurrentCanvasProjectId: s.setCurrentCanvasProjectId,
openDeleteProject: s.openDeleteProject,
closeDeleteProject: s.closeDeleteProject,
setDeleteProjectSubmitting: s.setDeleteProjectSubmitting,
clearProjectState: s.clearProjectState,
})));
2026-06-02 12:38:01 +08:00
// Task store
const {
tasks,
setTasks,
appendTask,
mergeServerTasks,
clearTasks,
} = useTaskStore(useShallow((s) => ({
tasks: s.tasks,
setTasks: s.setTasks,
appendTask: s.appendTask,
mergeServerTasks: s.mergeServerTasks,
clearTasks: s.clearTasks,
})));
2026-06-02 12:38:01 +08:00
// App store
const {
usage,
runtimeNotifications,
serverNotifications,
activeView,
workspaceExpanded,
imageWorkbenchTool,
pendingEcommerceTemplate,
backendHealth,
setUsage,
pushNotification,
setRuntimeNotifications,
setServerNotifications,
setView,
setWorkspaceExpanded,
setImageWorkbenchTool,
setPendingEcommerceTemplate,
setBackendHealth,
markNotificationRead,
markAllNotificationsRead,
clearAppState,
} = useAppStore(useShallow((s) => ({
usage: s.usage,
runtimeNotifications: s.runtimeNotifications,
serverNotifications: s.serverNotifications,
activeView: s.activeView,
workspaceExpanded: s.workspaceExpanded,
imageWorkbenchTool: s.imageWorkbenchTool,
pendingEcommerceTemplate: s.pendingEcommerceTemplate,
backendHealth: s.backendHealth,
setUsage: s.setUsage,
pushNotification: s.pushNotification,
setRuntimeNotifications: s.setRuntimeNotifications,
setServerNotifications: s.setServerNotifications,
setView: s.setView,
setWorkspaceExpanded: s.setWorkspaceExpanded,
setImageWorkbenchTool: s.setImageWorkbenchTool,
setPendingEcommerceTemplate: s.setPendingEcommerceTemplate,
setBackendHealth: s.setBackendHealth,
markNotificationRead: s.markNotificationRead,
markAllNotificationsRead: s.markAllNotificationsRead,
clearAppState: s.clearAppState,
})));
2026-06-02 12:38:01 +08:00
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
const isEcommerceActive = activeView === "ecommerce" || activeView === "ecommerceHub";
useEffect(() => {
if (isEcommerceActive && !ecommerceEverMounted) setEcommerceEverMounted(true);
}, [isEcommerceActive]); // eslint-disable-line react-hooks/exhaustive-deps
2026-06-05 17:35:54 +08:00
useEffect(() => {
if (LEGACY_PAGE_STYLE_VIEWS.has(activeView) || ecommerceEverMounted) {
void loadLegacyPageStyles();
}
}, [activeView, ecommerceEverMounted]);
2026-06-02 12:38:01 +08:00
// 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);
}
}, []);
// Pre-warm notification permission (lazy, on first click)
useEffect(() => { initNotificationPermission(); }, []);
// Global unhandled error / rejection listeners — report to server
useEffect(() => {
const handleUnhandled = (event: ErrorEvent) => {
reportError(event.error || new Error(event.message), "unhandled");
};
const handleRejection = (event: PromiseRejectionEvent) => {
reportError(event.reason instanceof Error ? event.reason : new Error(String(event.reason)), "rejection");
};
window.addEventListener("error", handleUnhandled);
window.addEventListener("unhandledrejection", handleRejection);
return () => {
window.removeEventListener("error", handleUnhandled);
window.removeEventListener("unhandledrejection", handleRejection);
};
}, []);
2026-06-02 12:38:01 +08:00
// 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
// ── Recover background tasks on app start ──────────
useEffect(() => {
recoverAndResumeTasks();
}, []);
2026-06-02 12:38:01 +08:00
const navItems = useMemo<WebNavItem[]>(
() => [
2026-06-05 20:42:34 +08:00
{ key: "home", label: "首页", hint: "项目入口", icon: <ShellIcon name="home" /> },
{ key: "workbench", label: "生成", hint: "对话生成页面", icon: <ShellIcon name="robot" /> },
2026-06-02 12:38:01 +08:00
{
key: "ecommerce",
label: "电商生成",
hint: "AI创作与海报生成",
2026-06-05 20:42:34 +08:00
icon: <ShellIcon name="shopping" />,
2026-06-02 12:38:01 +08:00
},
2026-06-05 20:42:34 +08:00
{ key: "canvas", label: "画布", hint: "进入自由画布编排", icon: <ShellIcon name="branches" /> },
{ key: "community", label: "社区", hint: "案例分享与导入", icon: <ShellIcon name="global" /> },
{ key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: <ShellIcon name="bar-chart" /> },
{ key: "tokenUsage", label: "Token消耗", hint: "成员、服务与调用记录", icon: <ShellIcon name="wallet" /> },
{ key: "providerHealth", label: "服务商健康", hint: "AI 服务商状态与监控", icon: <ShellIcon name="heart" /> },
{ key: "assets", label: "资产库", hint: "角色、场景、道具", icon: <ShellIcon name="folder" /> },
{ key: "agent", label: "Agent", hint: "拆解与规划", icon: <ShellIcon name="robot" /> },
{ key: "digitalHuman", label: "数字人", hint: "口播与人像生成", icon: <ShellIcon name="customer-service" /> },
{ key: "characterMix", label: "角色迁移", hint: "人物视频迁移", icon: <ShellIcon name="swap" /> },
{ key: "more", label: "工具盒", hint: "图像与镜头工具", icon: <ShellIcon name="tool" /> },
2026-06-02 12:38:01 +08:00
],
[],
);
const handleSetView = useCallback((view: WebViewKey) => {
if (view === "workbench" && Boolean(session)) {
setWorkbenchResetToken((token) => token + 1);
}
2026-06-02 12:38:01 +08:00
window.location.hash = `/${view}`;
setView(view);
if (view !== "login") {
lastNonAuthViewRef.current = view;
}
if (isWorkspaceView(view)) {
setWorkspaceExpanded(true);
}
}, [session, setView, setWorkspaceExpanded]);
2026-06-02 12:38:01 +08:00
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
clearAllUserStorage();
2026-06-02 12:38:01 +08:00
clearSessionState();
setUserMaxConcurrency(null);
2026-06-02 12:38:01 +08:00
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 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);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
2026-06-02 12:38:01 +08:00
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);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
2026-06-02 12:38:01 +08:00
} 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;
handleOpenProject(projects[0]).catch(() => {
// Reset flag on failure so auto-open can retry on next dependency change
canvasAutoOpenedRecentRef.current = false;
});
2026-06-02 12:38:01 +08:00
}, [
activeView,
canvasWorkflow.nodes.length,
canvasWorkflow.source,
2026-06-02 12:38:01 +08:00
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);
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
2026-06-02 12:38:01 +08:00
await hydrateAccountData(nextSession);
if (nextSession.user.email && !nextSession.user.emailVerified) {
toast.info("邮箱尚未验证,部分功能可能受限,请在登录页通过邮箱验证码完成验证");
}
2026-06-02 12:38:01 +08:00
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: "omni-水果 Pro", aspectRatio: "1:1", imageSize: "2K" },
2026-06-02 12:38:01 +08:00
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 = (() => {
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}
onOpenProject={handleOpenProject}
onRemoveWork={(task) => setTasks(tasks.filter((item) => item.id !== task.id))}
2026-06-02 12:38:01 +08:00
/>
);
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}
2026-06-02 12:38:01 +08:00
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 null;
case "ecommerceTemplates":
return (
<EcommerceTemplatesPage
projects={projects}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectTemplate={(template) => {
setPendingEcommerceTemplate(template);
handleSetView("ecommerce");
}}
onStartCreate={handleStartTemplateCanvasCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
/>
);
case "sizeTemplate":
return (
<SizeTemplatePage
isAuthenticated={Boolean(session)}
onOpenMore={() => handleSetView("more")}
onOpenEcommerce={() => handleSetView("ecommerce")}
onSelectView={handleSetView}
/>
);
2026-06-02 12:38:01 +08:00
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 "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 "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 "dialogGenerator":
return <DialogGeneratorPage />;
2026-06-02 12:38:01 +08:00
case "report":
return <ReportPage />;
case "providerHealth":
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
case "userAgreement":
return <CompliancePage kind="agreement" />;
case "privacyPolicy":
return <CompliancePage kind="privacy" />;
2026-06-02 12:38:01 +08:00
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")}
/>
);
2026-06-08 15:23:13 +08:00
case "betaApplications":
return <BetaApplicationsPage session={session} onOpenLogin={handleOpenLogin} />;
2026-06-02 12:38:01 +08:00
case "workbench":
return (
<WorkbenchPage
key={`workbench-${workbenchResetToken}`}
2026-06-02 12:38:01 +08:00
isAuthenticated={Boolean(session)}
session={session}
onRequireLogin={handleRequireTaskLogin}
onOpenResultInCanvas={handleOpenResultInCanvas}
onRefreshUsage={refreshUsage}
resetToken={workbenchResetToken}
2026-06-02 12:38:01 +08:00
/>
);
case "home":
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
/>
);
case "not-found":
default:
return <NotFoundPage onGoHome={() => handleSetView("home")} />;
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 key={activeView}>
2026-06-02 12:38:01 +08:00
<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>
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
{ecommerceEverMounted && (
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
<Suspense fallback={null}>
<EcommercePage
projects={projects}
isAuthenticated={Boolean(session)}
onStartCreate={handleStartCreate}
onOpenProject={handleOpenProject}
onDeleteProject={handleDeleteProject}
onImportWorkflow={handleImportWorkflow}
onCreateTask={handleCreateTask}
onRequireLogin={handleRequireTaskLogin}
initialTemplate={pendingEcommerceTemplate}
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
/>
</Suspense>
</div>
)}
2026-06-02 12:38:01 +08:00
{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">
2026-06-05 20:42:34 +08:00
<ShellIcon name="delete" />
2026-06-02 12:38:01 +08:00
</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;