From e2973284d0166d42e5e1a61666e7f4e314aeefb3 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 18 Mar 2026 12:02:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A6=E5=8F=B7=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E7=AE=A1=E6=8E=A7=20+=20=E5=86=85=E5=AE=B9=E8=B5=84=E4=BA=A7?= =?UTF-8?q?=E9=A1=B5=20+=20UI=E4=BF=AE=E7=BC=AE=20(v0.9.5=20&=20v0.9.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 38 ++- backend/apps/accounts/authentication.py | 30 +++ ...ord_alter_adminauditlog_action_and_more.py | 57 +++++ ...isting_users_must_change_password_false.py | 20 ++ backend/apps/accounts/models.py | 60 ++++- backend/apps/accounts/serializers.py | 2 +- backend/apps/accounts/tokens.py | 13 + backend/apps/accounts/views.py | 53 ++++- ...otaconfig_max_desktop_sessions_and_more.py | 23 ++ backend/apps/generation/models.py | 2 + backend/apps/generation/serializers.py | 2 + backend/apps/generation/urls.py | 9 + backend/apps/generation/views.py | 224 +++++++++++++++++- backend/config/settings.py | 6 +- web/package-lock.json | 24 +- web/src/App.tsx | 4 + .../ForceChangePasswordModal.module.css | 147 ++++++++++++ .../components/ForceChangePasswordModal.tsx | 97 ++++++++ web/src/components/GenerationCard.module.css | 13 + web/src/components/GenerationCard.tsx | 18 +- web/src/components/ProtectedRoute.tsx | 5 + web/src/components/VideoDetailModal.tsx | 14 +- web/src/lib/api.ts | 63 ++++- web/src/pages/AdminAssetsPage.module.css | 155 ++++++++++++ web/src/pages/AdminAssetsPage.tsx | 222 +++++++++++++++++ web/src/pages/AdminLayout.tsx | 1 + web/src/pages/LandingPage.tsx | 30 ++- web/src/pages/SettingsPage.module.css | 1 + web/src/pages/SettingsPage.tsx | 31 +++ web/src/pages/TeamAdminLayout.tsx | 1 + web/src/pages/TeamAssetsPage.tsx | 176 ++++++++++++++ web/src/store/auth.ts | 10 + web/src/types/index.ts | 32 +++ 33 files changed, 1545 insertions(+), 38 deletions(-) create mode 100644 backend/apps/accounts/authentication.py create mode 100644 backend/apps/accounts/migrations/0006_user_must_change_password_alter_adminauditlog_action_and_more.py create mode 100644 backend/apps/accounts/migrations/0007_set_existing_users_must_change_password_false.py create mode 100644 backend/apps/accounts/tokens.py create mode 100644 backend/apps/generation/migrations/0005_quotaconfig_max_desktop_sessions_and_more.py create mode 100644 web/src/components/ForceChangePasswordModal.module.css create mode 100644 web/src/components/ForceChangePasswordModal.tsx create mode 100644 web/src/pages/AdminAssetsPage.module.css create mode 100644 web/src/pages/AdminAssetsPage.tsx create mode 100644 web/src/pages/TeamAssetsPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 3e7e584..ccc5566 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//set-pool` | Directly set team total seconds pool | | POST | `/api/v1/admin/teams//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//members` | Content assets: team members with video stats | +| GET | `/api/v1/admin/assets/user//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//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) diff --git a/backend/apps/accounts/authentication.py b/backend/apps/accounts/authentication.py new file mode 100644 index 0000000..68c83c3 --- /dev/null +++ b/backend/apps/accounts/authentication.py @@ -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 diff --git a/backend/apps/accounts/migrations/0006_user_must_change_password_alter_adminauditlog_action_and_more.py b/backend/apps/accounts/migrations/0006_user_must_change_password_alter_adminauditlog_action_and_more.py new file mode 100644 index 0000000..0ce64d9 --- /dev/null +++ b/backend/apps/accounts/migrations/0006_user_must_change_password_alter_adminauditlog_action_and_more.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/accounts/migrations/0007_set_existing_users_must_change_password_false.py b/backend/apps/accounts/migrations/0007_set_existing_users_must_change_password_false.py new file mode 100644 index 0000000..2bd672c --- /dev/null +++ b/backend/apps/accounts/migrations/0007_set_existing_users_must_change_password_false.py @@ -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), + ] diff --git a/backend/apps/accounts/models.py b/backend/apps/accounts/models.py index fede85a..cf37366 100644 --- a/backend/apps/accounts/models.py +++ b/backend/apps/accounts/models.py @@ -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, diff --git a/backend/apps/accounts/serializers.py b/backend/apps/accounts/serializers.py index c028009..ea82943 100644 --- a/backend/apps/accounts/serializers.py +++ b/backend/apps/accounts/serializers.py @@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name') + fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name', 'must_change_password') class RegisterSerializer(serializers.Serializer): diff --git a/backend/apps/accounts/tokens.py b/backend/apps/accounts/tokens.py new file mode 100644 index 0000000..9b9c978 --- /dev/null +++ b/backend/apps/accounts/tokens.py @@ -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 diff --git a/backend/apps/accounts/views.py b/backend/apps/accounts/views.py index bae344a..da8f795 100644 --- a/backend/apps/accounts/views.py +++ b/backend/apps/accounts/views.py @@ -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, + }) diff --git a/backend/apps/generation/migrations/0005_quotaconfig_max_desktop_sessions_and_more.py b/backend/apps/generation/migrations/0005_quotaconfig_max_desktop_sessions_and_more.py new file mode 100644 index 0000000..eb0ae85 --- /dev/null +++ b/backend/apps/generation/migrations/0005_quotaconfig_max_desktop_sessions_and_more.py @@ -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='每用户最大移动端会话数'), + ), + ] diff --git a/backend/apps/generation/models.py b/backend/apps/generation/models.py index b976b5d..1d68bac 100644 --- a/backend/apps/generation/models.py +++ b/backend/apps/generation/models.py @@ -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: diff --git a/backend/apps/generation/serializers.py b/backend/apps/generation/serializers.py index 8131ac2..181559c 100644 --- a/backend/apps/generation/serializers.py +++ b/backend/apps/generation/serializers.py @@ -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 ── diff --git a/backend/apps/generation/urls.py b/backend/apps/generation/urls.py index dc80f00..bba864b 100644 --- a/backend/apps/generation/urls.py +++ b/backend/apps/generation/urls.py @@ -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//members', views.admin_assets_team_members, name='admin_assets_team_members'), + path('admin/assets/user//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//quota', views.team_member_quota_view, name='team_member_quota'), path('team/members//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//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'), diff --git a/backend/apps/generation/views.py b/backend/apps/generation/views.py index 03d871d..5b9a85f 100644 --- a/backend/apps/generation/views.py +++ b/backend/apps/generation/views.py @@ -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//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//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//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, + }) diff --git a/backend/config/settings.py b/backend/config/settings.py index 4ce911d..1d1ce52 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -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',), } diff --git a/web/package-lock.json b/web/package-lock.json index f4304c5..cfbc09c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/src/App.tsx b/web/src/App.tsx index 5bc70eb..b3d2977 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* Team Admin routes */} } /> } /> } /> + } /> } /> diff --git a/web/src/components/ForceChangePasswordModal.module.css b/web/src/components/ForceChangePasswordModal.module.css new file mode 100644 index 0000000..86eaafb --- /dev/null +++ b/web/src/components/ForceChangePasswordModal.module.css @@ -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; +} diff --git a/web/src/components/ForceChangePasswordModal.tsx b/web/src/components/ForceChangePasswordModal.tsx new file mode 100644 index 0000000..29a3f72 --- /dev/null +++ b/web/src/components/ForceChangePasswordModal.tsx @@ -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 ( +
+
+
+ + Air Drama +
+ +

+ 首次登录请修改密码后继续使用 +

+ +
+
+ + setOldPassword(e.target.value)} + placeholder="请输入当前密码" + autoFocus + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="至少8位" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder="再次输入新密码" + /> +
+ + {error &&
{error}
} + + +
+
+
+ ); +} diff --git a/web/src/components/GenerationCard.module.css b/web/src/components/GenerationCard.module.css index 143cc1b..e8be938 100644 --- a/web/src/components/GenerationCard.module.css +++ b/web/src/components/GenerationCard.module.css @@ -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); diff --git a/web/src/components/GenerationCard.tsx b/web/src/components/GenerationCard.tsx index c560109..90ea0db 100644 --- a/web/src/components/GenerationCard.tsx +++ b/web/src/components/GenerationCard.tsx @@ -47,6 +47,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) { const videoRef = useRef(null); const moreRef = useRef(null); const promptLineRef = useRef(null); + const promptWrapperRef = useRef(null); const labelsRef = useRef(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(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 */}
setPromptHover(false)} >
setPromptHover(true)} + onMouseEnter={() => { + const el = promptWrapperRef.current; + if (el) { + const rect = el.getBoundingClientRect(); + setPromptAbove(rect.bottom + 350 > window.innerHeight); + } + setPromptHover(true); + }} >{truncatedPrompt || '(无文字描述)'} setPromptHover(false)}> @@ -270,7 +280,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
{promptHover && task.prompt && ( -
+

{task.prompt}

diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 79cfde6..68846ec 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -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 ; } + if (mustChangePassword) { + return ; + } + if (requireAdmin && user?.role !== 'super_admin') { return ; } diff --git a/web/src/components/VideoDetailModal.tsx b/web/src/components/VideoDetailModal.tsx index 125bb91..d8add26 100644 --- a/web/src/components/VideoDetailModal.tsx +++ b/web/src/components/VideoDetailModal.tsx @@ -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 {task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}
+ {(onReEdit || onRegenerate) && (
+ {onReEdit && ( + )} + {onRegenerate && ( + )}
+ )}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f6f825e..694fe4e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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('/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 diff --git a/web/src/pages/AdminAssetsPage.module.css b/web/src/pages/AdminAssetsPage.module.css new file mode 100644 index 0000000..3f36ab1 --- /dev/null +++ b/web/src/pages/AdminAssetsPage.module.css @@ -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; +} diff --git a/web/src/pages/AdminAssetsPage.tsx b/web/src/pages/AdminAssetsPage.tsx new file mode 100644 index 0000000..1fe6131 --- /dev/null +++ b/web/src/pages/AdminAssetsPage.tsx @@ -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(null); + const [hover, setHover] = useState(false); + const durationLabel = `00:${String(video.duration).padStart(2, '0')}`; + + return ( +
{ setHover(true); videoRef.current?.play().catch(() => {}); }} + onMouseLeave={() => { setHover(false); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } }} + onClick={onClick} + > + {video.result_url ? ( +
+
+

登录设备限制

+

限制每个用户在不同设备类型上的同时登录数量

+
+
+ + setSettings({ ...settings, max_desktop_sessions: Number(e.target.value) })} + /> +
+
+ + setSettings({ ...settings, max_mobile_sessions: Number(e.target.value) })} + /> +
+
+

桌面端至少为 1,移动端设为 0 表示暂不启用移动端登录

+ +
+
diff --git a/web/src/pages/TeamAdminLayout.tsx b/web/src/pages/TeamAdminLayout.tsx index 75229dc..643b1dc 100644 --- a/web/src/pages/TeamAdminLayout.tsx +++ b/web/src/pages/TeamAdminLayout.tsx @@ -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() { diff --git a/web/src/pages/TeamAssetsPage.tsx b/web/src/pages/TeamAssetsPage.tsx new file mode 100644 index 0000000..b7b3d54 --- /dev/null +++ b/web/src/pages/TeamAssetsPage.tsx @@ -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(null); + const [hover, setHover] = useState(false); + const durationLabel = `00:${String(video.duration).padStart(2, '0')}`; + + return ( +
{ setHover(true); videoRef.current?.play().catch(() => {}); }} + onMouseLeave={() => { setHover(false); if (videoRef.current) { videoRef.current.pause(); videoRef.current.currentTime = 0; } }} + onClick={onClick} + > + {video.result_url ? ( +