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>
This commit is contained in:
seaislee1209 2026-05-18 15:58:18 +08:00
parent a842f87812
commit cec1e5d770
14 changed files with 200 additions and 28 deletions

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-05-18 15:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0015_add_username_update_audit_action'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_observer',
field=models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)'),
),
migrations.AlterField(
model_name='adminauditlog',
name='action',
field=models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), ('user_username_update', '修改用户名'), ('user_observer_toggle', '切换观察者标记')], max_length=30, verbose_name='操作类型'),
),
]

View File

@ -52,6 +52,7 @@ class User(AbstractUser):
)
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
is_team_owner = models.BooleanField(default=False, verbose_name='团队主管理员')
is_observer = models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)')
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
# ── 次数限额v0.10.0 新增) ──
@ -97,6 +98,7 @@ class AdminAuditLog(models.Model):
('member_status_toggle', '切换成员状态'),
('user_password_reset', '重置用户密码'),
('user_username_update', '修改用户名'),
('user_observer_toggle', '切换观察者标记'),
]
operator = models.ForeignKey(

View File

@ -43,3 +43,18 @@ class IsTeamMember(BasePermission):
and request.user.is_authenticated
and request.user.team is not None
)
class IsSuperAdminOrObserver(BasePermission):
"""超级管理员,或被标记为观察者的团队管理员(可查看全局内容资产)。"""
def has_permission(self, request, view):
u = request.user
if not (u and u.is_authenticated):
return False
# 超管
if u.is_staff and u.team is None:
return True
# 观察者团管
if u.is_team_admin and u.team is not None and getattr(u, 'is_observer', False):
return True
return False

View File

@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'role', 'team_name', 'must_change_password')
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'is_observer', 'role', 'team_name', 'must_change_password')
class RegisterSerializer(serializers.Serializer):

View File

@ -35,6 +35,7 @@ urlpatterns = [
path('admin/users/<int:user_id>/status', views.admin_user_status_view, name='admin_user_status'),
path('admin/users/<int:user_id>/reset-password', views.admin_reset_password_view, name='admin_reset_password'),
path('admin/users/<int:user_id>/username', views.admin_user_username_update_view, name='admin_user_username_update'),
path('admin/users/<int:user_id>/observer', views.admin_user_observer_toggle_view, name='admin_user_observer_toggle'),
# ── Super Admin: Records, Settings & Audit Logs ──
path('admin/records', views.admin_records_view, name='admin_records'),

View File

@ -23,7 +23,7 @@ from .serializers import (
TeamAnomalyConfigSerializer,
)
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember, IsSuperAdminOrObserver
from utils.tos_client import upload_file as tos_upload
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
from utils.billing import get_resolution, estimate_tokens, calculate_cost, calculate_base_cost
@ -1506,6 +1506,8 @@ def admin_users_list_view(request):
'disabled_by': u.disabled_by,
'is_staff': u.is_staff,
'is_team_admin': u.is_team_admin,
'is_team_owner': u.is_team_owner,
'is_observer': u.is_observer,
'team_id': u.team_id,
'team_name': u.team.name if u.team else None,
'date_joined': u.date_joined.isoformat(),
@ -1764,6 +1766,41 @@ def admin_user_username_update_view(request, user_id):
return Response({'user_id': user.id, 'username': user.username})
@api_view(['PATCH'])
@permission_classes([IsSuperAdmin])
def admin_user_observer_toggle_view(request, user_id):
"""PATCH /api/v1/admin/users/<id>/observer — 仅超管,把团管标记为观察者(或取消)。"""
try:
target = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
# 只允许给「团队管理员」打观察者标记;超管/普通成员一律拒
if target.is_staff and target.team_id is None:
return Response({'error': '超级管理员无需设观察者'}, status=status.HTTP_400_BAD_REQUEST)
if not (target.is_team_admin and target.team_id is not None):
return Response({'error': '观察者标记只能给团队管理员'}, status=status.HTTP_400_BAD_REQUEST)
is_observer = request.data.get('is_observer')
if is_observer is None:
return Response({'error': '请提供 is_observer 参数'}, status=status.HTTP_400_BAD_REQUEST)
new_val = bool(is_observer)
old_val = target.is_observer
if old_val == new_val:
return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val})
target.is_observer = new_val
target.save(update_fields=['is_observer'])
log_admin_action(
request, 'user_observer_toggle', 'user',
target_id=target.id, target_name=target.username,
before={'is_observer': old_val},
after={'is_observer': new_val},
)
return Response({'user_id': target.id, 'username': target.username, 'is_observer': new_val})
@api_view(['POST'])
@permission_classes([IsSuperAdmin])
def admin_create_user_view(request):
@ -3011,7 +3048,7 @@ def profile_records_view(request):
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
@permission_classes([IsSuperAdminOrObserver])
def admin_assets_overview(request):
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
from apps.accounts.models import Team
@ -3060,7 +3097,7 @@ def admin_assets_overview(request):
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
@permission_classes([IsSuperAdminOrObserver])
def admin_assets_team_members(request, team_id):
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
from apps.accounts.models import Team
@ -3100,7 +3137,7 @@ def admin_assets_team_members(request, team_id):
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
@permission_classes([IsSuperAdminOrObserver])
def admin_assets_user_videos(request, user_id):
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
try:

