feat: v0.13.0 主副管理员 + 素材引用 bug 修复 + admin 保护
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 17m15s

【主副管理员】
①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) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-25 00:14:58 +08:00
parent f4255a04ee
commit 727be720b4
14 changed files with 177 additions and 29 deletions

View File

@ -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='团队主管理员'),
),
]

View File

@ -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),
]

View File

@ -51,6 +51,7 @@ class User(AbstractUser):
verbose_name='所属团队', verbose_name='所属团队',
) )
is_team_admin = models.BooleanField(default=False, 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='每日秒数上限') daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限') monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
# ── 次数限额v0.10.0 新增) ── # ── 次数限额v0.10.0 新增) ──

View File

@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = User 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): class RegisterSerializer(serializers.Serializer):

View File

@ -61,6 +61,7 @@ urlpatterns = [
path('team/members/<int:member_id>', views.team_member_detail_view, name='team_member_detail'), path('team/members/<int:member_id>', views.team_member_detail_view, name='team_member_detail'),
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'), path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'), 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'),
# ── Team Admin: Consumption Records ── # ── Team Admin: Consumption Records ──
path('team/records', views.team_records_view, name='team_records'), path('team/records', views.team_records_view, name='team_records'),

View File

@ -299,6 +299,11 @@ def video_generate_view(request):
except (ValueError, Exception) as e: except (ValueError, Exception) as e:
logger.warning('Failed to resolve asset group URL %s: %s', url, 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': if ref_type == 'image':
item = {'type': 'image_url', 'image_url': {'url': resolved_url}} item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
if role: if role:
@ -1001,6 +1006,7 @@ def admin_team_detail_view(request, team_id):
'username': m.username, 'username': m.username,
'email': m.email, 'email': m.email,
'is_team_admin': m.is_team_admin, 'is_team_admin': m.is_team_admin,
'is_team_owner': m.is_team_owner,
'is_active': m.is_active, 'is_active': m.is_active,
'disabled_by': m.disabled_by, 'disabled_by': m.disabled_by,
'daily_seconds_limit': m.daily_seconds_limit, '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) return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
is_admin = request.data.get('is_team_admin') is_admin = request.data.get('is_team_admin')
if is_admin is None: is_owner = request.data.get('is_team_owner')
return Response({'error': '请提供 is_team_admin 参数'}, status=status.HTTP_400_BAD_REQUEST) 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} before = {'is_team_admin': member.is_team_admin, 'is_team_owner': member.is_team_owner}
member.is_team_admin = bool(is_admin) update_fields = []
member.save(update_fields=['is_team_admin']) 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, 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({ return Response({
'user_id': member.id, 'user_id': member.id,
'username': member.username, 'username': member.username,
'is_team_admin': member.is_team_admin, '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'], password=serializer.validated_data['password'],
team=team, team=team,
is_team_admin=True, is_team_admin=True,
is_team_owner=True,
daily_seconds_limit=team.daily_member_limit_default, daily_seconds_limit=team.daily_member_limit_default,
monthly_seconds_limit=-1, # Team admin unlimited by default monthly_seconds_limit=-1, # Team admin unlimited by default
daily_generation_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, 'username': m.username,
'email': m.email, 'email': m.email,
'is_team_admin': m.is_team_admin, 'is_team_admin': m.is_team_admin,
'is_team_owner': m.is_team_owner,
'is_active': m.is_active, 'is_active': m.is_active,
'daily_seconds_limit': m.daily_seconds_limit, 'daily_seconds_limit': m.daily_seconds_limit,
'monthly_seconds_limit': m.monthly_seconds_limit, 'monthly_seconds_limit': m.monthly_seconds_limit,
@ -2268,6 +2292,10 @@ def team_member_quota_view(request, member_id):
except User.DoesNotExist: except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) 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 = MemberQuotaSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@ -2312,9 +2340,15 @@ def team_member_status_view(request, member_id):
except User.DoesNotExist: except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) 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: if member.id == request.user.id:
return Response({'error': '不能停用自己的账号'}, status=status.HTTP_400_BAD_REQUEST) 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 = UserStatusSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 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/<id>/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 # Profile: User's own consumption data
# ────────────────────────────────────────────── # ──────────────────────────────────────────────

View File

@ -230,7 +230,7 @@ export function PromptInput() {
spans.forEach((span) => { spans.forEach((span) => {
if (span.dataset.refType === 'asset') return; // skip asset mentions if (span.dataset.refType === 'asset') return; // skip asset mentions
if (!refIds.has(span.dataset.refId!)) { if (!refIds.has(span.dataset.refId!)) {
span.replaceWith(''); span.remove();
changed = true; changed = true;
} }
}); });

View File

@ -353,6 +353,9 @@ export const teamApi = {
updateMemberStatus: (memberId: number, isActive: boolean) => updateMemberStatus: (memberId: number, isActive: boolean) =>
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }), 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 // Content Assets
getAssetsOverview: () => getAssetsOverview: () =>
api.get<{ api.get<{

View File

@ -3,9 +3,11 @@ import { teamApi } from '../lib/api';
import type { TeamMember } from '../types'; import type { TeamMember } from '../types';
import { showToast } from '../components/Toast'; import { showToast } from '../components/Toast';
import { ConfirmModal } from '../components/ConfirmModal'; import { ConfirmModal } from '../components/ConfirmModal';
import { useAuthStore } from '../store/auth';
import styles from './UsersPage.module.css'; import styles from './UsersPage.module.css';
export function TeamMembersPage() { export function TeamMembersPage() {
const currentUser = useAuthStore((s) => s.user);
const [members, setMembers] = useState<TeamMember[]>([]); const [members, setMembers] = useState<TeamMember[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -150,8 +152,10 @@ export function TeamMembersPage() {
{m.username} {m.username}
</td> </td>
<td> <td>
{m.is_team_admin ? ( {m.is_team_owner ? (
<span className={styles.statusBadge} style={{ background: 'rgba(108, 99, 255, 0.15)', color: '#6c63ff' }}></span> <span className={styles.statusBadge} style={{ background: 'rgba(0, 184, 230, 0.15)', color: '#00b8e6' }}></span>
) : m.is_team_admin ? (
<span className={styles.statusBadge} style={{ background: 'rgba(167, 139, 250, 0.15)', color: '#a78bfa' }}></span>
) : ( ) : (
<span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span> <span style={{ color: 'var(--color-text-secondary)', fontSize: 12 }}></span>
)} )}
@ -169,6 +173,17 @@ export function TeamMembersPage() {
<td> <td>
<div className={styles.actions}> <div className={styles.actions}>
<button className={styles.editBtn} onClick={() => openEditModal(m)}></button> <button className={styles.editBtn} onClick={() => openEditModal(m)}></button>
{currentUser?.is_team_owner && !m.is_team_owner && (
m.is_team_admin ? (
<button className={styles.editBtn} onClick={async () => {
try { await teamApi.setMemberRole(m.id, false); showToast('已取消副管理员'); fetchMembers(); } catch { showToast('操作失败'); }
}}></button>
) : (
<button className={styles.editBtn} onClick={async () => {
try { await teamApi.setMemberRole(m.id, true); showToast('已设为副管理员'); fetchMembers(); } catch { showToast('操作失败'); }
}}></button>
)
)}
<button <button
className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`} className={`${styles.toggleBtn} ${m.is_active ? styles.disableBtn : styles.enableBtn}`}
onClick={() => setConfirmMember(m)} onClick={() => setConfirmMember(m)}

View File

@ -257,6 +257,15 @@
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
} }
.ownerBadge {
background: rgba(0, 184, 230, 0.15);
color: var(--color-primary, #00b8e6);
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.adminBadge { .adminBadge {
background: rgba(167, 139, 250, 0.15); background: rgba(167, 139, 250, 0.15);
color: #a78bfa; color: #a78bfa;

View File

@ -822,19 +822,21 @@ export function TeamsPage() {
</td> </td>
<td>{m.email}</td> <td>{m.email}</td>
<td> <td>
{m.is_team_admin ? ( {m.is_team_owner ? (
<span className={styles.adminBadge} style={{ cursor: 'pointer' }} title="点击取消管理员" onClick={async () => { <span className={styles.ownerBadge}></span>
) : m.is_team_admin ? (
<span className={styles.adminBadge} style={{ cursor: 'pointer' }} title="点击取消副管理员" onClick={async () => {
try { try {
await adminApi.setMemberRole(detailTeam!.id, m.id, false); await adminApi.setMemberRole(detailTeam!.id, m.id, false);
showToast('已取消管理员'); showToast('已取消管理员');
const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed);
} catch { showToast('操作失败'); } } catch { showToast('操作失败'); }
}}></span> }}></span>
) : ( ) : (
<span style={{ cursor: 'pointer', color: 'var(--color-text-secondary)' }} title="点击设为管理员" onClick={async () => { <span style={{ cursor: 'pointer', color: 'var(--color-text-secondary)' }} title="点击设为管理员" onClick={async () => {
try { try {
await adminApi.setMemberRole(detailTeam!.id, m.id, true); await adminApi.setMemberRole(detailTeam!.id, m.id, true);
showToast('已设为管理员'); showToast('已设为管理员');
const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed); const { data: refreshed } = await adminApi.getTeamDetail(detailTeam!.id); setDetailTeam(refreshed);
} catch { showToast('操作失败'); } } catch { showToast('操作失败'); }
}}></span> }}></span>

View File

@ -429,18 +429,23 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}); });
} }
// Fallback: also add from inputBar assetMentions (for regenerate scenario) // Fallback: only use inputBar assetMentions when editorHtml has NO asset spans
const inputAssetMentions = input.assetMentions || []; // (regenerate scenario where editorHtml is plain text)
for (const am of inputAssetMentions) { // If user edited the HTML and removed some asset tags, respect that — don't re-add from store
if (am.groupId && !seenGroupIds.has(am.groupId)) { const htmlHadAssetSpans = input.editorHtml?.includes('data-ref-type="asset"');
seenGroupIds.add(am.groupId); if (!htmlHadAssetSpans) {
uploadedRefs.push({ const inputAssetMentions = input.assetMentions || [];
url: `asset://group-${am.groupId}`, for (const am of inputAssetMentions) {
type: 'image', if (am.groupId && !seenGroupIds.has(am.groupId)) {
role: 'reference_image', seenGroupIds.add(am.groupId);
label: am.label, uploadedRefs.push({
thumb_url: am.thumbUrl || '', url: `asset://group-${am.groupId}`,
}); type: 'image',
role: 'reference_image',
label: am.label,
thumb_url: am.thumbUrl || '',
});
}
} }
} }

View File

@ -237,6 +237,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
mode, mode,
prevReferences: state.references, prevReferences: state.references,
references: [], references: [],
assetMentions: [],
aspectRatio: '16:9', aspectRatio: '16:9',
duration: 5, duration: 5,
}); });
@ -246,6 +247,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl); if (state.lastFrame) URL.revokeObjectURL(state.lastFrame.previewUrl);
set({ set({
mode, mode,
assetMentions: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
references: state.prevReferences, references: state.prevReferences,

View File

@ -82,6 +82,7 @@ export interface User {
email: string; email: string;
is_staff: boolean; is_staff: boolean;
is_team_admin: boolean; is_team_admin: boolean;
is_team_owner?: boolean;
role: UserRole; role: UserRole;
team_name: string | null; team_name: string | null;
must_change_password: boolean; must_change_password: boolean;
@ -153,6 +154,7 @@ export interface AdminUser {
disabled_by: string; disabled_by: string;
is_staff: boolean; is_staff: boolean;
is_team_admin: boolean; is_team_admin: boolean;
is_team_owner?: boolean;
team_id: number | null; team_id: number | null;
team_name: string | null; team_name: string | null;
date_joined: string; date_joined: string;
@ -312,6 +314,7 @@ export interface TeamMember {
username: string; username: string;
email: string; email: string;
is_team_admin: boolean; is_team_admin: boolean;
is_team_owner?: boolean;
is_active: boolean; is_active: boolean;
disabled_by: string; disabled_by: string;
daily_seconds_limit: number; daily_seconds_limit: number;
@ -369,6 +372,7 @@ export interface AssetMemberSummary {
id: number; id: number;
username: string; username: string;
is_team_admin: boolean; is_team_admin: boolean;
is_team_owner?: boolean;
video_count: number; video_count: number;
seconds_consumed: number; seconds_consumed: number;
cost_consumed?: number; cost_consumed?: number;