video-shuoshan/web/src/pages/AnomalyLogPage.tsx
seaislee1209 f0f47e8368 feat(theme): 亮色主题切换完整实现 — dark/light 双套 var + Sidebar 切换 + 浅色色板
Stage 1 (var 化, 350 处): 425 处硬编码颜色 → CSS var, 涉及 49 个 tsx/css module 文件,
   按 hot files (DashboardPage/TeamDashboardPage/RecordDetailModal/ReferenceList) →
   Modal/Asset/Profile/Login → 生成页家族/管理后台/公共 UI 三波 8 个 sub-agent 并行处理。
   index.css :root 加 ~70 个新 var (modal/text 层级/状态色 bg 变体/chart/mention pill 等)。
Stage 2 (双套 var): :root 保留 DARK 默认值, [data-theme="light"] 覆盖 ~95 个 token。
   浅色色板按 Vercel Geist (#fafafa / #171717 / shadow-border) + Linear Light surface 分层规范,
   主色 #6c63ff → #5048cc 加深 18% 满足 WCAG AA。aurora 极光在 light 下 display:none。
Stage 3 (切换机制): 新建 store/theme.ts (Zustand + localStorage 持久化),
   Sidebar 加月亮/太阳 SVG 切换按钮 (位于头像上方),
   DashboardPage/TeamDashboardPage/ProfilePage 的 ECharts 配 key={theme} 强制重渲染。
Stage 4 (微调): LandingPage 强制 data-theme="dark" 保持品牌识别 (登录流程一直深色),
   sidebar bg / card bg / border 在浅色下加深 0.02 提升轮廓辨识度。
Stage 5 (验证): Playwright 头无浏览器自动登录 admin + screenshot_user, 截深/浅各 12 个页面 = 24 张
   到 docs/screenshots/ (本地档, .gitignore 排除 png 不入库)。
   vitest 71fail/162pass 与改造前基线完全一致, 无新增回归。

完成报告: docs/todo/亮色主题切换-完成报告.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:10:35 +08:00

225 lines
8.3 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { LoginAnomaly, Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './AuditLogsPage.module.css';
const RULE_OPTIONS = [
{ label: '全部规则', value: '' },
{ label: 'R1 登录地区不对', value: 'region_mismatch' },
{ label: 'R2 不可能的旅行', value: 'impossible_travel' },
{ label: 'R3 登录太频繁', value: 'login_frequency' },
{ label: 'R4 团队遍地开花', value: 'multi_city' },
{ label: 'R5 海外IP太杂', value: 'overseas_ip_diversity' },
];
const LEVEL_OPTIONS = [
{ label: '全部级别', value: '' },
{ label: '警告', value: 'warning' },
{ label: '严重', value: 'critical' },
];
const RULE_LABELS: Record<string, string> = {
region_mismatch: 'R1 登录地区不对',
impossible_travel: 'R2 不可能的旅行',
login_frequency: 'R3 登录太频繁',
multi_city: 'R4 团队遍地开花',
overseas_ip_diversity: 'R5 海外IP太杂',
};
export function AnomalyLogPage() {
const [anomalies, setAnomalies] = useState<LoginAnomaly[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [ruleFilter, setRuleFilter] = useState('');
const [levelFilter, setLevelFilter] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const [teams, setTeams] = useState<Team[]>([]);
const pageSize = 20;
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchAnomalies = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getLoginAnomalies({
page, page_size: pageSize,
rule: ruleFilter || undefined,
level: levelFilter || undefined,
team_id: teamFilter ? Number(teamFilter) : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setAnomalies(data.results);
setTotal(data.total);
} catch {
showToast('加载安全日志失败');
} finally {
setLoading(false);
}
}, [page, ruleFilter, levelFilter, teamFilter, startDate, endDate]);
useEffect(() => { fetchAnomalies(); }, [fetchAnomalies]);
const handleSearch = () => {
setPage(1);
fetchAnomalies();
};
const totalPages = Math.ceil(total / pageSize);
const teamOptions = [
{ label: '全部团队', value: '' },
...teams.map((t) => ({ label: t.name, value: String(t.id) })),
];
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<Select
value={ruleFilter}
onChange={(v) => { setRuleFilter(v); setPage(1); }}
placeholder="全部规则"
options={RULE_OPTIONS}
/>
<Select
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1); }}
placeholder="全部级别"
options={LEVEL_OPTIONS}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={teamOptions}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchAnomalies}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th>IP / </th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : anomalies.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
anomalies.map((a) => (
<tr key={a.id}>
<td className={styles.timeCell}>{new Date(a.created_at).toLocaleString('zh-CN')}</td>
<td>{a.team_name}</td>
<td>{a.username}</td>
<td>
<span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
background: a.level === 'critical' ? 'var(--color-danger-bg)' : 'var(--color-warning-bg)',
color: a.level === 'critical' ? 'var(--color-danger)' : 'var(--color-warning)',
}}>
{a.level === 'critical' ? '严重' : '警告'}
</span>
</td>
<td><span className={styles.actionBadge}>{RULE_LABELS[a.rule] || a.rule}</span></td>
<td className={styles.ipCell}>
{a.ip_address}
{(a.geo_country || a.geo_city) && (
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
{[a.geo_country, a.geo_province, a.geo_city].filter(Boolean).join(' ')}
</div>
)}
</td>
<td style={{ fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{renderDetail(a)}
</td>
<td>
{a.auto_disabled ? (
<span style={{ fontSize: 12, color: 'var(--color-danger)' }}>
{a.disabled_target === 'team' ? '团队' : '用户'}
</span>
) : a.alerted ? (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}></span>
) : (
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>-</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}
function renderDetail(a: LoginAnomaly): string {
const d = a.detail;
switch (a.rule) {
case 'region_mismatch':
return `预期: ${(d.expected as string[] || []).join(',')} | 实际: ${d.city || ''}`;
case 'impossible_travel':
return `${d.previous_city || ''}${d.current_city || ''}`;
case 'login_frequency':
return `${d.count || 0} 次 / ${d.window_seconds || 0}s`;
case 'multi_city':
return `预期外城市: ${(d.unexpected_cities as string[] || []).join(',')}`;
case 'overseas_ip_diversity':
return `海外国家: ${(d.countries as string[] || []).join(',')}`;
default:
return JSON.stringify(d);
}
}