feat(users): 超管+团管可改用户名 — 内联编辑 + 5 步权限矩阵

后端:
- AdminAuditLog 新增 user_username_update 操作类型 (0015 migration)
- admin_user_username_update_view (PATCH /admin/users/<id>/username, 仅超管, admin 账号不可改)
- team_member_username_update_view (PATCH /team/members/<id>/username, 团管, 同 reset-password 5 步矩阵: 同团/拒自己/拒admin/拒主管/副管不改副管)
- 长度按 UTF-8 字节计 3-20 字节 (≈ 3-20 英文字符 或 1-6 中文字符)

前端:
- adminApi.updateUserUsername + teamApi.updateMemberUsername
- UsersPage 用户名 cell 内联「改名」按钮 (admin 行隐藏)
- TeamMembersPage 用户名 cell 内联「改名」按钮 (canEditUsernameFor 守卫)
- 按现有 TeamsPage inline edit 模板 (inline-flex + whiteSpace:nowrap)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-18 15:43:35 +08:00
parent e500c2d6a0
commit a842f87812
8 changed files with 835 additions and 14 deletions

View File

@ -0,0 +1,18 @@
# 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

@ -96,6 +96,7 @@ class AdminAuditLog(models.Model):
('member_quota_update', '更新成员额度'),
('member_status_toggle', '切换成员状态'),
('user_password_reset', '重置用户密码'),
('user_username_update', '修改用户名'),
]
operator = models.ForeignKey(

View File

@ -34,6 +34,7 @@ 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'),
# ── Super Admin: Records, Settings & Audit Logs ──
path('admin/records', views.admin_records_view, name='admin_records'),
@ -65,6 +66,7 @@ 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

@ -1723,6 +1723,47 @@ 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(['POST'])
@permission_classes([IsSuperAdmin])
def admin_create_user_view(request):
@ -2743,6 +2784,66 @@ 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
# ──────────────────────────────────────────────

View File

@ -0,0 +1,567 @@
# 用户名修改 + 观察者标记 — 实施计划
> 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

@ -256,6 +256,9 @@ 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 }),
getRecords: (params: {
page?: number;
page_size?: number;
@ -383,6 +386,9 @@ 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

@ -31,6 +31,10 @@ export function TeamMembersPage() {
// Reset password result modal — 显示新生成的随机密码 + 复制按钮
const [resetResult, setResetResult] = useState<{ username: string; newPassword: string } | null>(null);
// Inline username edit
const [editingUsernameId, setEditingUsernameId] = useState<number | null>(null);
const [editingUsernameValue, setEditingUsernameValue] = useState('');
// 权限矩阵:
// 主管(is_team_owner) → 可重置「副管 + 成员」(不可重置主管/自己)
// 副管(is_team_admin) → 只能重置「成员」(不可重置副管/主管/自己)
@ -43,6 +47,40 @@ 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 startEditUsername = (m: TeamMember) => {
setEditingUsernameId(m.id);
setEditingUsernameValue(m.username);
};
const cancelEditUsername = () => {
setEditingUsernameId(null);
setEditingUsernameValue('');
};
const handleSaveUsername = async (m: TeamMember) => {
const newName = editingUsernameValue.trim();
if (!newName) { showToast('请输入用户名'); return; }
if (newName === m.username) { cancelEditUsername(); return; }
try {
await teamApi.updateMemberUsername(m.id, newName);
showToast(`已更新用户名为「${newName}`);
cancelEditUsername();
fetchMembers();
} catch (e: any) {
showToast(e?.response?.data?.error || '操作失败');
}
};
const handleResetPassword = async (m: TeamMember) => {
if (!window.confirm(`重置「${m.username}」的密码?\n成员下次登录需要修改新密码。`)) return;
try {
@ -174,12 +212,42 @@ export function TeamMembersPage() {
members.map((m) => (
<tr key={m.id}>
<td>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: m.is_online ? 'var(--color-success)' : 'var(--color-text-quaternary)', marginRight: 6,
verticalAlign: 'middle',
}} />
{m.username}
{editingUsernameId === m.id ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="text"
value={editingUsernameValue}
onChange={(e) => setEditingUsernameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveUsername(m);
else if (e.key === 'Escape') cancelEditUsername();
}}
autoFocus
style={{ width: 140, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button className={styles.editBtn} onClick={() => handleSaveUsername(m)} style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}></button>
<button className={styles.editBtn} onClick={cancelEditUsername} style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}></button>
</span>
) : (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: m.is_online ? 'var(--color-success)' : 'var(--color-text-quaternary)',
verticalAlign: 'middle',
}} />
<span>{m.username}</span>
{canEditUsernameFor(m) && (
<button
className={styles.editBtn}
onClick={() => startEditUsername(m)}
title="修改用户名"
style={{ fontSize: 12, padding: '2px 8px', whiteSpace: 'nowrap' }}
>
</button>
)}
</span>
)}
</td>
<td>
{m.is_team_owner ? (

View File

@ -35,6 +35,10 @@ export function UsersPage() {
const [resetPwValue, setResetPwValue] = useState('');
const [resetPwError, setResetPwError] = useState('');
// Inline username edit
const [editingUsernameId, setEditingUsernameId] = useState<number | null>(null);
const [editingUsernameValue, setEditingUsernameValue] = useState('');
// Create user modal
const [createOpen, setCreateOpen] = useState(false);
const [newUsername, setNewUsername] = useState('');
@ -142,6 +146,30 @@ export function UsersPage() {
}
};
const startEditUsername = (u: AdminUser) => {
setEditingUsernameId(u.id);
setEditingUsernameValue(u.username);
};
const cancelEditUsername = () => {
setEditingUsernameId(null);
setEditingUsernameValue('');
};
const handleSaveUsername = async (u: AdminUser) => {
const newName = editingUsernameValue.trim();
if (!newName) { showToast('请输入用户名'); return; }
if (newName === u.username) { cancelEditUsername(); return; }
try {
await adminApi.updateUserUsername(u.id, newName);
showToast(`已更新用户名为「${newName}`);
cancelEditUsername();
fetchUsers();
} catch (e: any) {
showToast(e.response?.data?.error || '操作失败');
}
};
const handleResetPassword = async () => {
if (!resetPwUser) return;
setResetPwError('');
@ -228,14 +256,44 @@ export function UsersPage() {
users.map((u) => (
<tr key={u.id}>
<td>
<button className={styles.usernameLink} onClick={() => openDrawer(u.id)}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: u.is_online ? '#00b894' : '#555', marginRight: 6,
verticalAlign: 'middle',
}} />
{u.username}
</button>
{editingUsernameId === u.id ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="text"
value={editingUsernameValue}
onChange={(e) => setEditingUsernameValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveUsername(u);
else if (e.key === 'Escape') cancelEditUsername();
}}
autoFocus
style={{ width: 140, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button className={styles.editBtn} onClick={() => handleSaveUsername(u)} style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}></button>
<button className={styles.editBtn} onClick={cancelEditUsername} style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}></button>
</span>
) : (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<button className={styles.usernameLink} onClick={() => openDrawer(u.id)}>
<span style={{
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
background: u.is_online ? '#00b894' : '#555', marginRight: 6,
verticalAlign: 'middle',
}} />
{u.username}
</button>
{u.username !== 'admin' && (
<button
className={styles.editBtn}
onClick={() => startEditUsername(u)}
title="修改用户名"
style={{ fontSize: 12, padding: '2px 8px', whiteSpace: 'nowrap' }}
>
</button>
)}
</span>
)}
</td>
<td>{u.team_name || '-'}</td>
<td>{u.email}</td>