f5a75074a4
【认证系统】 - 新增邮箱验证码注册/登录流程 (sendEmailCode / verifyEmail / forgotPassword / resetPassword) - register-email 现在需要验证码 - 服务端新增 email_verification_codes 表 + patch-email-verification.js - App.tsx 登录后 emailVerified 检查提醒 - keyServerClient token 显式传递修复 401 错误 【电商模块】 - 自动推进: 策划完成后自动生成分镜图/视频 - 模特图选项 (性别/年龄/种族/体型/场景) 注入 AI 提示词 - 任务持久化指纹修复 (图片数量替代 blob URL) - 新增「视频换装」入口 (happyhorse-1.0-video-edit) 【剧本评分】 - 新增 .docx/.doc Word 文档支持 (ZIP解压+XML提取) - 历史记录支持点击查看/恢复评测结果 【画布】 - ReactFlow 节点禁止内置拖拽避免冲突 - 连接线拖拽弹窗优化 (预览线不消失, 弹窗跟踪鼠标) 【页面修复】 - 首页轮播图改为 aspect-ratio: 16/9 解决尺寸问题 - 资产库新增悬停删除按钮 - scriptEvalClient 改用服务端 /api/ai/chat 端点 - TokenUsagePage 未登录跳过 API 调用
111 lines
3.8 KiB
TypeScript
111 lines
3.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { keyServerClient } from "../api/keyServerClient";
|
|
|
|
export 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;
|