View File

@ -29,6 +29,15 @@ import { TeamAssetsPage } from './pages/TeamAssetsPage';
import { useAuthStore } from './store/auth';
// 观察者团管进 /admin 跳 assets,超管进 /admin 跳 dashboard
function RoleAwareAdminIndexRedirect() {
const user = useAuthStore((s) => s.user);
if (user?.role === 'team_admin' && user?.is_observer) {
return <Navigate to="/admin/assets" replace />;
}
return <Navigate to="/admin/dashboard" replace />;
}
export default function App() {
const initialize = useAuthStore((s) => s.initialize);
@ -77,24 +86,24 @@ export default function App() {
</ProtectedRoute>
}
/>
{/* Super Admin routes */}
{/* Super Admin routes — 父 requireAdminOrObserver,子页面除 assets 外仍 requireAdmin (观察者团管只能进 assets) */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdmin>
<ProtectedRoute requireAdminOrObserver>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="teams" element={<TeamsPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="security" element={<AnomalyLogPage />} />
<Route path="login-records" element={<LoginRecordsPage />} />
<Route path="logs" element={<AuditLogsPage />} />
<Route index element={<RoleAwareAdminIndexRedirect />} />
<Route path="dashboard" element={<ProtectedRoute requireAdmin><DashboardPage /></ProtectedRoute>} />
<Route path="teams" element={<ProtectedRoute requireAdmin><TeamsPage /></ProtectedRoute>} />
<Route path="users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
<Route path="records" element={<ProtectedRoute requireAdmin><RecordsPage /></ProtectedRoute>} />
<Route path="settings" element={<ProtectedRoute requireAdmin><SettingsPage /></ProtectedRoute>} />
<Route path="security" element={<ProtectedRoute requireAdmin><AnomalyLogPage /></ProtectedRoute>} />
<Route path="login-records" element={<ProtectedRoute requireAdmin><LoginRecordsPage /></ProtectedRoute>} />
<Route path="logs" element={<ProtectedRoute requireAdmin><AuditLogsPage /></ProtectedRoute>} />
<Route path="assets" element={<AdminAssetsPage />} />
</Route>
{/* Team Admin routes */}

View File

@ -5,11 +5,12 @@ import { useAuthStore } from '../store/auth';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
requireAdminOrObserver?: boolean;
requireTeamAdmin?: boolean;
requireTeamMember?: boolean;
}
export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requireTeamMember }: Props) {
export function ProtectedRoute({ children, requireAdmin, requireAdminOrObserver, requireTeamAdmin, requireTeamMember }: Props) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const user = useAuthStore((s) => s.user);
@ -67,9 +68,20 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
}
if (requireAdmin && user?.role !== 'super_admin') {
// 智能 fallback:团管被 admin 子页面拒 → 回团队管理,普通成员/未登录 → /app
if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
return <Navigate to="/app" replace />;
}
if (requireAdminOrObserver) {
const isAdmin = user?.role === 'super_admin';
const isObserverTeamAdmin = user?.role === 'team_admin' && !!user?.is_observer;
if (!isAdmin && !isObserverTeamAdmin) {
if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
return <Navigate to="/app" replace />;
}
}
if (requireTeamAdmin && user?.role !== 'team_admin') {
return <Navigate to="/app" replace />;
}

View File

