From 727be720b402942b8d358480288d521908494ca8 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 25 Mar 2026 00:14:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.13.0=20=E4=B8=BB=E5=89=AF=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=20+=20=E7=B4=A0=E6=9D=90=E5=BC=95=E7=94=A8?= =?UTF-8?q?=20bug=20=E4=BF=AE=E5=A4=8D=20+=20admin=20=E4=BF=9D=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【主副管理员】 ①User 加 is_team_owner 字段,现有团管自动升为主管 ②主管可指定/取消副管理员,副管不能再指定别人 ③副管不能禁用/修改其他管理员 ④超管团队详情支持三种角色显示和切换 【素材引用 bug 修复】 ⑤span.replaceWith('') → span.remove(),删除引用后标签真正移除 ⑥switchMode 时清空 assetMentions,切换模式不带旧素材 ⑦fallback 只在纯文本时生效,用户删标签后不再偷偷加回 ⑧后端跳过未解析的 asset:// URL,不发给火山 API 【admin 保护】 ⑨admin 账号不可被任何人禁用 ⑩admin 密码不可被其他超管重置 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0013_user_is_team_owner.py | 18 ++++ .../0014_set_existing_admins_as_owners.py | 19 +++++ backend/apps/accounts/models.py | 1 + backend/apps/accounts/serializers.py | 2 +- backend/apps/generation/urls.py | 1 + backend/apps/generation/views.py | 83 +++++++++++++++++-- web/src/components/PromptInput.tsx | 2 +- web/src/lib/api.ts | 3 + web/src/pages/TeamMembersPage.tsx | 19 ++++- web/src/pages/TeamsPage.module.css | 9 ++ web/src/pages/TeamsPage.tsx | 14 ++-- web/src/store/generation.ts | 29 ++++--- web/src/store/inputBar.ts | 2 + web/src/types/index.ts | 4 + 14 files changed, 177 insertions(+), 29 deletions(-) create mode 100644 backend/apps/accounts/migrations/0013_user_is_team_owner.py create mode 100644 backend/apps/accounts/migrations/0014_set_existing_admins_as_owners.py diff --git a/backend/apps/accounts/migrations/0013_user_is_team_owner.py b/backend/apps/accounts/migrations/0013_user_is_team_owner.py new file mode 100644 index 0000000..9c01671 --- /dev/null +++ b/backend/apps/accounts/migrations/0013_user_is_team_owner.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-24 03:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0012_user_last_read_announcement'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='is_team_owner', + field=models.BooleanField(default=False, verbose_name='团队主管理员'), + ), + ] diff --git a/backend/apps/accounts/migrations/0014_set_existing_admins_as_owners.py b/backend/apps/accounts/migrations/0014_set_existing_admins_as_owners.py new file mode 100644 index 0000000..18c5c21 --- /dev/null +++ b/backend/apps/accounts/migrations/0014_set_existing_admins_as_owners.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.29 on 2026-03-24 03:34 + +from django.db import migrations + + +def set_admins_as_owners(apps, schema_editor): + User = apps.get_model('accounts', 'User') + User.objects.filter(is_team_admin=True).update(is_team_owner=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0013_user_is_team_owner'), + ] + + operations = [ + migrations.RunPython(set_admins_as_owners, migrations.RunPython.noop), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index e9c2fa2..ffedd24 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -51,6 +51,7 @@ class User(AbstractUser): verbose_name='所属团队', ) is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员') + is_team_owner = 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 新增) ── diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index ea82943..8331141 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name', 'must_change_password') + fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'is_team_owner', 'role', 'team_name', 'must_change_password') class RegisterSerializer(serializers.Serializer): diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index 21813e0..8110e1f 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -61,6 +61,7 @@ urlpatterns = [ path('team/members/', views.team_member_detail_view, name='team_member_detail'), path('team/members//quota', views.team_member_quota_view, name='team_member_quota'), path('team/members//status', views.team_member_status_view, name='team_member_status'), + path('team/members//role', views.team_member_role_view, name='team_member_role'), # ── Team Admin: Consumption Records ── path('team/records', views.team_records_view, name='team_records'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index fae7829..640ef39 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -299,6 +299,11 @@ def video_generate_view(request): except (ValueError, Exception) as e: logger.warning('Failed to resolve asset group URL %s: %s', url, e) + # 未解析成功的 asset URL 不发给火山 API(会导致 InvalidParameter) + if resolved_url.startswith('asset://'): + logger.warning('Skipping unresolved asset URL: %s', resolved_url) + continue + if ref_type == 'image': item = {'type': 'image_url', 'image_url': {'url': resolved_url}} if role: @@ -1001,6 +1006,7 @@ def admin_team_detail_view(request, team_id): 'username': m.username, 'email': m.email, 'is_team_admin': m.is_team_admin, + 'is_team_owner': m.is_team_owner, 'is_active': m.is_active, 'disabled_by': m.disabled_by, 'daily_seconds_limit': m.daily_seconds_limit, @@ -1033,19 +1039,35 @@ def admin_team_member_role_view(request, team_id, member_id): return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) is_admin = request.data.get('is_team_admin') - if is_admin is None: - return Response({'error': '请提供 is_team_admin 参数'}, status=status.HTTP_400_BAD_REQUEST) + is_owner = request.data.get('is_team_owner') + if is_admin is None and is_owner is None: + return Response({'error': '请提供 is_team_admin 或 is_team_owner 参数'}, status=status.HTTP_400_BAD_REQUEST) - before = {'is_team_admin': member.is_team_admin} - member.is_team_admin = bool(is_admin) - member.save(update_fields=['is_team_admin']) + before = {'is_team_admin': member.is_team_admin, 'is_team_owner': member.is_team_owner} + update_fields = [] + if is_admin is not None: + member.is_team_admin = bool(is_admin) + update_fields.append('is_team_admin') + # 取消管理员时同时取消主管 + if not bool(is_admin): + member.is_team_owner = False + update_fields.append('is_team_owner') + if is_owner is not None: + member.is_team_owner = bool(is_owner) + if bool(is_owner): + member.is_team_admin = True # 主管一定是管理员 + update_fields.append('is_team_admin') + if 'is_team_owner' not in update_fields: + update_fields.append('is_team_owner') + member.save(update_fields=update_fields) log_admin_action(request, 'team_update', 'user', target_id=member.id, target_name=member.username, - before=before, after={'is_team_admin': member.is_team_admin}) + before=before, after={'is_team_admin': member.is_team_admin, 'is_team_owner': member.is_team_owner}) return Response({ 'user_id': member.id, 'username': member.username, 'is_team_admin': member.is_team_admin, + 'is_team_owner': member.is_team_owner, }) @@ -1195,6 +1217,7 @@ def admin_team_create_admin_view(request, team_id): password=serializer.validated_data['password'], team=team, is_team_admin=True, + is_team_owner=True, daily_seconds_limit=team.daily_member_limit_default, monthly_seconds_limit=-1, # Team admin unlimited by default daily_generation_limit=-1, # Team admin unlimited by default @@ -2116,6 +2139,7 @@ def team_members_list_view(request): 'username': m.username, 'email': m.email, 'is_team_admin': m.is_team_admin, + 'is_team_owner': m.is_team_owner, 'is_active': m.is_active, 'daily_seconds_limit': m.daily_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit, @@ -2268,6 +2292,10 @@ def team_member_quota_view(request, member_id): except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + # 副管不能修改主管或其他副管的额度 + if not request.user.is_team_owner and member.is_team_admin: + return Response({'error': '副管理员不能修改其他管理员的额度'}, status=status.HTTP_403_FORBIDDEN) + serializer = MemberQuotaSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -2312,9 +2340,15 @@ def team_member_status_view(request, member_id): except User.DoesNotExist: return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) - # Cannot disable yourself or other team admins + # Cannot disable yourself if member.id == request.user.id: return Response({'error': '不能停用自己的账号'}, status=status.HTTP_400_BAD_REQUEST) + # 副管不能禁用主管或其他副管 + if not request.user.is_team_owner and member.is_team_admin: + return Response({'error': '副管理员不能操作其他管理员'}, status=status.HTTP_403_FORBIDDEN) + # 主管不能被副管禁用 + if member.is_team_owner and not request.user.is_team_owner: + return Response({'error': '不能停用主管理员'}, status=status.HTTP_403_FORBIDDEN) serializer = UserStatusSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -2332,6 +2366,41 @@ def team_member_status_view(request, member_id): }) +@api_view(['PATCH']) +@permission_classes([IsTeamAdmin]) +def team_member_role_view(request, member_id): + """PATCH /api/v1/team/members//role — Owner toggles vice admin.""" + if not request.user.is_team_owner: + return Response({'error': '只有主管理员可以设置副管理员'}, status=status.HTTP_403_FORBIDDEN) + + team = request.user.team + try: + member = team.members.get(id=member_id) + except User.DoesNotExist: + return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) + + if member.id == request.user.id: + return Response({'error': '不能修改自己的角色'}, status=status.HTTP_400_BAD_REQUEST) + if member.is_team_owner: + return Response({'error': '不能修改主管理员的角色'}, status=status.HTTP_403_FORBIDDEN) + + is_admin = request.data.get('is_team_admin') + if is_admin is None: + return Response({'error': '请提供 is_team_admin 参数'}, status=status.HTTP_400_BAD_REQUEST) + + before = {'is_team_admin': member.is_team_admin} + member.is_team_admin = bool(is_admin) + member.save(update_fields=['is_team_admin']) + log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username, + before=before, after={'is_team_admin': member.is_team_admin}) + + return Response({ + 'user_id': member.id, + 'username': member.username, + 'is_team_admin': member.is_team_admin, + }) + + # ────────────────────────────────────────────── # Profile: User's own consumption data # ────────────────────────────────────────────── diff --git a/web/src/components/PromptInput.tsx b/web/src/components/PromptInput.tsx index 5f3f557..e41cac9 100644 --- a/web/src/components/PromptInput.tsx +++ b/web/src/components/PromptInput.tsx @@ -230,7 +230,7 @@ export function PromptInput() { spans.forEach((span) => { if (span.dataset.refType === 'asset') return; // skip asset mentions if (!refIds.has(span.dataset.refId!)) { - span.replaceWith(''); + span.remove(); changed = true; } }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5487f4e..dda387c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -353,6 +353,9 @@ export const teamApi = { updateMemberStatus: (memberId: number, isActive: boolean) => api.patch(`/team/members/${memberId}/status`, { is_active: isActive }), + setMemberRole: (memberId: number, isTeamAdmin: boolean) => + api.patch(`/team/members/${memberId}/role`, { is_team_admin: isTeamAdmin }), + // Content Assets getAssetsOverview: () => api.get<{ diff --git a/web/src/pages/TeamMembersPage.tsx b/web/src/pages/TeamMembersPage.tsx index c930033..9538898 100644 --- a/web/src/pages/TeamMembersPage.tsx +++ b/web/src/pages/TeamMembersPage.tsx @@ -3,9 +3,11 @@ import { teamApi } from '../lib/api'; import type { TeamMember } from '../types'; import { showToast } from '../components/Toast'; import { ConfirmModal } from '../components/ConfirmModal'; +import { useAuthStore } from '../store/auth'; import styles from './UsersPage.module.css'; export function TeamMembersPage() { + const currentUser = useAuthStore((s) => s.user); const [members, setMembers] = useState([]); const [loading, setLoading] = useState(true); @@ -150,8 +152,10 @@ export function TeamMembersPage() { {m.username} - {m.is_team_admin ? ( - 管理员 + {m.is_team_owner ? ( + 主管理员 + ) : m.is_team_admin ? ( + 副管理员 ) : ( 成员 )} @@ -169,6 +173,17 @@ export function TeamMembersPage() {
+ {currentUser?.is_team_owner && !m.is_team_owner && ( + m.is_team_admin ? ( + + ) : ( + + ) + )}