From cec1e5d770df5baa49b0296cc7b26b55434ac95d Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 18 May 2026 15:58:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(observer):=20=E5=9B=A2=E7=AE=A1=E8=A7=82?= =?UTF-8?q?=E5=AF=9F=E8=80=85=E6=A0=87=E8=AE=B0=20=E2=80=94=20=E5=8F=AF?= =?UTF-8?q?=E7=9C=8B=E5=85=A8=E5=B1=80=E5=86=85=E5=AE=B9=E8=B5=84=E4=BA=A7?= =?UTF-8?q?(=E4=B8=8D=E8=A7=81=20=C2=A5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 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//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) --- .../migrations/0016_user_is_observer.py | 23 ++++++++++ backend/apps/accounts/models.py | 2 + backend/apps/accounts/permissions.py | 15 +++++++ backend/apps/accounts/serializers.py | 2 +- backend/apps/generation/urls.py | 1 + backend/apps/generation/views.py | 45 +++++++++++++++++-- web/src/App.tsx | 31 ++++++++----- web/src/components/ProtectedRoute.tsx | 14 +++++- web/src/lib/api.ts | 3 ++ web/src/pages/AdminAssetsPage.tsx | 27 ++++++++--- web/src/pages/AdminLayout.tsx | 15 +++++-- web/src/pages/TeamAdminLayout.tsx | 14 ++++++ web/src/pages/UsersPage.tsx | 33 ++++++++++++++ web/src/types/index.ts | 3 ++ 14 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 backend/apps/accounts/migrations/0016_user_is_observer.py diff --git a/backend/apps/accounts/migrations/0016_user_is_observer.py b/backend/apps/accounts/migrations/0016_user_is_observer.py new file mode 100644 index 0000000..faf4627 --- /dev/null +++ b/backend/apps/accounts/migrations/0016_user_is_observer.py @@ -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='操作类型'), + ), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index 43e4fa3..aaa9134 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -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( diff --git a/backend/apps/accounts/permissions.py b/backend/apps/accounts/permissions.py index 7a8da9f..dd03bb1 100644 --- a/backend/apps/accounts/permissions.py +++ b/backend/apps/accounts/permissions.py @@ -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 diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index 8331141..29d7d34 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -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): diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index df6a67d..422743e 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -35,6 +35,7 @@ urlpatterns = [ path('admin/users//status', views.admin_user_status_view, name='admin_user_status'), path('admin/users//reset-password', views.admin_reset_password_view, name='admin_reset_password'), path('admin/users//username', views.admin_user_username_update_view, name='admin_user_username_update'), + path('admin/users//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'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 701ed37..36ec146 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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//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//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//videos — Completed videos for a user (paginated).""" try: diff --git a/web/src/App.tsx b/web/src/App.tsx index 333a889..b458e9a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ; + } + return ; +} + export default function App() { const initialize = useAuthStore((s) => s.initialize); @@ -77,24 +86,24 @@ export default function App() { } /> - {/* Super Admin routes */} + {/* Super Admin routes — 父 requireAdminOrObserver,子页面除 assets 外仍 requireAdmin (观察者团管只能进 assets) */} + } > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> {/* Team Admin routes */} diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 020450b..889da8a 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -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 ; return ; } + 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 ; + return ; + } + } + if (requireTeamAdmin && user?.role !== 'team_admin') { return ; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 1e9735a..2a3a161 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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; diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx index dda82cf..578d7dc 100644 --- a/web/src/pages/AdminAssetsPage.tsx +++ b/web/src/pages/AdminAssetsPage.tsx @@ -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() {
总视频数
{overview.total_videos}
-
-
总费用
-
{formatCost(overview.total_seconds)}
-
+ {!hideMoney && ( +
+
总费用
+
{formatCost(overview.total_seconds)}
+
+ )}
团队数
{overview.total_teams}
@@ -171,7 +178,9 @@ export function AdminAssetsPage() { {team.name}
{team.video_count} 个视频 - {formatCost(team.cost_consumed ?? team.seconds_consumed)} + {!hideMoney && ( + {formatCost(team.cost_consumed ?? team.seconds_consumed)} + )}
{expandedTeam === team.id && ( @@ -192,7 +201,9 @@ export function AdminAssetsPage() {
{member.video_count} 个视频 - {formatCost(member.cost_consumed ?? member.seconds_consumed)} + {!hideMoney && ( + {formatCost(member.cost_consumed ?? member.seconds_consumed)} + )}
{expandedMember === member.id && memberVideos[member.id] && ( @@ -235,7 +246,9 @@ export function AdminAssetsPage() { 无团队用户
{overview.no_team.video_count} 个视频 - {formatCost(overview.no_team.seconds_consumed)} + {!hideMoney && ( + {formatCost(overview.no_team.seconds_consumed)} + )}
diff --git a/web/src/pages/AdminLayout.tsx b/web/src/pages/AdminLayout.tsx index 2fcf08c..f18dac6 100644 --- a/web/src/pages/AdminLayout.tsx +++ b/web/src/pages/AdminLayout.tsx @@ -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() {
AirDrama - {!collapsed && AirDrama Admin} + {!collapsed && {isObserverOnly ? 'AirDrama 观察者' : 'AirDrama Admin'}}
diff --git a/web/src/pages/UsersPage.tsx b/web/src/pages/UsersPage.tsx index 6a9e085..6fc400d 100644 --- a/web/src/pages/UsersPage.tsx +++ b/web/src/pages/UsersPage.tsx @@ -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() { 改名 )} + {u.is_observer && ( + + 观察者 + + )} )} @@ -317,6 +341,15 @@ export function UsersPage() {
+ {u.is_team_admin && u.team_id && ( + + )}