feat: 账号安全管控 + 内容资产页 + UI修缮 (v0.9.5 & v0.9.6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s

v0.9.5 — 账号安全管控 + 内容资产页:
- 首次登录强制改密(must_change_password + ForceChangePasswordModal)
- 并发会话限制(ActiveSession + SessionJWT认证,可配置桌面/移动端会话数)
- Token生命周期缩短(access 30min, refresh 1天)
- 登录IP记录(LoginRecord模型,为异常检测打基础)
- 内容资产页(超管三级折叠/团队管两级折叠,按需懒加载)

v0.9.6 — UI修缮:
- 侧栏导航排序(内容资产移到用户管理下方)
- 视频网格高度调整(440px,3行+暗示可滚动)
- 秒数单位统一(不再换算为分钟/小时)
- 提示词标签溢出修复 + 弹窗方向自适应

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-03-18 12:02:54 +08:00
parent 45b7ca00d1
commit e2973284d0
33 changed files with 1545 additions and 38 deletions

View File

@ -121,10 +121,11 @@ jimeng-clone/
### Auth (`/api/v1/auth/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/auth/register` | User registration |
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens) |
| POST | `/api/v1/auth/register` | User registration (disabled) |
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens, creates ActiveSession) |
| POST | `/api/v1/auth/token/refresh` | Refresh JWT access token |
| GET | `/api/v1/auth/me` | Get current user info |
| GET | `/api/v1/auth/me` | Get current user info + quota + team + must_change_password |
| POST | `/api/v1/auth/change-password` | Change own password (clears must_change_password) |
### Video Generation (`/api/v1/`)
| Method | Endpoint | Description |
@ -152,6 +153,15 @@ jimeng-clone/
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
| GET | `/api/v1/admin/assets/overview` | Content assets: global stats + per-team summary |
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
| GET | `/api/v1/admin/assets/user/<id>/videos` | Content assets: user's completed videos (paginated) |
### Team Admin Assets (`/api/v1/team/assets/`)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/team/assets/overview` | Team stats + per-member video summary |
| GET | `/api/v1/team/assets/member/<id>/videos` | Member's completed videos (paginated) |
### Profile (`/api/v1/profile/`)
| Method | Endpoint | Description |
@ -163,6 +173,8 @@ jimeng-clone/
### User (extends AbstractUser)
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
- `must_change_password` (default: True) — forces password change on first login
- `team` (FK to Team), `is_team_admin`
- `created_at`, `updated_at`
### GenerationRecord
@ -176,9 +188,20 @@ jimeng-clone/
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
- `ip_address`, `created_at` (indexed)
### ActiveSession
- `user` (FK), `session_id` (UUID, unique), `device_type` (desktop|mobile|unknown)
- `user_agent`, `created_at`
- Used for concurrent session limiting via JWT session_id claim
### LoginRecord
- `user` (FK), `ip_address`, `user_agent`, `created_at` (indexed)
- Records every login for future anomaly detection
### QuotaConfig (Singleton, pk=1)
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
- `announcement`, `announcement_enabled`, `updated_at`
- `announcement`, `announcement_enabled`
- `max_desktop_sessions` (default: 1), `max_mobile_sessions` (default: 0)
- `updated_at`
## Frontend Routes
@ -192,6 +215,8 @@ jimeng-clone/
| `/admin/records` | RecordsPage | Admin | Generation records |
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) |
## Incremental Development Guide
@ -381,6 +406,11 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack |
| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack |
| 2026-03-18 | v0.9.0: 首次登录强制改密 — must_change_password 字段 + ForceChangePasswordModal | Full stack |
| 2026-03-18 | v0.9.0: 并发会话限制 — ActiveSession + SessionJWT + 可配置桌面/移动端会话数 | Full stack |
| 2026-03-18 | v0.9.0: 登录记录 — LoginRecord 模型IP + User-Agent为异常检测打基础 | Backend |
| 2026-03-18 | v0.9.0: Token 生命周期缩短 — access 30min, refresh 1天 | Backend |
| 2026-03-18 | v0.9.0: 内容资产页 — 超管/团队管三级折叠式资产浏览(团队→成员→视频) | Full stack |
### Phase 4 Details (2026-03-13)

View File

@ -0,0 +1,30 @@
"""Custom JWT authentication — validates session_id against ActiveSession table."""
from rest_framework_simplejwt.authentication import JWTAuthentication
from rest_framework_simplejwt.exceptions import InvalidToken
class SessionJWTAuthentication(JWTAuthentication):
"""
Extends JWTAuthentication to check that the session_id in the token
still exists in the ActiveSession table.
Legacy tokens (without session_id) are allowed through for backward compatibility.
"""
def get_user(self, validated_token):
user = super().get_user(validated_token)
session_id = validated_token.get('session_id')
if session_id is None:
# Legacy token without session_id — allow through
return user
from .models import ActiveSession
if not ActiveSession.objects.filter(user=user, session_id=session_id).exists():
raise InvalidToken({
'detail': '您的账号已在其他设备登录',
'code': 'session_expired_other_device',
})
return user

View File

