feat: 错误监控面板、生成通知、社区搜索、任务队列优化
- AdminMonitor: admin用户可见的客户端错误实时监控面板,右下角浮窗 - generationNotifier: 生成完成浏览器通知 + 站内Toast - CommunityPage: 新增搜索框,标题/描述/标签模糊匹配,防抖300ms - App.tsx: 全局unhandled error/rejection监听上报 - WorkbenchPage: 任务并发提示改为显示当前任务数 - serverConnection: 后端client-errors路由注册 - WelcomeSplash: 欢迎按钮全程显示 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
+21
@@ -16,6 +16,8 @@ import {
|
|||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
|
import { reportError } from "./utils/errorReporting";
|
||||||
|
import { initNotificationPermission } from "./utils/generationNotifier";
|
||||||
import PageTransition from "./components/PageTransition";
|
import PageTransition from "./components/PageTransition";
|
||||||
import ToastContainer from "./components/toast/ToastContainer";
|
import ToastContainer from "./components/toast/ToastContainer";
|
||||||
import { aiGenerationClient } from "./api/aiGenerationClient";
|
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
|
// Initialize canvasWorkflow if null
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasWorkflow) {
|
if (!canvasWorkflow) {
|
||||||
|
|||||||
@@ -928,4 +928,9 @@ export const keyServerClient = {
|
|||||||
method: "DELETE",
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<ClientErrorItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button type="button" className="admin-monitor-trigger" onClick={() => setOpen(true)} title="错误监控">
|
||||||
|
<span className="admin-monitor-trigger__dot" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-monitor" role="dialog" aria-label="客户端错误监控">
|
||||||
|
<header className="admin-monitor__header">
|
||||||
|
<strong>客户端错误 ({total})</strong>
|
||||||
|
<div className="admin-monitor__actions">
|
||||||
|
<button type="button" onClick={() => void fetchErrors(1)} disabled={loading}>
|
||||||
|
{loading ? "刷新中..." : "刷新"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOpen(false)}>关闭</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<section className="admin-monitor__list">
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<div className="admin-monitor__empty">暂无错误</div>
|
||||||
|
) : (
|
||||||
|
errors.map((err) => (
|
||||||
|
<details key={err.id} className="admin-monitor__item">
|
||||||
|
<summary>
|
||||||
|
<span className="admin-monitor__source">{err.source}</span>
|
||||||
|
<span className="admin-monitor__msg">{err.message.slice(0, 120)}</span>
|
||||||
|
<span className="admin-monitor__count">{err.count}</span>
|
||||||
|
<time>{formatTime(err.last_seen)}</time>
|
||||||
|
</summary>
|
||||||
|
<div className="admin-monitor__detail">
|
||||||
|
<div><b>URL:</b> {err.url}</div>
|
||||||
|
<div><b>User:</b> {err.user_id || "匿名"}</div>
|
||||||
|
{err.stack ? <pre>{err.stack.slice(0, 1000)}</pre> : null}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{maxPage > 1 ? (
|
||||||
|
<footer className="admin-monitor__pager">
|
||||||
|
<button type="button" disabled={page <= 1} onClick={() => fetchErrors(page - 1)}>上一页</button>
|
||||||
|
<span>{page} / {maxPage}</span>
|
||||||
|
<button type="button" disabled={page >= maxPage} onClick={() => fetchErrors(page + 1)}>下一页</button>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminMonitor;
|
||||||
@@ -21,6 +21,7 @@ import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebV
|
|||||||
import NotificationCenter from "./NotificationCenter";
|
import NotificationCenter from "./NotificationCenter";
|
||||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||||
import { AnimatedPanel } from "./AnimatedPanel";
|
import { AnimatedPanel } from "./AnimatedPanel";
|
||||||
|
import AdminMonitor from "./AdminMonitor";
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
activeView: WebViewKey;
|
activeView: WebViewKey;
|
||||||
@@ -470,6 +471,7 @@ function AppShell({
|
|||||||
<div className="web-shell__page">{children}</div>
|
<div className="web-shell__page">{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
PictureOutlined,
|
PictureOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
|
SearchOutlined,
|
||||||
} from "@ant-design/icons";
|
} from "@ant-design/icons";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useDebounce } from "../../hooks/useDebounce";
|
||||||
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
import { communityClient, type ServerCommunityCase } from "../../api/communityClient";
|
||||||
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
import WorkspacePageShell from "../../components/WorkspacePageShell";
|
||||||
import OptimizedImage from "../../components/OptimizedImage";
|
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) {
|
function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject, onDeleteProject, onImportWorkflow, onRequireLogin }: CommunityPageProps) {
|
||||||
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
|
const [serverCases, setServerCases] = useState<ServerCommunityCase[]>([]);
|
||||||
const [serverNotice, setServerNotice] = useState<string | null>(null);
|
const [serverNotice, setServerNotice] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const debouncedQuery = useDebounce(query, 300);
|
||||||
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
const [favoriteIds, setFavoriteIds] = useState<string[]>([]);
|
||||||
const canUseProtectedAction = (action: string) => onRequireLogin?.(action) !== false;
|
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 (
|
return (
|
||||||
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
|
<WorkspacePageShell title="社区" fullWidth className="community-page page-motion">
|
||||||
@@ -387,6 +401,15 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
|
|||||||
<div>
|
<div>
|
||||||
<h2>社区精选</h2>
|
<h2>社区精选</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<label className="asset-search">
|
||||||
|
<SearchOutlined />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="搜索案例..."
|
||||||
|
/>
|
||||||
|
{query ? <button type="button" className="asset-search__clear" onClick={() => setQuery("")} aria-label="清除搜索">×</button> : null}
|
||||||
|
</label>
|
||||||
{serverNotice ? <span className="studio-pill">{serverNotice}</span> : null}
|
{serverNotice ? <span className="studio-pill">{serverNotice}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{liveCases.length ? (
|
{liveCases.length ? (
|
||||||
@@ -473,8 +496,8 @@ function CommunityPage({ projects, isAuthenticated, onStartCreate, onOpenProject
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<ImportOutlined style={{ fontSize: 48 }} />}
|
icon={<ImportOutlined style={{ fontSize: 48 }} />}
|
||||||
title="社区暂无模板"
|
title={debouncedQuery ? "无匹配结果" : "社区暂无模板"}
|
||||||
description="管理员审核通过后,画布社区案例会显示在这里。"
|
description={debouncedQuery ? "尝试其他关键词,或清除搜索查看全部案例" : "管理员审核通过后,画布社区案例会显示在这里。"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const prefersReducedMotion = typeof window !== "undefined"
|
|||||||
export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) {
|
export default function WelcomeSplash({ onEnter }: WelcomeSplashProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rafRef = useRef(0);
|
const rafRef = useRef(0);
|
||||||
const [showWelcome, setShowWelcome] = useState(false);
|
const [showWelcome, setShowWelcome] = useState(true);
|
||||||
const [exiting, setExiting] = useState(false);
|
const [exiting, setExiting] = useState(false);
|
||||||
|
|
||||||
const handleEnter = useCallback(() => {
|
const handleEnter = useCallback(() => {
|
||||||
|
|||||||
@@ -949,6 +949,11 @@ function WorkbenchPage({
|
|||||||
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
|
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
|
||||||
removeKeepaliveTask(task.taskId);
|
removeKeepaliveTask(task.taskId);
|
||||||
onRefreshUsage?.();
|
onRefreshUsage?.();
|
||||||
|
if (status.status === "completed") {
|
||||||
|
import("../../utils/generationNotifier").then((m) =>
|
||||||
|
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (status.resultUrl) {
|
if (status.resultUrl) {
|
||||||
const persistedResult = await persistWorkbenchResultAsset({
|
const persistedResult = await persistWorkbenchResultAsset({
|
||||||
@@ -992,6 +997,11 @@ function WorkbenchPage({
|
|||||||
});
|
});
|
||||||
removeKeepaliveTask(task.taskId);
|
removeKeepaliveTask(task.taskId);
|
||||||
onRefreshUsage?.();
|
onRefreshUsage?.();
|
||||||
|
if (status.status === "completed") {
|
||||||
|
import("../../utils/generationNotifier").then((m) =>
|
||||||
|
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
|
||||||
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2105,7 +2115,7 @@ function WorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
||||||
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
|
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -2227,7 +2237,7 @@ function WorkbenchPage({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
|
||||||
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
|
setProjectError(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
|||||||
@@ -642,3 +642,187 @@
|
|||||||
.info-popover__links a:hover {
|
.info-popover__links a:hover {
|
||||||
text-decoration: underline;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<boolean> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user