Compare commits

..

No commits in common. "ab790fbe65b09982e1e130512720b53ff1d8beeb" and "e500c2d6a0d2cef020d90006b3efd248db40f2f4" have entirely different histories.

18 changed files with 59 additions and 1136 deletions

View File

@ -1,18 +0,0 @@
# Generated by Django 4.2.29 on 2026-05-18 15:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0014_set_existing_admins_as_owners'),
]
operations = [
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', '修改用户名')], max_length=30, verbose_name='操作类型'),
),
]

View File

@ -1,23 +0,0 @@
# 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,7 +52,6 @@ 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,8 +96,6 @@ class AdminAuditLog(models.Model):
('member_quota_update', '更新成员额度'),
('member_status_toggle', '切换成员状态'),
('user_password_reset', '重置用户密码'),
('user_username_update', '修改用户名'),
('user_observer_toggle', '切换观察者标记'),
]
operator = models.ForeignKey(

View File

@ -43,18 +43,3 @@ 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', 'is_observer', 'role', 'team_name', 'must_change_password')
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'role', 'team_name', 'must_change_password')
class RegisterSerializer(serializers.Serializer):

View File

@ -34,8 +34,6 @@ urlpatterns = [
path('admin/users/<int:user_id>/quota', views.admin_user_quota_view, name='admin_user_quota'),
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'),
@ -67,7 +65,6 @@ urlpatterns = [
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
path('team/members/<int:member_id>/role', views.team_member_role_view, name='team_member_role'),
path('team/members/<int:member_id>/reset-password', views.team_reset_member_password_view, name='team_reset_member_password'),
path('team/members/<int:member_id>/username', views.team_member_username_update_view, name='team_member_username_update'),
# ── Team Admin: Consumption Records ──
path('team/records', views.team_records_view, name='team_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, IsSuperAdminOrObserver
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
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,8 +1506,6 @@ 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(),
@ -1725,82 +1723,6 @@ def admin_reset_password_view(request, user_id):
return Response({'message': f'已重置 {user.username} 的密码'})
@api_view(['PATCH'])
@permission_classes([IsSuperAdmin])
def admin_user_username_update_view(request, user_id):
"""PATCH /api/v1/admin/users/<id>/username — 超管修改任意用户的用户名。"""
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
# admin 账号保护:用户名不可修改(无论操作者是谁)
if user.username == 'admin':
return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
new_username = (request.data.get('username') or '').strip()
# 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符
new_bytes = len(new_username.encode('utf-8'))
if not (3 <= new_bytes <= 20):
return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
if new_username == user.username:
return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=new_username).exclude(id=user.id).exists():
return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)
from django.core.exceptions import ValidationError as DjangoValidationError
old_username = user.username
user.username = new_username
try:
user.full_clean(exclude=['password'])
except DjangoValidationError:
return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
user.save(update_fields=['username'])
log_admin_action(
request, 'user_username_update', 'user',
target_id=user.id, target_name=new_username,
before={'username': old_username},
after={'username': new_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'])
@permission_classes([IsSuperAdmin])
def admin_create_user_view(request):
@ -2821,66 +2743,6 @@ def team_reset_member_password_view(request, member_id):
})
@api_view(['PATCH'])
@permission_classes([IsTeamAdmin])
def team_member_username_update_view(request, member_id):
"""PATCH /api/v1/team/members/<id>/username — 团管修改本团队成员用户名。
权限矩阵( team_reset_member_password_view):
- 主管 (is_team_owner=True): 可改同团队的副管 + 成员的用户名
- 副管 (is_team_admin=True && !is_team_owner): 只能改同团队成员的用户名
"""
team = request.user.team
if team is None:
return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST)
try:
target = team.members.get(id=member_id)
except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
operator = request.user
# 防御性兜底
if target.team_id != operator.team_id:
return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN)
if target.id == operator.id:
return Response({'error': '不能修改自己的用户名'}, status=status.HTTP_400_BAD_REQUEST)
if target.username == 'admin':
return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
if target.is_team_owner:
return Response({'error': '不能修改主管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
if target.is_team_admin and not operator.is_team_owner:
return Response({'error': '只有主管理员能修改副管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
new_username = (request.data.get('username') or '').strip()
# 长度按 UTF-8 字节计:ASCII 1 字节、中文 3 字节;3-20 字节 ≈ 3-20 个英文字符 或 ~1-6 个中文字符
new_bytes = len(new_username.encode('utf-8'))
if not (3 <= new_bytes <= 20):
return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
if new_username == target.username:
return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=new_username).exclude(id=target.id).exists():
return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)
from django.core.exceptions import ValidationError as DjangoValidationError
old_username = target.username
target.username = new_username
try:
target.full_clean(exclude=['password'])
except DjangoValidationError:
return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
target.save(update_fields=['username'])
log_admin_action(
request, 'user_username_update', 'user',
target_id=target.id, target_name=new_username,
before={'username': old_username},
after={'username': new_username},
)
return Response({'user_id': target.id, 'username': target.username})
# ──────────────────────────────────────────────
# Profile: User's own consumption data
# ──────────────────────────────────────────────
@ -3048,7 +2910,7 @@ def profile_records_view(request):
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdminOrObserver])
@permission_classes([IsSuperAdmin])
def admin_assets_overview(request):
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
from apps.accounts.models import Team
@ -3097,7 +2959,7 @@ def admin_assets_overview(request):
@api_view(['GET'])
@permission_classes([IsSuperAdminOrObserver])
@permission_classes([IsSuperAdmin])
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
@ -3137,7 +2999,7 @@ def admin_assets_team_members(request, team_id):
@api_view(['GET'])
@permission_classes([IsSuperAdminOrObserver])
@permission_classes([IsSuperAdmin])
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

