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/`) ### Auth (`/api/v1/auth/`)
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| |--------|----------|-------------|
| POST | `/api/v1/auth/register` | User registration | | POST | `/api/v1/auth/register` | User registration (disabled) |
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens) | | POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens, creates ActiveSession) |
| POST | `/api/v1/auth/token/refresh` | Refresh JWT access token | | 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/`) ### Video Generation (`/api/v1/`)
| Method | Endpoint | Description | | Method | Endpoint | Description |
@ -152,6 +153,15 @@ jimeng-clone/
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool | | 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 | | 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/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/`) ### Profile (`/api/v1/profile/`)
| Method | Endpoint | Description | | Method | Endpoint | Description |
@ -163,6 +173,8 @@ jimeng-clone/
### User (extends AbstractUser) ### User (extends AbstractUser)
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000) - `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` - `created_at`, `updated_at`
### GenerationRecord ### GenerationRecord
@ -176,9 +188,20 @@ jimeng-clone/
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON) - `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
- `ip_address`, `created_at` (indexed) - `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) ### QuotaConfig (Singleton, pk=1)
- `default_daily_seconds_limit`, `default_monthly_seconds_limit` - `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 ## Frontend Routes
@ -192,6 +215,8 @@ jimeng-clone/
| `/admin/records` | RecordsPage | Admin | Generation records | | `/admin/records` | RecordsPage | Admin | Generation records |
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement | | `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs | | `/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 ## 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.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | 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-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) ### 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.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
@ -37,6 +39,7 @@ class User(AbstractUser):
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员') is_team_admin = 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='每月秒数上限')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
updated_at = models.DateTimeField(auto_now=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}' 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): 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( AdminAuditLog.objects.create(
operator=request.user, operator=request.user,
operator_name=request.user.username, operator_name=request.user.username,

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') fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name', 'must_change_password')
class RegisterSerializer(serializers.Serializer): 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.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate, get_user_model from django.contrib.auth import authenticate, get_user_model
from django.utils import timezone from django.utils import timezone
from django.db.models import Sum from django.db.models import Sum
from .serializers import UserSerializer 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 from django.contrib.auth.hashers import check_password
User = get_user_model() 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']) @api_view(['POST'])
@permission_classes([AllowAny]) @permission_classes([AllowAny])
@throttle_classes([LoginRateThrottle]) @throttle_classes([LoginRateThrottle])
@ -53,7 +84,17 @@ def login_view(request):
status=status.HTTP_401_UNAUTHORIZED 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({ return Response({
'user': UserSerializer(user).data, 'user': UserSerializer(user).data,
'tokens': { 'tokens': {
@ -141,5 +182,9 @@ def change_password_view(request):
) )
request.user.set_password(new_password) request.user.set_password(new_password)
request.user.save() request.user.must_change_password = False
return Response({'message': '密码修改成功'}) 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='默认每月秒数上限') default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
announcement = models.TextField(blank=True, default='', verbose_name='系统公告') announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
announcement_enabled = models.BooleanField(default=False, 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) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:

View File

@ -33,6 +33,8 @@ class SystemSettingsSerializer(serializers.Serializer):
default_monthly_seconds_limit = serializers.IntegerField(min_value=0) default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
announcement = serializers.CharField(required=False, allow_blank=True, default='') announcement = serializers.CharField(required=False, allow_blank=True, default='')
announcement_enabled = serializers.BooleanField(required=False, default=False) 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 ── # ── Team serializers ──

View File

@ -35,6 +35,11 @@ urlpatterns = [
path('admin/settings', views.admin_settings_view, name='admin_settings'), path('admin/settings', views.admin_settings_view, name='admin_settings'),
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'), 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 ── # ── Team Admin: Team management ──
path('team/info', views.team_info_view, name='team_info'), path('team/info', views.team_info_view, name='team_info'),
path('team/stats', views.team_stats_view, name='team_stats'), 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>/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'),
# ── 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 ── # ── Profile: User's own data ──
path('profile/overview', views.profile_overview_view, name='profile_overview'), path('profile/overview', views.profile_overview_view, name='profile_overview'),
path('profile/records', views.profile_records_view, name='profile_records'), 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) return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
user.set_password(new_password) 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) log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
return Response({'message': f'已重置 {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, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement, 'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled, 'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
}) })
serializer = SystemSettingsSerializer(data=request.data) serializer = SystemSettingsSerializer(data=request.data)
@ -1089,11 +1092,15 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement, 'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled, '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_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit'] config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
config.announcement = serializer.validated_data.get('announcement', '') config.announcement = serializer.validated_data.get('announcement', '')
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False) 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() config.save()
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置', log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
before=before, before=before,
@ -1102,6 +1109,8 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement, 'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled, 'announcement_enabled': config.announcement_enabled,
'max_desktop_sessions': config.max_desktop_sessions,
'max_mobile_sessions': config.max_mobile_sessions,
}) })
return Response({ return Response({
@ -1109,6 +1118,8 @@ def admin_settings_view(request):
'default_monthly_seconds_limit': config.default_monthly_seconds_limit, 'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
'announcement': config.announcement, 'announcement': config.announcement,
'announcement_enabled': config.announcement_enabled, '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(), 'updated_at': config.updated_at.isoformat(),
}) })
@ -1542,3 +1553,214 @@ def profile_records_view(request):
'page_size': page_size, 'page_size': page_size,
'results': results, '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
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication', 'apps.accounts.authentication.SessionJWTAuthentication',
), ),
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.IsAuthenticated',
@ -132,8 +132,8 @@ REST_FRAMEWORK = {
# JWT settings # JWT settings
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False, 'ROTATE_REFRESH_TOKENS': False,
'AUTH_HEADER_TYPES': ('Bearer',), 'AUTH_HEADER_TYPES': ('Bearer',),
} }

