video-shuoshan/web/src/pages/AdminLayout.tsx
seaislee1209 be656900c0
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
feat: v0.9.7 登录风控第二期 — IP归属地解析 + 异常检测(R1-R5) + 飞书告警 + 自动封禁
- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级
- 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂
- 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标
- R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线
- 系统设置页全局配置 + 团队详情页独立阈值覆盖
- 安全日志页面 + 管理员修改密码入口

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 00:02:56 +08:00

144 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { useState, useCallback } from 'react';
import { authApi } from '../lib/api';
import logoImg from '../assets/logo_32.png';
import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/admin/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/admin/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
];
export function AdminLayout() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
const [pwModalOpen, setPwModalOpen] = useState(false);
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwSaving, setPwSaving] = useState(false);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
};
const handleChangePassword = useCallback(async () => {
if (!oldPw || !newPw) return;
if (newPw.length < 6) { alert('新密码至少6位'); return; }
if (newPw !== confirmPw) { alert('两次输入的新密码不一致'); return; }
setPwSaving(true);
try {
await authApi.changePassword(oldPw, newPw);
alert('密码修改成功');
setPwModalOpen(false);
setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (e: any) {
alert(e.response?.data?.error || e.response?.data?.detail || '修改失败');
} finally {
setPwSaving(false);
}
}, [oldPw, newPw, confirmPw]);
return (
<div className={styles.layout}>
<aside className={`${styles.sidebar} ${collapsed ? styles.collapsed : ''}`}>
<div className={styles.sidebarHeader}>
<div className={styles.logo}>
<img src={logoImg} alt="AirDrama" width="24" height="24" />
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
</div>
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
{collapsed ? (
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
) : (
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
)}
</svg>
</button>
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`${styles.navItem} ${isActive ? styles.navItemActive : ''}`
}
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d={item.icon} />
</svg>
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<div className={styles.sidebarFooter}>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && (
<div className={styles.userMeta}>
<span className={styles.userName}>{user?.username}</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button className={styles.logoutLink} onClick={() => setPwModalOpen(true)}></button>
<button className={styles.logoutLink} onClick={handleLogout}>退</button>
</div>
</div>
)}
</div>
</div>
</aside>
<main className={`${styles.content} ${collapsed ? styles.contentExpanded : ''}`}>
<Outlet />
</main>
{pwModalOpen && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setPwModalOpen(false)}>
<div style={{ background: 'var(--color-bg-card)', borderRadius: '12px', padding: '24px', width: '360px', border: '1px solid var(--color-border-card)' }}
onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', color: 'var(--color-text-primary)', fontSize: '16px' }}></h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<input type="password" placeholder="当前密码" value={oldPw} onChange={(e) => setOldPw(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<input type="password" placeholder="新密码至少6位" value={newPw} onChange={(e) => setNewPw(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<input type="password" placeholder="确认新密码" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleChangePassword()}
style={{ padding: '8px 12px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: '14px' }} />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '4px' }}>
<button onClick={() => setPwModalOpen(false)}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid var(--color-border-card)', background: 'transparent', color: 'var(--color-text-secondary)', cursor: 'pointer', fontSize: '13px' }}></button>
<button onClick={handleChangePassword} disabled={pwSaving}
style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', background: 'var(--color-primary)', color: '#fff', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}>
{pwSaving ? '修改中...' : '确认修改'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}