Compare commits
8 Commits
e500c2d6a0
...
ab790fbe65
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab790fbe65 | ||
|
|
dccb4cb5e1 | ||
|
|
75b950849d | ||
|
|
f54bf94422 | ||
|
|
690b0c00e7 | ||
|
|
c1dbc7ac86 | ||
|
|
cec1e5d770 | ||
|
|
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='操作类型'),
|
||||
),
|
||||
]
|
||||
23
backend/apps/accounts/migrations/0016_user_is_observer.py
Normal file
23
backend/apps/accounts/migrations/0016_user_is_observer.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-18 15:46
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0015_add_username_update_audit_action'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='is_observer',
|
||||
field=models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adminauditlog',
|
||||
name='action',
|
||||
field=models.CharField(choices=[('team_create', '创建团队'), ('team_update', '更新团队'), ('team_topup', '团队充值'), ('team_set_pool', '设置团队额度池'), ('team_create_admin', '创建团队管理员'), ('user_create', '创建用户'), ('user_quota_update', '更新用户额度'), ('user_status_toggle', '切换用户状态'), ('settings_update', '更新系统设置'), ('member_create', '创建团队成员'), ('member_quota_update', '更新成员额度'), ('member_status_toggle', '切换成员状态'), ('user_password_reset', '重置用户密码'), ('user_username_update', '修改用户名'), ('user_observer_toggle', '切换观察者标记')], max_length=30, verbose_name='操作类型'),
|
||||
),
|
||||
]
|
||||
@ -52,6 +52,7 @@ class User(AbstractUser):
|
||||
)
|
||||
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
||||
is_team_owner = models.BooleanField(default=False, verbose_name='团队主管理员')
|
||||
is_observer = models.BooleanField(default=False, verbose_name='观察者(仅对团管生效,可看全局资产)')
|
||||
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
||||
# ── 次数限额(v0.10.0 新增) ──
|
||||
@ -96,6 +97,8 @@ class AdminAuditLog(models.Model):
|
||||
('member_quota_update', '更新成员额度'),
|
||||
('member_status_toggle', '切换成员状态'),
|
||||
('user_password_reset', '重置用户密码'),
|
||||
('user_username_update', '修改用户名'),
|
||||
('user_observer_toggle', '切换观察者标记'),
|
||||
]
|
||||
|
||||
operator = models.ForeignKey(
|
||||
|
||||
@ -43,3 +43,18 @@ class IsTeamMember(BasePermission):
|
||||
and request.user.is_authenticated
|
||||
and request.user.team is not None
|
||||
)
|
||||
|
||||
|
||||
class IsSuperAdminOrObserver(BasePermission):
|
||||
"""超级管理员,或被标记为观察者的团队管理员(可查看全局内容资产)。"""
|
||||
def has_permission(self, request, view):
|
||||
u = request.user
|
||||
if not (u and u.is_authenticated):
|
||||
return False
|
||||
# 超管
|
||||
if u.is_staff and u.team is None:
|
||||
return True
|
||||
# 观察者团管
|
||||
if u.is_team_admin and u.team is not None and getattr(u, 'is_observer', False):
|
||||
return True
|
||||
return False
|
||||
|
||||
@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'role', 'team_name', 'must_change_password')
|
||||
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'is_observer', 'role', 'team_name', 'must_change_password')
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.Serializer):
|
||||
|
||||
@ -34,6 +34,8 @@ 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'),
|
||||
@ -65,6 +67,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'),
|
||||
|
||||
@ -23,7 +23,7 @@ from .serializers import (
|
||||
TeamAnomalyConfigSerializer,
|
||||
)
|
||||
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord
|
||||
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
|
||||
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember, IsSuperAdminOrObserver
|
||||
from utils.tos_client import upload_file as tos_upload
|
||||
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
|
||||
from utils.billing import get_resolution, estimate_tokens, calculate_cost, calculate_base_cost
|
||||
@ -1506,6 +1506,8 @@ def admin_users_list_view(request):
|
||||
'disabled_by': u.disabled_by,
|
||||
'is_staff': u.is_staff,
|
||||
'is_team_admin': u.is_team_admin,
|
||||
'is_team_owner': u.is_team_owner,
|
||||
'is_observer': u.is_observer,
|
||||
'team_id': u.team_id,
|
||||
'team_name': u.team.name if u.team else None,
|
||||
'date_joined': u.date_joined.isoformat(),
|
||||
@ -1723,6 +1725,82 @@ 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):
|
||||
@ -2743,6 +2821,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
|
||||
# ──────────────────────────────────────────────
|
||||
@ -2910,7 +3048,7 @@ def profile_records_view(request):
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
@permission_classes([IsSuperAdminOrObserver])
|
||||
def admin_assets_overview(request):
|
||||
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
|
||||
from apps.accounts.models import Team
|
||||
@ -2959,7 +3097,7 @@ def admin_assets_overview(request):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
@permission_classes([IsSuperAdminOrObserver])
|
||||
def admin_assets_team_members(request, team_id):
|
||||
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
|
||||
from apps.accounts.models import Team
|
||||
@ -2999,7 +3137,7 @@ def admin_assets_team_members(request, team_id):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
@permission_classes([IsSuperAdminOrObserver])
|
||||
def admin_assets_user_videos(request, user_id):
|
||||
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
|
||||
try:
|
||||
|
||||
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 项验收
|
||||
```
|
||||
@ -29,6 +29,15 @@ import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
||||
|
||||
import { useAuthStore } from './store/auth';
|
||||
|
||||
// 观察者团管进 /admin 跳 assets,超管进 /admin 跳 dashboard
|
||||
function RoleAwareAdminIndexRedirect() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (user?.role === 'team_admin' && user?.is_observer) {
|
||||
return <Navigate to="/admin/assets" replace />;
|
||||
}
|
||||
return <Navigate to="/admin/dashboard" replace />;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const initialize = useAuthStore((s) => s.initialize);
|
||||
|
||||
@ -77,24 +86,24 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Super Admin routes */}
|
||||
{/* Super Admin routes — 父 requireAdminOrObserver,子页面除 assets 外仍 requireAdmin (观察者团管只能进 assets) */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute requireAdmin>
|
||||
<ProtectedRoute requireAdminOrObserver>
|
||||
<AdminLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="teams" element={<TeamsPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="security" element={<AnomalyLogPage />} />
|
||||
<Route path="login-records" element={<LoginRecordsPage />} />
|
||||
<Route path="logs" element={<AuditLogsPage />} />
|
||||
<Route index element={<RoleAwareAdminIndexRedirect />} />
|
||||
<Route path="dashboard" element={<ProtectedRoute requireAdmin><DashboardPage /></ProtectedRoute>} />
|
||||
<Route path="teams" element={<ProtectedRoute requireAdmin><TeamsPage /></ProtectedRoute>} />
|
||||
<Route path="users" element={<ProtectedRoute requireAdmin><UsersPage /></ProtectedRoute>} />
|
||||
<Route path="records" element={<ProtectedRoute requireAdmin><RecordsPage /></ProtectedRoute>} />
|
||||
<Route path="settings" element={<ProtectedRoute requireAdmin><SettingsPage /></ProtectedRoute>} />
|
||||
<Route path="security" element={<ProtectedRoute requireAdmin><AnomalyLogPage /></ProtectedRoute>} />
|
||||
<Route path="login-records" element={<ProtectedRoute requireAdmin><LoginRecordsPage /></ProtectedRoute>} />
|
||||
<Route path="logs" element={<ProtectedRoute requireAdmin><AuditLogsPage /></ProtectedRoute>} />
|
||||
<Route path="assets" element={<AdminAssetsPage />} />
|
||||
</Route>
|
||||
{/* Team Admin routes */}
|
||||
|
||||
@ -5,11 +5,12 @@ import { useAuthStore } from '../store/auth';
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
requireAdmin?: boolean;
|
||||
requireAdminOrObserver?: boolean;
|
||||
requireTeamAdmin?: boolean;
|
||||
requireTeamMember?: boolean;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requireTeamMember }: Props) {
|
||||
export function ProtectedRoute({ children, requireAdmin, requireAdminOrObserver, requireTeamAdmin, requireTeamMember }: Props) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@ -67,9 +68,20 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
||||
}
|
||||
|
||||
if (requireAdmin && user?.role !== 'super_admin') {
|
||||
// 智能 fallback:团管被 admin 子页面拒 → 回团队管理,普通成员/未登录 → /app
|
||||
if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
if (requireAdminOrObserver) {
|
||||
const isAdmin = user?.role === 'super_admin';
|
||||
const isObserverTeamAdmin = user?.role === 'team_admin' && !!user?.is_observer;
|
||||
if (!isAdmin && !isObserverTeamAdmin) {
|
||||
if (user?.role === 'team_admin') return <Navigate to="/team/dashboard" replace />;
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
}
|
||||
|
||||
if (requireTeamAdmin && user?.role !== 'team_admin') {
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
@ -256,6 +256,12 @@ 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;
|
||||
@ -383,6 +389,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<{
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { adminApi, rewriteTosUrl } from '../lib/api';
|
||||
import { VideoDetailModal } from '../components/VideoDetailModal';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
|
||||
import styles from './AdminAssetsPage.module.css';
|
||||
|
||||
@ -35,6 +36,7 @@ function isAssetUrl(url: string): boolean {
|
||||
return url.startsWith('asset://') || url.startsWith('Asset://');
|
||||
}
|
||||
|
||||
// 不传 tokensConsumed/costAmount — 观察者团管隐藏 ¥ 依赖此默认行为
|
||||
function assetVideoToTask(v: AssetVideo): GenerationTask {
|
||||
const references = (v.reference_urls || []).map((ref, i) => {
|
||||
const url = ref.url || '';
|
||||
@ -77,6 +79,9 @@ function Chevron({ open }: { open: boolean }) {
|
||||
}
|
||||
|
||||
export function AdminAssetsPage() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
// 观察者团管不是超管 → 隐藏 ¥(成本/费用)
|
||||
const hideMoney = currentUser?.role !== 'super_admin';
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [overview, setOverview] = useState<{
|
||||
total_videos: number; total_seconds: number; total_teams: number;
|
||||
@ -153,10 +158,12 @@ export function AdminAssetsPage() {
|
||||
<div className={styles.statLabel}>总视频数</div>
|
||||
<div className={styles.statValue}>{overview.total_videos}</div>
|
||||
</div>
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>总费用</div>
|
||||
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
|
||||
</div>
|
||||
{!hideMoney && (
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>总费用</div>
|
||||
<div className={styles.statValue}>{formatCost(overview.total_seconds)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statLabel}>团队数</div>
|
||||
<div className={styles.statValue}>{overview.total_teams}</div>
|
||||
@ -171,7 +178,9 @@ export function AdminAssetsPage() {
|
||||
<span className={styles.accordionName}>{team.name}</span>
|
||||
<div className={styles.accordionMeta}>
|
||||
<span className={styles.accordionBadge}>{team.video_count} 个视频</span>
|
||||
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
|
||||
{!hideMoney && (
|
||||
<span className={styles.accordionBadge}>{formatCost(team.cost_consumed ?? team.seconds_consumed)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedTeam === team.id && (
|
||||
@ -192,7 +201,9 @@ export function AdminAssetsPage() {
|
||||
</span>
|
||||
<div className={styles.accordionMeta}>
|
||||
<span className={styles.accordionBadge}>{member.video_count} 个视频</span>
|
||||
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
|
||||
{!hideMoney && (
|
||||
<span className={styles.accordionBadge}>{formatCost(member.cost_consumed ?? member.seconds_consumed)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expandedMember === member.id && memberVideos[member.id] && (
|
||||
@ -235,7 +246,9 @@ export function AdminAssetsPage() {
|
||||
<span className={styles.accordionName}>无团队用户</span>
|
||||
<div className={styles.accordionMeta}>
|
||||
<span className={styles.accordionBadge}>{overview.no_team.video_count} 个视频</span>
|
||||
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
|
||||
{!hideMoney && (
|
||||
<span className={styles.accordionBadge}>{formatCost(overview.no_team.seconds_consumed)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -28,6 +28,9 @@ export function AdminLayout() {
|
||||
const fetchUnreadCount = useNotificationStore((s) => s.fetchUnreadCount);
|
||||
const navigate = useNavigate();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// 观察者团管 = 团管 + is_observer,在 /admin 下只能看「内容资产」一项
|
||||
const isObserverOnly = user?.role === 'team_admin' && !!user?.is_observer;
|
||||
const visibleNavItems = isObserverOnly ? navItems.filter((i) => i.path === '/admin/assets') : navItems;
|
||||
|
||||
// 60s 轮询未读数 + tab 重新可见时立即拉一次
|
||||
useEffect(() => {
|
||||
@ -72,7 +75,7 @@ export function AdminLayout() {
|
||||
<div className={styles.sidebarHeader}>
|
||||
<div className={styles.logo}>
|
||||
<img src={logoImg} alt="AirDrama" width="24" height="24" />
|
||||
{!collapsed && <span className={styles.logoText}>AirDrama Admin</span>}
|
||||
{!collapsed && <span className={styles.logoText}>{isObserverOnly ? 'AirDrama 观察者' : 'AirDrama Admin'}</span>}
|
||||
</div>
|
||||
<button className={styles.collapseBtn} onClick={() => setCollapsed(!collapsed)}>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="var(--color-text-secondary)">
|
||||
@ -86,14 +89,18 @@ export function AdminLayout() {
|
||||
</div>
|
||||
|
||||
<nav className={styles.nav}>
|
||||
<button className={styles.navItem} onClick={() => navigate('/app')} style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}>
|
||||
<button
|
||||
className={styles.navItem}
|
||||
onClick={() => navigate(isObserverOnly ? '/team/dashboard' : '/app')}
|
||||
style={{ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left', width: '100%' }}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
|
||||
</svg>
|
||||
{!collapsed && <span>返回首页</span>}
|
||||
{!collapsed && <span>{isObserverOnly ? '返回团队管理' : '返回首页'}</span>}
|
||||
</button>
|
||||
<div className={styles.navDivider} />
|
||||
{navItems.map((item) => (
|
||||
{visibleNavItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useState } from 'react';
|
||||
import { useThemeStore } from '../store/theme';
|
||||
import { useNotificationStore } from '../store/notification';
|
||||
import { useState, useEffect } from 'react';
|
||||
import logoImg from '../assets/logo_32.png';
|
||||
import styles from './AdminLayout.module.css';
|
||||
|
||||
@ -14,9 +16,23 @@ 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 });
|
||||
@ -63,9 +79,67 @@ 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 && (
|
||||
|
||||
@ -22,11 +22,16 @@ export function TeamMembersPage() {
|
||||
// Confirm toggle
|
||||
const [confirmMember, setConfirmMember] = useState<TeamMember | null>(null);
|
||||
|
||||
// Edit quota modal
|
||||
// Edit member modal (username + role + quota)
|
||||
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);
|
||||
@ -43,6 +48,24 @@ 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 {
|
||||
@ -87,20 +110,42 @@ 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('');
|
||||
};
|
||||
|
||||
const handleSaveQuota = async () => {
|
||||
// 串调:username → role → quota。任一失败 toast 并停留。
|
||||
const handleSaveMember = 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 {
|
||||
showToast('更新失败');
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.error || e?.response?.data?.detail || '保存失败';
|
||||
setEditError(msg);
|
||||
showToast(msg);
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -202,18 +247,7 @@ 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>
|
||||
{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>
|
||||
)
|
||||
)}
|
||||
<button className={styles.editBtn} onClick={() => openEditModal(m)}>编辑</button>
|
||||
{canResetPasswordFor(m) && (
|
||||
<button className={styles.editBtn} onClick={() => handleResetPassword(m)}>
|
||||
重置密码
|
||||
@ -248,9 +282,53 @@ export function TeamMembersPage() {
|
||||
|
||||
{/* Edit Quota Modal */}
|
||||
{editMember && (
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditMember(null); }}>
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget && !editSaving) setEditMember(null); }}>
|
||||
<div className={styles.modal}>
|
||||
<h3 className={styles.modalTitle}>编辑配额 — {editMember.username}</h3>
|
||||
<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>
|
||||
)}
|
||||
<div className={styles.formGroup}>
|
||||
<label>每日生成次数上限(-1 为不限)</label>
|
||||
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
||||
@ -263,9 +341,14 @@ 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)}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
||||
<button className={styles.cancelBtn} onClick={() => setEditMember(null)} disabled={editSaving}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleSaveMember} disabled={editSaving}>
|
||||
{editSaving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -47,6 +47,20 @@
|
||||
.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; } }
|
||||
|
||||
@ -17,11 +17,15 @@ export function UsersPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pageSize = 20;
|
||||
|
||||
// Quota edit modal
|
||||
// User edit modal (username + observer + quota)
|
||||
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);
|
||||
@ -85,20 +89,43 @@ 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('');
|
||||
};
|
||||
|
||||
const handleSaveQuota = async () => {
|
||||
// 串行调多个 PATCH:username → observer → quota。任一失败 toast 并停留在 modal,已成功的改动保留。
|
||||
const handleSaveUser = 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('配额已更新');
|
||||
|
||||
showToast(observerJustEnabled ? '已保存(观察者标记需该用户重新登录后生效)' : '已保存');
|
||||
setEditUser(null);
|
||||
fetchUsers();
|
||||
} catch {
|
||||
showToast('更新失败');
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.error || e.response?.data?.detail || '保存失败';
|
||||
setEditError(msg);
|
||||
showToast(msg);
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -236,6 +263,33 @@ 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>
|
||||
@ -310,9 +364,36 @@ export function UsersPage() {
|
||||
|
||||
{/* Quota Edit Modal */}
|
||||
{editUser && (
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget) setEditUser(null); }}>
|
||||
<div className={styles.modalOverlay} onMouseDown={(e) => { if (e.target === e.currentTarget && !editSaving) setEditUser(null); }}>
|
||||
<div className={styles.modal}>
|
||||
<h3 className={styles.modalTitle}>编辑配额 — {editUser.username}</h3>
|
||||
<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>
|
||||
)}
|
||||
<div className={styles.formGroup}>
|
||||
<label>每日生成次数上限(-1 为不限)</label>
|
||||
<input type="number" value={editDaily} onChange={(e) => setEditDaily(e.target.value)} />
|
||||
@ -325,9 +406,14 @@ 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)}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleSaveQuota}>保存</button>
|
||||
<button className={styles.cancelBtn} onClick={() => setEditUser(null)} disabled={editSaving}>取消</button>
|
||||
<button className={styles.saveBtn} onClick={handleSaveUser} disabled={editSaving}>
|
||||
{editSaving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -93,6 +93,7 @@ export interface User {
|
||||
is_staff: boolean;
|
||||
is_team_admin: boolean;
|
||||
is_team_owner?: boolean;
|
||||
is_observer?: boolean;
|
||||
role: UserRole;
|
||||
team_name: string | null;
|
||||
must_change_password: boolean;
|
||||
@ -170,6 +171,7 @@ export interface AdminUser {
|
||||
is_staff: boolean;
|
||||
is_team_admin: boolean;
|
||||
is_team_owner?: boolean;
|
||||
is_observer?: boolean;
|
||||
team_id: number | null;
|
||||
team_name: string | null;
|
||||
date_joined: string;
|
||||
@ -345,6 +347,7 @@ export interface TeamMember {
|
||||
email: string;
|
||||
is_team_admin: boolean;
|
||||
is_team_owner?: boolean;
|
||||
is_observer?: boolean;
|
||||
is_active: boolean;
|
||||
disabled_by: string;
|
||||
daily_seconds_limit: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user