24
web/package-lock.json generated
View File

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

View File

@ -19,6 +19,8 @@ import { AssetsPage } from './pages/AssetsPage';
import { TeamAdminLayout } from './pages/TeamAdminLayout'; import { TeamAdminLayout } from './pages/TeamAdminLayout';
import { TeamDashboardPage } from './pages/TeamDashboardPage'; import { TeamDashboardPage } from './pages/TeamDashboardPage';
import { TeamMembersPage } from './pages/TeamMembersPage'; import { TeamMembersPage } from './pages/TeamMembersPage';
import { AdminAssetsPage } from './pages/AdminAssetsPage';
import { TeamAssetsPage } from './pages/TeamAssetsPage';
import { useAuthStore } from './store/auth'; import { useAuthStore } from './store/auth';
@ -76,6 +78,7 @@ export default function App() {
<Route path="records" element={<RecordsPage />} /> <Route path="records" element={<RecordsPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="logs" element={<AuditLogsPage />} /> <Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} />
</Route> </Route>
{/* Team Admin routes */} {/* Team Admin routes */}
<Route <Route
@ -89,6 +92,7 @@ export default function App() {
<Route index element={<Navigate to="/team/dashboard" replace />} /> <Route index element={<Navigate to="/team/dashboard" replace />} />
<Route path="dashboard" element={<TeamDashboardPage />} /> <Route path="dashboard" element={<TeamDashboardPage />} />
<Route path="members" element={<TeamMembersPage />} /> <Route path="members" element={<TeamMembersPage />} />
<Route path="assets" element={<TeamAssetsPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </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; line-height: 1.6;
word-break: break-word; word-break: break-word;
max-height: calc(1.6em * 2); max-height: calc(1.6em * 2);
overflow: hidden;
} }
.promptTooltip { .promptTooltip {
@ -97,6 +98,18 @@
to { opacity: 1; transform: translateY(0); } 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 { .promptTooltipText {
font-size: 13px; font-size: 13px;
color: var(--color-text-primary); color: var(--color-text-primary);

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import type {
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail, User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
} from '../types'; } from '../types';
import { reportError } from './logCenter'; import { reportError } from './logCenter';
@ -31,6 +31,16 @@ api.interceptors.response.use(
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep)); const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) { 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; originalRequest._retry = true;
const refreshToken = localStorage.getItem('refresh_token'); const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) { if (refreshToken) {
@ -203,6 +213,36 @@ export const adminApi = {
updateSettings: (settings: SystemSettings) => updateSettings: (settings: SystemSettings) =>
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings), 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: { getAuditLogs: (params: {
page?: number; page?: number;
page_size?: number; page_size?: number;
@ -239,6 +279,27 @@ 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 }),
// 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 // 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/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/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/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/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/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' }, { 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 { useAuthStore } from '../store/auth';
import { AuroraCanvas } from '../components/AuroraCanvas'; import { AuroraCanvas } from '../components/AuroraCanvas';
import { LoginModal } from '../components/LoginModal'; import { LoginModal } from '../components/LoginModal';
import { ForceChangePasswordModal } from '../components/ForceChangePasswordModal';
import logoImg from '../assets/logo_512.png'; import logoImg from '../assets/logo_512.png';
import styles from './LandingPage.module.css'; import styles from './LandingPage.module.css';
@ -13,7 +14,9 @@ interface Props {
export function LandingPage({ autoLogin }: Props) { export function LandingPage({ autoLogin }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
const [showForceChange, setShowForceChange] = useState(false);
const [showSpark, setShowSpark] = useState(false); const [showSpark, setShowSpark] = useState(false);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null); const audioRef = useRef<HTMLAudioElement | null>(null);
@ -26,9 +29,20 @@ export function LandingPage({ autoLogin }: Props) {
} }
}, [autoLogin, isAuthenticated]); }, [autoLogin, isAuthenticated]);
// Auto-show force change password modal if authenticated but must change
useEffect(() => {
if (isAuthenticated && mustChangePassword) {
setShowForceChange(true);
}
}, [isAuthenticated, mustChangePassword]);
const handleAirDrama = () => { const handleAirDrama = () => {
if (isAuthenticated) { if (isAuthenticated) {
if (mustChangePassword) {
setShowForceChange(true);
} else {
navigate('/app'); navigate('/app');
}
} else { } else {
setShowLogin(true); setShowLogin(true);
} }
@ -40,6 +54,15 @@ export function LandingPage({ autoLogin }: Props) {
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
setShowLogin(false); setShowLogin(false);
if (mustChangePassword) {
setShowForceChange(true);
} else {
navigate('/app', { replace: true });
}
};
const handleForceChangeSuccess = () => {
setShowForceChange(false);
navigate('/app', { replace: true }); navigate('/app', { replace: true });
}; };
@ -161,6 +184,11 @@ export function LandingPage({ autoLogin }: Props) {
onClose={() => setShowLogin(false)} onClose={() => setShowLogin(false)}
onSuccess={handleLoginSuccess} onSuccess={handleLoginSuccess}
/> />
{/* Force change password modal (unclosable) */}
{showForceChange && (
<ForceChangePasswordModal onSuccess={handleForceChangeSuccess} />
)}
</div> </div>
); );
} }

