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_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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user