feat: 账号安全管控 + 内容资产页 + UI修缮 (v0.9.5 & v0.9.6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
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:
parent
45b7ca00d1
commit
e2973284d0
38
CLAUDE.md
38
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/<id>/set-pool` | Directly set team total seconds pool |
|
||||
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
||||
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
|
||||
| GET | `/api/v1/admin/assets/overview` | Content assets: global stats + per-team summary |
|
||||
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
|
||||
| GET | `/api/v1/admin/assets/user/<id>/videos` | Content assets: user's completed videos (paginated) |
|
||||
|
||||
### Team Admin Assets (`/api/v1/team/assets/`)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/v1/team/assets/overview` | Team stats + per-member video summary |
|
||||
| GET | `/api/v1/team/assets/member/<id>/videos` | Member's completed videos (paginated) |
|
||||
|
||||
### Profile (`/api/v1/profile/`)
|
||||
| Method | Endpoint | Description |
|
||||
@ -163,6 +173,8 @@ jimeng-clone/
|
||||
|
||||
### User (extends AbstractUser)
|
||||
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
||||
- `must_change_password` (default: True) — forces password change on first login
|
||||
- `team` (FK to Team), `is_team_admin`
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
### GenerationRecord
|
||||
@ -176,9 +188,20 @@ jimeng-clone/
|
||||
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
|
||||
- `ip_address`, `created_at` (indexed)
|
||||
|
||||
### ActiveSession
|
||||
- `user` (FK), `session_id` (UUID, unique), `device_type` (desktop|mobile|unknown)
|
||||
- `user_agent`, `created_at`
|
||||
- Used for concurrent session limiting via JWT session_id claim
|
||||
|
||||
### LoginRecord
|
||||
- `user` (FK), `ip_address`, `user_agent`, `created_at` (indexed)
|
||||
- Records every login for future anomaly detection
|
||||
|
||||
### QuotaConfig (Singleton, pk=1)
|
||||
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
||||
- `announcement`, `announcement_enabled`, `updated_at`
|
||||
- `announcement`, `announcement_enabled`
|
||||
- `max_desktop_sessions` (default: 1), `max_mobile_sessions` (default: 0)
|
||||
- `updated_at`
|
||||
|
||||
## Frontend Routes
|
||||
|
||||
@ -192,6 +215,8 @@ jimeng-clone/
|
||||
| `/admin/records` | RecordsPage | Admin | Generation records |
|
||||
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
|
||||
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
|
||||
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
|
||||
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) |
|
||||
|
||||
## Incremental Development Guide
|
||||
|
||||
@ -381,6 +406,11 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
|
||||
| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
|
||||
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack |
|
||||
| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack |
|
||||
| 2026-03-18 | v0.9.0: 首次登录强制改密 — must_change_password 字段 + ForceChangePasswordModal | Full stack |
|
||||
| 2026-03-18 | v0.9.0: 并发会话限制 — ActiveSession + SessionJWT + 可配置桌面/移动端会话数 | Full stack |
|
||||
| 2026-03-18 | v0.9.0: 登录记录 — LoginRecord 模型(IP + User-Agent)为异常检测打基础 | Backend |
|
||||
| 2026-03-18 | v0.9.0: Token 生命周期缩短 — access 30min, refresh 1天 | Backend |
|
||||
| 2026-03-18 | v0.9.0: 内容资产页 — 超管/团队管三级折叠式资产浏览(团队→成员→视频) | Full stack |
|
||||
|
||||
### Phase 4 Details (2026-03-13)
|
||||
|
||||
|
||||
30
backend/apps/accounts/authentication.py
Normal file
30
backend/apps/accounts/authentication.py
Normal 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
|
||||
@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
13
backend/apps/accounts/tokens.py
Normal file
13
backend/apps/accounts/tokens.py
Normal 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
|
||||
@ -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,
|
||||
})
|
||||
|
||||
@ -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='每用户最大移动端会话数'),
|
||||
),
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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 ──
|
||||
|
||||
@ -35,6 +35,11 @@ urlpatterns = [
|
||||
path('admin/settings', views.admin_settings_view, name='admin_settings'),
|
||||
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
|
||||
|
||||
# ── Super Admin: Content Assets ──
|
||||
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
|
||||
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),
|
||||
path('admin/assets/user/<int:user_id>/videos', views.admin_assets_user_videos, name='admin_assets_user_videos'),
|
||||
|
||||
# ── Team Admin: Team management ──
|
||||
path('team/info', views.team_info_view, name='team_info'),
|
||||
path('team/stats', views.team_stats_view, name='team_stats'),
|
||||
@ -44,6 +49,10 @@ urlpatterns = [
|
||||
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
|
||||
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
|
||||
|
||||
# ── Team Admin: Content Assets ──
|
||||
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
|
||||
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
|
||||
|
||||
# ── Profile: User's own data ──
|
||||
path('profile/overview', views.profile_overview_view, name='profile_overview'),
|
||||
path('profile/records', views.profile_records_view, name='profile_records'),
|
||||
|
||||
@ -964,7 +964,8 @@ def admin_reset_password_view(request, user_id):
|
||||
return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
user.must_change_password = True
|
||||
user.save(update_fields=['password', 'must_change_password'])
|
||||
log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
|
||||
|
||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
||||
@ -1079,6 +1080,8 @@ def admin_settings_view(request):
|
||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||
'announcement': config.announcement,
|
||||
'announcement_enabled': config.announcement_enabled,
|
||||
'max_desktop_sessions': config.max_desktop_sessions,
|
||||
'max_mobile_sessions': config.max_mobile_sessions,
|
||||
})
|
||||
|
||||
serializer = SystemSettingsSerializer(data=request.data)
|
||||
@ -1089,11 +1092,15 @@ def admin_settings_view(request):
|
||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||
'announcement': config.announcement,
|
||||
'announcement_enabled': config.announcement_enabled,
|
||||
'max_desktop_sessions': config.max_desktop_sessions,
|
||||
'max_mobile_sessions': config.max_mobile_sessions,
|
||||
}
|
||||
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
|
||||
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
|
||||
config.announcement = serializer.validated_data.get('announcement', '')
|
||||
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
|
||||
config.max_desktop_sessions = serializer.validated_data.get('max_desktop_sessions', 1)
|
||||
config.max_mobile_sessions = serializer.validated_data.get('max_mobile_sessions', 0)
|
||||
config.save()
|
||||
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
|
||||
before=before,
|
||||
@ -1102,6 +1109,8 @@ def admin_settings_view(request):
|
||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||
'announcement': config.announcement,
|
||||
'announcement_enabled': config.announcement_enabled,
|
||||
'max_desktop_sessions': config.max_desktop_sessions,
|
||||
'max_mobile_sessions': config.max_mobile_sessions,
|
||||
})
|
||||
|
||||
return Response({
|
||||
@ -1109,6 +1118,8 @@ def admin_settings_view(request):
|
||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||
'announcement': config.announcement,
|
||||
'announcement_enabled': config.announcement_enabled,
|
||||
'max_desktop_sessions': config.max_desktop_sessions,
|
||||
'max_mobile_sessions': config.max_mobile_sessions,
|
||||
'updated_at': config.updated_at.isoformat(),
|
||||
})
|
||||
|
||||
@ -1542,3 +1553,214 @@ def profile_records_view(request):
|
||||
'page_size': page_size,
|
||||
'results': results,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Admin: Content Assets (hierarchical view)
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
def admin_assets_overview(request):
|
||||
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
|
||||
from apps.accounts.models import Team
|
||||
|
||||
teams = Team.objects.all().order_by('name')
|
||||
team_data = []
|
||||
total_videos = 0
|
||||
total_seconds = 0
|
||||
|
||||
for team in teams:
|
||||
team_records = GenerationRecord.objects.filter(
|
||||
user__team=team, status='completed'
|
||||
)
|
||||
video_count = team_records.count()
|
||||
seconds_consumed = team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||
total_videos += video_count
|
||||
total_seconds += seconds_consumed
|
||||
team_data.append({
|
||||
'id': team.id,
|
||||
'name': team.name,
|
||||
'video_count': video_count,
|
||||
'seconds_consumed': seconds_consumed,
|
||||
'member_count': team.members.count(),
|
||||
'is_active': team.is_active,
|
||||
})
|
||||
|
||||
# Also count videos from users without a team
|
||||
no_team_records = GenerationRecord.objects.filter(
|
||||
user__team__isnull=True, status='completed'
|
||||
)
|
||||
no_team_count = no_team_records.count()
|
||||
no_team_seconds = no_team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||
total_videos += no_team_count
|
||||
total_seconds += no_team_seconds
|
||||
|
||||
return Response({
|
||||
'total_videos': total_videos,
|
||||
'total_seconds': total_seconds,
|
||||
'total_teams': teams.count(),
|
||||
'teams': team_data,
|
||||
'no_team': {
|
||||
'video_count': no_team_count,
|
||||
'seconds_consumed': no_team_seconds,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
def admin_assets_team_members(request, team_id):
|
||||
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
|
||||
from apps.accounts.models import Team
|
||||
try:
|
||||
team = Team.objects.get(id=team_id)
|
||||
except Team.DoesNotExist:
|
||||
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
members = team.members.all().order_by('username')
|
||||
member_data = []
|
||||
total_videos = 0
|
||||
total_seconds = 0
|
||||
|
||||
for member in members:
|
||||
records = member.generation_records.filter(status='completed')
|
||||
video_count = records.count()
|
||||
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||
total_videos += video_count
|
||||
total_seconds += seconds_consumed
|
||||
member_data.append({
|
||||
'id': member.id,
|
||||
'username': member.username,
|
||||
'is_team_admin': member.is_team_admin,
|
||||
'video_count': video_count,
|
||||
'seconds_consumed': seconds_consumed,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'team_id': team.id,
|
||||
'team_name': team.name,
|
||||
'total_videos': total_videos,
|
||||
'total_seconds': total_seconds,
|
||||
'member_count': len(member_data),
|
||||
'members': member_data,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsSuperAdmin])
|
||||
def admin_assets_user_videos(request, user_id):
|
||||
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
|
||||
try:
|
||||
target_user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
page = int(request.query_params.get('page', 1))
|
||||
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
||||
|
||||
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
|
||||
total = qs.count()
|
||||
offset = (page - 1) * page_size
|
||||
records = _eval_qs(qs[offset:offset + page_size])
|
||||
|
||||
results = []
|
||||
for r in records:
|
||||
results.append({
|
||||
'id': r.id,
|
||||
'task_id': str(r.task_id),
|
||||
'prompt': r.prompt,
|
||||
'result_url': r.result_url or '',
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'user_id': target_user.id,
|
||||
'username': target_user.username,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'results': results,
|
||||
})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Team Admin: Content Assets
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsTeamAdmin])
|
||||
def team_assets_overview(request):
|
||||
"""GET /api/v1/team/assets/overview — Team stats + per-member video/seconds summary."""
|
||||
team = request.user.team
|
||||
members = team.members.all().order_by('username')
|
||||
member_data = []
|
||||
total_videos = 0
|
||||
total_seconds = 0
|
||||
|
||||
for member in members:
|
||||
records = member.generation_records.filter(status='completed')
|
||||
video_count = records.count()
|
||||
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||
total_videos += video_count
|
||||
total_seconds += seconds_consumed
|
||||
member_data.append({
|
||||
'id': member.id,
|
||||
'username': member.username,
|
||||
'is_team_admin': member.is_team_admin,
|
||||
'video_count': video_count,
|
||||
'seconds_consumed': seconds_consumed,
|
||||
})
|
||||
|
||||
return Response({
|
||||
'team_id': team.id,
|
||||
'team_name': team.name,
|
||||
'total_videos': total_videos,
|
||||
'total_seconds': total_seconds,
|
||||
'member_count': len(member_data),
|
||||
'members': member_data,
|
||||
})
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([IsTeamAdmin])
|
||||
def team_assets_member_videos(request, member_id):
|
||||
"""GET /api/v1/team/assets/member/<id>/videos — Completed videos for a team member (paginated)."""
|
||||
team = request.user.team
|
||||
try:
|
||||
member = team.members.get(id=member_id)
|
||||
except User.DoesNotExist:
|
||||
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
page = int(request.query_params.get('page', 1))
|
||||
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
||||
|
||||
qs = member.generation_records.filter(status='completed').order_by('-created_at')
|
||||
total = qs.count()
|
||||
offset = (page - 1) * page_size
|
||||
records = _eval_qs(qs[offset:offset + page_size])
|
||||
|
||||
results = []
|
||||
for r in records:
|
||||
results.append({
|
||||
'id': r.id,
|
||||
'task_id': str(r.task_id),
|
||||
'prompt': r.prompt,
|
||||
'result_url': r.result_url or '',
|
||||
'duration': r.duration,
|
||||
'seconds_consumed': r.seconds_consumed,
|
||||
'aspect_ratio': r.aspect_ratio,
|
||||
'created_at': r.created_at.isoformat(),
|
||||
})
|
||||
|
||||
return Response({
|
||||
'user_id': member.id,
|
||||
'username': member.username,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'page_size': page_size,
|
||||
'results': results,
|
||||
})
|
||||
|
||||
@ -110,7 +110,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
'apps.accounts.authentication.SessionJWTAuthentication',
|
||||
),
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
@ -132,8 +132,8 @@ REST_FRAMEWORK = {
|
||||
|
||||
# JWT settings
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
}
|
||||
|
||||
24
web/package-lock.json
generated
24
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -19,6 +19,8 @@ import { AssetsPage } from './pages/AssetsPage';
|
||||
import { TeamAdminLayout } from './pages/TeamAdminLayout';
|
||||
import { TeamDashboardPage } from './pages/TeamDashboardPage';
|
||||
import { TeamMembersPage } from './pages/TeamMembersPage';
|
||||
import { AdminAssetsPage } from './pages/AdminAssetsPage';
|
||||
import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
||||
|
||||
import { useAuthStore } from './store/auth';
|
||||
|
||||
@ -76,6 +78,7 @@ export default function App() {
|
||||
<Route path="records" element={<RecordsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="logs" element={<AuditLogsPage />} />
|
||||
<Route path="assets" element={<AdminAssetsPage />} />
|
||||
</Route>
|
||||
{/* Team Admin routes */}
|
||||
<Route
|
||||
@ -89,6 +92,7 @@ export default function App() {
|
||||
<Route index element={<Navigate to="/team/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="members" element={<TeamMembersPage />} />
|
||||
<Route path="assets" element={<TeamAssetsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
147
web/src/components/ForceChangePasswordModal.module.css
Normal file
147
web/src/components/ForceChangePasswordModal.module.css
Normal 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;
|
||||
}
|
||||
97
web/src/components/ForceChangePasswordModal.tsx
Normal file
97
web/src/components/ForceChangePasswordModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -47,6 +47,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const moreRef = useRef<HTMLDivElement>(null);
|
||||
const promptLineRef = useRef<HTMLDivElement>(null);
|
||||
const promptWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const labelsRef = useRef<HTMLSpanElement>(null);
|
||||
const [videoHover, setVideoHover] = useState(false);
|
||||
const [promptHover, setPromptHover] = useState(false);
|
||||
@ -55,6 +56,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [detailHover, setDetailHover] = useState(false);
|
||||
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
||||
const [promptAbove, setPromptAbove] = useState(false);
|
||||
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Close more menu on click outside
|
||||
@ -84,8 +86,8 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
// Measure labels width
|
||||
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
|
||||
|
||||
// Two lines of available width, minus labels on line 2
|
||||
const totalAvailable = containerWidth * 2 - labelsWidth;
|
||||
// Two lines of available width, minus labels on line 2, with safety margin
|
||||
const totalAvailable = containerWidth * 2 - labelsWidth - 24;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
@ -215,12 +217,20 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
{/* Right: prompt + inline labels */}
|
||||
<div className={styles.headerRight}>
|
||||
<div
|
||||
ref={promptWrapperRef}
|
||||
className={styles.promptWrapper}
|
||||
onMouseLeave={() => setPromptHover(false)}
|
||||
>
|
||||
<div ref={promptLineRef} className={styles.promptLine}>
|
||||
<span
|
||||
onMouseEnter={() => setPromptHover(true)}
|
||||
onMouseEnter={() => {
|
||||
const el = promptWrapperRef.current;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setPromptAbove(rect.bottom + 350 > window.innerHeight);
|
||||
}
|
||||
setPromptHover(true);
|
||||
}}
|
||||
>{truncatedPrompt || '(无文字描述)'}</span>
|
||||
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
|
||||
<span className={styles.label}>
|
||||
@ -270,7 +280,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
||||
</span>
|
||||
</div>
|
||||
{promptHover && task.prompt && (
|
||||
<div className={styles.promptTooltip}>
|
||||
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}>
|
||||
<p className={styles.promptTooltipText}>{task.prompt}</p>
|
||||
<button className={styles.copyBtn} onClick={handleCopyPrompt}>复制</button>
|
||||
</div>
|
||||
|
||||
@ -12,6 +12,7 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@ -33,6 +34,10 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (mustChangePassword) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
if (requireAdmin && user?.role !== 'super_admin') {
|
||||
return <Navigate to="/app" replace />;
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import styles from './VideoDetailModal.module.css';
|
||||
interface Props {
|
||||
task: GenerationTask | null;
|
||||
onClose: () => void;
|
||||
onReEdit: (id: string) => void;
|
||||
onRegenerate: (id: string) => void;
|
||||
onReEdit?: (id: string) => void;
|
||||
onRegenerate?: (id: string) => void;
|
||||
onDelete?: (id: string) => void;
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
@ -200,14 +200,14 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
};
|
||||
|
||||
const handleReEdit = () => {
|
||||
if (task) {
|
||||
if (task && onReEdit) {
|
||||
onReEdit(task.id);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (task) {
|
||||
if (task && onRegenerate) {
|
||||
onRegenerate(task.id);
|
||||
onClose();
|
||||
}
|
||||
@ -480,7 +480,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
||||
</div>
|
||||
|
||||
{(onReEdit || onRegenerate) && (
|
||||
<div className={styles.cardActions}>
|
||||
{onReEdit && (
|
||||
<button className={styles.cardBtn} onClick={handleReEdit}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
@ -488,6 +490,8 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
</svg>
|
||||
重新编辑
|
||||
</button>
|
||||
)}
|
||||
{onRegenerate && (
|
||||
<button className={styles.cardBtn} onClick={handleRegenerate}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
@ -495,7 +499,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
||||
</svg>
|
||||
再次生成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@ import type {
|
||||
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
|
||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
||||
AuditLog,
|
||||
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
||||
} from '../types';
|
||||
import { reportError } from './logCenter';
|
||||
|
||||
@ -31,6 +31,16 @@ api.interceptors.response.use(
|
||||
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
|
||||
|
||||
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
||||
// Check if session was kicked by another device login
|
||||
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
|
||||
if (errorCode === 'session_expired_other_device') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
alert('您的账号已在其他设备登录,请重新登录');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (refreshToken) {
|
||||
@ -203,6 +213,36 @@ export const adminApi = {
|
||||
updateSettings: (settings: SystemSettings) =>
|
||||
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
|
||||
|
||||
// Content Assets
|
||||
getAssetsOverview: () =>
|
||||
api.get<{
|
||||
total_videos: number;
|
||||
total_seconds: number;
|
||||
total_teams: number;
|
||||
teams: AssetTeamSummary[];
|
||||
no_team: { video_count: number; seconds_consumed: number };
|
||||
}>('/admin/assets/overview'),
|
||||
|
||||
getAssetsTeamMembers: (teamId: number) =>
|
||||
api.get<{
|
||||
team_id: number;
|
||||
team_name: string;
|
||||
total_videos: number;
|
||||
total_seconds: number;
|
||||
member_count: number;
|
||||
members: AssetMemberSummary[];
|
||||
}>(`/admin/assets/team/${teamId}/members`),
|
||||
|
||||
getAssetsUserVideos: (userId: number, page: number = 1, pageSize: number = 30) =>
|
||||
api.get<{
|
||||
user_id: number;
|
||||
username: string;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
results: AssetVideo[];
|
||||
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
|
||||
|
||||
getAuditLogs: (params: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
@ -239,6 +279,27 @@ export const teamApi = {
|
||||
|
||||
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
||||
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
|
||||
|
||||
// Content Assets
|
||||
getAssetsOverview: () =>
|
||||
api.get<{
|
||||
team_id: number;
|
||||
team_name: string;
|
||||
total_videos: number;
|
||||
total_seconds: number;
|
||||
member_count: number;
|
||||
members: AssetMemberSummary[];
|
||||
}>('/team/assets/overview'),
|
||||
|
||||
getAssetsMemberVideos: (memberId: number, page: number = 1, pageSize: number = 30) =>
|
||||
api.get<{
|
||||
user_id: number;
|
||||
username: string;
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
results: AssetVideo[];
|
||||
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
|
||||
};
|
||||
|
||||
// Profile APIs
|
||||
|
||||
155
web/src/pages/AdminAssetsPage.module.css
Normal file
155
web/src/pages/AdminAssetsPage.module.css
Normal 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;
|
||||
}
|
||||
222
web/src/pages/AdminAssetsPage.tsx
Normal file
222
web/src/pages/AdminAssetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ const navItems = [
|
||||
{ path: '/admin/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
||||
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
|
||||
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
||||
{ path: '/admin/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
|
||||
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
|
||||
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
|
||||
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
|
||||
|
||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { AuroraCanvas } from '../components/AuroraCanvas';
|
||||
import { LoginModal } from '../components/LoginModal';
|
||||
import { ForceChangePasswordModal } from '../components/ForceChangePasswordModal';
|
||||
import logoImg from '../assets/logo_512.png';
|
||||
import styles from './LandingPage.module.css';
|
||||
|
||||
@ -13,7 +14,9 @@ interface Props {
|
||||
export function LandingPage({ autoLogin }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showForceChange, setShowForceChange] = useState(false);
|
||||
const [showSpark, setShowSpark] = useState(false);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
@ -26,9 +29,20 @@ export function LandingPage({ autoLogin }: Props) {
|
||||
}
|
||||
}, [autoLogin, isAuthenticated]);
|
||||
|
||||
// Auto-show force change password modal if authenticated but must change
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && mustChangePassword) {
|
||||
setShowForceChange(true);
|
||||
}
|
||||
}, [isAuthenticated, mustChangePassword]);
|
||||
|
||||
const handleAirDrama = () => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/app');
|
||||
if (mustChangePassword) {
|
||||
setShowForceChange(true);
|
||||
} else {
|
||||
navigate('/app');
|
||||
}
|
||||
} else {
|
||||
setShowLogin(true);
|
||||
}
|
||||
@ -40,6 +54,15 @@ export function LandingPage({ autoLogin }: Props) {
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
setShowLogin(false);
|
||||
if (mustChangePassword) {
|
||||
setShowForceChange(true);
|
||||
} else {
|
||||
navigate('/app', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceChangeSuccess = () => {
|
||||
setShowForceChange(false);
|
||||
navigate('/app', { replace: true });
|
||||
};
|
||||
|
||||
@ -161,6 +184,11 @@ export function LandingPage({ autoLogin }: Props) {
|
||||
onClose={() => setShowLogin(false)}
|
||||
onSuccess={handleLoginSuccess}
|
||||
/>
|
||||
|
||||
{/* Force change password modal (unclosable) */}
|
||||
{showForceChange && (
|
||||
<ForceChangePasswordModal onSuccess={handleForceChangeSuccess} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
|
||||
.cardDesc { color: var(--color-text-secondary); font-size: 13px; margin-bottom: 20px; }
|
||||
.cardHint { color: var(--color-text-secondary); font-size: 12px; margin-bottom: 16px; margin-top: -4px; }
|
||||
|
||||
.formRow { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.formGroup { margin-bottom: 16px; }
|
||||
|
||||
@ -10,6 +10,8 @@ export function SettingsPage() {
|
||||
default_monthly_seconds_limit: 6000,
|
||||
announcement: '',
|
||||
announcement_enabled: false,
|
||||
max_desktop_sessions: 1,
|
||||
max_mobile_sessions: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@ -91,6 +93,35 @@ export function SettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.cardTitle}>登录设备限制</h2>
|
||||
<p className={styles.cardDesc}>限制每个用户在不同设备类型上的同时登录数量</p>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label>最大桌面端会话数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={settings.max_desktop_sessions}
|
||||
onChange={(e) => setSettings({ ...settings, max_desktop_sessions: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label>最大移动端会话数</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={settings.max_mobile_sessions}
|
||||
onChange={(e) => setSettings({ ...settings, max_mobile_sessions: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={styles.cardHint}>桌面端至少为 1,移动端设为 0 表示暂不启用移动端登录</p>
|
||||
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
||||
{saving ? '保存中...' : '保存登录限制'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardHeader}>
|
||||
<div>
|
||||
|
||||
@ -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() {
|
||||
|
||||
176
web/src/pages/TeamAssetsPage.tsx
Normal file
176
web/src/pages/TeamAssetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -11,12 +11,14 @@ interface AuthState {
|
||||
quota: Quota | null;
|
||||
team: TeamInfo | null;
|
||||
teamDisabled: boolean;
|
||||
mustChangePassword: boolean;
|
||||
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshAccessToken: () => Promise<void>;
|
||||
fetchUserInfo: () => Promise<void>;
|
||||
initialize: () => Promise<void>;
|
||||
clearMustChangePassword: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@ -28,6 +30,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
quota: null,
|
||||
team: null,
|
||||
teamDisabled: false,
|
||||
mustChangePassword: false,
|
||||
|
||||
login: async (username, password) => {
|
||||
const { data } = await authApi.login(username, password);
|
||||
@ -38,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
accessToken: data.tokens.access,
|
||||
refreshToken: data.tokens.refresh,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: data.user.must_change_password || false,
|
||||
});
|
||||
// Fetch quota after login
|
||||
await get().fetchUserInfo();
|
||||
@ -54,6 +58,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
quota: null,
|
||||
team: null,
|
||||
teamDisabled: false,
|
||||
mustChangePassword: false,
|
||||
});
|
||||
},
|
||||
|
||||
@ -75,6 +80,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
team: team || null,
|
||||
teamDisabled: team_disabled || false,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: user.must_change_password || false,
|
||||
});
|
||||
} catch {
|
||||
// Token invalid
|
||||
@ -93,4 +99,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
}
|
||||
set({ isLoading: false });
|
||||
},
|
||||
|
||||
clearMustChangePassword: () => {
|
||||
set({ mustChangePassword: false });
|
||||
},
|
||||
}));
|
||||
|
||||
@ -74,6 +74,7 @@ export interface User {
|
||||
is_team_admin: boolean;
|
||||
role: UserRole;
|
||||
team_name: string | null;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
export interface TeamInfo {
|
||||
@ -155,6 +156,8 @@ export interface SystemSettings {
|
||||
default_monthly_seconds_limit: number;
|
||||
announcement: string;
|
||||
announcement_enabled: boolean;
|
||||
max_desktop_sessions: number;
|
||||
max_mobile_sessions: number;
|
||||
}
|
||||
|
||||
export interface ProfileOverview {
|
||||
@ -218,6 +221,35 @@ export interface TeamStats {
|
||||
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
||||
}
|
||||
|
||||
// Asset management types
|
||||
export interface AssetTeamSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
video_count: number;
|
||||
seconds_consumed: number;
|
||||
member_count: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AssetMemberSummary {
|
||||
id: number;
|
||||
username: string;
|
||||
is_team_admin: boolean;
|
||||
video_count: number;
|
||||
seconds_consumed: number;
|
||||
}
|
||||
|
||||
export interface AssetVideo {
|
||||
id: number;
|
||||
task_id: string;
|
||||
prompt: string;
|
||||
result_url: string;
|
||||
duration: number;
|
||||
seconds_consumed: number;
|
||||
aspect_ratio: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
operator_name: string;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user