2026-06-03 23:20:57 +08:00
|
|
|
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
2026-06-05 17:04:01 +08:00
|
|
|
|
import { useShallow } from "zustand/react/shallow";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
import ErrorBoundary from "./components/ErrorBoundary";
|
2026-06-03 02:01:21 +08:00
|
|
|
|
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";
|
2026-06-03 20:19:07 +08:00
|
|
|
|
import { toast } from "./components/toast/toastStore";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
import { aiGenerationClient } from "./api/aiGenerationClient";
|
|
|
|
|
|
import { keyServerClient } from "./api/keyServerClient";
|
2026-06-08 14:47:27 +08:00
|
|
|
|
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,
|
2026-06-04 16:03:49 +08:00
|
|
|
|
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";
|
2026-06-03 20:19:07 +08:00
|
|
|
|
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";
|
2026-06-03 12:16:33 +08:00
|
|
|
|
const NotFoundPage = lazy(() => import("./components/NotFoundPage"));
|
2026-06-03 20:19:07 +08:00
|
|
|
|
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"));
|
2026-06-05 00:37:38 +08:00
|
|
|
|
const DialogGeneratorPage = lazy(() => import("./features/dialog-generator/DialogGeneratorPage"));
|
2026-06-02 12:38:01 +08:00
|
|
|
|
const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage"));
|
2026-06-05 19:45:56 +08:00
|
|
|
|
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"));
|
2026-06-05 19:45:56 +08:00
|
|
|
|
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",
|
2026-06-05 19:45:56 +08:00
|
|
|
|
"ecommerceTemplates",
|
|
|
|
|
|
"sizeTemplate",
|
2026-06-02 12:38:01 +08:00
|
|
|
|
"scriptTokens",
|
|
|
|
|
|
"tokenUsage",
|
|
|
|
|
|
"imageWorkbench",
|
|
|
|
|
|
"resolutionUpscale",
|
|
|
|
|
|
"watermarkRemoval",
|
|
|
|
|
|
"subtitleRemoval",
|
2026-06-05 00:37:38 +08:00
|
|
|
|
"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",
|
2026-06-03 20:19:07 +08:00
|
|
|
|
"userAgreement",
|
|
|
|
|
|
"privacyPolicy",
|
2026-06-03 12:16:33 +08:00
|
|
|
|
"not-found",
|
2026-06-02 12:38:01 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-06-05 00:37:38 +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",
|
2026-06-05 19:45:56 +08:00
|
|
|
|
"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"
|
2026-06-03 20:19:07 +08:00
|
|
|
|
: rawView === "ecommerceHub"
|
|
|
|
|
|
? "ecommerce"
|
2026-06-08 13:54:45 +08:00
|
|
|
|
: rawView === "bug-feedback" || rawView === "feedback"
|
|
|
|
|
|
? "report"
|
2026-06-03 20:19:07 +08:00
|
|
|
|
: 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;
|
2026-06-03 12:16:33 +08:00
|
|
|
|
return VIEW_KEYS.has(normalized as WebViewKey) ? (normalized as WebViewKey) : "not-found";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function readViewFromHash(): WebViewKey {
|
2026-06-04 16:03:49 +08:00
|
|
|
|
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" &&
|
2026-06-03 12:16:33 +08:00
|
|
|
|
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: {
|
2026-06-08 14:40:47 +08:00
|
|
|
|
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
|
2026-06-05 17:04:01 +08:00
|
|
|
|
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
|
2026-06-05 17:04:01 +08:00
|
|
|
|
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
|
2026-06-05 17:04:01 +08:00
|
|
|
|
const {
|
|
|
|
|
|
tasks,
|
2026-06-08 13:44:03 +08:00
|
|
|
|
setTasks,
|
2026-06-05 17:04:01 +08:00
|
|
|
|
appendTask,
|
|
|
|
|
|
mergeServerTasks,
|
|
|
|
|
|
clearTasks,
|
|
|
|
|
|
} = useTaskStore(useShallow((s) => ({
|
|
|
|
|
|
tasks: s.tasks,
|
2026-06-08 13:44:03 +08:00
|
|
|
|
setTasks: s.setTasks,
|
2026-06-05 17:04:01 +08:00
|
|
|
|
appendTask: s.appendTask,
|
|
|
|
|
|
mergeServerTasks: s.mergeServerTasks,
|
|
|
|
|
|
clearTasks: s.clearTasks,
|
|
|
|
|
|
})));
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
// App store
|
2026-06-05 17:04:01 +08:00
|
|
|
|
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
|
|
|
|
|
2026-06-03 23:20:57 +08:00
|
|
|
|
const [ecommerceEverMounted, setEcommerceEverMounted] = useState(false);
|
2026-06-08 17:30:21 +08:00
|
|
|
|
const [workbenchResetToken, setWorkbenchResetToken] = useState(0);
|
2026-06-03 23:20:57 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-03 02:01:21 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-06-03 20:19:07 +08:00
|
|
|
|
// ── 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) => {
|
2026-06-08 17:30:21 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-06-08 17:30:21 +08:00
|
|
|
|
}, [session, setView, setWorkspaceExpanded]);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
const clearAuthenticatedState = useCallback((options?: { resetView?: boolean }) => {
|
2026-06-04 16:03:49 +08:00
|
|
|
|
clearAllUserStorage();
|
2026-06-02 12:38:01 +08:00
|
|
|
|
clearSessionState();
|
2026-06-08 14:47:27 +08:00
|
|
|
|
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) {
|
2026-06-02 18:47:00 +08:00
|
|
|
|
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) => {
|
2026-06-02 19:37:29 +08:00
|
|
|
|
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);
|
2026-06-08 14:47:27 +08:00
|
|
|
|
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);
|
2026-06-08 14:47:27 +08:00
|
|
|
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
} else {
|
2026-06-02 18:47:00 +08:00
|
|
|
|
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;
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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,
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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);
|
2026-06-08 14:47:27 +08:00
|
|
|
|
setUserMaxConcurrency(nextSession?.user?.maxConcurrency);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
await hydrateAccountData(nextSession);
|
|
|
|
|
|
|
2026-06-03 20:19:07 +08:00
|
|
|
|
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" }
|
2026-06-08 14:40:47 +08:00
|
|
|
|
: { 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}
|
2026-06-07 11:42:00 +08:00
|
|
|
|
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
|
2026-06-03 01:39:06 +08:00
|
|
|
|
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":
|
2026-06-03 23:20:57 +08:00
|
|
|
|
return null;
|
2026-06-05 19:45:56 +08:00
|
|
|
|
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}
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-05 00:37:38 +08:00
|
|
|
|
case "dialogGenerator":
|
|
|
|
|
|
return <DialogGeneratorPage />;
|
2026-06-02 12:38:01 +08:00
|
|
|
|
case "report":
|
|
|
|
|
|
return <ReportPage />;
|
|
|
|
|
|
case "providerHealth":
|
|
|
|
|
|
return <ProviderHealthPage session={session} onOpenLogin={handleOpenLogin} />;
|
2026-06-03 20:19:07 +08:00
|
|
|
|
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
|
2026-06-08 17:30:21 +08:00
|
|
|
|
key={`workbench-${workbenchResetToken}`}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
isAuthenticated={Boolean(session)}
|
|
|
|
|
|
session={session}
|
|
|
|
|
|
onRequireLogin={handleRequireTaskLogin}
|
|
|
|
|
|
onOpenResultInCanvas={handleOpenResultInCanvas}
|
|
|
|
|
|
onRefreshUsage={refreshUsage}
|
2026-06-08 17:30:21 +08:00
|
|
|
|
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")}
|
2026-06-02 18:58:13 +08:00
|
|
|
|
onSelectView={handleSetView}
|
|
|
|
|
|
onOpenImageTool={handleOpenImageWorkbenchTool}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
/>
|
|
|
|
|
|
);
|
2026-06-03 12:16:33 +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}
|
|
|
|
|
|
>
|
2026-06-04 01:12:51 +08:00
|
|
|
|
<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>
|
2026-06-03 23:25:10 +08:00
|
|
|
|
</Suspense>
|
|
|
|
|
|
</ErrorBoundary>
|
2026-06-03 23:20:57 +08:00
|
|
|
|
|
2026-06-03 23:25:10 +08:00
|
|
|
|
{/* KeepAlive: EcommercePage stays mounted once visited, hidden via display:none */}
|
|
|
|
|
|
{ecommerceEverMounted && (
|
|
|
|
|
|
<div className="keepalive-ecommerce" style={{ display: isEcommerceActive ? undefined : "none" }}>
|
|
|
|
|
|
<Suspense fallback={null}>
|
2026-06-03 23:20:57 +08:00
|
|
|
|
<EcommercePage
|
|
|
|
|
|
projects={projects}
|
|
|
|
|
|
isAuthenticated={Boolean(session)}
|
|
|
|
|
|
onStartCreate={handleStartCreate}
|
|
|
|
|
|
onOpenProject={handleOpenProject}
|
|
|
|
|
|
onDeleteProject={handleDeleteProject}
|
|
|
|
|
|
onImportWorkflow={handleImportWorkflow}
|
|
|
|
|
|
onCreateTask={handleCreateTask}
|
|
|
|
|
|
onRequireLogin={handleRequireTaskLogin}
|
|
|
|
|
|
initialTemplate={pendingEcommerceTemplate}
|
|
|
|
|
|
onInitialTemplateConsumed={() => setPendingEcommerceTemplate(null)}
|
|
|
|
|
|
/>
|
2026-06-03 23:25:10 +08:00
|
|
|
|
</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;
|