@ -259,6 +259,9 @@ export const adminApi = {
updateUserUsername: (userId: number, username: string) =>
api.patch<{ user_id: number; username: string }>(`/admin/users/${userId}/username`, { username }),
toggleUserObserver: (userId: number, isObserver: boolean) =>
api.patch<{ user_id: number; username: string; is_observer: boolean }>(`/admin/users/${userId}/observer`, { is_observer: isObserver }),
getRecords: (params: {
page?: number;
page_size?: number;

View File

@ -1,6 +1,7 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { adminApi, rewriteTosUrl } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import { useAuthStore } from '../store/auth';
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
@ -35,6 +36,7 @@ function isAssetUrl(url: string): boolean {
return url.startsWith('asset://') || url.startsWith('Asset://');
}
// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为
function assetVideoToTask(v: AssetVideo): GenerationTask {
const references = (v.reference_urls || []).map((ref, i) => {
const url = ref.url || '';
@ -77,6 +79,9 @@ function Chevron({ open }: { open: boolean }) {
}
export function AdminAssetsPage() {
const currentUser = useAuthStore((s) => s.user);
// 观察者团管不是超管 → 隐藏 ¥(成本/费用)
const hideMoney = currentUser?.role !== 'super_admin';
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<{
total_videos: number; total_seconds: number; total_teams: number;
@ -153,10 +158,12 @@ export function AdminAssetsPage() {
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_videos}</div>
</div>
{!hideMoney && (
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
</div>
)}
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_teams}</div>
@ -171,7 +178,9 @@ export function AdminAssetsPage() {
<span className={styles.accordionName}>{team.name}</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{team.video_count} </span>
{!hideMoney && (
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
)}
</div>
</div>
{expandedTeam === team.id && (
@ -192,7 +201,9 @@ export function AdminAssetsPage() {
</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{member.video_count} </span>
{!hideMoney && (
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
)}
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
@ -235,7 +246,9 @@ export function AdminAssetsPage() {
<span className={styles.accordionName}></span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{overview.no_team.video_count} </span>
{!hideMoney && (
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
)}
</div>
</div>
</div>

View File

@ -28,6 +28,9 @@ export function AdminLayout() {
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(() => {
@ -72,7 +75,7 @@ export function AdminLayout() {
<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>}
{!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)">
@ -86,14 +89,18 @@ export function AdminLayout() {
</div>
<nav className={styles.nav}>
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<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></span>}
{!collapsed && <span>{isObserverOnly ? '返回团队管理' : '返回首页'}</span>}
</button>
<div className={styles.navDivider} />
{navItems.map((item) => (
{visibleNavItems.map((item) => (
<NavLink
key={item.path}
to={item.path}

View File

@ -63,6 +63,20 @@ export function TeamAdminLayout() {
{!collapsed && <span>{item.label}</span>}
</NavLink>
))}
{/* 观察者团管: 加「全局资产」入口跳到 /admin/assets */}
{user?.is_observer && (
<button
className={styles.navItem}
onClick={() => navigate('/admin/assets')}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}
title="查看全部团队的内容资产(观察者权限)"
>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
{!collapsed && <span></span>}
</button>
)}
</nav>
<div className={styles.sidebarFooter}>

View File

@ -170,6 +170,21 @@ export function UsersPage() {
}
};
const handleToggleObserver = async (u: AdminUser) => {
try {
const next = !u.is_observer;
await adminApi.toggleUserObserver(u.id, next);
showToast(
next
? `已将「${u.username}」设为观察者(需该用户重新登录后生效)`
: `已取消「${u.username}」的观察者标记`
);
fetchUsers();
} catch (e: any) {
showToast(e.response?.data?.error || '操作失败');
}
};
const handleResetPassword = async () => {
if (!resetPwUser) return;
setResetPwError('');
@ -292,6 +307,15 @@ export function UsersPage() {
</button>
)}
{u.is_observer && (
<span
className={styles.statusBadge}
style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)' }}
title="该团管被标记为观察者,可查看全局内容资产"
>
</span>
)}
</span>
)}
</td>
@ -317,6 +341,15 @@ export function UsersPage() {
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(u)}></button>
<button className={styles.editBtn} onClick={() => { setResetPwUser(u); setResetPwValue(''); setResetPwError(''); }}></button>
{u.is_team_admin && u.team_id && (
<button
className={styles.editBtn}
onClick={() => handleToggleObserver(u)}
title={u.is_observer ? '取消该团管的观察者标记' : '把该团管标记为观察者(可看全局内容资产,无费用)'}
>
{u.is_observer ? '取消观察者' : '设为观察者'}
</button>
)}
<button
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmUser(u)}

View File

@ -93,6 +93,7 @@ export interface User {
is_staff: boolean;
is_team_admin: boolean;
is_team_owner?: boolean;
is_observer?: boolean;
role: UserRole;
team_name: string | null;
must_change_password: boolean;
@ -170,6 +171,7 @@ export interface AdminUser {
is_staff: boolean;
is_team_admin: boolean;
is_team_owner?: boolean;
is_observer?: boolean;
team_id: number | null;
team_name: string | null;
date_joined: string;
@ -345,6 +347,7 @@ export interface TeamMember {
email: string;
is_team_admin: boolean;
is_team_owner?: boolean;
is_observer?: boolean;
is_active: boolean;
disabled_by: string;
daily_seconds_limit: number;