@ -0,0 +1,57 @@
# Generated by Django 4.2.29 on 2026-03-17 16:23
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('accounts', '0005_adminauditlog'),
]
operations = [
migrations.AddField(
model_name='user',
name='must_change_password',
field=models.BooleanField(default=True, 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', '重置用户密码')], max_length=30, verbose_name='操作类型'),
),
migrations.CreateModel(
name='LoginRecord',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='IP地址')),
('user_agent', models.TextField(blank=True, default='', verbose_name='User-Agent')),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='登录时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_records', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '登录记录',
'verbose_name_plural': '登录记录',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ActiveSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_id', models.UUIDField(db_index=True, default=uuid.uuid4, unique=True, verbose_name='会话ID')),
('device_type', models.CharField(choices=[('desktop', '桌面端'), ('mobile', '移动端'), ('unknown', '未知')], default='unknown', max_length=10, verbose_name='设备类型')),
('user_agent', models.TextField(blank=True, default='', verbose_name='User-Agent')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='active_sessions', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '活跃会话',
'verbose_name_plural': '活跃会话',
'ordering': ['created_at'],
},
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.29 on 2026-03-17 16:23
from django.db import migrations
def set_existing_users_false(apps, schema_editor):
"""现有用户不需要强制改密,只有新创建的用户才需要。"""
User = apps.get_model('accounts', 'User')
User.objects.all().update(must_change_password=False)
class Migration(migrations.Migration):
dependencies = [
('accounts', '0006_user_must_change_password_alter_adminauditlog_action_and_more'),
]
operations = [
migrations.RunPython(set_existing_users_false, migrations.RunPython.noop),
]

View File

@ -1,3 +1,5 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
@ -37,6 +39,7 @@ class User(AbstractUser):
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
@ -98,9 +101,64 @@ class AdminAuditLog(models.Model):
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
class ActiveSession(models.Model):
"""活跃会话 — 用于并发登录设备限制。"""
DEVICE_TYPE_CHOICES = [
('desktop', '桌面端'),
('mobile', '移动端'),
('unknown', '未知'),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='active_sessions', verbose_name='用户')
session_id = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True, verbose_name='会话ID')
device_type = models.CharField(max_length=10, choices=DEVICE_TYPE_CHOICES, default='unknown', verbose_name='设备类型')
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
verbose_name = '活跃会话'
verbose_name_plural = '活跃会话'
ordering = ['created_at']
def __str__(self):
return f'{self.user.username} - {self.device_type} - {self.session_id}'
class LoginRecord(models.Model):
"""登录记录 — 为团队级异常检测打基础。"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_records', verbose_name='用户')
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='登录时间')
class Meta:
verbose_name = '登录记录'
verbose_name_plural = '登录记录'
ordering = ['-created_at']
def __str__(self):
return f'{self.user.username} - {self.ip_address} - {self.created_at}'
def get_client_ip(request):
"""从请求中提取客户端 IP 地址。"""
return request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
def parse_device_type(user_agent):
"""根据 User-Agent 判断设备类型。"""
ua_lower = (user_agent or '').lower()
mobile_keywords = ['iphone', 'ipad', 'android', 'mobile', 'ipod', 'windows phone']
if any(kw in ua_lower for kw in mobile_keywords):
return 'mobile'
if ua_lower:
return 'desktop'
return 'unknown'
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
"""记录管理员操作日志"""
ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
ip = get_client_ip(request)
AdminAuditLog.objects.create(
operator=request.user,
operator_name=request.user.username,

View File

@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name')
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name', 'must_change_password')
class RegisterSerializer(serializers.Serializer):

View File

@ -0,0 +1,13 @@
"""Custom JWT token — embeds session_id for concurrent session management."""
from rest_framework_simplejwt.tokens import RefreshToken
class SessionRefreshToken(RefreshToken):
"""RefreshToken subclass that writes session_id into JWT claims."""
@classmethod
def for_user_session(cls, user, session_id):
token = cls.for_user(user)
token['session_id'] = str(session_id)
return token

View File

@ -3,12 +3,13 @@ from rest_framework.decorators import api_view, permission_classes, throttle_cla
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone
from django.db.models import Sum
from .serializers import UserSerializer
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
from .tokens import SessionRefreshToken
from django.contrib.auth.hashers import check_password
User = get_user_model()
@ -28,6 +29,36 @@ def register_view(request):
)
def _enforce_session_limit(user, device_type):
"""Enforce concurrent session limits: remove oldest sessions if over limit."""
from apps.generation.models import QuotaConfig
config = QuotaConfig.objects.filter(pk=1).first()
if device_type == 'desktop':
max_sessions = config.max_desktop_sessions if config else 1
elif device_type == 'mobile':
max_sessions = config.max_mobile_sessions if config else 0
else:
max_sessions = 1
if max_sessions <= 0:
# 0 means no sessions allowed for this device type — but still allow login
# (treat as unlimited for unknown device types)
if device_type == 'unknown':
return
# For mobile with limit 0, still allow (no mobile enforcement yet)
return
existing = ActiveSession.objects.filter(
user=user, device_type=device_type
).order_by('created_at')
# If at or over limit, delete oldest sessions to make room for the new one
over_count = existing.count() - max_sessions + 1
if over_count > 0:
ids_to_remove = list(existing.values_list('id', flat=True)[:over_count])
ActiveSession.objects.filter(id__in=ids_to_remove).delete()
@api_view(['POST'])
@permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle])
@ -53,7 +84,17 @@ def login_view(request):
status=status.HTTP_401_UNAUTHORIZED
)
refresh = RefreshToken.for_user(user)
# Record login IP and User-Agent
ip = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
LoginRecord.objects.create(user=user, ip_address=ip, user_agent=user_agent)
# Concurrent session management
device_type = parse_device_type(user_agent)
_enforce_session_limit(user, device_type)
session = ActiveSession.objects.create(user=user, device_type=device_type, user_agent=user_agent)
refresh = SessionRefreshToken.for_user_session(user, session.session_id)
return Response({
'user': UserSerializer(user).data,
'tokens': {
@ -141,5 +182,9 @@ def change_password_view(request):
)
request.user.set_password(new_password)
request.user.save()
return Response({'message': '密码修改成功'})
request.user.must_change_password = False
request.user.save(update_fields=['password', 'must_change_password'])
return Response({
'message': '密码修改成功',
'user': UserSerializer(request.user).data,
})

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-17 16:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0004_alter_generationrecord_model'),
]
operations = [
migrations.AddField(
model_name='quotaconfig',
name='max_desktop_sessions',
field=models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数'),
),
migrations.AddField(
model_name='quotaconfig',
name='max_mobile_sessions',
field=models.IntegerField(default=0, verbose_name='每用户最大移动端会话数'),
),
]

View File

@ -58,6 +58,8 @@ class QuotaConfig(models.Model):
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
max_mobile_sessions = models.IntegerField(default=0, verbose_name='每用户最大移动端会话数')
updated_at = models.DateTimeField(auto_now=True)
class Meta:

View File

@ -33,6 +33,8 @@ class SystemSettingsSerializer(serializers.Serializer):
default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
announcement = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False)
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
max_mobile_sessions = serializers.IntegerField(min_value=0, required=False, default=0)
# ── Team serializers ──

View File

@ -35,6 +35,11 @@ urlpatterns = [
path('admin/settings', views.admin_settings_view, name='admin_settings'),
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
# ── Super Admin: Content Assets ──
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),
path('admin/assets/user/<int:user_id>/videos', views.admin_assets_user_videos, name='admin_assets_user_videos'),
# ── Team Admin: Team management ──
path('team/info', views.team_info_view, name='team_info'),
path('team/stats', views.team_stats_view, name='team_stats'),
@ -44,6 +49,10 @@ urlpatterns = [
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'),
# ── Team Admin: Content Assets ──
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
# ── Profile: User's own data ──
path('profile/overview', views.profile_overview_view, name='profile_overview'),
path('profile/records', views.profile_records_view, name='profile_records'),

View File

@ -964,7 +964,8 @@ def admin_reset_password_view(request, user_id):
return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password)
user.save()
user.must_change_password = True
user.save(update_fields=['password', 'must_change_password'])
log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
return Response({'message': f'已重置 {user.username} 的密码'})
@ -1079,6 +1080,8 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
})
serializer = SystemSettingsSerializer(data=request.data)
@ -1089,11 +1092,15 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
}
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
config.announcement = serializer.validated_data.get('announcement', '')
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
config.max_desktop_sessions = serializer.validated_data.get('max_desktop_sessions', 1)
config.max_mobile_sessions = serializer.validated_data.get('max_mobile_sessions', 0)
config.save()
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
before=before,
@ -1102,6 +1109,8 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
})
return Response({
@ -1109,6 +1118,8 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
'updated_at': config.updated_at.isoformat(),
})
@ -1542,3 +1553,214 @@ def profile_records_view(request):
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Admin: Content Assets (hierarchical view)
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
def admin_assets_overview(request):
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
from apps.accounts.models import Team
teams = Team.objects.all().order_by('name')
team_data = []
total_videos = 0
total_seconds = 0
for team in teams:
team_records = GenerationRecord.objects.filter(
user__team=team, status='completed'
)
video_count = team_records.count()
seconds_consumed = team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
total_videos += video_count
total_seconds += seconds_consumed
team_data.append({
'id': team.id,
'name': team.name,
'video_count': video_count,
'seconds_consumed': seconds_consumed,
'member_count': team.members.count(),
'is_active': team.is_active,
})
# Also count videos from users without a team
no_team_records = GenerationRecord.objects.filter(
user__team__isnull=True, status='completed'
)
no_team_count = no_team_records.count()
no_team_seconds = no_team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
total_videos += no_team_count
total_seconds += no_team_seconds
return Response({
'total_videos': total_videos,
'total_seconds': total_seconds,
'total_teams': teams.count(),
'teams': team_data,
'no_team': {
'video_count': no_team_count,
'seconds_consumed': no_team_seconds,
},
})
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
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
try:
team = Team.objects.get(id=team_id)
except Team.DoesNotExist:
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
members = team.members.all().order_by('username')
member_data = []
total_videos = 0
total_seconds = 0
for member in members:
records = member.generation_records.filter(status='completed')
video_count = records.count()
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
total_videos += video_count
total_seconds += seconds_consumed
member_data.append({
'id': member.id,
'username': member.username,
'is_team_admin': member.is_team_admin,
'video_count': video_count,
'seconds_consumed': seconds_consumed,
})
return Response({
'team_id': team.id,
'team_name': team.name,
'total_videos': total_videos,
'total_seconds': total_seconds,
'member_count': len(member_data),
'members': member_data,
})
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
def admin_assets_user_videos(request, user_id):
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
try:
target_user = User.objects.get(id=user_id)
except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 30)), 100)
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count()
offset = (page - 1) * page_size
records = _eval_qs(qs[offset:offset + page_size])
results = []
for r in records:
results.append({
'id': r.id,
'task_id': str(r.task_id),
'prompt': r.prompt,
'result_url': r.result_url or '',
'duration': r.duration,
'seconds_consumed': r.seconds_consumed,
'aspect_ratio': r.aspect_ratio,
'created_at': r.created_at.isoformat(),
})
return Response({
'user_id': target_user.id,
'username': target_user.username,
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Team Admin: Content Assets
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsTeamAdmin])
def team_assets_overview(request):
"""GET /api/v1/team/assets/overview — Team stats + per-member video/seconds summary."""
team = request.user.team
members = team.members.all().order_by('username')
member_data = []
total_videos = 0
total_seconds = 0
for member in members:
records = member.generation_records.filter(status='completed')
video_count = records.count()
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
total_videos += video_count
total_seconds += seconds_consumed
member_data.append({
'id': member.id,
'username': member.username,
'is_team_admin': member.is_team_admin,
'video_count': video_count,
'seconds_consumed': seconds_consumed,
})
return Response({
'team_id': team.id,
'team_name': team.name,
'total_videos': total_videos,
'total_seconds': total_seconds,
'member_count': len(member_data),
'members': member_data,
})
@api_view(['GET'])
@permission_classes([IsTeamAdmin])
def team_assets_member_videos(request, member_id):
"""GET /api/v1/team/assets/member/<id>/videos — Completed videos for a team member (paginated)."""
team = request.user.team
try:
member = team.members.get(id=member_id)
except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1))
page_size = min(int(request.query_params.get('page_size', 30)), 100)
qs = member.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count()
offset = (page - 1) * page_size
records = _eval_qs(qs[offset:offset + page_size])
results = []
for r in records:
results.append({
'id': r.id,
'task_id': str(r.task_id),
'prompt': r.prompt,
'result_url': r.result_url or '',
'duration': r.duration,
'seconds_consumed': r.seconds_consumed,
'aspect_ratio': r.aspect_ratio,
'created_at': r.created_at.isoformat(),
})
return Response({
'user_id': member.id,
'username': member.username,
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})

View File

@ -110,7 +110,7 @@ AUTH_PASSWORD_VALIDATORS = [
# REST Framework
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
'apps.accounts.authentication.SessionJWTAuthentication',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
@ -132,8 +132,8 @@ REST_FRAMEWORK = {
# JWT settings
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False,
'AUTH_HEADER_TYPES': ('Bearer',),
}

24
web/package-lock.json generated
View File

@ -172,7 +172,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@ -534,7 +533,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@ -575,7 +573,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
@ -1565,7 +1562,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@ -1667,7 +1665,6 @@
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@ -1679,7 +1676,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^18.0.0"
}
@ -1862,6 +1858,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@ -1872,6 +1869,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@ -1971,7 +1969,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -2231,7 +2228,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dom-helpers": {
"version": "5.2.1",
@ -2710,7 +2708,6 @@
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.31",
"@asamuzakjp/dom-selector": "^6.8.1",
@ -2806,6 +2803,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@ -2959,7 +2957,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -3049,6 +3046,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@ -3063,7 +3061,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
@ -3103,7 +3102,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@ -3128,7 +3126,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@ -3624,7 +3621,6 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",

View File

@ -19,6 +19,8 @@ import { AssetsPage } from './pages/AssetsPage';
import { TeamAdminLayout } from './pages/TeamAdminLayout';
import { TeamDashboardPage } from './pages/TeamDashboardPage';
import { TeamMembersPage } from './pages/TeamMembersPage';
import { AdminAssetsPage } from './pages/AdminAssetsPage';
import { TeamAssetsPage } from './pages/TeamAssetsPage';
import { useAuthStore } from './store/auth';
@ -76,6 +78,7 @@ export default function App() {
<Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} />
</Route>
{/* Team Admin routes */}
<Route
@ -89,6 +92,7 @@ export default function App() {
<Route index element={<Navigate to="/team/dashboard" replace />} />
<Route path="dashboard" element={<TeamDashboardPage />} />
<Route path="members" element={<TeamMembersPage />} />
<Route path="assets" element={<TeamAssetsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -0,0 +1,147 @@
.overlay {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
animation: overlayIn 0.3s ease-out;
}
@keyframes overlayIn {
from { opacity: 0; }
to { opacity: 1; }
}
.panel {
position: relative;
width: 100%;
max-width: 420px;
margin: 0 20px;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 36px 32px 32px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.05) inset;
animation: panelIn 0.3s ease-out;
}
@keyframes panelIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 8px;
}
.headerLogo {
width: 28px;
height: 28px;
}
.headerTitle {
font-family: 'Space Grotesk', sans-serif;
font-size: 18px;
font-weight: 400;
color: #f1f0ff;
letter-spacing: 0.05em;
}
.notice {
text-align: center;
font-size: 13px;
color: #8b8ea8;
margin-bottom: 24px;
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
color: #8b8ea8;
font-weight: 500;
}
.input {
height: 44px;
padding: 0 14px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #f1f0ff;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input::placeholder {
color: #4c4f6b;
}
.input:focus {
border-color: rgba(126, 220, 200, 0.5);
}
.error {
color: #ff4d4f;
font-size: 13px;
text-align: center;
padding: 8px;
background: rgba(255, 77, 79, 0.08);
border-radius: 8px;
}
.submitBtn {
height: 44px;
width: 55%;
align-self: center;
margin-top: 18px;
background: rgba(120, 220, 200, 0.08);
border: 1px solid rgba(120, 220, 200, 0.3);
color: #7edcc8;
border-radius: 10px;
font-family: 'Space Grotesk', sans-serif;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.submitBtn:hover {
background: rgba(120, 220, 200, 0.18);
box-shadow: 0 0 24px rgba(120, 220, 200, 0.12);
}
.submitBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}

View File

@ -0,0 +1,97 @@
import { useState, useCallback } from 'react';
import { useAuthStore } from '../store/auth';
import { authApi } from '../lib/api';
import logoImg from '../assets/logo_32.png';
import styles from './ForceChangePasswordModal.module.css';
interface Props {
onSuccess: () => void;
}
export function ForceChangePasswordModal({ onSuccess }: Props) {
const clearMustChangePassword = useAuthStore((s) => s.clearMustChangePassword);
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!oldPassword) { setError('请输入当前密码'); return; }
if (newPassword.length < 8) { setError('新密码至少8位'); return; }
if (newPassword !== confirmPassword) { setError('两次输入的新密码不一致'); return; }
if (oldPassword === newPassword) { setError('新密码不能与当前密码相同'); return; }
setLoading(true);
try {
await authApi.changePassword(oldPassword, newPassword);
clearMustChangePassword();
onSuccess();
} catch (err: any) {
const msg = err.response?.data?.message || err.response?.data?.error || '密码修改失败,请重试';
setError(msg);
} finally {
setLoading(false);
}
}, [oldPassword, newPassword, confirmPassword, clearMustChangePassword, onSuccess]);
return (
<div className={styles.overlay}>
<div className={styles.panel}>
<div className={styles.header}>
<img src={logoImg} alt="" className={styles.headerLogo} />
<span className={styles.headerTitle}>Air Drama</span>
</div>
<p className={styles.notice}>
使
</p>
<form onSubmit={handleSubmit} className={styles.form}>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="请输入当前密码"
autoFocus
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="至少8位"
/>
</div>
<div className={styles.field}>
<label className={styles.label}></label>
<input
type="password"
className={styles.input}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入新密码"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.submitBtn} disabled={loading}>
{loading ? '修改中...' : '修改密码'}
</button>
</form>
</div>
</div>
);
}

View File

@ -76,6 +76,7 @@
line-height: 1.6;
word-break: break-word;
max-height: calc(1.6em * 2);
overflow: hidden;
}
.promptTooltip {
@ -97,6 +98,18 @@
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipAbove {
top: auto;
bottom: 100%;
margin-bottom: 4px;
animation: tooltipFadeInAbove 0.15s ease-out;
}
@keyframes tooltipFadeInAbove {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipText {
font-size: 13px;
color: var(--color-text-primary);

View File

@ -47,6 +47,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const moreRef = useRef<HTMLDivElement>(null);
const promptLineRef = useRef<HTMLDivElement>(null);
const promptWrapperRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null);
const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false);
@ -55,6 +56,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [detailHover, setDetailHover] = useState(false);
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
const [promptAbove, setPromptAbove] = useState(false);
const detailLinkRef = useRef<HTMLSpanElement>(null);
// Close more menu on click outside
@ -84,8 +86,8 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
// Measure labels width
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
// Two lines of available width, minus labels on line 2
const totalAvailable = containerWidth * 2 - labelsWidth;
// Two lines of available width, minus labels on line 2, with safety margin
const totalAvailable = containerWidth * 2 - labelsWidth - 24;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
@ -215,12 +217,20 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{/* Right: prompt + inline labels */}
<div className={styles.headerRight}>
<div
ref={promptWrapperRef}
className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)}
>
<div ref={promptLineRef} className={styles.promptLine}>
<span
onMouseEnter={() => setPromptHover(true)}
onMouseEnter={() => {
const el = promptWrapperRef.current;
if (el) {
const rect = el.getBoundingClientRect();
setPromptAbove(rect.bottom + 350 > window.innerHeight);
}
setPromptHover(true);
}}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
<span className={styles.label}>
@ -270,7 +280,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</span>
</div>
{promptHover && task.prompt && (
<div className={styles.promptTooltip}>
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}>
<p className={styles.promptTooltipText}>{task.prompt}</p>
<button className={styles.copyBtn} onClick={handleCopyPrompt}></button>
</div>

View File

@ -12,6 +12,7 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const isLoading = useAuthStore((s) => s.isLoading);
const user = useAuthStore((s) => s.user);
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
if (isLoading) {
return (
@ -33,6 +34,10 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
return <Navigate to="/login" replace />;
}
if (mustChangePassword) {
return <Navigate to="/" replace />;
}
if (requireAdmin && user?.role !== 'super_admin') {
return <Navigate to="/app" replace />;
}

View File

@ -7,8 +7,8 @@ import styles from './VideoDetailModal.module.css';
interface Props {
task: GenerationTask | null;
onClose: () => void;
onReEdit: (id: string) => void;
onRegenerate: (id: string) => void;
onReEdit?: (id: string) => void;
onRegenerate?: (id: string) => void;
onDelete?: (id: string) => void;
onPrev?: () => void;
onNext?: () => void;
@ -200,14 +200,14 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
};
const handleReEdit = () => {
if (task) {
if (task && onReEdit) {
onReEdit(task.id);
onClose();
}
};
const handleRegenerate = () => {
if (task) {
if (task && onRegenerate) {
onRegenerate(task.id);
onClose();
}
@ -480,7 +480,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
</div>
{(onReEdit || onRegenerate) && (
<div className={styles.cardActions}>
{onReEdit && (
<button className={styles.cardBtn} onClick={handleReEdit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
@ -488,6 +490,8 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg>
</button>
)}
{onRegenerate && (
<button className={styles.cardBtn} onClick={handleRegenerate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
@ -495,7 +499,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg>
</button>
)}
</div>
)}
</div>
</div>

View File

@ -3,7 +3,7 @@ import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
} from '../types';
import { reportError } from './logCenter';
@ -31,6 +31,16 @@ api.interceptors.response.use(
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
// Check if session was kicked by another device login
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
if (errorCode === 'session_expired_other_device') {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
alert('您的账号已在其他设备登录,请重新登录');
window.location.href = '/login';
return Promise.reject(error);
}
originalRequest._retry = true;
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
@ -203,6 +213,36 @@ export const adminApi = {
updateSettings: (settings: SystemSettings) =>
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
// Content Assets
getAssetsOverview: () =>
api.get<{
total_videos: number;
total_seconds: number;
total_teams: number;
teams: AssetTeamSummary[];
no_team: { video_count: number; seconds_consumed: number };
}>('/admin/assets/overview'),
getAssetsTeamMembers: (teamId: number) =>
api.get<{
team_id: number;
team_name: string;
total_videos: number;
total_seconds: number;
member_count: number;
members: AssetMemberSummary[];
}>(`/admin/assets/team/${teamId}/members`),
getAssetsUserVideos: (userId: number, page: number = 1, pageSize: number = 30) =>
api.get<{
user_id: number;
username: string;
total: number;
page: number;
page_size: number;
results: AssetVideo[];
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
getAuditLogs: (params: {
page?: number;
page_size?: number;
@ -239,6 +279,27 @@ export const teamApi = {
updateMemberStatus: (memberId: number, isActive: boolean) =>
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
// Content Assets
getAssetsOverview: () =>
api.get<{
team_id: number;
team_name: string;
total_videos: number;
total_seconds: number;
member_count: number;
members: AssetMemberSummary[];
}>('/team/assets/overview'),
getAssetsMemberVideos: (memberId: number, page: number = 1, pageSize: number = 30) =>
api.get<{
user_id: number;
username: string;
total: number;
page: number;
page_size: number;
results: AssetVideo[];
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
};
// Profile APIs

View File

@ -0,0 +1,155 @@
.page { max-width: 1200px; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 24px; }
/* Stats bar */
.statsBar {
display: flex; gap: 16px; margin-bottom: 24px;
}
.statCard {
flex: 1; padding: 16px 20px;
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card);
}
.statLabel { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; }
.statValue { font-size: 20px; font-weight: 600; color: var(--color-text-primary); font-variant-numeric: tabular-nums; }
/* Accordion */
.accordion { display: flex; flex-direction: column; gap: 2px; }
.accordionItem {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow: hidden;
}
.accordionHeader {
display: flex; align-items: center; gap: 12px;
padding: 14px 20px; cursor: pointer; user-select: none;
transition: background 0.15s;
}
.accordionHeader:hover { background: rgba(255,255,255,0.03); }
.chevron {
width: 16px; height: 16px; flex-shrink: 0;
transition: transform 0.2s;
color: var(--color-text-secondary);
}
.chevronOpen { transform: rotate(90deg); }
.accordionName {
font-size: 14px; font-weight: 500; color: var(--color-text-primary); flex: 1;
}
.accordionBadge {
font-size: 12px; color: var(--color-text-secondary); white-space: nowrap;
}
.accordionMeta {
display: flex; gap: 16px; align-items: center;
}
.adminBadge {
font-size: 11px; padding: 1px 6px; border-radius: 4px;
background: rgba(0, 184, 230, 0.12); color: #00b8e6;
}
/* Accordion body — team members or video grid */
.accordionBody {
border-top: 1px solid var(--color-border-card);
padding: 0;
}
/* Nested members inside a team */
.memberList { padding: 0; }
.memberItem {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px 12px 40px; cursor: pointer;
transition: background 0.15s;
}
.memberItem:hover { background: rgba(255,255,255,0.03); }
.memberItem + .memberItem { border-top: 1px solid rgba(255,255,255,0.04); }
.memberName { font-size: 13px; color: var(--color-text-primary); flex: 1; display: flex; align-items: center; gap: 8px; }
/* Video grid inside expanded member */
.videoSection {
max-height: 440px;
overflow-y: auto;
padding: 12px 20px 12px 40px;
border-top: 1px solid rgba(255,255,255,0.04);
}
.videoGrid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 10px;
}
@media (max-width: 1100px) { .videoGrid { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 800px) { .videoGrid { grid-template-columns: repeat(3, 1fr); } }
/* Video thumbnail — same style as AssetsPage */
.thumbnail {
position: relative;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
background: rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.15s;
}
.thumbnail:hover { transform: scale(1.02); }
.thumbVideo {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.thumbPlaceholder {
width: 100%; height: 100%; background: #1a1a24;
}
.durationBadge {
position: absolute; bottom: 4px; left: 4px;
padding: 1px 5px; border-radius: 3px;
background: rgba(0, 0, 0, 0.6); color: #fff;
font-size: 10px; font-variant-numeric: tabular-nums;
}
.thumbOverlay {
position: absolute; inset: 0;
background: rgba(0, 0, 0, 0.15); pointer-events: none;
}
.timeBadge {
position: absolute; bottom: 4px; right: 4px;
font-size: 10px; color: rgba(255,255,255,0.5);
}
.loadMore {
text-align: center; padding: 8px;
}
.loadMoreBtn {
background: none; border: 1px solid var(--color-border-card);
color: var(--color-text-secondary); font-size: 12px; padding: 4px 16px;
border-radius: 6px; cursor: pointer; transition: all 0.15s;
}
.loadMoreBtn:hover { background: rgba(255,255,255,0.04); color: var(--color-text-primary); }
.empty {
color: var(--color-text-disabled); font-size: 13px;
text-align: center; padding: 40px 0;
}
.loading {
color: var(--color-text-secondary); font-size: 14px;
text-align: center; padding: 60px 0;
}

View File

@ -0,0 +1,222 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { adminApi } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetTeamSummary, AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
function formatSeconds(s: number) {
return `${s.toLocaleString()}s`;
}
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hover, setHover] = useState(false);
const durationLabel = `00:${String(video.duration).padStart(2, '0')}`;
return (
<div
className={styles.thumbnail}
onMouseEnter={() => { setHover(true); videoRef.current?.play().catch(() => {}); }}
onMouseLeave={() => { setHover(false); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } }}
onClick={onClick}
>
{video.result_url ? (
<video ref={videoRef} src={video.result_url} className={styles.thumbVideo} muted loop preload="metadata" />
) : (
<div className={styles.thumbPlaceholder} />
)}
<span className={styles.durationBadge}>{durationLabel}</span>
{hover && <div className={styles.thumbOverlay} />}
</div>
);
}
function assetVideoToTask(v: AssetVideo): GenerationTask {
return {
id: String(v.id),
taskId: v.task_id,
prompt: v.prompt,
editorHtml: '',
mode: 'universal',
model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any,
references: [],
status: 'completed',
progress: 100,
resultUrl: v.result_url,
createdAt: new Date(v.created_at).getTime(),
};
}
// Chevron icon
function Chevron({ open }: { open: boolean }) {
return (
<svg className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 6 15 12 9 18" />
</svg>
);
}
export function AdminAssetsPage() {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<{
total_videos: number; total_seconds: number; total_teams: number;
teams: AssetTeamSummary[];
no_team: { video_count: number; seconds_consumed: number };
} | null>(null);
// Expanded states
const [expandedTeam, setExpandedTeam] = useState<number | null>(null);
const [teamMembers, setTeamMembers] = useState<Record<number, AssetMemberSummary[]>>({});
const [expandedMember, setExpandedMember] = useState<number | null>(null);
const [memberVideos, setMemberVideos] = useState<Record<number, { videos: AssetVideo[]; total: number; page: number }>>({});
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
useEffect(() => {
adminApi.getAssetsOverview().then(({ data }) => {
setOverview(data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const toggleTeam = useCallback(async (teamId: number) => {
if (expandedTeam === teamId) {
setExpandedTeam(null);
setExpandedMember(null);
return;
}
setExpandedTeam(teamId);
setExpandedMember(null);
if (!teamMembers[teamId]) {
const { data } = await adminApi.getAssetsTeamMembers(teamId);
setTeamMembers((prev) => ({ ...prev, [teamId]: data.members }));
}
}, [expandedTeam, teamMembers]);
const toggleMember = useCallback(async (memberId: number) => {
if (expandedMember === memberId) {
setExpandedMember(null);
return;
}
setExpandedMember(memberId);
if (!memberVideos[memberId]) {
const { data } = await adminApi.getAssetsUserVideos(memberId, 1);
setMemberVideos((prev) => ({ ...prev, [memberId]: { videos: data.results, total: data.total, page: 1 } }));
}
}, [expandedMember, memberVideos]);
const loadMoreVideos = useCallback(async (memberId: number) => {
const current = memberVideos[memberId];
if (!current) return;
const nextPage = current.page + 1;
const { data } = await adminApi.getAssetsUserVideos(memberId, nextPage);
setMemberVideos((prev) => ({
...prev,
[memberId]: { videos: [...current.videos, ...data.results], total: data.total, page: nextPage },
}));
}, [memberVideos]);
if (loading) return <div className={styles.loading}>...</div>;
if (!overview) return <div className={styles.empty}></div>;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.statsBar}>
<div className={styles.statCard}>
<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}>{formatSeconds(overview.total_seconds)}</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.total_teams}</div>
</div>
</div>
<div className={styles.accordion}>
{overview.teams.map((team) => (
<div key={team.id} className={styles.accordionItem}>
<div className={styles.accordionHeader} onClick={() => toggleTeam(team.id)}>
<Chevron open={expandedTeam === team.id} />
<span className={styles.accordionName}>{team.name}</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{team.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(team.seconds_consumed)}</span>
</div>
</div>
{expandedTeam === team.id && (
<div className={styles.accordionBody}>
<div className={styles.memberList}>
{(teamMembers[team.id] || []).map((member) => (
<div key={member.id}>
<div className={styles.memberItem} onClick={() => toggleMember(member.id)}>
<Chevron open={expandedMember === member.id} />
<span className={styles.memberName}>
{member.username}
{member.is_team_admin && <span className={styles.adminBadge}></span>}
</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{member.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(member.seconds_consumed)}</span>
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
<div className={styles.videoSection}>
{memberVideos[member.id].videos.length === 0 ? (
<div className={styles.empty}></div>
) : (
<>
<div className={styles.videoGrid}>
{memberVideos[member.id].videos.map((video) => (
<VideoThumbnail
key={video.id}
video={video}
onClick={() => setDetailTask(assetVideoToTask(video))}
/>
))}
</div>
{memberVideos[member.id].videos.length < memberVideos[member.id].total && (
<div className={styles.loadMore}>
<button className={styles.loadMoreBtn} onClick={() => loadMoreVideos(member.id)}>
</button>
</div>
)}
</>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
))}
{overview.no_team.video_count > 0 && (
<div className={styles.accordionItem}>
<div className={styles.accordionHeader}>
<span className={styles.accordionName}></span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{overview.no_team.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(overview.no_team.seconds_consumed)}</span>
</div>
</div>
</div>
)}
</div>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
/>
</div>
);
}

View File

@ -8,6 +8,7 @@ const navItems = [
{ path: '/admin/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/admin/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/auth';
import { AuroraCanvas } from '../components/AuroraCanvas';
import { LoginModal } from '../components/LoginModal';
import { ForceChangePasswordModal } from '../components/ForceChangePasswordModal';
import logoImg from '../assets/logo_512.png';
import styles from './LandingPage.module.css';
@ -13,7 +14,9 @@ interface Props {
export function LandingPage({ autoLogin }: Props) {
const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
const [showLogin, setShowLogin] = useState(false);
const [showForceChange, setShowForceChange] = useState(false);
const [showSpark, setShowSpark] = useState(false);
const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
@ -26,9 +29,20 @@ export function LandingPage({ autoLogin }: Props) {
}
}, [autoLogin, isAuthenticated]);
// Auto-show force change password modal if authenticated but must change
useEffect(() => {
if (isAuthenticated && mustChangePassword) {
setShowForceChange(true);
}
}, [isAuthenticated, mustChangePassword]);
const handleAirDrama = () => {
if (isAuthenticated) {
navigate('/app');
if (mustChangePassword) {
setShowForceChange(true);
} else {
navigate('/app');
}
} else {
setShowLogin(true);
}
@ -40,6 +54,15 @@ export function LandingPage({ autoLogin }: Props) {
const handleLoginSuccess = () => {
setShowLogin(false);
if (mustChangePassword) {
setShowForceChange(true);
} else {
navigate('/app', { replace: true });
}
};
const handleForceChangeSuccess = () => {
setShowForceChange(false);
navigate('/app', { replace: true });
};
@ -161,6 +184,11 @@ export function LandingPage({ autoLogin }: Props) {
onClose={() => setShowLogin(false)}
onSuccess={handleLoginSuccess}
/>
{/* Force change password modal (unclosable) */}
{showForceChange && (
<ForceChangePasswordModal onSuccess={handleForceChangeSuccess} />
)}
</div>
);
}

View File

@ -8,6 +8,7 @@
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
.cardDesc { color: var(--color-text-secondary); font-size: 13px; margin-bottom: 20px; }
.cardHint { color: var(--color-text-secondary); font-size: 12px; margin-bottom: 16px; margin-top: -4px; }
.formRow { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; }

View File

@ -10,6 +10,8 @@ export function SettingsPage() {
default_monthly_seconds_limit: 6000,
announcement: '',
announcement_enabled: false,
max_desktop_sessions: 1,
max_mobile_sessions: 0,
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@ -91,6 +93,35 @@ export function SettingsPage() {
</button>
</div>
<div className={styles.card}>
<h2 className={styles.cardTitle}></h2>
<p className={styles.cardDesc}></p>
<div className={styles.formRow}>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
min={1}
value={settings.max_desktop_sessions}
onChange={(e) => setSettings({ ...settings, max_desktop_sessions: Number(e.target.value) })}
/>
</div>
<div className={styles.formGroup}>
<label></label>
<input
type="number"
min={0}
value={settings.max_mobile_sessions}
onChange={(e) => setSettings({ ...settings, max_mobile_sessions: Number(e.target.value) })}
/>
</div>
</div>
<p className={styles.cardHint}> 1 0 </p>
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
{saving ? '保存中...' : '保存登录限制'}
</button>
</div>
<div className={styles.card}>
<div className={styles.cardHeader}>
<div>

View File

@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
const navItems = [
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/team/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
];
export function TeamAdminLayout() {

View File

@ -0,0 +1,176 @@
import { useEffect, useState, useRef, useCallback } from 'react';
import { teamApi } from '../lib/api';
import { VideoDetailModal } from '../components/VideoDetailModal';
import type { AssetMemberSummary, AssetVideo, GenerationTask } from '../types';
import styles from './AdminAssetsPage.module.css';
function formatSeconds(s: number) {
return `${s.toLocaleString()}s`;
}
function VideoThumbnail({ video, onClick }: { video: AssetVideo; onClick: () => void }) {
const videoRef = useRef<HTMLVideoElement>(null);
const [hover, setHover] = useState(false);
const durationLabel = `00:${String(video.duration).padStart(2, '0')}`;
return (
<div
className={styles.thumbnail}
onMouseEnter={() => { setHover(true); videoRef.current?.play().catch(() => {}); }}
onMouseLeave={() => { setHover(false); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } }}
onClick={onClick}
>
{video.result_url ? (
<video ref={videoRef} src={video.result_url} className={styles.thumbVideo} muted loop preload="metadata" />
) : (
<div className={styles.thumbPlaceholder} />
)}
<span className={styles.durationBadge}>{durationLabel}</span>
{hover && <div className={styles.thumbOverlay} />}
</div>
);
}
function assetVideoToTask(v: AssetVideo): GenerationTask {
return {
id: String(v.id),
taskId: v.task_id,
prompt: v.prompt,
editorHtml: '',
mode: 'universal',
model: 'seedance_2.0',
aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any,
references: [],
status: 'completed',
progress: 100,
resultUrl: v.result_url,
createdAt: new Date(v.created_at).getTime(),
};
}
function Chevron({ open }: { open: boolean }) {
return (
<svg className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="9 6 15 12 9 18" />
</svg>
);
}
export function TeamAssetsPage() {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState<{
team_id: number; team_name: string;
total_videos: number; total_seconds: number;
member_count: number; members: AssetMemberSummary[];
} | null>(null);
const [expandedMember, setExpandedMember] = useState<number | null>(null);
const [memberVideos, setMemberVideos] = useState<Record<number, { videos: AssetVideo[]; total: number; page: number }>>({});
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
useEffect(() => {
teamApi.getAssetsOverview().then(({ data }) => {
setOverview(data);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
const toggleMember = useCallback(async (memberId: number) => {
if (expandedMember === memberId) {
setExpandedMember(null);
return;
}
setExpandedMember(memberId);
if (!memberVideos[memberId]) {
const { data } = await teamApi.getAssetsMemberVideos(memberId, 1);
setMemberVideos((prev) => ({ ...prev, [memberId]: { videos: data.results, total: data.total, page: 1 } }));
}
}, [expandedMember, memberVideos]);
const loadMoreVideos = useCallback(async (memberId: number) => {
const current = memberVideos[memberId];
if (!current) return;
const nextPage = current.page + 1;
const { data } = await teamApi.getAssetsMemberVideos(memberId, nextPage);
setMemberVideos((prev) => ({
...prev,
[memberId]: { videos: [...current.videos, ...data.results], total: data.total, page: nextPage },
}));
}, [memberVideos]);
if (loading) return <div className={styles.loading}>...</div>;
if (!overview) return <div className={styles.empty}></div>;
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.statsBar}>
<div className={styles.statCard}>
<div className={styles.statLabel}></div>
<div className={styles.statValue}>{overview.member_count}</div>
</div>
<div className={styles.statCard}>
<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}>{formatSeconds(overview.total_seconds)}</div>
</div>
</div>
<div className={styles.accordion}>
{overview.members.map((member) => (
<div key={member.id} className={styles.accordionItem}>
<div className={styles.accordionHeader} onClick={() => toggleMember(member.id)}>
<Chevron open={expandedMember === member.id} />
<span className={styles.accordionName}>
{member.username}
{member.is_team_admin && <span className={styles.adminBadge}></span>}
</span>
<div className={styles.accordionMeta}>
<span className={styles.accordionBadge}>{member.video_count} </span>
<span className={styles.accordionBadge}>{formatSeconds(member.seconds_consumed)}</span>
</div>
</div>
{expandedMember === member.id && memberVideos[member.id] && (
<div className={styles.accordionBody}>
<div className={styles.videoSection} style={{ paddingLeft: 20 }}>
{memberVideos[member.id].videos.length === 0 ? (
<div className={styles.empty}></div>
) : (
<>
<div className={styles.videoGrid}>
{memberVideos[member.id].videos.map((video) => (
<VideoThumbnail
key={video.id}
video={video}
onClick={() => setDetailTask(assetVideoToTask(video))}
/>
))}
</div>
{memberVideos[member.id].videos.length < memberVideos[member.id].total && (
<div className={styles.loadMore}>
<button className={styles.loadMoreBtn} onClick={() => loadMoreVideos(member.id)}>
</button>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
))}
</div>
<VideoDetailModal
task={detailTask}
onClose={() => setDetailTask(null)}
/>
</div>
);
}

View File

@ -11,12 +11,14 @@ interface AuthState {
quota: Quota | null;
team: TeamInfo | null;
teamDisabled: boolean;
mustChangePassword: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => void;
refreshAccessToken: () => Promise<void>;
fetchUserInfo: () => Promise<void>;
initialize: () => Promise<void>;
clearMustChangePassword: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
@ -28,6 +30,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
quota: null,
team: null,
teamDisabled: false,
mustChangePassword: false,
login: async (username, password) => {
const { data } = await authApi.login(username, password);
@ -38,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accessToken: data.tokens.access,
refreshToken: data.tokens.refresh,
isAuthenticated: true,
mustChangePassword: data.user.must_change_password || false,
});
// Fetch quota after login
await get().fetchUserInfo();
@ -54,6 +58,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
quota: null,
team: null,
teamDisabled: false,
mustChangePassword: false,
});
},
@ -75,6 +80,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
team: team || null,
teamDisabled: team_disabled || false,
isAuthenticated: true,
mustChangePassword: user.must_change_password || false,
});
} catch {
// Token invalid
@ -93,4 +99,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
set({ isLoading: false });
},
clearMustChangePassword: () => {
set({ mustChangePassword: false });
},
}));

View File

@ -74,6 +74,7 @@ export interface User {
is_team_admin: boolean;
role: UserRole;
team_name: string | null;
must_change_password: boolean;
}
export interface TeamInfo {
@ -155,6 +156,8 @@ export interface SystemSettings {
default_monthly_seconds_limit: number;
announcement: string;
announcement_enabled: boolean;
max_desktop_sessions: number;
max_mobile_sessions: number;
}
export interface ProfileOverview {
@ -218,6 +221,35 @@ export interface TeamStats {
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
}
// Asset management types
export interface AssetTeamSummary {
id: number;
name: string;
video_count: number;
seconds_consumed: number;
member_count: number;
is_active: boolean;
}
export interface AssetMemberSummary {
id: number;
username: string;
is_team_admin: boolean;
video_count: number;
seconds_consumed: number;
}
export interface AssetVideo {
id: number;
task_id: string;
prompt: string;
result_url: string;
duration: number;
seconds_consumed: number;
aspect_ratio: string;
created_at: string;
}
export interface AuditLog {
id: number;
operator_name: string;