video-shuoshan/web/src/pages/AdminLayout.tsx
seaislee1209 cec1e5d770 feat(observer): 团管观察者标记 — 可看全局内容资产(不见 ¥)
后端:
- 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>
2026-05-18 15:58:18 +08:00

211 lines
13 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 { 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>
);
}