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:
parent
a842f87812
commit
cec1e5d770
23
backend/apps/accounts/migrations/0016_user_is_observer.py
Normal file
23
backend/apps/accounts/migrations/0016_user_is_observer.py
Normal 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='操作类型'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -52,6 +52,7 @@ class User(AbstractUser):
|
|||||||
)
|
)
|
||||||
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
||||||
is_team_owner = 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='每日秒数上限')
|
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
||||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
||||||
# ── 次数限额(v0.10.0 新增) ──
|
# ── 次数限额(v0.10.0 新增) ──
|
||||||
@ -97,6 +98,7 @@ class AdminAuditLog(models.Model):
|
|||||||
('member_status_toggle', '切换成员状态'),
|
('member_status_toggle', '切换成员状态'),
|
||||||
('user_password_reset', '重置用户密码'),
|
('user_password_reset', '重置用户密码'),
|
||||||
('user_username_update', '修改用户名'),
|
('user_username_update', '修改用户名'),
|
||||||
|
('user_observer_toggle', '切换观察者标记'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operator = models.ForeignKey(
|
operator = models.ForeignKey(
|
||||||
|
|||||||
@ -43,3 +43,18 @@ class IsTeamMember(BasePermission):
|
|||||||
and request.user.is_authenticated
|
and request.user.is_authenticated
|
||||||
and request.user.team is not None
|
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
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
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):
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
|||||||
@ -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>/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>/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>/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 ──
|
# ── Super Admin: Records, Settings & Audit Logs ──
|
||||||
path('admin/records', views.admin_records_view, name='admin_records'),
|
path('admin/records', views.admin_records_view, name='admin_records'),
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from .serializers import (
|
|||||||
TeamAnomalyConfigSerializer,
|
TeamAnomalyConfigSerializer,
|
||||||
)
|
)
|
||||||
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord
|
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.tos_client import upload_file as tos_upload
|
||||||
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
|
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
|
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,
|
'disabled_by': u.disabled_by,
|
||||||
'is_staff': u.is_staff,
|
'is_staff': u.is_staff,
|
||||||
'is_team_admin': u.is_team_admin,
|
'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_id': u.team_id,
|
||||||
'team_name': u.team.name if u.team else None,
|
'team_name': u.team.name if u.team else None,
|
||||||
'date_joined': u.date_joined.isoformat(),
|
'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})
|
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'])
|
@api_view(['POST'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdmin])
|
||||||
def admin_create_user_view(request):
|
def admin_create_user_view(request):
|
||||||
@ -3011,7 +3048,7 @@ def profile_records_view(request):
|
|||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdminOrObserver])
|
||||||
def admin_assets_overview(request):
|
def admin_assets_overview(request):
|
||||||
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
|
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
|
||||||
from apps.accounts.models import Team
|
from apps.accounts.models import Team
|
||||||
@ -3060,7 +3097,7 @@ def admin_assets_overview(request):
|
|||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdminOrObserver])
|
||||||
def admin_assets_team_members(request, team_id):
|
def admin_assets_team_members(request, team_id):
|
||||||
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
|
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
|
||||||
from apps.accounts.models import Team
|
from apps.accounts.models import Team
|
||||||
@ -3100,7 +3137,7 @@ def admin_assets_team_members(request, team_id):
|
|||||||
|
|
||||||
|
|
||||||
@api_view(['GET'])
|
@api_view(['GET'])
|
||||||
@permission_classes([IsSuperAdmin])
|
@permission_classes([IsSuperAdminOrObserver])
|
||||||
def admin_assets_user_videos(request, user_id):
|
def admin_assets_user_videos(request, user_id):
|
||||||
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
|
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -29,6 +29,15 @@ import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
|||||||
|
|
||||||
import { useAuthStore } from './store/auth';
|
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() {
|
export default function App() {
|
||||||
const initialize = useAuthStore((s) => s.initialize);
|
const initialize = useAuthStore((s) => s.initialize);
|
||||||
|
|
||||||
@ -77,24 +86,24 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{/* Super Admin routes */}
|
{/* Super Admin routes — 父 requireAdminOrObserver,子页面除 assets 外仍 requireAdmin (观察者团管只能进 assets) */}
|
||||||
<Route
|
<Route
|
||||||
path="/admin"
|
path="/admin"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requireAdmin>
|
<ProtectedRoute requireAdminOrObserver>
|
||||||
<AdminLayout />
|
<AdminLayout />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<RoleAwareAdminIndexRedirect />} />
|
||||||
<Route path="dashboard" element={<DashboardPage />} />
|
<Route path="dashboard" element={<ProtectedRoute requireAdmin><DashboardPage /></ProtectedRoute>} />
|
||||||
<Route path="teams" element={<TeamsPage />} />
|
<Route path="teams" element={<ProtectedRoute requireAdmin><TeamsPage /></ProtectedRoute>} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
|
||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<ProtectedRoute requireAdmin><RecordsPage /></ProtectedRoute>} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<ProtectedRoute requireAdmin><SettingsPage /></ProtectedRoute>} />
|
||||||
<Route path="security" element={<AnomalyLogPage />} />
|
<Route path="security" element={<ProtectedRoute requireAdmin><AnomalyLogPage /></ProtectedRoute>} />
|
||||||
<Route path="login-records" element={<LoginRecordsPage />} />
|
<Route path="login-records" element={<ProtectedRoute requireAdmin><LoginRecordsPage /></ProtectedRoute>} />
|
||||||
<Route path="logs" element={<AuditLogsPage />} />
|
<Route path="logs" element={<ProtectedRoute requireAdmin><AuditLogsPage /></ProtectedRoute>} />
|
||||||
<Route path="assets" element={<AdminAssetsPage />} />
|
<Route path="assets" element={<AdminAssetsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* Team Admin routes */}
|
{/* Team Admin routes */}
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import { useAuthStore } from '../store/auth';
|
|||||||
interface Props {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
requireAdmin?: boolean;
|
requireAdmin?: boolean;
|
||||||
|
requireAdminOrObserver?: boolean;
|
||||||
requireTeamAdmin?: boolean;
|
requireTeamAdmin?: boolean;
|
||||||
requireTeamMember?: 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 isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
@ -67,9 +68,20 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requireAdmin && user?.role !== 'super_admin') {
|
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 />;
|
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') {
|
if (requireTeamAdmin && user?.role !== 'team_admin') {
|
||||||
return <Navigate to="/app" replace />;
|
return <Navigate to="/app" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -259,6 +259,9 @@ export const adminApi = {
|
|||||||
updateUserUsername: (userId: number, username: string) =>
|
updateUserUsername: (userId: number, username: string) =>
|
||||||
api.patch<{ user_id: number; username: string }>(`/admin/users/${userId}/username`, { username }),
|
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: {
|
getRecords: (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { adminApi, rewriteTosUrl } from '../lib/api';
|
import { adminApi, rewriteTosUrl } from '../lib/api';
|
||||||
import { VideoDetailModal } from '../components/VideoDetailModal';
|
import { VideoDetailModal } from '../components/VideoDetailModal';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
||||||
import styles from './AdminAssetsPage.module.css';
|
import styles from './AdminAssetsPage.module.css';
|
||||||
|
|
||||||
@ -35,6 +36,7 @@ function isAssetUrl(url: string): boolean {
|
|||||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为
|
||||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||||
const references = (v.reference_urls || []).map((ref, i) => {
|
const references = (v.reference_urls || []).map((ref, i) => {
|
||||||
const url = ref.url || '';
|
const url = ref.url || '';
|
||||||
@ -77,6 +79,9 @@ function Chevron({ open }: { open: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminAssetsPage() {
|
export function AdminAssetsPage() {
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
// 观察者团管不是超管 → 隐藏 ¥(成本/费用)
|
||||||
|
const hideMoney = currentUser?.role !== 'super_admin';
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [overview, setOverview] = useState<{
|
const [overview, setOverview] = useState<{
|
||||||
total_videos: number; total_seconds: number; total_teams: number;
|
total_videos: number; total_seconds: number; total_teams: number;
|
||||||
@ -153,10 +158,12 @@ export function AdminAssetsPage() {
|
|||||||
<div className={styles.statLabel}>总视频数</div>
|
<div className={styles.statLabel}>总视频数</div>
|
||||||
<div className={styles.statValue}>{overview.total_videos}</div>
|
<div className={styles.statValue}>{overview.total_videos}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!hideMoney && (
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<div className={styles.statLabel}>总费用</div>
|
<div className={styles.statLabel}>总费用</div>
|
||||||
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
|
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className={styles.statCard}>
|
<div className={styles.statCard}>
|
||||||
<div className={styles.statLabel}>团队数</div>
|
<div className={styles.statLabel}>团队数</div>
|
||||||
<div className={styles.statValue}>{overview.total_teams}</div>
|
<div className={styles.statValue}>{overview.total_teams}</div>
|
||||||
@ -171,7 +178,9 @@ export function AdminAssetsPage() {
|
|||||||
<span className={styles.accordionName}>{team.name}</span>
|
<span className={styles.accordionName}>{team.name}</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{team.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{team.video_count} 个视频</span>
|
||||||
|
{!hideMoney && (
|
||||||
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedTeam === team.id && (
|
{expandedTeam === team.id && (
|
||||||
@ -192,7 +201,9 @@ export function AdminAssetsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{member.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{member.video_count} 个视频</span>
|
||||||
|
{!hideMoney && (
|
||||||
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{expandedMember === member.id && memberVideos[member.id] && (
|
{expandedMember === member.id && memberVideos[member.id] && (
|
||||||
@ -235,7 +246,9 @@ export function AdminAssetsPage() {
|
|||||||
<span className={styles.accordionName}>无团队用户</span>
|
<span className={styles.accordionName}>无团队用户</span>
|
||||||
<div className={styles.accordionMeta}>
|
<div className={styles.accordionMeta}>
|
||||||
<span className={styles.accordionBadge}>{overview.no_team.video_count} 个视频</span>
|
<span className={styles.accordionBadge}>{overview.no_team.video_count} 个视频</span>
|
||||||
|
{!hideMoney && (
|
||||||
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
|
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,9 @@ export function AdminLayout() {
|
|||||||
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
|
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 重新可见时立即拉一次
|
// 60s 轮询未读数 + tab 重新可见时立即拉一次
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -72,7 +75,7 @@ export function AdminLayout() {
|
|||||||
<div className={styles.sidebarHeader}>
|
<div className={styles.sidebarHeader}>
|
||||||
<div className={styles.logo}>
|
<div className={styles.logo}>
|
||||||
<img src={logoImg} alt="AirDrama" width="24" height="24" />
|
<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>
|
</div>
|
||||||
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
|
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
|
||||||
@ -86,14 +89,18 @@ export function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={styles.nav}>
|
<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">
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{!collapsed && <span>返回首页</span>}
|
{!collapsed && <span>{isObserverOnly ? '返回团队管理' : '返回首页'}</span>}
|
||||||
</button>
|
</button>
|
||||||
<div className={styles.navDivider} />
|
<div className={styles.navDivider} />
|
||||||
{navItems.map((item) => (
|
{visibleNavItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
|
|||||||
@ -63,6 +63,20 @@ export function TeamAdminLayout() {
|
|||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
</NavLink>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<div className={styles.sidebarFooter}>
|
<div className={styles.sidebarFooter}>
|
||||||
|
|||||||
@ -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 () => {
|
const handleResetPassword = async () => {
|
||||||
if (!resetPwUser) return;
|
if (!resetPwUser) return;
|
||||||
setResetPwError('');
|
setResetPwError('');
|
||||||
@ -292,6 +307,15 @@ export function UsersPage() {
|
|||||||
改名
|
改名
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{u.is_observer && (
|
||||||
|
<span
|
||||||
|
className={styles.statusBadge}
|
||||||
|
style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)' }}
|
||||||
|
title="该团管被标记为观察者,可查看全局内容资产"
|
||||||
|
>
|
||||||
|
观察者
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@ -317,6 +341,15 @@ export function UsersPage() {
|
|||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
<button className={styles.editBtn} onClick={() => openEditModal(u)}>编辑</button>
|
||||||
<button className={styles.editBtn} onClick={() => { setResetPwUser(u); setResetPwValue(''); setResetPwError(''); }}>重置密码</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
|
<button
|
||||||
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
|
className={`${styles.toggleBtn} ${u.is_active ? styles.disableBtn : styles.enableBtn}`}
|
||||||
onClick={() => setConfirmUser(u)}
|
onClick={() => setConfirmUser(u)}
|
||||||
|
|||||||
@ -93,6 +93,7 @@ export interface User {
|
|||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
is_team_owner?: boolean;
|
is_team_owner?: boolean;
|
||||||
|
is_observer?: boolean;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
team_name: string | null;
|
team_name: string | null;
|
||||||
must_change_password: boolean;
|
must_change_password: boolean;
|
||||||
@ -170,6 +171,7 @@ export interface AdminUser {
|
|||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
is_team_owner?: boolean;
|
is_team_owner?: boolean;
|
||||||
|
is_observer?: boolean;
|
||||||
team_id: number | null;
|
team_id: number | null;
|
||||||
team_name: string | null;
|
team_name: string | null;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
@ -345,6 +347,7 @@ export interface TeamMember {
|
|||||||
email: string;
|
email: string;
|
||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
is_team_owner?: boolean;
|
is_team_owner?: boolean;
|
||||||
|
is_observer?: boolean;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
disabled_by: string;
|
disabled_by: string;
|
||||||
daily_seconds_limit: number;
|
daily_seconds_limit: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user