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:
parent
e500c2d6a0
commit
a842f87812
@ -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='操作类型'),
|
||||
),
|
||||
]
|
||||
@ -96,6 +96,7 @@ class AdminAuditLog(models.Model):
|
||||
('member_quota_update', '更新成员额度'),
|
||||
('member_status_toggle', '切换成员状态'),
|
||||
('user_password_reset', '重置用户密码'),
|
||||
('user_username_update', '修改用户名'),
|
||||
]
|
||||
|
||||
operator = models.ForeignKey(
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
567
docs/todo/用户名修改+观察者标记.md
Normal file
567
docs/todo/用户名修改+观察者标记.md
Normal 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-271;username at L237 (`<button styles.usernameLink>`) |
|
||||
| TeamMembersPage row | L174-231;username 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=true)GET `/admin/assets/overview` | 200 全局数据 |
|
||||
| B5 | `tudou`(is_observer=false)GET `/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()` 自动处理;不另加禁止
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序(两个 commit,A 不依赖 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 项验收
|
||||
```
|
||||
@ -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<{
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user