diff --git a/src/App.tsx b/src/App.tsx index 473fcf5..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"; @@ -282,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) { 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/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 48be19d..3457460 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -21,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; @@ -470,6 +471,7 @@ function AppShell({
{children}
+ {session?.user.role === "admin" ? : null} setRechargeOpen(false)} currentBalance={displayedBalanceCents} /> ); diff --git a/src/features/community/CommunityPage.tsx b/src/features/community/CommunityPage.tsx index 102b6aa..4770c90 100644 --- a/src/features/community/CommunityPage.tsx +++ b/src/features/community/CommunityPage.tsx @@ -7,8 +7,10 @@ import { PictureOutlined, PlusOutlined, RightOutlined, + SearchOutlined, } from "@ant-design/icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDebounce } from "../../hooks/useDebounce"; import { communityClient, type ServerCommunityCase } from "../../api/communityClient"; import WorkspacePageShell from "../../components/WorkspacePageShell"; import OptimizedImage from "../../components/OptimizedImage"; @@ -70,6 +72,8 @@ function buildWorkflowFromServerCase(item: ServerCommunityCase, fallback: WebCan function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject, onDeleteProject, onImportWorkflow, onRequireLogin }: CommunityPageProps) { const [serverCases, setServerCases] = useState([]); const [serverNotice, setServerNotice] = useState(null); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebounce(query, 300); const [favoriteIds, setFavoriteIds] = useState([]); const canUseProtectedAction = (action: string) => onRequireLogin?.(action) !== false; @@ -260,7 +264,17 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject } }; - const liveCases: ServerCommunityCase[] = serverCases.slice(0, 12); + const filteredCases = useMemo(() => { + const q = debouncedQuery.trim().toLowerCase(); + if (!q) return serverCases; + return serverCases.filter((c) => + (c.title || "").toLowerCase().includes(q) || + (c.description || "").toLowerCase().includes(q) || + (c.tags || []).some((t: string) => t.toLowerCase().includes(q)) + ); + }, [serverCases, debouncedQuery]); + + const liveCases: ServerCommunityCase[] = filteredCases.slice(0, 12); return ( @@ -387,6 +401,15 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject

社区精选

