后端: - User.is_observer BooleanField (0016 migration, default=False) - AdminAuditLog 加 user_observer_toggle 操作类型 - UserSerializer fields 含 is_observer (/auth/me 透出) - IsSuperAdminOrObserver permission 类:超管 + (is_team_admin && is_observer) - 3 个 assets endpoint (overview/team_members/user_videos) 权限从 IsSuperAdmin 改为 IsSuperAdminOrObserver - admin_user_observer_toggle_view (PATCH /admin/users/<id>/observer): 仅超管,只允许打在团管上,拒超管自己 + 拒成员 - admin_users_list_view 返回 is_team_owner/is_observer 字段(前端 row-level 判断用) 前端: - User/AdminUser/TeamMember type 加 is_observer - adminApi.toggleUserObserver - ProtectedRoute 新 requireAdminOrObserver prop + requireAdmin 智能 fallback(团管被拒回 /team/dashboard) - App.tsx /admin 父路由 requireAdminOrObserver,子路由除 assets 外仍 requireAdmin (race 防御) - RoleAwareAdminIndexRedirect:观察者团管入 /admin 跳 /admin/assets,超管跳 /admin/dashboard - AdminLayout sidebar 角色过滤:观察者只见「内容资产」+ 「返回首页」改「返回团队管理」+ logo「观察者」字样 - TeamAdminLayout 观察者团管加「全局资产」入口跳 /admin/assets - AdminAssetsPage 4 处 ¥ 条件渲染 (hideMoney = role !== 'super_admin') - UsersPage 行加「设为观察者/取消观察者」按钮(仅 is_team_admin && team_id) + 观察者 badge - toast 提示「需该用户重新登录后生效」(JWT 不缓存 is_observer claim) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
211 lines
13 KiB
TypeScript
211 lines
13 KiB
TypeScript
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||
import { useAuthStore } from '../store/auth';
|
||
import { useThemeStore } from '../store/theme';
|
||
import { useNotificationStore } from '../store/notification';
|
||
import { useState, useCallback, useEffect } 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/login-records', label: '登录记录', icon: 'M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z' },
|
||
{ 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 theme = useThemeStore((s) => s.theme);
|
||
const toggleTheme = useThemeStore((s) => s.toggleTheme);
|
||
const unreadCount = useNotificationStore((s) => s.unreadCount);
|
||
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
|
||
const navigate = useNavigate();
|
||
const [collapsed, setCollapsed] = useState(false);
|
||
// 观察者团管 = 团管 + is_observer,在 /admin 下只能看「内容资产」一项
|
||
const isObserverOnly = user?.role === 'team_admin' && !!user?.is_observer;
|
||
const visibleNavItems = isObserverOnly ? navItems.filter((i) => i.path === '/admin/assets') : navItems;
|
||
|
||
// 60s 轮询未读数 + tab 重新可见时立即拉一次
|
||
useEffect(() => {
|
||
if (!user) return;
|
||
fetchUnreadCount();
|
||
const tick = setInterval(fetchUnreadCount, 60_000);
|
||
const onVis = () => { if (!document.hidden) fetchUnreadCount(); };
|
||
document.addEventListener('visibilitychange', onVis);
|
||
return () => { clearInterval(tick); document.removeEventListener('visibilitychange', onVis); };
|
||
}, [user, fetchUnreadCount]);
|
||
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}>{isObserverOnly ? 'AirDrama 观察者' : '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(isObserverOnly ? '/team/dashboard' : '/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>{isObserverOnly ? '返回团队管理' : '返回首页'}</span>}
|
||
</button>
|
||
<div className={styles.navDivider} />
|
||
{visibleNavItems.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}>
|
||
{/* 消息中心铃铛 — admin/团管都显示,有未读时右上角红点 */}
|
||
<button
|
||
className={styles.themeToggle}
|
||
onClick={() => navigate('/notifications')}
|
||
title={unreadCount > 0 ? `${unreadCount} 条未读消息` : '消息中心'}
|
||
aria-label="消息中心"
|
||
style={{ position: 'relative' }}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||
</svg>
|
||
{!collapsed && <span>消息中心{unreadCount > 0 ? ` (${unreadCount})` : ''}</span>}
|
||
{unreadCount > 0 && (
|
||
<span style={{
|
||
position: 'absolute',
|
||
top: 6, left: collapsed ? 22 : 22,
|
||
width: 8, height: 8, borderRadius: '50%',
|
||
background: 'var(--color-danger)',
|
||
boxShadow: '0 0 0 2px var(--color-bg-sidebar)',
|
||
}} />
|
||
)}
|
||
</button>
|
||
|
||
{/* 主题切换 — 月亮/太阳 SVG,跟 components/Sidebar 一致 */}
|
||
<button
|
||
className={styles.themeToggle}
|
||
onClick={toggleTheme}
|
||
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
|
||
aria-label={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
|
||
>
|
||
{theme === 'dark' ? (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||
</svg>
|
||
) : (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="4" />
|
||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||
</svg>
|
||
)}
|
||
{!collapsed && <span>{theme === 'dark' ? '浅色' : '深色'}</span>}
|
||
</button>
|
||
<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: 'var(--color-overlay-soft)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
|
||
onClick={() => setPwModalOpen(false)}>
|
||
<div style={{ background: 'var(--color-bg-modal-elevated)', 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: 'var(--color-on-primary)', cursor: 'pointer', fontSize: '13px', opacity: pwSaving ? 0.6 : 1 }}>
|
||
{pwSaving ? '修改中...' : '确认修改'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|