@ -1,567 +0,0 @@
# 用户名修改 + 观察者标记 — 实施计划
> Plan agent 基于代码现状产出不靠猜。dev agent 可照着实施。
## 用户拍板的决策
| # | 决策 |
|---|------|
| 1 | 改用户名权限:超管+团管都能改、各管各的范围 |
| 2 | 观察者标记只能给团管打(普通成员先升团管) |
| 3 | 观察者视野:全部团队(含自己团队) |
| 4 | 观察者能下载(能看就能下) |
---
## Phase 0 — 现状事实表plan agent 读完代码后确认)
### Backend
| Item | Truth |
|---|---|
| accounts 最高 migration | `0014_set_existing_admins_as_owners.py` → 下一个 **0015** |
| `User.role` property | `'super_admin'` / `'team_admin'` / `'member'` (`accounts/models.py:74-80`) |
| `AdminAuditLog.ACTION_CHOICES` | 13 actions 末尾 `('user_password_reset', '重置用户密码')` (`accounts/models.py:84-99`) |
| `UserSerializer` fields | `id, username, email, is_staff, is_team_admin, is_team_owner, role, team_name, must_change_password` (`accounts/serializers.py:14`) |
| `admin_user_detail_view` | GET only (`views.py:1535`) |
| `team_member_detail_view` | GET only (`views.py:2494`) |
| **照抄 5 步权限矩阵** | `team_reset_member_password_view` at `views.py:2683-2743` |
| **admin 账号保护** | `admin_user_status_view:1675` (`if user.username == 'admin': return 403`) |
| Username validator | Django `UnicodeUsernameValidator` 允许 `[\w.@+-]` Unicode → 中文 OK |
| 三个资产 endpoint | `views.py:2912 / 2961 / 3001` 全用 `[IsSuperAdmin]` |
| `log_admin_action` 签名 | `(request, action, target_type, target_id=None, target_name='', before=None, after=None)` (`accounts/models.py:243`) |
### Frontend
| Item | Truth |
|---|---|
| 内联编辑模板 | `TeamsPage.tsx:445-491``<span style={{display:'inline-flex', gap:6, alignItems:'center', flexWrap:'nowrap'}}>` + 保存/取消按 `whiteSpace:'nowrap'`**没有可复用 InlineEdit 组件,就地写** |
| `User` type (`types/index.ts:89`) | 已有 `is_team_owner?: boolean`,需加 `is_observer` |
| `ProtectedRoute` props | 已有 `requireAdmin / requireTeamAdmin / requireTeamMember`,需加 `requireAdminOrObserver` |
| `AdminLayout` nav | hardcoded `navItems` at top |
| `TeamAdminLayout` | 4 项 dashboard/members/records/assets团队的 |
| UsersPage row | L228-271username at L237 (`<button styles.usernameLink>`) |
| TeamMembersPage row | L174-231username at L182无 button |
### AdminAssetsPage 的 ¥ 出现位置(精确)
1. `AdminAssetsPage.tsx:8``formatCost` helper
2. **L158** — 总费用统计卡 value
3. **L174** — team accordion badge
4. **L195** — member accordion badge
5. **L238** — no-team badge
6. **VideoDetailModal.tsx:548-554** — token + ¥ block已确认 `assetVideoToTask` 不填 `tokensConsumed/costAmount`**已经不渲染**,无需改)
---
## Phase A — 修改用户名commit 1
### 关键决策
| 决策 | 选择 | 理由 |
|---|---|---|
| `admin_user_detail` 加 PUT 还是新 endpoint | **新建 `admin_user_username_update_view`** | 1) detail GET 是聚合查询不能污染 2) 项目惯例:`/quota``/status``/reset-password` 都是专项写 endpoint 3) 审计/限流粒度好控 |
| 复用什么 inline edit | **就地按 TeamsPage:445-491 模板写**(不抽组件) | 项目无 `<InlineEdit>` 组件、本次不抽避免范围蔓延 |
| 5 步矩阵 | **照抄 `team_reset_member_password_view`** | 1) 同团队 2) 不能改自己 3) 不能改主管 4) 副管不能改副管 5) 走到合法 |
### Backend 改动
**文件 1`backend/apps/accounts/models.py`** — `ACTION_CHOICES` 末尾加
```python
('user_username_update', '修改用户名'),
```
**文件 2迁移**
```bash
cd backend && python manage.py makemigrations accounts -n add_username_update_audit_action
```
**文件 3`backend/apps/generation/views.py`** — 新增两个 view位置靠近 `admin_reset_password_view` 之后team 那个靠近 `team_reset_member_password_view` 之后)
```python
@api_view(['PATCH'])
@permission_classes([IsSuperAdmin])
def admin_user_username_update_view(request, user_id):
"""PATCH /api/v1/admin/users/<id>/username — 超管修改任意用户的用户名。"""
try:
user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
if user.username == 'admin':
return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
new_username = (request.data.get('username') or '').strip()
if not (3 <= len(new_username) <= 20):
return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
if new_username == user.username:
return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=new_username).exclude(id=user.id).exists():
return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)
from django.core.exceptions import ValidationError as DjangoValidationError
old_username = user.username
user.username = new_username
try:
user.full_clean(exclude=['password'])
except DjangoValidationError:
return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
user.save(update_fields=['username'])
log_admin_action(request, 'user_username_update', 'user',
target_id=user.id, target_name=new_username,
before={'username': old_username},
after={'username': new_username})
return Response({'user_id': user.id, 'username': user.username})
@api_view(['PATCH'])
@permission_classes([IsTeamAdmin])
def team_member_username_update_view(request, member_id):
"""PATCH /api/v1/team/members/<id>/username — 团管修改本团队成员用户名。5 步矩阵。"""
team = request.user.team
if team is None:
return Response({'error': '当前用户没有团队'}, status=status.HTTP_400_BAD_REQUEST)
try:
target = team.members.get(id=member_id)
except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
operator = request.user
if target.team_id != operator.team_id:
return Response({'error': '不在同一团队'}, status=status.HTTP_403_FORBIDDEN)
if target.id == operator.id:
return Response({'error': '不能修改自己的用户名'}, status=status.HTTP_400_BAD_REQUEST)
if target.username == 'admin': # 兜底admin 不在团队里所以理论不会进来,但保险加)
return Response({'error': '不能修改超级管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
if target.is_team_owner:
return Response({'error': '不能修改主管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
if target.is_team_admin and not operator.is_team_owner:
return Response({'error': '只有主管理员能修改副管理员的用户名'}, status=status.HTTP_403_FORBIDDEN)
new_username = (request.data.get('username') or '').strip()
if not (3 <= len(new_username) <= 20):
return Response({'error': '用户名长度需 3-20 个字符'}, status=status.HTTP_400_BAD_REQUEST)
if new_username == target.username:
return Response({'error': '新用户名与原用户名相同'}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=new_username).exclude(id=target.id).exists():
return Response({'error': '该用户名已被占用'}, status=status.HTTP_400_BAD_REQUEST)
from django.core.exceptions import ValidationError as DjangoValidationError
old_username = target.username
target.username = new_username
try:
target.full_clean(exclude=['password'])
except DjangoValidationError:
return Response({'error': '用户名包含非法字符'}, status=status.HTTP_400_BAD_REQUEST)
target.save(update_fields=['username'])
log_admin_action(request, 'user_username_update', 'user',
target_id=target.id, target_name=new_username,
before={'username': old_username},
after={'username': new_username})
return Response({'user_id': target.id, 'username': target.username})
```
**文件 4`backend/apps/generation/urls.py`** — 加 2 条 route位置reset-password 那两行之后)
```python
path('admin/users/<int:user_id>/username', views.admin_user_username_update_view, name='admin_user_username_update'),
path('team/members/<int:member_id>/username', views.team_member_username_update_view, name='team_member_username_update'),
```
### Frontend 改动
**文件 5`web/src/lib/api.ts`** — 加 2 方法
```typescript
// adminApi 段resetUserPassword 之后)
updateUserUsername: (userId: number, username: string) =>
api.patch<{ user_id: number; username: string }>(`/admin/users/${userId}/username`, { username }),
// teamApi 段resetMemberPassword 之后)
updateMemberUsername: (memberId: number, username: string) =>
api.patch<{ user_id: number; username: string }>(`/team/members/${memberId}/username`, { username }),
```
**文件 6`web/src/pages/UsersPage.tsx`** — 用户名 cell 加内联编辑L237 附近)
state`const [editingUsernameId, setEditingUsernameId] = useState<number|null>(null); const [editingUsernameValue, setEditingUsernameValue] = useState('');`
显示态:保留现有 `<button>` 用户名链接,旁边加铅笔小图标按钮触发编辑
编辑态:`<input value={editingUsernameValue} />` + 保存按钮 + 取消按钮(容器 `display:'inline-flex', gap:6, alignItems:'center', flexWrap:'nowrap'`**保存/取消按钮 `whiteSpace:'nowrap'` 强制不换行**
**前端不显示编辑按钮的情况**`u.username === 'admin'`admin 行只读)
**文件 7`web/src/pages/TeamMembersPage.tsx`** — 用户名 cell 同上L182 附近)
加 helper
```typescript
function canEditUsernameFor(m: TeamMember, op: User | null): boolean {
if (!op || !m) return false;
if (m.id === op.id) return false;
if (m.username === 'admin') return false;
if (m.is_team_owner) return false;
if (m.is_team_admin && !op.is_team_owner) return false;
return true;
}
```
前端只在 `canEditUsernameFor` 返回 true 时显示编辑按钮(后端永远是真相)。
Toast成功 `已更新用户名为「xxx」`,失败显示 `err.response?.data?.error || '操作失败'`
### Phase A 验收 cases
| # | Scenario | 期望 |
|---|---|---|
| A1 | 超管把 `tudou``豆豆`(中文) | 200, audit log `user_username_update` |
| A2 | 超管改 `admin` | 403 `不能修改超级管理员的用户名` |
| A3 | 超管设新名 = 已存在 | 400 `该用户名已被占用` |
| A4 | 超管设新名 = `ab`2 字) | 400 `长度需 3-20` |
| A5 | 团管(主管)改自己 | 400 `不能修改自己的用户名` |
| A6 | 副管改另一个副管 | 403 `只有主管理员能修改副管理员` |
| A7 | 团管 A 提交团队 B 的 member_id | 404 `成员不存在` |
| A8 | 主管改副管 → 成员 | 200前端列表实时刷新 |
---
## Phase B — 观察者标记commit 2
### 关键决策
| 决策 | 选择 | 理由 |
|---|---|---|
| 「设观察者」入口放哪 | **`UsersPage.tsx` 用户行操作列** | 1) 编辑用户角色属性最自然在用户管理 2) TeamsPage 团队详情入口太深 3) 已有 reset密码/编辑/禁用 3 个 row-level 操作,加这个语义一致 |
| 校验目标 | **后端硬拒绝非团管 + 拒绝超管自己** | 避免数据库里有半生效状态 |
### Backend 改动
**文件 1`backend/apps/accounts/models.py`**
```python
# User 类内is_team_owner 那行之后)
is_observer = models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)')
# ACTION_CHOICES 加(在 A 加的 user_username_update 之后)
('user_observer_toggle', '切换观察者标记'),
```
**文件 2迁移**
```bash
cd backend && python manage.py makemigrations accounts -n user_is_observer
cd backend && python manage.py migrate
```
**MySQL 严格模式提醒** — grep 验证:
```bash
grep -rn "User(" backend/apps/ | grep "save()" | grep -v migrations
```
如有裸 `User(**data).save()` 调用点需审BooleanField default=False 无 NULL 风险。
**文件 3`backend/apps/accounts/serializers.py`**
```python
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner',
'is_observer', # ← 新增
'role', 'team_name', 'must_change_password')
```
**文件 4`backend/apps/accounts/permissions.py`** — 新 class
```python
class IsSuperAdminOrObserver(BasePermission):
"""超级管理员 或 团队管理员且 is_observer=True"""
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
```
**文件 5`backend/apps/generation/views.py`**
5a. 3 个资产 endpoint 权限替换(`views.py:2912 / 2961 / 3001`
```python
@permission_classes([IsSuperAdminOrObserver]) # 替换 IsSuperAdmin
```
并把 `IsSuperAdminOrObserver` 加入 import 行:
```python
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember, IsSuperAdminOrObserver
```
5b. 新 endpoint
```python
@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 not (target.is_team_admin and target.team_id is not None):
return Response({'error': '观察者标记只能给团队管理员'}, status=status.HTTP_400_BAD_REQUEST)
if target.is_staff and target.team_id is 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})
```
**文件 6`backend/apps/generation/urls.py`**
```python
path('admin/users/<int:user_id>/observer', views.admin_user_observer_toggle_view, name='admin_user_observer_toggle'),
```
### Frontend 改动
**文件 7`web/src/types/index.ts`** — 3 处加 `is_observer`
```typescript
// L89 User
is_observer?: boolean;
// L164 附近 AdminUser
is_observer?: boolean;
// TeamMember如有也加便于团管页面识别
```
**文件 8`web/src/lib/api.ts`**
```typescript
toggleUserObserver: (userId: number, isObserver: boolean) =>
api.patch<{ user_id: number; username: string; is_observer: boolean }>(
`/admin/users/${userId}/observer`, { is_observer: isObserver }),
```
**文件 9`web/src/components/ProtectedRoute.tsx`** — 加 prop + 智能 fallback
```typescript
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
requireAdminOrObserver?: boolean; // ← 新
requireTeamAdmin?: boolean;
requireTeamMember?: boolean;
}
// 在 requireAdmin 判断段加智能 fallback
if (requireAdmin && user?.role !== 'super_admin') {
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 />;
}
}
```
**文件 10`web/src/App.tsx`** — 路由分级守卫
```tsx
<Route
path="/admin"
element={
<ProtectedRoute requireAdminOrObserver>
<AdminLayout />
</ProtectedRoute>
}
>
<Route index element={<RoleAwareIndexRedirect />} /> {/* 见下方 */}
<Route path="dashboard" element={<ProtectedRoute requireAdmin><DashboardPage /></ProtectedRoute>} />
<Route path="users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
{/* ... 所有其他 admin 子页面都用 requireAdmin 包 ... */}
<Route path="assets" element={<AdminAssetsPage />} /> {/* 父路由的 requireAdminOrObserver 已 cover */}
</Route>
```
`RoleAwareIndexRedirect` 是小辅助组件:
```tsx
function RoleAwareIndexRedirect() {
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 />;
}
```
**文件 11`web/src/pages/AdminLayout.tsx`** — sidebar 按角色过滤
```tsx
const user = useAuthStore((s) => s.user);
const isObserverOnly = user?.role === 'team_admin' && !!user?.is_observer;
const visibleNavItems = isObserverOnly
? navItems.filter(i => i.path === '/admin/assets')
: navItems;
// 渲染 visibleNavItems 替代 navItems
// logo 文案isObserverOnly 时显示「观察者」替代「管理后台」
// 「返回首页」按钮isObserverOnly 时改跳 /team/dashboard 并改 label「返回团队管理」
```
**文件 12`web/src/pages/TeamAdminLayout.tsx`** — sidebar 末尾加「全局资产」入口(仅观察者可见)
```tsx
const user = useAuthStore((s) => s.user);
const isObserver = !!user?.is_observer;
// 在现有 4 个 nav 之后加一个额外按钮(不在 navItems 数组中,因为跳的是不同 layout
{isObserver && (
<button className={styles.navItem} onClick={() => navigate('/admin/assets')}>
<GlobeIcon /> <span>全局资产</span>
</button>
)}
```
**文件 13`web/src/pages/AdminAssetsPage.tsx`** — 4 处 ¥ 条件渲染
```tsx
const user = useAuthStore((s) => s.user);
const hideMoney = user?.role !== 'super_admin'; // observer 团管 = 非超管 = 隐藏
// L158 总费用统计卡(整张条件渲染)
{!hideMoney && (
<div className={styles.statCard}>
<div className={styles.statLabel}>总费用</div>
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
</div>
)}
// L174 team badge
{!hideMoney && <span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>}
// L195 member badge — 同 L174
// L238 no-team badge — 同 L174
```
`VideoDetailModal` 不动(`assetVideoToTask` 已经不填 tokensConsumed/costAmount¥ 行已经不渲染)。**在 `assetVideoToTask` 上方加注释**
```tsx
// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为
```
**文件 14`web/src/pages/UsersPage.tsx`** — 行加切换按钮 + observer badge
```tsx
{u.is_team_admin && u.team_id && (
<button
className={styles.editBtn}
onClick={async () => {
try {
await adminApi.toggleUserObserver(u.id, !u.is_observer);
showToast(
u.is_observer
? `已取消「${u.username}」的观察者标记`
: `已将「${u.username}」设为观察者(需该用户重新登录后生效)`
);
fetchUsers();
} catch (e: any) {
showToast(e.response?.data?.error || '操作失败');
}
}}
>
{u.is_observer ? '取消观察者' : '设为观察者'}
</button>
)}
// 用户名/团队 cell 后加 badge
{u.is_observer && (
<span className={styles.statusBadge} style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)', marginLeft: 6 }}>
观察者
</span>
)}
```
### Phase B 验收 cases
| # | Scenario | 期望 |
|---|---|---|
| B1 | 超管 PATCH 把 `tudou`(团管)`is_observer=true` | 200, DB 更新, audit log `user_observer_toggle` |
| B2 | 超管对普通成员设 | 400 `观察者标记只能给团队管理员` |
| B3 | 超管对 `admin` 自己设 | 400 `超级管理员无需设观察者` |
| B4 | `tudou`is_observer=trueGET `/admin/assets/overview` | 200 全局数据 |
| B5 | `tudou`is_observer=falseGET `/admin/assets/overview` | 403 |
| B6 | 普通成员 `bob` GET `/admin/assets/overview` | 403 |
| B7 | 观察者 `tudou` 登录后 `/auth/me` 返回 `is_observer: true` → sidebar 出现「全局资产」入口 | 视觉验证 |
| B8 | 观察者打开 `/admin/assets`3 张统计卡少 1 张,所有 badge 仅剩「N 个视频」(**无 ¥** | 视觉验证 |
| B9 | 观察者点视频详情info bar 无 tokens/¥ | 视觉验证 |
| B10 | 观察者尝试访问 `/admin/users` | 被 `requireAdmin` 拒,自动 302 到 `/team/dashboard`(团管 fallback |
---
## 9 个关键风险点
1. **admin 账号在 team_member 那条线也得兜底拒**(已在 view 内加 `if target.username == 'admin'`
2. **副管/主管判断顺序**:先 `is_team_owner``is_team_admin`(照抄 `team_reset_member_password_view:2717-2722`
3. **MySQL 严格模式**`is_observer` BooleanField default=False 无风险;但需 grep 验证无裸 `User(**data).save()` 用法
4. **JWT token 不缓存 `is_observer`**,但**前端 `auth.user.is_observer` 是登录时拉的快照** → 观察者标记切换后那个用户必须重新登录或刷新 `/auth/me` 才能在自己客户端看到「全局资产」入口。UsersPage toast 已写「需该用户重新登录后生效」
5. **`/admin/users` 等子路由的 race**:父路由 `requireAdminOrObserver`,子页面分别用 `requireAdmin` 拦截(观察者团管根本进不去子页面渲染)
6. **普通团管被 `requireAdmin` 拒** → 弹回 `/team/dashboard` 不是 `/app`ProtectedRoute 智能 fallback
7. **迁移编号**A=0015, B=0016已确认 accounts 当前最高 0014
8. **观察者下载**:用户决策「能看就能下」→ VideoDetailModal 已有下载按钮保留不动
9. **空白/前后空格**:已 `.strip()`;中文/特殊字符按 `UnicodeUsernameValidator` `full_clean()` 自动处理;不另加禁止
---
## 实施顺序(两个 commitA 不依赖 B
```
commit 1 (Phase A 修改用户名):
- backend: models.py choices, views.py 2 view, urls.py 2 行, migration 0015
- frontend: api.ts 2 方法, UsersPage.tsx inline edit, TeamMembersPage.tsx inline edit
- 跑测试: cd backend && python manage.py check && python manage.py migrate
cd web && npm run build && npx vitest run
- 跑 curl A1-A8 8 项验收
commit 2 (Phase B 观察者):
- backend: models.py 字段+choice, migration 0016, serializers.py, permissions.py,
views.py 3 endpoint 权限改 + 1 新 view, urls.py 1 行
- frontend: types/index.ts 3 处 is_observer, api.ts 1 方法,
ProtectedRoute.tsx 新 prop + 智能 fallback, App.tsx 路由分级守卫,
AdminLayout.tsx 角色过滤, TeamAdminLayout.tsx 加全局资产入口,
AdminAssetsPage.tsx 4 处 ¥ 条件渲染, UsersPage.tsx 切换按钮 + badge
- 跑测试: 同上
- 跑 curl B1-B10 10 项验收
```

View File

@ -29,15 +29,6 @@ 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);
@ -86,24 +77,24 @@ export default function App() {
</ProtectedRoute>
}
/>
{/* Super Admin routes — 父 requireAdminOrObserver,子页面除 assets 外仍 requireAdmin (观察者团管只能进 assets) */}
{/* Super Admin routes */}
<Route
path="/admin"
element={
<ProtectedRoute requireAdminOrObserver>
<ProtectedRoute requireAdmin>
<AdminLayout />
</ProtectedRoute>
}
>
<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 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 path="assets" element={<AdminAssetsPage />} />
</Route>
{/* Team Admin routes */}

View File

@ -5,12 +5,11 @@ import { useAuthStore } from '../store/auth';
interface Props {
children: React.ReactNode;
requireAdmin?: boolean;
requireAdminOrObserver?: boolean;
requireTeamAdmin?: boolean;
requireTeamMember?: boolean;
}
export function ProtectedRoute({ children, requireAdmin, requireAdminOrObserver, requireTeamAdmin, requireTeamMember }: Props) {
export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requireTeamMember }: Props) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const user = useAuthStore((s) => s.user);
@ -68,20 +67,9 @@ export function ProtectedRoute({ children, requireAdmin, requireAdminOrObserver,
}
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

@ -256,12 +256,6 @@ export const adminApi = {
resetUserPassword: (userId: number, newPassword: string) =>
api.post(`/admin/users/${userId}/reset-password`, { new_password: newPassword }),
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;
@ -389,9 +383,6 @@ export const teamApi = {
resetMemberPassword: (memberId: number) =>
api.post<{ user_id: number; username: string; new_password: string; message: string }>(`/team/members/${memberId}/reset-password`),
updateMemberUsername: (memberId: number, username: string) =>
api.patch<{ user_id: number; username: string }>(`/team/members/${memberId}/username`, { username }),
// Content Assets
getAssetsOverview: () =>
api.get<{

View File

@ -1,7 +1,6 @@
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';
@ -36,7 +35,6 @@ 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 || '';
@ -79,9 +77,6 @@ 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;
@ -158,12 +153,10 @@ 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}>{formatCost(overview.total_seconds)}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_teams}</div>
@ -178,9 +171,7 @@ 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>
)}
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
</div>
</div>
{expandedTeam === team.id && (
@ -201,9 +192,7 @@ 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>
)}
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
@ -246,9 +235,7 @@ 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>
)}
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
</div>
</div>
</div>

View File

@ -28,9 +28,6 @@ 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(() => {
@ -75,7 +72,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}>{isObserverOnly ? 'AirDrama 观察者' : 'AirDrama Admin'}</span>}
{!collapsed && <span className={styles.logoText}>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)">
@ -89,18 +86,14 @@ export function AdminLayout() {
</div>
<nav className={styles.nav}>
<button
className={styles.navItem}
onClick={() => navigate(isObserverOnly ? '/team/dashboard' : '/app')}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}
>
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>
{!collapsed && <span>{isObserverOnly ? '返回团队管理' : '返回首页'}</span>}
{!collapsed && <span></span>}
</button>
<div className={styles.navDivider} />
{visibleNavItems.map((item) => (
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}

View File

@ -1,8 +1,6 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { useThemeStore } from '../store/theme';
import { useNotificationStore } from '../store/notification';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import logoImg from '../assets/logo_32.png';
import styles from './AdminLayout.module.css';
@ -16,23 +14,9 @@ const navItems = [
export function TeamAdminLayout() {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const theme = useThemeStore((s) => s.theme);
const toggleTheme = useThemeStore((s) => s.toggleTheme);
const unreadCount = useNotificationStore((s) => s.unreadCount);
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
const navigate = useNavigate();
const [collapsed, setCollapsed] = useState(false);
// 60s 轮询未读数 + tab 重新可见时立即拉一次(和 AdminLayout 一致)
useEffect(() => {
if (!user) return;
fetchUnreadCount();
const tick = setInterval(fetchUnreadCount, 60_000);
const onVis = () => { if (!document.hidden) fetchUnreadCount(); };
document.addEventListener('visibilitychange', onVis);
return () => { clearInterval(tick); document.removeEventListener('visibilitychange', onVis); };
}, [user, fetchUnreadCount]);
const handleLogout = () => {
logout();
navigate('/login', { replace: true });
@ -79,67 +63,9 @@ 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}>
{/* 消息中心铃铛 — 有未读时右上角红点 */}
<button
className={styles.themeToggle}
onClick={() => navigate('/notifications')}
title={unreadCount > 0 ? `${unreadCount} 条未读消息` : '消息中心'}
aria-label="消息中心"
style={{ position: 'relative' }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
{!collapsed && <span>{unreadCount > 0 ? ` (${unreadCount})` : ''}</span>}
{unreadCount > 0 && (
<span style={{
position: 'absolute',
top: 6, left: collapsed ? 22 : 22,
width: 8, height: 8, borderRadius: '50%',
background: 'var(--color-danger)',
boxShadow: '0 0 0 2px var(--color-bg-sidebar)',
}} />
)}
</button>
{/* 主题切换 — 月亮/太阳 SVG */}
<button
className={styles.themeToggle}
onClick={toggleTheme}
title={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
aria-label={theme === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
>
{theme === 'dark' ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
)}
{!collapsed && <span>{theme === 'dark' ? '浅色' : '深色'}</span>}
</button>
<div className={styles.userInfo}>
<div className={styles.userAvatar}>{user?.username.charAt(0).toUpperCase()}</div>
{!collapsed && (

View File

@ -22,16 +22,11 @@ export function TeamMembersPage() {
// Confirm toggle
const [confirmMember, setConfirmMember] = useState<TeamMember | null>(null);
// Edit member modal (username + role + quota)
// Edit quota modal
const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editUsername, setEditUsername] = useState('');
// role 取值:'admin'(副管) | 'member'(成员);主管不可在此切换
const [editRole, setEditRole] = useState<'admin' | 'member'>('member');
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState('');
// Reset password result modal — 显示新生成的随机密码 + 复制按钮
const [resetResult, setResetResult] = useState<{ username: string; newPassword: string } | null>(null);
@ -48,24 +43,6 @@ export function TeamMembersPage() {
return true;
};
// 权限矩阵(用户名修改): 同 canResetPasswordFor 但额外拒绝 admin 账号
const canEditUsernameFor = (m: TeamMember): boolean => {
if (!currentUser) return false;
if (m.id === currentUser.id) return false;
if (m.username === 'admin') return false;
if (m.is_team_owner) return false;
if (m.is_team_admin && !currentUser.is_team_owner) return false;
return true;
};
// 权限矩阵(角色切换): 仅主管能切非主管成员的副管/成员角色
const canEditRoleFor = (m: TeamMember): boolean => {
if (!currentUser?.is_team_owner) return false;
if (m.is_team_owner) return false;
if (m.id === currentUser.id) return false;
return true;
};
const handleResetPassword = async (m: TeamMember) => {
if (!window.confirm(`重置「${m.username}」的密码?\n成员下次登录需要修改新密码。`)) return;
try {
@ -110,42 +87,20 @@ export function TeamMembersPage() {
const openEditModal = (member: TeamMember) => {
setEditMember(member);
setEditUsername(member.username);
setEditRole(member.is_team_admin ? 'admin' : 'member');
setEditDaily(String(member.daily_generation_limit ?? 50));
setEditMonthly(String(member.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(member.spending_limit ?? -1));
setEditError('');
};
// 串调:username → role → quota。任一失败 toast 并停留。
const handleSaveMember = async () => {
const handleSaveQuota = async () => {
if (!editMember) return;
setEditError('');
setEditSaving(true);
try {
// 1) 用户名:只在 canEditUsernameFor 且有变化时调
const newUsername = editUsername.trim();
if (canEditUsernameFor(editMember) && newUsername && newUsername !== editMember.username) {
await teamApi.updateMemberUsername(editMember.id, newUsername);
}
// 2) 角色:只在 canEditRoleFor 且有变化时调
const currentRoleIsAdmin = !!editMember.is_team_admin;
const targetRoleIsAdmin = editRole === 'admin';
if (canEditRoleFor(editMember) && currentRoleIsAdmin !== targetRoleIsAdmin) {
await teamApi.setMemberRole(editMember.id, targetRoleIsAdmin);
}
// 3) 配额:始终保存
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast('已保存');
showToast('配额已更新');
setEditMember(null);
fetchMembers();
} catch (e: any) {
const msg = e?.response?.data?.error || e?.response?.data?.detail || '保存失败';
setEditError(msg);
showToast(msg);
} finally {
setEditSaving(false);
} catch {
showToast('更新失败');
}
};
@ -247,7 +202,18 @@ export function TeamMembersPage() {
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
<td>
<div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
{currentUser?.is_team_owner && !m.is_team_owner && (
m.is_team_admin ? (
<button className={styles.editBtn} onClick={async () => {
try { await teamApi.setMemberRole(m.id, false); showToast('已取消副管理员'); fetchMembers(); } catch { showToast('操作失败'); }
}}></button>
) : (
<button className={styles.editBtn} onClick={async () => {
try { await teamApi.setMemberRole(m.id, true); showToast('已设为副管理员'); fetchMembers(); } catch { showToast('操作失败'); }
}}></button>
)
)}
{canResetPasswordFor(m) && (
<button className={styles.editBtn} onClick={() => handleResetPassword(m)}>
@ -282,53 +248,9 @@ export function TeamMembersPage() {
{/* Edit Quota Modal */}
{editMember && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget && !editSaving) setEditMember(null); }}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditMember(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>
{!canEditUsernameFor(editMember) && '(无权修改)'}
{canEditUsernameFor(editMember) && '3-20 字符,支持中文)'}
</label>
<input
type="text"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
disabled={!canEditUsernameFor(editMember)}
/>
</div>
{canEditRoleFor(editMember) ? (
<div className={styles.formGroup}>
<label></label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value as 'admin' | 'member')}
style={{
width: '100%',
padding: '8px 36px 8px 12px',
borderRadius: 6,
border: '1px solid var(--color-border-card)',
background: `var(--color-bg-page) url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>") no-repeat right 12px center`,
color: 'var(--color-text-primary)',
fontSize: 14,
appearance: 'none',
WebkitAppearance: 'none',
MozAppearance: 'none',
}}
>
<option value="member"></option>
<option value="admin"></option>
</select>
</div>
) : (
<div className={styles.formGroup}>
<label></label>
<div style={{ color: 'var(--color-text-secondary)', fontSize: 13 }}>
{editMember.is_team_owner ? '主管理员(不可在此修改)' : editMember.is_team_admin ? '副管理员' : '成员'}
</div>
</div>
)}
<h3 className={styles.modalTitle}> {editMember.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
@ -341,14 +263,9 @@ export function TeamMembersPage() {
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
{editError && (
<div style={{ color: 'var(--color-danger)', fontSize: 12, marginBottom: 8 }}>{editError}</div>
)}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)} disabled={editSaving}></button>
<button className={styles.saveBtn} onClick={handleSaveMember} disabled={editSaving}>
{editSaving ? '保存中…' : '保存'}
</button>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>
</div>
</div>
</div>

View File

@ -47,20 +47,6 @@
.enableBtn { background: transparent; border: 1px solid var(--color-success); color: var(--color-success); }
.enableBtn:hover { background: var(--color-success-bg-hover); }
/* Toggle switch — 用于编辑用户 modal 内的「设为观察者」等 boolean 字段 */
.switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute; cursor: pointer; inset: 0;
background: var(--color-border-card); border-radius: 24px; transition: 0.3s;
}
.slider::before {
content: ''; position: absolute; height: 18px; width: 18px; left: 3px; bottom: 3px;
background: var(--color-on-primary); border-radius: 50%; transition: 0.3s;
}
.switch input:checked + .slider { background: var(--color-primary); }
.switch input:checked + .slider::before { transform: translateX(20px); }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }

View File

@ -17,15 +17,11 @@ export function UsersPage() {
const [loading, setLoading] = useState(true);
const pageSize = 20;
// User edit modal (username + observer + quota)
// Quota edit modal
const [editUser, setEditUser] = useState<AdminUser | null>(null);
const [editUsername, setEditUsername] = useState('');
const [editIsObserver, setEditIsObserver] = useState(false);
const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState('');
// User detail drawer
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
@ -89,43 +85,20 @@ export function UsersPage() {
const openEditModal = (user: AdminUser) => {
setEditUser(user);
setEditUsername(user.username);
setEditIsObserver(!!user.is_observer);
setEditDaily(String(user.daily_generation_limit ?? 50));
setEditMonthly(String(user.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(user.spending_limit ?? -1));
setEditError('');
};
// 串行调多个 PATCH:username → observer → quota。任一失败 toast 并停留在 modal,已成功的改动保留。
const handleSaveUser = async () => {
const handleSaveQuota = async () => {
if (!editUser) return;
setEditError('');
setEditSaving(true);
let observerJustEnabled = false;
try {
// 1) 用户名:admin 行只读不发请求;有改动才调
const newUsername = editUsername.trim();
if (editUser.username !== 'admin' && newUsername && newUsername !== editUser.username) {
await adminApi.updateUserUsername(editUser.id, newUsername);
}
// 2) 观察者标记:仅团管能切,有变化才调
if (editUser.is_team_admin && editUser.team_id && (!!editUser.is_observer) !== editIsObserver) {
await adminApi.toggleUserObserver(editUser.id, editIsObserver);
observerJustEnabled = editIsObserver;
}
// 3) 配额:始终保存
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast(observerJustEnabled ? '已保存(观察者标记需该用户重新登录后生效)' : '已保存');
showToast('配额已更新');
setEditUser(null);
fetchUsers();
} catch (e: any) {
const msg = e.response?.data?.error || e.response?.data?.detail || '保存失败';
setEditError(msg);
showToast(msg);
} finally {
setEditSaving(false);
} catch {
showToast('更新失败');
}
};
@ -263,33 +236,6 @@ export function UsersPage() {
}} />
{u.username}
</button>
{u.is_team_owner && (
<span
className={styles.statusBadge}
style={{ background: 'var(--color-info-bg)', color: 'var(--color-info)', marginLeft: 6 }}
title="该团队的主管理员"
>
</span>
)}
{u.is_team_admin && !u.is_team_owner && (
<span
className={styles.statusBadge}
style={{ background: 'var(--color-purple-bg)', color: 'var(--color-purple-accent)', marginLeft: 6 }}
title="该团队的副管理员"
>
</span>
)}
{u.is_observer && (
<span
className={styles.statusBadge}
style={{ background: 'var(--color-success-bg)', color: 'var(--color-success)', marginLeft: 6 }}
title="该团管被标记为观察者,可查看全局内容资产"
>
</span>
)}
</td>
<td>{u.team_name || '-'}</td>
<td>{u.email}</td>
@ -364,36 +310,9 @@ export function UsersPage() {
{/* Quota Edit Modal */}
{editUser && (
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget && !editSaving) setEditUser(null); }}>
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditUser(null); }}>
<div className={styles.modal}>
<h3 className={styles.modalTitle}> {editUser.username}</h3>
<div className={styles.formGroup}>
<label>{editUser.username === 'admin' ? '(超级管理员不可修改)' : '3-20 字符,支持中文)'}</label>
<input
type="text"
value={editUsername}
onChange={(e) => setEditUsername(e.target.value)}
disabled={editUser.username === 'admin'}
/>
</div>
{editUser.is_team_admin && editUser.team_id && (
<div className={styles.formGroup}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 14, color: 'var(--color-text-primary)' }}></span>
<label className={styles.switch}>
<input
type="checkbox"
checked={editIsObserver}
onChange={(e) => setEditIsObserver(e.target.checked)}
/>
<span className={styles.slider}></span>
</label>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', marginTop: 6, lineHeight: 1.5 }}>
</div>
</div>
)}
<h3 className={styles.modalTitle}> {editUser.username}</h3>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
@ -406,14 +325,9 @@ export function UsersPage() {
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
{editError && (
<div style={{ color: 'var(--color-danger)', fontSize: 12, marginBottom: 8 }}>{editError}</div>
)}
<div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditUser(null)} disabled={editSaving}></button>
<button className={styles.saveBtn} onClick={handleSaveUser} disabled={editSaving}>
{editSaving ? '保存中…' : '保存'}
</button>
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button>
</div>
</div>
</div>

View File

@ -93,7 +93,6 @@ 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;
@ -171,7 +170,6 @@ 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;
@ -347,7 +345,6 @@ 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;