View File

@ -8,6 +8,7 @@
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; } .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; } .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; } .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; } .formRow { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.formGroup { margin-bottom: 16px; } .formGroup { margin-bottom: 16px; }

View File

@ -10,6 +10,8 @@ export function SettingsPage() {
default_monthly_seconds_limit: 6000, default_monthly_seconds_limit: 6000,
announcement: '', announcement: '',
announcement_enabled: false, announcement_enabled: false,
max_desktop_sessions: 1,
max_mobile_sessions: 0,
}); });
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -91,6 +93,35 @@ export function SettingsPage() {
</button> </button>
</div> </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.card}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<div> <div>

View File

@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
const navItems = [ const navItems = [
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' }, { 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/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() { 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; quota: Quota | null;
team: TeamInfo | null; team: TeamInfo | null;
teamDisabled: boolean; teamDisabled: boolean;
mustChangePassword: boolean;
login: (username: string, password: string) => Promise<void>; login: (username: string, password: string) => Promise<void>;
logout: () => void; logout: () => void;
refreshAccessToken: () => Promise<void>; refreshAccessToken: () => Promise<void>;
fetchUserInfo: () => Promise<void>; fetchUserInfo: () => Promise<void>;
initialize: () => Promise<void>; initialize: () => Promise<void>;
clearMustChangePassword: () => void;
} }
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
@ -28,6 +30,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
quota: null, quota: null,
team: null, team: null,
teamDisabled: false, teamDisabled: false,
mustChangePassword: false,
login: async (username, password) => { login: async (username, password) => {
const { data } = await authApi.login(username, password); const { data } = await authApi.login(username, password);
@ -38,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
accessToken: data.tokens.access, accessToken: data.tokens.access,
refreshToken: data.tokens.refresh, refreshToken: data.tokens.refresh,
isAuthenticated: true, isAuthenticated: true,
mustChangePassword: data.user.must_change_password || false,
}); });
// Fetch quota after login // Fetch quota after login
await get().fetchUserInfo(); await get().fetchUserInfo();
@ -54,6 +58,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
quota: null, quota: null,
team: null, team: null,
teamDisabled: false, teamDisabled: false,
mustChangePassword: false,
}); });
}, },
@ -75,6 +80,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
team: team || null, team: team || null,
teamDisabled: team_disabled || false, teamDisabled: team_disabled || false,
isAuthenticated: true, isAuthenticated: true,
mustChangePassword: user.must_change_password || false,
}); });
} catch { } catch {
// Token invalid // Token invalid
@ -93,4 +99,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
} }
set({ isLoading: false }); set({ isLoading: false });
}, },
clearMustChangePassword: () => {
set({ mustChangePassword: false });
},
})); }));

View File

@ -74,6 +74,7 @@ export interface User {
is_team_admin: boolean; is_team_admin: boolean;
role: UserRole; role: UserRole;
team_name: string | null; team_name: string | null;
must_change_password: boolean;
} }
export interface TeamInfo { export interface TeamInfo {
@ -155,6 +156,8 @@ export interface SystemSettings {
default_monthly_seconds_limit: number; default_monthly_seconds_limit: number;
announcement: string; announcement: string;
announcement_enabled: boolean; announcement_enabled: boolean;
max_desktop_sessions: number;
max_mobile_sessions: number;
} }
export interface ProfileOverview { export interface ProfileOverview {
@ -218,6 +221,35 @@ export interface TeamStats {
member_consumption: { user_id: number; username: string; seconds_consumed: number }[]; 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 { export interface AuditLog {
id: number; id: number;
operator_name: string; operator_name: string;