diff --git a/src/App.tsx b/src/App.tsx index 7198f5e..f301622 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,8 @@ import { } from "@ant-design/icons"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react"; import ErrorBoundary from "./components/ErrorBoundary"; +import { reportError } from "./utils/errorReporting"; +import { initNotificationPermission } from "./utils/generationNotifier"; import PageTransition from "./components/PageTransition"; import ToastContainer from "./components/toast/ToastContainer"; import { aiGenerationClient } from "./api/aiGenerationClient"; @@ -42,8 +44,6 @@ const CommunityReviewPage = lazy(() => import("./features/community-review/Commu const AvatarConsolePage = lazy(() => import("./features/digital-human/AvatarConsolePage")); const DigitalHumanPage = lazy(() => import("./features/digital-human/DigitalHumanPage")); const EcommercePage = lazy(() => import("./features/ecommerce/EcommercePage")); -const EcommerceTemplatesPage = lazy(() => import("./features/ecommerce/EcommerceTemplatesPage")); -import type { TemplateCase } from "./features/ecommerce/ecommerceTemplates"; const HomePage = lazy(() => import("./features/home/HomePage")); const ImageWorkbenchPage = lazy(() => import("./features/image-workbench/ImageWorkbenchPage")); const MorePage = lazy(() => import("./features/more/MorePage")); @@ -54,7 +54,6 @@ const ResolutionUpscalePage = lazy(() => import("./features/resolution-upscale/R const WatermarkRemovalPage = lazy(() => import("./features/watermark-removal/WatermarkRemovalPage")); const SubtitleRemovalPage = lazy(() => import("./features/subtitle-removal/SubtitleRemovalPage")); const ScriptTokensPage = lazy(() => import("./features/script-tokens/ScriptTokensPage")); -const SizeTemplatePage = lazy(() => import("./features/size-template/SizeTemplatePage")); const TokenUsagePage = lazy(() => import("./features/script-tokens/TokenUsagePage")); const SettingsPage = lazy(() => import("./features/settings/SettingsPage")); const WorkbenchPage = lazy(() => import("./features/workbench/WorkbenchPage")); @@ -101,7 +100,6 @@ const VIEW_KEYS = new Set([ "assets", "ecommerceHub", "ecommerce", - "ecommerceTemplates", "scriptTokens", "tokenUsage", "settings", @@ -113,14 +111,13 @@ const VIEW_KEYS = new Set([ "avatarConsole", "characterMix", "more", - "sizeTemplate", "communityReview", "communityCaseAdd", "report", "providerHealth", ]); -const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more", "ecommerceTemplates", "sizeTemplate"]); +const PUBLIC_VIEW_SET = new Set(["home", "login", "community", "more"]); function normalizeViewKey(rawView: string): WebViewKey { const normalized = @@ -287,6 +284,25 @@ function App() { } }, []); + // 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); + }; + }, []); + // Initialize canvasWorkflow if null useEffect(() => { if (!canvasWorkflow) { @@ -312,12 +328,6 @@ function App() { hint: "AI创作与海报生成", icon: , }, - { - key: "sizeTemplate", - label: "示例模板", - hint: "平台比例与导出尺寸", - icon: , - }, { key: "canvas", label: "画布", hint: "进入自由画布编排", icon: }, { key: "community", label: "社区", hint: "案例分享与导入", icon: }, { key: "scriptTokens", label: "剧本评分", hint: "剧本评分系统", icon: }, @@ -362,7 +372,7 @@ function App() { }, [clearSessionState, setProjects, setProjectsLoaded, setUsage, clearTasks, setRuntimeNotifications, setServerNotifications, setCanvasWorkflow, setCurrentCanvasProjectId, setWorkspaceExpanded, handleSetView]); const showSessionReplacedModal = useCallback((message?: string) => { - clearAuthenticatedState(); + clearAuthenticatedState({ resetView: true }); showSessionReplaced(message); }, [clearAuthenticatedState, showSessionReplaced]); @@ -380,11 +390,6 @@ function App() { }; }, [showSessionReplacedModal]); - const handleOpenEcommerceTemplate = useCallback((template: TemplateCase) => { - setPendingEcommerceTemplate(template); - handleSetView("ecommerce"); - }, [setPendingEcommerceTemplate, handleSetView]); - const hydrateAccountData = useCallback(async (nextSession: WebUserSession | null) => { setProjectsLoaded(false); if (!nextSession) { @@ -681,11 +686,14 @@ function App() { } canvasAutoOpenedRecentRef.current = true; - void handleOpenProject(projects[0]); + handleOpenProject(projects[0]).catch(() => { + // Reset flag on failure so auto-open can retry on next dependency change + canvasAutoOpenedRecentRef.current = false; + }); }, [ activeView, - canvasWorkflow?.nodes.length, - canvasWorkflow?.source, + canvasWorkflow.nodes.length, + canvasWorkflow.source, currentCanvasProjectId, handleOpenProject, projects, @@ -987,25 +995,6 @@ function App() { }, [activeView, session]); // eslint-disable-line react-hooks/exhaustive-deps const activePage = (() => { - if (!session && !PUBLIC_VIEWS.has(activeView)) { - return ( - handleSetView("workbench")} - onOpenCommunity={() => handleSetView("community")} - onDeleteProject={handleDeleteProject} - /> - ); - } switch (activeView) { case "login": return ( @@ -1049,7 +1038,7 @@ function App() { case "canvas": return ( setPendingEcommerceTemplate(null)} /> ); - case "ecommerceTemplates": - return ( - handleSetView("more")} - onOpenEcommerce={() => handleSetView("ecommerce")} - onSelectTemplate={handleOpenEcommerceTemplate} - onStartCreate={handleStartTemplateCanvasCreate} - onOpenProject={handleOpenProject} - onDeleteProject={handleDeleteProject} - /> - ); case "digitalHuman": return ( ; - case "sizeTemplate": - return ( - handleSetView("more")} - onOpenEcommerce={() => handleSetView("ecommerce")} - onSelectView={handleSetView} - /> - ); case "scriptTokens": return ; case "tokenUsage": diff --git a/src/api/keyServerClient.ts b/src/api/keyServerClient.ts index ea51e68..16b02e0 100644 --- a/src/api/keyServerClient.ts +++ b/src/api/keyServerClient.ts @@ -928,4 +928,9 @@ export const keyServerClient = { method: "DELETE", }); }, + + async getClientErrors(page = 1): Promise<{ items: unknown[]; total: number }> { + const data = await request<{ items: unknown[]; total: number }>(`/client-errors?page=${page}`); + return data; + }, }; diff --git a/src/api/scriptEvalClient.ts b/src/api/scriptEvalClient.ts index e930e79..c4f1dc5 100644 --- a/src/api/scriptEvalClient.ts +++ b/src/api/scriptEvalClient.ts @@ -1,5 +1,3 @@ -import { buildApiUrl, buildAuthHeaders } from "./serverConnection"; - export interface ScriptEvalResult { totalScore: number; grade: string; @@ -10,6 +8,10 @@ export interface ScriptEvalResult { suggestions: string[]; } +const DASHSCOPE_API_KEY = import.meta.env.VITE_DASHSCOPE_API_KEY || ""; +const DASHSCOPE_ENDPOINT = "/dashscope-api/chat/completions"; +const MODEL = "qwen3.7-max"; + const EVAL_SYSTEM_PROMPT = `你是一位资深影视剧本评审专家,拥有二十年以上的编剧、制片和剧本医生经验。你精通各类影视叙事理论(三幕式、英雄之旅、起承转合、序列法),同时深度跟踪AIGC短剧/漫剧行业最新趋势。你的任务是对用户提供的剧本进行严谨、系统、多维度的量化评分。 【剧本类型识别】 @@ -67,31 +69,35 @@ function extractJson(text: string): unknown { } export async function evaluateScript(script: string, signal?: AbortSignal): Promise { - console.log("[API] 发送评测请求,剧本长度:", script.slice(0, 8000).length, "字符"); - const res = await fetch(buildApiUrl("ai/chat"), { + if (!DASHSCOPE_API_KEY) { + throw new Error("DashScope API key 未配置,请在 .env.local 中设置 VITE_DASHSCOPE_API_KEY"); + } + + const res = await fetch(DASHSCOPE_ENDPOINT, { method: "POST", - headers: buildAuthHeaders(), + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${DASHSCOPE_API_KEY}`, + }, body: JSON.stringify({ - model: "qwen3.7-max", + model: MODEL, messages: [ { role: "system", content: EVAL_SYSTEM_PROMPT }, { role: "user", content: `请评测以下剧本:\n\n${script.slice(0, 8000)}` }, ], stream: false, temperature: 0.3, + max_tokens: 4096, }), signal, }); - console.log("[API] 响应状态:", res.status, res.statusText); - if (!res.ok) { - throw new Error(`评测请求失败 (${res.status})`); + const errText = await res.text().catch(() => ""); + throw new Error(`评测请求失败 (${res.status}): ${errText.slice(0, 200)}`); } const payload = await res.json(); - console.log("[API] 原始响应体:", payload); - const content: string = payload?.choices?.[0]?.message?.content ?? payload?.result?.content ?? payload?.content @@ -100,11 +106,7 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom if (!content) throw new Error("模型未返回有效内容"); - console.log("[API] 模型返回内容 (前500字符):", content.slice(0, 500)); - const parsed = extractJson(content) as Record; - console.log("[API] 解析后的JSON:", parsed); - const dimensionScores: Record = {}; const rawScores = parsed.dimensionScores as Record | undefined; if (!rawScores || typeof rawScores !== "object") throw new Error("评分格式异常"); @@ -115,7 +117,6 @@ export async function evaluateScript(script: string, signal?: AbortSignal): Prom } const { totalScore, grade } = computeTotalAndGrade(dimensionScores); - console.log("[API] 计算后总分:", totalScore, "等级:", grade); return { totalScore, diff --git a/src/api/serverConnection.ts b/src/api/serverConnection.ts index bf8d9be..6b72197 100644 --- a/src/api/serverConnection.ts +++ b/src/api/serverConnection.ts @@ -234,6 +234,10 @@ function notifySessionExpired(status: number, response: Response, payload: unkno if (/\/auth\//i.test(response.url)) return; // SESSION_REPLACED has its own dedicated handling/modal. if (getPayloadCode(payload) === "SESSION_REPLACED") return; + // If the user never had a session, a 401 is expected — not a session expiry. + if (!readStoredSession()) return; + // Deliberate early-exit for unauthenticated users — not a real auth failure. + if (getPayloadCode(payload) === "NOT_LOGGED_IN") return; const now = Date.now(); if (now - lastSessionExpiredEventAt < 1500) return; @@ -250,6 +254,7 @@ function notifySessionReplaced(status: number, payload: unknown, fallbackMessage const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录"; const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录"); if (!isSessionReplaced || typeof window === "undefined") return; + if (!readStoredSession()) return; const now = Date.now(); if (now - lastSessionReplacedEventAt < 1500) return; diff --git a/src/assets/home-features/home-ecommerce-template-1.png b/src/assets/home-features/home-ecommerce-template-1.png new file mode 100644 index 0000000..aa4091f Binary files /dev/null and b/src/assets/home-features/home-ecommerce-template-1.png differ diff --git a/src/assets/home-features/home-ecommerce-template-2.png b/src/assets/home-features/home-ecommerce-template-2.png new file mode 100644 index 0000000..3ff5165 Binary files /dev/null and b/src/assets/home-features/home-ecommerce-template-2.png differ diff --git a/src/assets/home-features/home-ecommerce-template-3.png b/src/assets/home-features/home-ecommerce-template-3.png new file mode 100644 index 0000000..c620c4d Binary files /dev/null and b/src/assets/home-features/home-ecommerce-template-3.png differ diff --git a/src/components/AdminMonitor.tsx b/src/components/AdminMonitor.tsx new file mode 100644 index 0000000..4fc41f1 --- /dev/null +++ b/src/components/AdminMonitor.tsx @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { keyServerClient } from "../api/keyServerClient"; + +interface ClientErrorItem { + id: number; + message: string; + stack?: string; + source: string; + url: string; + user_agent?: string; + user_id?: number; + count: number; + first_seen: string; + last_seen: string; +} + +const STORAGE_KEY = "omniai:admin-monitor-open"; +const POLL_INTERVAL = 30000; + +function formatTime(iso: string) { + const d = new Date(iso); + return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function AdminMonitor() { + const [open, setOpen] = useState(() => { + try { return sessionStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; } + }); + const [errors, setErrors] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const intervalRef = useRef>(); + + const fetchErrors = useCallback(async (p = 1) => { + setLoading(true); + try { + const data = await keyServerClient.getClientErrors(p); + setErrors(data.items); + setTotal(data.total); + setPage(p); + } catch { /* silent */ } + setLoading(false); + }, []); + + useEffect(() => { + if (!open) return; + void fetchErrors(1); + intervalRef.current = setInterval(() => fetchErrors(1), POLL_INTERVAL); + return () => clearInterval(intervalRef.current); + }, [open, fetchErrors]); + + useEffect(() => { + try { sessionStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch { /* */ } + }, [open]); + + const maxPage = Math.max(1, Math.ceil(total / 50)); + + if (!open) { + return ( + + ); + } + + return ( +
+
+ 客户端错误 ({total}) +
+ + +
+
+
+ {errors.length === 0 ? ( +
暂无错误
+ ) : ( + errors.map((err) => ( +
+ + {err.source} + {err.message.slice(0, 120)} + {err.count} + + +
+
URL: {err.url}
+
User: {err.user_id || "匿名"}
+ {err.stack ?
{err.stack.slice(0, 1000)}
: null} +
+
+ )) + )} +
+ {maxPage > 1 ? ( +
+ + {page} / {maxPage} + +
+ ) : null} +
+ ); +} + +export default AdminMonitor; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 6178646..3457460 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -3,8 +3,12 @@ import { ArrowUpOutlined, CheckCircleOutlined, FlagOutlined, + InfoCircleOutlined, LoginOutlined, LogoutOutlined, + PhoneOutlined, + SafetyOutlined, + EnvironmentOutlined, PlusCircleOutlined, UserOutlined, WalletOutlined, @@ -17,6 +21,7 @@ import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebV import NotificationCenter from "./NotificationCenter"; import { RechargeModal } from "./RechargeModal/RechargeModal"; import { AnimatedPanel } from "./AnimatedPanel"; +import AdminMonitor from "./AdminMonitor"; interface AppShellProps { activeView: WebViewKey; @@ -61,6 +66,8 @@ function AppShell({ const submenuHideTimerRef = useRef(null); const [profileOpen, setProfileOpen] = useState(false); const [rechargeOpen, setRechargeOpen] = useState(false); + const [infoOpen, setInfoOpen] = useState(false); + const infoRef = useRef(null); const [openSubmenuKey, setOpenSubmenuKey] = useState(null); const prevActiveViewRef = useRef(activeView); const [navJustActivated, setNavJustActivated] = useState(null); @@ -140,6 +147,17 @@ function AppShell({ return () => document.removeEventListener("pointerdown", handlePointerDown); }, [profileOpen]); + useEffect(() => { + if (!infoOpen) return; + const handleInfoOutside = (event: PointerEvent) => { + if (!infoRef.current?.contains(event.target as Node)) { + setInfoOpen(false); + } + }; + document.addEventListener("pointerdown", handleInfoOutside); + return () => document.removeEventListener("pointerdown", handleInfoOutside); + }, [infoOpen]); + useEffect(() => { if (!session) { setProfileOpen(false); @@ -307,6 +325,30 @@ function AppShell({ onMarkAllRead={onMarkAllNotificationsRead} /> )} +
+ + +
+
备案信息
+
苏ICP备2026021747号-1
+
公司地址
+
江苏省南京市江北新区扬子江数字视听产业园9栋A楼501
+
联系电话
+
15155073618
+
+ +
+
) : null} - {!shouldShowEmptyProjectState && recentProjectsOpen ? ( + {(!shouldShowEmptyProjectState || isWaitingForProjects) && recentProjectsOpen ? (