+ {serverNotice ? {serverNotice} : null} {liveCases.length ? ( @@ -473,8 +496,8 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject ) : ( } - title="社区暂无模板" - description="管理员审核通过后,画布社区案例会显示在这里。" + title={debouncedQuery ? "无匹配结果" : "社区暂无模板"} + description={debouncedQuery ? "尝试其他关键词,或清除搜索查看全部案例" : "管理员审核通过后,画布社区案例会显示在这里。"} /> )} diff --git a/src/features/home/WelcomeSplash.tsx b/src/features/home/WelcomeSplash.tsx index 63418d3..e9a0271 100644 --- a/src/features/home/WelcomeSplash.tsx +++ b/src/features/home/WelcomeSplash.tsx @@ -15,7 +15,7 @@ const prefersReducedMotion = typeof window !== "undefined" export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) { const canvasRef = useRef(null); const rafRef = useRef(0); - const [showWelcome, setShowWelcome] = useState(false); + const [showWelcome, setShowWelcome] = useState(true); const [exiting, setExiting] = useState(false); const handleEnter = useCallback(() => { diff --git a/src/features/workbench/WorkbenchPage.tsx b/src/features/workbench/WorkbenchPage.tsx index 7830b48..9777ba8 100644 --- a/src/features/workbench/WorkbenchPage.tsx +++ b/src/features/workbench/WorkbenchPage.tsx @@ -949,6 +949,11 @@ function WorkbenchPage({ await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch); removeKeepaliveTask(task.taskId); onRefreshUsage?.(); + if (status.status === "completed") { + import("../../utils/generationNotifier").then((m) => + m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"), + ); + } try { if (status.resultUrl) { const persistedResult = await persistWorkbenchResultAsset({ @@ -992,6 +997,11 @@ function WorkbenchPage({ }); removeKeepaliveTask(task.taskId); onRefreshUsage?.(); + if (status.status === "completed") { + import("../../utils/generationNotifier").then((m) => + m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"), + ); + } return; } @@ -2105,7 +2115,7 @@ function WorkbenchPage({ return; } if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) { - setProjectError("当前任务数已达上限(3个),请等待任务完成后再试"); + setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`); return; } if (!isAuthenticated) { @@ -2227,7 +2237,7 @@ function WorkbenchPage({ return; } if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) { - setProjectError("当前任务数已达上限(3个),请等待任务完成后再试"); + setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`); return; } if (!isAuthenticated) { diff --git a/src/styles/shell/app-shell.css b/src/styles/shell/app-shell.css index 98847c6..da19b01 100644 --- a/src/styles/shell/app-shell.css +++ b/src/styles/shell/app-shell.css @@ -642,3 +642,187 @@ .info-popover__links a:hover { text-decoration: underline; } + +/* ── Admin monitor ──────────────────────────── */ +.admin-monitor-trigger { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 200; + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid rgba(var(--accent-rgb), 0.3); + border-radius: 50%; + background: var(--bg-panel); + cursor: pointer; + box-shadow: 0 2px 12px rgba(0,0,0,0.2); +} + +.admin-monitor-trigger__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + animation: admin-monitor-pulse 2s ease-in-out infinite; +} + +@keyframes admin-monitor-pulse { + 0%, 100% { opacity: 0.4; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.3); } +} + +.admin-monitor { + position: fixed; + bottom: 60px; + right: 16px; + z-index: 199; + width: min(480px, calc(100vw - 32px)); + max-height: 70vh; + display: flex; + flex-direction: column; + border: 1px solid var(--border-normal); + border-radius: 12px; + background: var(--bg-panel); + box-shadow: 0 8px 40px rgba(0,0,0,0.35); + overflow: hidden; +} + +.admin-monitor__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-weak); +} + +.admin-monitor__header strong { + font-size: 13px; + color: var(--fg-body); +} + +.admin-monitor__actions { + display: flex; + gap: 6px; +} + +.admin-monitor__actions button { + padding: 3px 12px; + border: 1px solid var(--border-normal); + border-radius: 5px; + background: transparent; + color: var(--fg-muted); + font-size: 11px; + cursor: pointer; +} + +.admin-monitor__actions button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} + +.admin-monitor__list { + flex: 1; + overflow: auto; + padding: 8px; +} + +.admin-monitor__empty { + padding: 24px; + text-align: center; + color: var(--fg-muted); + font-size: 12px; +} + +.admin-monitor__item { + border-bottom: 1px solid var(--border-weak); +} + +.admin-monitor__item summary { + display: grid; + grid-template-columns: 60px 1fr 36px 100px; + align-items: center; + gap: 8px; + padding: 8px 4px; + cursor: pointer; + font-size: 11px; +} + +.admin-monitor__source { + display: inline-block; + padding: 2px 6px; + border-radius: 3px; + background: rgba(var(--accent-rgb), 0.12); + color: var(--accent); + font-size: 10px; + font-weight: 700; + text-align: center; +} + +.admin-monitor__msg { + overflow: hidden; + color: var(--fg-body); + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-monitor__count { + display: inline-block; + min-width: 24px; + padding: 1px 5px; + border-radius: 999px; + background: rgba(255, 107, 53, 0.15); + color: #ff6b35; + font-size: 10px; + font-weight: 800; + text-align: center; +} + +.admin-monitor__item time { + color: var(--fg-muted); + font-size: 10px; + text-align: right; +} + +.admin-monitor__detail { + padding: 8px 12px 12px; + color: var(--fg-muted); + font-size: 11px; + line-height: 1.6; +} + +.admin-monitor__detail pre { + margin-top: 6px; + padding: 8px; + border-radius: 4px; + background: var(--bg-inset); + font-size: 10px; + max-height: 120px; + overflow: auto; +} + +.admin-monitor__pager { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 8px; + border-top: 1px solid var(--border-weak); + font-size: 11px; + color: var(--fg-muted); +} + +.admin-monitor__pager button { + padding: 2px 10px; + border: 1px solid var(--border-normal); + border-radius: 4px; + background: transparent; + color: var(--fg-muted); + cursor: pointer; +} + +.admin-monitor__pager button:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} diff --git a/src/utils/generationNotifier.ts b/src/utils/generationNotifier.ts new file mode 100644 index 0000000..d8b459b --- /dev/null +++ b/src/utils/generationNotifier.ts @@ -0,0 +1,50 @@ +/** + * Browser notification + in-app toast for generation task completions. + * Falls back gracefully when Notification API is unavailable. + */ + +let permissionGranted = false; + +async function requestPermission(): Promise { + if (permissionGranted) return true; + if (typeof Notification === "undefined") return false; + if (Notification.permission === "granted") { permissionGranted = true; return true; } + if (Notification.permission === "denied") return false; + try { + const result = await Notification.requestPermission(); + permissionGranted = result === "granted"; + return permissionGranted; + } catch { + return false; + } +} + +export function notifyTaskCompleted(label: string, mode: "image" | "video" = "image") { + const emoji = mode === "video" ? "🎬" : "🖼️"; + const title = `${emoji} ${label}生成完成`; + const body = "点击返回查看生成结果"; + + // Browser notification (background tab) + if (typeof Notification !== "undefined" && Notification.permission === "granted") { + try { new Notification(title, { body, icon: "/favicon.ico", tag: "gen-complete" }); } catch { /* */ } + } + + // In-app toast + dispatchGenToast(title); +} + +// Use the existing toast system for in-app notifications +function dispatchGenToast(msg: string) { + try { + import("../components/toast/toastStore").then((m) => m.toast(msg, "success")); + } catch { /* toast system not loaded */ } +} + +/** Call once on app init to pre-warm permission. */ +export async function initNotificationPermission() { + if (typeof Notification === "undefined") return; + if (Notification.permission === "default") { + // Don't prompt immediately — wait for first user interaction + document.addEventListener("click", () => requestPermission(), { once: true }); + } +}