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/`)
|
### Auth (`/api/v1/auth/`)
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
|--------|----------|-------------|
|
||||||
| POST | `/api/v1/auth/register` | User registration |
|
| POST | `/api/v1/auth/register` | User registration (disabled) |
|
||||||
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens) |
|
| POST | `/api/v1/auth/login` | JWT login (returns access + refresh tokens, creates ActiveSession) |
|
||||||
| POST | `/api/v1/auth/token/refresh` | Refresh JWT access token |
|
| POST | `/api/v1/auth/token/refresh` | Refresh JWT access token |
|
||||||
| GET | `/api/v1/auth/me` | Get current user info |
|
| GET | `/api/v1/auth/me` | Get current user info + quota + team + must_change_password |
|
||||||
|
| POST | `/api/v1/auth/change-password` | Change own password (clears must_change_password) |
|
||||||
|
|
||||||
### Video Generation (`/api/v1/`)
|
### Video Generation (`/api/v1/`)
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -152,6 +153,15 @@ jimeng-clone/
|
|||||||
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
|
| PUT | `/api/v1/admin/teams/<id>/set-pool` | Directly set team total seconds pool |
|
||||||
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
| POST | `/api/v1/admin/teams/<id>/admin` | Create team admin user |
|
||||||
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
|
| GET | `/api/v1/admin/logs` | Audit logs (filter by action/operator/date) |
|
||||||
|
| GET | `/api/v1/admin/assets/overview` | Content assets: global stats + per-team summary |
|
||||||
|
| GET | `/api/v1/admin/assets/team/<id>/members` | Content assets: team members with video stats |
|
||||||
|
| GET | `/api/v1/admin/assets/user/<id>/videos` | Content assets: user's completed videos (paginated) |
|
||||||
|
|
||||||
|
### Team Admin Assets (`/api/v1/team/assets/`)
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/team/assets/overview` | Team stats + per-member video summary |
|
||||||
|
| GET | `/api/v1/team/assets/member/<id>/videos` | Member's completed videos (paginated) |
|
||||||
|
|
||||||
### Profile (`/api/v1/profile/`)
|
### Profile (`/api/v1/profile/`)
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
@ -163,6 +173,8 @@ jimeng-clone/
|
|||||||
|
|
||||||
### User (extends AbstractUser)
|
### User (extends AbstractUser)
|
||||||
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
- `email` (unique), `daily_seconds_limit` (default: 600), `monthly_seconds_limit` (default: 6000)
|
||||||
|
- `must_change_password` (default: True) — forces password change on first login
|
||||||
|
- `team` (FK to Team), `is_team_admin`
|
||||||
- `created_at`, `updated_at`
|
- `created_at`, `updated_at`
|
||||||
|
|
||||||
### GenerationRecord
|
### GenerationRecord
|
||||||
@ -176,9 +188,20 @@ jimeng-clone/
|
|||||||
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
|
- `target_type`, `target_id`, `target_name`, `before` (JSON), `after` (JSON)
|
||||||
- `ip_address`, `created_at` (indexed)
|
- `ip_address`, `created_at` (indexed)
|
||||||
|
|
||||||
|
### ActiveSession
|
||||||
|
- `user` (FK), `session_id` (UUID, unique), `device_type` (desktop|mobile|unknown)
|
||||||
|
- `user_agent`, `created_at`
|
||||||
|
- Used for concurrent session limiting via JWT session_id claim
|
||||||
|
|
||||||
|
### LoginRecord
|
||||||
|
- `user` (FK), `ip_address`, `user_agent`, `created_at` (indexed)
|
||||||
|
- Records every login for future anomaly detection
|
||||||
|
|
||||||
### QuotaConfig (Singleton, pk=1)
|
### QuotaConfig (Singleton, pk=1)
|
||||||
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
- `default_daily_seconds_limit`, `default_monthly_seconds_limit`
|
||||||
- `announcement`, `announcement_enabled`, `updated_at`
|
- `announcement`, `announcement_enabled`
|
||||||
|
- `max_desktop_sessions` (default: 1), `max_mobile_sessions` (default: 0)
|
||||||
|
- `updated_at`
|
||||||
|
|
||||||
## Frontend Routes
|
## Frontend Routes
|
||||||
|
|
||||||
@ -192,6 +215,8 @@ jimeng-clone/
|
|||||||
| `/admin/records` | RecordsPage | Admin | Generation records |
|
| `/admin/records` | RecordsPage | Admin | Generation records |
|
||||||
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
|
| `/admin/settings` | SettingsPage | Admin | Global quota & announcement |
|
||||||
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
|
| `/admin/logs` | AuditLogsPage | Admin | Admin operation audit logs |
|
||||||
|
| `/admin/assets` | AdminAssetsPage | Admin | Content assets (team→member→video hierarchy) |
|
||||||
|
| `/team/assets` | TeamAssetsPage | TeamAdmin | Team content assets (member→video hierarchy) |
|
||||||
|
|
||||||
## Incremental Development Guide
|
## Incremental Development Guide
|
||||||
|
|
||||||
@ -381,6 +406,11 @@ npx tsx src/index.ts --resume /Users/maidong/Desktop/zyc/研究openclaw/视频
|
|||||||
| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
|
| 2026-03-16 | v0.8.2: 管理后台 UI 修复 — DatePicker/Select 暗色主题、公告跑马灯、Toast 全局化、失败原因 tooltip | Full stack |
|
||||||
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack |
|
| 2026-03-16 | v0.8.3: 团队详情抽屉→弹窗重构(VideoDetailModal 规范) + 修改秒数池功能 + member_count 修复 | Full stack |
|
||||||
| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack |
|
| 2026-03-16 | v0.8.4: 管理员操作审计日志 — AdminAuditLog 模型 + 12 处埋点 + 日志查询页面 | Full stack |
|
||||||
|
| 2026-03-18 | v0.9.0: 首次登录强制改密 — must_change_password 字段 + ForceChangePasswordModal | Full stack |
|
||||||
|
| 2026-03-18 | v0.9.0: 并发会话限制 — ActiveSession + SessionJWT + 可配置桌面/移动端会话数 | Full stack |
|
||||||
|
| 2026-03-18 | v0.9.0: 登录记录 — LoginRecord 模型(IP + User-Agent)为异常检测打基础 | Backend |
|
||||||
|
| 2026-03-18 | v0.9.0: Token 生命周期缩短 — access 30min, refresh 1天 | Backend |
|
||||||
|
| 2026-03-18 | v0.9.0: 内容资产页 — 超管/团队管三级折叠式资产浏览(团队→成员→视频) | Full stack |
|
||||||
|
|
||||||
### Phase 4 Details (2026-03-13)
|
### Phase 4 Details (2026-03-13)
|
||||||
|
|
||||||
|
|||||||
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.contrib.auth.models import AbstractUser
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ class User(AbstractUser):
|
|||||||
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
is_team_admin = models.BooleanField(default=False, verbose_name='团队管理员')
|
||||||
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
daily_seconds_limit = models.IntegerField(default=600, verbose_name='每日秒数上限')
|
||||||
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='每月秒数上限')
|
||||||
|
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
updated_at = models.DateTimeField(auto_now=True, verbose_name='更新时间')
|
||||||
|
|
||||||
@ -98,9 +101,64 @@ class AdminAuditLog(models.Model):
|
|||||||
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
|
return f'{self.operator_name} - {self.get_action_display()} - {self.target_name}'
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveSession(models.Model):
|
||||||
|
"""活跃会话 — 用于并发登录设备限制。"""
|
||||||
|
DEVICE_TYPE_CHOICES = [
|
||||||
|
('desktop', '桌面端'),
|
||||||
|
('mobile', '移动端'),
|
||||||
|
('unknown', '未知'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='active_sessions', verbose_name='用户')
|
||||||
|
session_id = models.UUIDField(default=uuid.uuid4, unique=True, db_index=True, verbose_name='会话ID')
|
||||||
|
device_type = models.CharField(max_length=10, choices=DEVICE_TYPE_CHOICES, default='unknown', verbose_name='设备类型')
|
||||||
|
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '活跃会话'
|
||||||
|
verbose_name_plural = '活跃会话'
|
||||||
|
ordering = ['created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} - {self.device_type} - {self.session_id}'
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRecord(models.Model):
|
||||||
|
"""登录记录 — 为团队级异常检测打基础。"""
|
||||||
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='login_records', verbose_name='用户')
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True, verbose_name='IP地址')
|
||||||
|
user_agent = models.TextField(blank=True, default='', verbose_name='User-Agent')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='登录时间')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = '登录记录'
|
||||||
|
verbose_name_plural = '登录记录'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} - {self.ip_address} - {self.created_at}'
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request):
|
||||||
|
"""从请求中提取客户端 IP 地址。"""
|
||||||
|
return request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_device_type(user_agent):
|
||||||
|
"""根据 User-Agent 判断设备类型。"""
|
||||||
|
ua_lower = (user_agent or '').lower()
|
||||||
|
mobile_keywords = ['iphone', 'ipad', 'android', 'mobile', 'ipod', 'windows phone']
|
||||||
|
if any(kw in ua_lower for kw in mobile_keywords):
|
||||||
|
return 'mobile'
|
||||||
|
if ua_lower:
|
||||||
|
return 'desktop'
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
|
def log_admin_action(request, action, target_type, target_id=None, target_name='', before=None, after=None):
|
||||||
"""记录管理员操作日志"""
|
"""记录管理员操作日志"""
|
||||||
ip = request.META.get('HTTP_X_FORWARDED_FOR', '').split(',')[0].strip() or request.META.get('REMOTE_ADDR')
|
ip = get_client_ip(request)
|
||||||
AdminAuditLog.objects.create(
|
AdminAuditLog.objects.create(
|
||||||
operator=request.user,
|
operator=request.user,
|
||||||
operator_name=request.user.username,
|
operator_name=request.user.username,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name')
|
fields = ('id', 'username', 'email', 'is_staff', 'is_team_admin', 'role', 'team_name', 'must_change_password')
|
||||||
|
|
||||||
|
|
||||||
class RegisterSerializer(serializers.Serializer):
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
|||||||
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.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.throttling import ScopedRateThrottle
|
from rest_framework.throttling import ScopedRateThrottle
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
|
||||||
from django.contrib.auth import authenticate, get_user_model
|
from django.contrib.auth import authenticate, get_user_model
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
|
||||||
from .serializers import UserSerializer
|
from .serializers import UserSerializer
|
||||||
|
from .models import ActiveSession, LoginRecord, get_client_ip, parse_device_type
|
||||||
|
from .tokens import SessionRefreshToken
|
||||||
from django.contrib.auth.hashers import check_password
|
from django.contrib.auth.hashers import check_password
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@ -28,6 +29,36 @@ def register_view(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_session_limit(user, device_type):
|
||||||
|
"""Enforce concurrent session limits: remove oldest sessions if over limit."""
|
||||||
|
from apps.generation.models import QuotaConfig
|
||||||
|
config = QuotaConfig.objects.filter(pk=1).first()
|
||||||
|
if device_type == 'desktop':
|
||||||
|
max_sessions = config.max_desktop_sessions if config else 1
|
||||||
|
elif device_type == 'mobile':
|
||||||
|
max_sessions = config.max_mobile_sessions if config else 0
|
||||||
|
else:
|
||||||
|
max_sessions = 1
|
||||||
|
|
||||||
|
if max_sessions <= 0:
|
||||||
|
# 0 means no sessions allowed for this device type — but still allow login
|
||||||
|
# (treat as unlimited for unknown device types)
|
||||||
|
if device_type == 'unknown':
|
||||||
|
return
|
||||||
|
# For mobile with limit 0, still allow (no mobile enforcement yet)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = ActiveSession.objects.filter(
|
||||||
|
user=user, device_type=device_type
|
||||||
|
).order_by('created_at')
|
||||||
|
|
||||||
|
# If at or over limit, delete oldest sessions to make room for the new one
|
||||||
|
over_count = existing.count() - max_sessions + 1
|
||||||
|
if over_count > 0:
|
||||||
|
ids_to_remove = list(existing.values_list('id', flat=True)[:over_count])
|
||||||
|
ActiveSession.objects.filter(id__in=ids_to_remove).delete()
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
@permission_classes([AllowAny])
|
@permission_classes([AllowAny])
|
||||||
@throttle_classes([LoginRateThrottle])
|
@throttle_classes([LoginRateThrottle])
|
||||||
@ -53,7 +84,17 @@ def login_view(request):
|
|||||||
status=status.HTTP_401_UNAUTHORIZED
|
status=status.HTTP_401_UNAUTHORIZED
|
||||||
)
|
)
|
||||||
|
|
||||||
refresh = RefreshToken.for_user(user)
|
# Record login IP and User-Agent
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||||
|
LoginRecord.objects.create(user=user, ip_address=ip, user_agent=user_agent)
|
||||||
|
|
||||||
|
# Concurrent session management
|
||||||
|
device_type = parse_device_type(user_agent)
|
||||||
|
_enforce_session_limit(user, device_type)
|
||||||
|
session = ActiveSession.objects.create(user=user, device_type=device_type, user_agent=user_agent)
|
||||||
|
|
||||||
|
refresh = SessionRefreshToken.for_user_session(user, session.session_id)
|
||||||
return Response({
|
return Response({
|
||||||
'user': UserSerializer(user).data,
|
'user': UserSerializer(user).data,
|
||||||
'tokens': {
|
'tokens': {
|
||||||
@ -141,5 +182,9 @@ def change_password_view(request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
request.user.set_password(new_password)
|
request.user.set_password(new_password)
|
||||||
request.user.save()
|
request.user.must_change_password = False
|
||||||
return Response({'message': '密码修改成功'})
|
request.user.save(update_fields=['password', 'must_change_password'])
|
||||||
|
return Response({
|
||||||
|
'message': '密码修改成功',
|
||||||
|
'user': UserSerializer(request.user).data,
|
||||||
|
})
|
||||||
|
|||||||
@ -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='默认每月秒数上限')
|
default_monthly_seconds_limit = models.IntegerField(default=6000, verbose_name='默认每月秒数上限')
|
||||||
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
announcement = models.TextField(blank=True, default='', verbose_name='系统公告')
|
||||||
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
announcement_enabled = models.BooleanField(default=False, verbose_name='启用公告')
|
||||||
|
max_desktop_sessions = models.IntegerField(default=1, verbose_name='每用户最大桌面端会话数')
|
||||||
|
max_mobile_sessions = models.IntegerField(default=0, verbose_name='每用户最大移动端会话数')
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@ -33,6 +33,8 @@ class SystemSettingsSerializer(serializers.Serializer):
|
|||||||
default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
|
default_monthly_seconds_limit = serializers.IntegerField(min_value=0)
|
||||||
announcement = serializers.CharField(required=False, allow_blank=True, default='')
|
announcement = serializers.CharField(required=False, allow_blank=True, default='')
|
||||||
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
announcement_enabled = serializers.BooleanField(required=False, default=False)
|
||||||
|
max_desktop_sessions = serializers.IntegerField(min_value=1, required=False, default=1)
|
||||||
|
max_mobile_sessions = serializers.IntegerField(min_value=0, required=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
# ── Team serializers ──
|
# ── Team serializers ──
|
||||||
|
|||||||
@ -35,6 +35,11 @@ urlpatterns = [
|
|||||||
path('admin/settings', views.admin_settings_view, name='admin_settings'),
|
path('admin/settings', views.admin_settings_view, name='admin_settings'),
|
||||||
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
|
path('admin/logs', views.admin_audit_logs_view, name='admin_audit_logs'),
|
||||||
|
|
||||||
|
# ── Super Admin: Content Assets ──
|
||||||
|
path('admin/assets/overview', views.admin_assets_overview, name='admin_assets_overview'),
|
||||||
|
path('admin/assets/team/<int:team_id>/members', views.admin_assets_team_members, name='admin_assets_team_members'),
|
||||||
|
path('admin/assets/user/<int:user_id>/videos', views.admin_assets_user_videos, name='admin_assets_user_videos'),
|
||||||
|
|
||||||
# ── Team Admin: Team management ──
|
# ── Team Admin: Team management ──
|
||||||
path('team/info', views.team_info_view, name='team_info'),
|
path('team/info', views.team_info_view, name='team_info'),
|
||||||
path('team/stats', views.team_stats_view, name='team_stats'),
|
path('team/stats', views.team_stats_view, name='team_stats'),
|
||||||
@ -44,6 +49,10 @@ urlpatterns = [
|
|||||||
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
|
path('team/members/<int:member_id>/quota', views.team_member_quota_view, name='team_member_quota'),
|
||||||
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
|
path('team/members/<int:member_id>/status', views.team_member_status_view, name='team_member_status'),
|
||||||
|
|
||||||
|
# ── Team Admin: Content Assets ──
|
||||||
|
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'),
|
||||||
|
path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
|
||||||
|
|
||||||
# ── Profile: User's own data ──
|
# ── Profile: User's own data ──
|
||||||
path('profile/overview', views.profile_overview_view, name='profile_overview'),
|
path('profile/overview', views.profile_overview_view, name='profile_overview'),
|
||||||
path('profile/records', views.profile_records_view, name='profile_records'),
|
path('profile/records', views.profile_records_view, name='profile_records'),
|
||||||
|
|||||||
@ -964,7 +964,8 @@ def admin_reset_password_view(request, user_id):
|
|||||||
return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'error': '密码至少8位'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save()
|
user.must_change_password = True
|
||||||
|
user.save(update_fields=['password', 'must_change_password'])
|
||||||
log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
|
log_admin_action(request, 'user_password_reset', 'user', target_id=user.id, target_name=user.username)
|
||||||
|
|
||||||
return Response({'message': f'已重置 {user.username} 的密码'})
|
return Response({'message': f'已重置 {user.username} 的密码'})
|
||||||
@ -1079,6 +1080,8 @@ def admin_settings_view(request):
|
|||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||||
'announcement': config.announcement,
|
'announcement': config.announcement,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'announcement_enabled': config.announcement_enabled,
|
||||||
|
'max_desktop_sessions': config.max_desktop_sessions,
|
||||||
|
'max_mobile_sessions': config.max_mobile_sessions,
|
||||||
})
|
})
|
||||||
|
|
||||||
serializer = SystemSettingsSerializer(data=request.data)
|
serializer = SystemSettingsSerializer(data=request.data)
|
||||||
@ -1089,11 +1092,15 @@ def admin_settings_view(request):
|
|||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||||
'announcement': config.announcement,
|
'announcement': config.announcement,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'announcement_enabled': config.announcement_enabled,
|
||||||
|
'max_desktop_sessions': config.max_desktop_sessions,
|
||||||
|
'max_mobile_sessions': config.max_mobile_sessions,
|
||||||
}
|
}
|
||||||
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
|
config.default_daily_seconds_limit = serializer.validated_data['default_daily_seconds_limit']
|
||||||
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
|
config.default_monthly_seconds_limit = serializer.validated_data['default_monthly_seconds_limit']
|
||||||
config.announcement = serializer.validated_data.get('announcement', '')
|
config.announcement = serializer.validated_data.get('announcement', '')
|
||||||
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
|
config.announcement_enabled = serializer.validated_data.get('announcement_enabled', False)
|
||||||
|
config.max_desktop_sessions = serializer.validated_data.get('max_desktop_sessions', 1)
|
||||||
|
config.max_mobile_sessions = serializer.validated_data.get('max_mobile_sessions', 0)
|
||||||
config.save()
|
config.save()
|
||||||
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
|
log_admin_action(request, 'settings_update', 'settings', target_name='系统设置',
|
||||||
before=before,
|
before=before,
|
||||||
@ -1102,6 +1109,8 @@ def admin_settings_view(request):
|
|||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||||
'announcement': config.announcement,
|
'announcement': config.announcement,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'announcement_enabled': config.announcement_enabled,
|
||||||
|
'max_desktop_sessions': config.max_desktop_sessions,
|
||||||
|
'max_mobile_sessions': config.max_mobile_sessions,
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@ -1109,6 +1118,8 @@ def admin_settings_view(request):
|
|||||||
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
'default_monthly_seconds_limit': config.default_monthly_seconds_limit,
|
||||||
'announcement': config.announcement,
|
'announcement': config.announcement,
|
||||||
'announcement_enabled': config.announcement_enabled,
|
'announcement_enabled': config.announcement_enabled,
|
||||||
|
'max_desktop_sessions': config.max_desktop_sessions,
|
||||||
|
'max_mobile_sessions': config.max_mobile_sessions,
|
||||||
'updated_at': config.updated_at.isoformat(),
|
'updated_at': config.updated_at.isoformat(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1542,3 +1553,214 @@ def profile_records_view(request):
|
|||||||
'page_size': page_size,
|
'page_size': page_size,
|
||||||
'results': results,
|
'results': results,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Admin: Content Assets (hierarchical view)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_assets_overview(request):
|
||||||
|
"""GET /api/v1/admin/assets/overview — Global stats + per-team video/seconds summary."""
|
||||||
|
from apps.accounts.models import Team
|
||||||
|
|
||||||
|
teams = Team.objects.all().order_by('name')
|
||||||
|
team_data = []
|
||||||
|
total_videos = 0
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
|
for team in teams:
|
||||||
|
team_records = GenerationRecord.objects.filter(
|
||||||
|
user__team=team, status='completed'
|
||||||
|
)
|
||||||
|
video_count = team_records.count()
|
||||||
|
seconds_consumed = team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||||
|
total_videos += video_count
|
||||||
|
total_seconds += seconds_consumed
|
||||||
|
team_data.append({
|
||||||
|
'id': team.id,
|
||||||
|
'name': team.name,
|
||||||
|
'video_count': video_count,
|
||||||
|
'seconds_consumed': seconds_consumed,
|
||||||
|
'member_count': team.members.count(),
|
||||||
|
'is_active': team.is_active,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Also count videos from users without a team
|
||||||
|
no_team_records = GenerationRecord.objects.filter(
|
||||||
|
user__team__isnull=True, status='completed'
|
||||||
|
)
|
||||||
|
no_team_count = no_team_records.count()
|
||||||
|
no_team_seconds = no_team_records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||||
|
total_videos += no_team_count
|
||||||
|
total_seconds += no_team_seconds
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'total_videos': total_videos,
|
||||||
|
'total_seconds': total_seconds,
|
||||||
|
'total_teams': teams.count(),
|
||||||
|
'teams': team_data,
|
||||||
|
'no_team': {
|
||||||
|
'video_count': no_team_count,
|
||||||
|
'seconds_consumed': no_team_seconds,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_assets_team_members(request, team_id):
|
||||||
|
"""GET /api/v1/admin/assets/team/<id>/members — Members of a team with video/seconds stats."""
|
||||||
|
from apps.accounts.models import Team
|
||||||
|
try:
|
||||||
|
team = Team.objects.get(id=team_id)
|
||||||
|
except Team.DoesNotExist:
|
||||||
|
return Response({'error': '团队不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
members = team.members.all().order_by('username')
|
||||||
|
member_data = []
|
||||||
|
total_videos = 0
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
records = member.generation_records.filter(status='completed')
|
||||||
|
video_count = records.count()
|
||||||
|
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||||
|
total_videos += video_count
|
||||||
|
total_seconds += seconds_consumed
|
||||||
|
member_data.append({
|
||||||
|
'id': member.id,
|
||||||
|
'username': member.username,
|
||||||
|
'is_team_admin': member.is_team_admin,
|
||||||
|
'video_count': video_count,
|
||||||
|
'seconds_consumed': seconds_consumed,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'team_id': team.id,
|
||||||
|
'team_name': team.name,
|
||||||
|
'total_videos': total_videos,
|
||||||
|
'total_seconds': total_seconds,
|
||||||
|
'member_count': len(member_data),
|
||||||
|
'members': member_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsSuperAdmin])
|
||||||
|
def admin_assets_user_videos(request, user_id):
|
||||||
|
"""GET /api/v1/admin/assets/user/<id>/videos — Completed videos for a user (paginated)."""
|
||||||
|
try:
|
||||||
|
target_user = User.objects.get(id=user_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
page = int(request.query_params.get('page', 1))
|
||||||
|
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
||||||
|
|
||||||
|
qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
|
||||||
|
total = qs.count()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
records = _eval_qs(qs[offset:offset + page_size])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in records:
|
||||||
|
results.append({
|
||||||
|
'id': r.id,
|
||||||
|
'task_id': str(r.task_id),
|
||||||
|
'prompt': r.prompt,
|
||||||
|
'result_url': r.result_url or '',
|
||||||
|
'duration': r.duration,
|
||||||
|
'seconds_consumed': r.seconds_consumed,
|
||||||
|
'aspect_ratio': r.aspect_ratio,
|
||||||
|
'created_at': r.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'user_id': target_user.id,
|
||||||
|
'username': target_user.username,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'results': results,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Team Admin: Content Assets
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsTeamAdmin])
|
||||||
|
def team_assets_overview(request):
|
||||||
|
"""GET /api/v1/team/assets/overview — Team stats + per-member video/seconds summary."""
|
||||||
|
team = request.user.team
|
||||||
|
members = team.members.all().order_by('username')
|
||||||
|
member_data = []
|
||||||
|
total_videos = 0
|
||||||
|
total_seconds = 0
|
||||||
|
|
||||||
|
for member in members:
|
||||||
|
records = member.generation_records.filter(status='completed')
|
||||||
|
video_count = records.count()
|
||||||
|
seconds_consumed = records.aggregate(total=Sum('seconds_consumed'))['total'] or 0
|
||||||
|
total_videos += video_count
|
||||||
|
total_seconds += seconds_consumed
|
||||||
|
member_data.append({
|
||||||
|
'id': member.id,
|
||||||
|
'username': member.username,
|
||||||
|
'is_team_admin': member.is_team_admin,
|
||||||
|
'video_count': video_count,
|
||||||
|
'seconds_consumed': seconds_consumed,
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'team_id': team.id,
|
||||||
|
'team_name': team.name,
|
||||||
|
'total_videos': total_videos,
|
||||||
|
'total_seconds': total_seconds,
|
||||||
|
'member_count': len(member_data),
|
||||||
|
'members': member_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsTeamAdmin])
|
||||||
|
def team_assets_member_videos(request, member_id):
|
||||||
|
"""GET /api/v1/team/assets/member/<id>/videos — Completed videos for a team member (paginated)."""
|
||||||
|
team = request.user.team
|
||||||
|
try:
|
||||||
|
member = team.members.get(id=member_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
page = int(request.query_params.get('page', 1))
|
||||||
|
page_size = min(int(request.query_params.get('page_size', 30)), 100)
|
||||||
|
|
||||||
|
qs = member.generation_records.filter(status='completed').order_by('-created_at')
|
||||||
|
total = qs.count()
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
records = _eval_qs(qs[offset:offset + page_size])
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in records:
|
||||||
|
results.append({
|
||||||
|
'id': r.id,
|
||||||
|
'task_id': str(r.task_id),
|
||||||
|
'prompt': r.prompt,
|
||||||
|
'result_url': r.result_url or '',
|
||||||
|
'duration': r.duration,
|
||||||
|
'seconds_consumed': r.seconds_consumed,
|
||||||
|
'aspect_ratio': r.aspect_ratio,
|
||||||
|
'created_at': r.created_at.isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'user_id': member.id,
|
||||||
|
'username': member.username,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'results': results,
|
||||||
|
})
|
||||||
|
|||||||
@ -110,7 +110,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# REST Framework
|
# REST Framework
|
||||||
REST_FRAMEWORK = {
|
REST_FRAMEWORK = {
|
||||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
'apps.accounts.authentication.SessionJWTAuthentication',
|
||||||
),
|
),
|
||||||
'DEFAULT_PERMISSION_CLASSES': (
|
'DEFAULT_PERMISSION_CLASSES': (
|
||||||
'rest_framework.permissions.IsAuthenticated',
|
'rest_framework.permissions.IsAuthenticated',
|
||||||
@ -132,8 +132,8 @@ REST_FRAMEWORK = {
|
|||||||
|
|
||||||
# JWT settings
|
# JWT settings
|
||||||
SIMPLE_JWT = {
|
SIMPLE_JWT = {
|
||||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
|
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
|
||||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
|
||||||
'ROTATE_REFRESH_TOKENS': False,
|
'ROTATE_REFRESH_TOKENS': False,
|
||||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
}
|
}
|
||||||
|
|||||||
24
web/package-lock.json
generated
24
web/package-lock.json
generated
@ -172,7 +172,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@ -534,7 +533,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
},
|
},
|
||||||
@ -575,7 +573,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
@ -1565,7 +1562,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
@ -1667,7 +1665,6 @@
|
|||||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prop-types": "*",
|
"@types/prop-types": "*",
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
@ -1679,7 +1676,6 @@
|
|||||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
@ -1862,6 +1858,7 @@
|
|||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@ -1872,6 +1869,7 @@
|
|||||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
@ -1971,7 +1969,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@ -2231,7 +2228,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
@ -2710,7 +2708,6 @@
|
|||||||
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
"integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@acemir/cssom": "^0.9.31",
|
"@acemir/cssom": "^0.9.31",
|
||||||
"@asamuzakjp/dom-selector": "^6.8.1",
|
"@asamuzakjp/dom-selector": "^6.8.1",
|
||||||
@ -2806,6 +2803,7 @@
|
|||||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"lz-string": "bin/bin.js"
|
"lz-string": "bin/bin.js"
|
||||||
}
|
}
|
||||||
@ -2959,7 +2957,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@ -3049,6 +3046,7 @@
|
|||||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1",
|
"ansi-regex": "^5.0.1",
|
||||||
"ansi-styles": "^5.0.0",
|
"ansi-styles": "^5.0.0",
|
||||||
@ -3063,7 +3061,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
@ -3103,7 +3102,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -3128,7 +3126,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@ -3624,7 +3621,6 @@
|
|||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|||||||
@ -19,6 +19,8 @@ import { AssetsPage } from './pages/AssetsPage';
|
|||||||
import { TeamAdminLayout } from './pages/TeamAdminLayout';
|
import { TeamAdminLayout } from './pages/TeamAdminLayout';
|
||||||
import { TeamDashboardPage } from './pages/TeamDashboardPage';
|
import { TeamDashboardPage } from './pages/TeamDashboardPage';
|
||||||
import { TeamMembersPage } from './pages/TeamMembersPage';
|
import { TeamMembersPage } from './pages/TeamMembersPage';
|
||||||
|
import { AdminAssetsPage } from './pages/AdminAssetsPage';
|
||||||
|
import { TeamAssetsPage } from './pages/TeamAssetsPage';
|
||||||
|
|
||||||
import { useAuthStore } from './store/auth';
|
import { useAuthStore } from './store/auth';
|
||||||
|
|
||||||
@ -76,6 +78,7 @@ export default function App() {
|
|||||||
<Route path="records" element={<RecordsPage />} />
|
<Route path="records" element={<RecordsPage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="logs" element={<AuditLogsPage />} />
|
<Route path="logs" element={<AuditLogsPage />} />
|
||||||
|
<Route path="assets" element={<AdminAssetsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* Team Admin routes */}
|
{/* Team Admin routes */}
|
||||||
<Route
|
<Route
|
||||||
@ -89,6 +92,7 @@ export default function App() {
|
|||||||
<Route index element={<Navigate to="/team/dashboard" replace />} />
|
<Route index element={<Navigate to="/team/dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<TeamDashboardPage />} />
|
<Route path="dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="members" element={<TeamMembersPage />} />
|
<Route path="members" element={<TeamMembersPage />} />
|
||||||
|
<Route path="assets" element={<TeamAssetsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
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;
|
line-height: 1.6;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
max-height: calc(1.6em * 2);
|
max-height: calc(1.6em * 2);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.promptTooltip {
|
.promptTooltip {
|
||||||
@ -97,6 +98,18 @@
|
|||||||
to { opacity: 1; transform: translateY(0); }
|
to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.promptTooltipAbove {
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
animation: tooltipFadeInAbove 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tooltipFadeInAbove {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.promptTooltipText {
|
.promptTooltipText {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const moreRef = useRef<HTMLDivElement>(null);
|
const moreRef = useRef<HTMLDivElement>(null);
|
||||||
const promptLineRef = useRef<HTMLDivElement>(null);
|
const promptLineRef = useRef<HTMLDivElement>(null);
|
||||||
|
const promptWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const labelsRef = useRef<HTMLSpanElement>(null);
|
const labelsRef = useRef<HTMLSpanElement>(null);
|
||||||
const [videoHover, setVideoHover] = useState(false);
|
const [videoHover, setVideoHover] = useState(false);
|
||||||
const [promptHover, setPromptHover] = useState(false);
|
const [promptHover, setPromptHover] = useState(false);
|
||||||
@ -55,6 +56,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
const [detailHover, setDetailHover] = useState(false);
|
const [detailHover, setDetailHover] = useState(false);
|
||||||
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
const [detailPos, setDetailPos] = useState({ top: 0, right: 0 });
|
||||||
|
const [promptAbove, setPromptAbove] = useState(false);
|
||||||
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
const detailLinkRef = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
// Close more menu on click outside
|
// Close more menu on click outside
|
||||||
@ -84,8 +86,8 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
// Measure labels width
|
// Measure labels width
|
||||||
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
|
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
|
||||||
|
|
||||||
// Two lines of available width, minus labels on line 2
|
// Two lines of available width, minus labels on line 2, with safety margin
|
||||||
const totalAvailable = containerWidth * 2 - labelsWidth;
|
const totalAvailable = containerWidth * 2 - labelsWidth - 24;
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d')!;
|
||||||
@ -215,12 +217,20 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
{/* Right: prompt + inline labels */}
|
{/* Right: prompt + inline labels */}
|
||||||
<div className={styles.headerRight}>
|
<div className={styles.headerRight}>
|
||||||
<div
|
<div
|
||||||
|
ref={promptWrapperRef}
|
||||||
className={styles.promptWrapper}
|
className={styles.promptWrapper}
|
||||||
onMouseLeave={() => setPromptHover(false)}
|
onMouseLeave={() => setPromptHover(false)}
|
||||||
>
|
>
|
||||||
<div ref={promptLineRef} className={styles.promptLine}>
|
<div ref={promptLineRef} className={styles.promptLine}>
|
||||||
<span
|
<span
|
||||||
onMouseEnter={() => setPromptHover(true)}
|
onMouseEnter={() => {
|
||||||
|
const el = promptWrapperRef.current;
|
||||||
|
if (el) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
setPromptAbove(rect.bottom + 350 > window.innerHeight);
|
||||||
|
}
|
||||||
|
setPromptHover(true);
|
||||||
|
}}
|
||||||
>{truncatedPrompt || '(无文字描述)'}</span>
|
>{truncatedPrompt || '(无文字描述)'}</span>
|
||||||
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
|
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
|
||||||
<span className={styles.label}>
|
<span className={styles.label}>
|
||||||
@ -270,7 +280,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{promptHover && task.prompt && (
|
{promptHover && task.prompt && (
|
||||||
<div className={styles.promptTooltip}>
|
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}>
|
||||||
<p className={styles.promptTooltipText}>{task.prompt}</p>
|
<p className={styles.promptTooltipText}>{task.prompt}</p>
|
||||||
<button className={styles.copyBtn} onClick={handleCopyPrompt}>复制</button>
|
<button className={styles.copyBtn} onClick={handleCopyPrompt}>复制</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
|||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const isLoading = useAuthStore((s) => s.isLoading);
|
const isLoading = useAuthStore((s) => s.isLoading);
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -33,6 +34,10 @@ export function ProtectedRoute({ children, requireAdmin, requireTeamAdmin, requi
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mustChangePassword) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
if (requireAdmin && user?.role !== 'super_admin') {
|
if (requireAdmin && user?.role !== 'super_admin') {
|
||||||
return <Navigate to="/app" replace />;
|
return <Navigate to="/app" replace />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import styles from './VideoDetailModal.module.css';
|
|||||||
interface Props {
|
interface Props {
|
||||||
task: GenerationTask | null;
|
task: GenerationTask | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onReEdit: (id: string) => void;
|
onReEdit?: (id: string) => void;
|
||||||
onRegenerate: (id: string) => void;
|
onRegenerate?: (id: string) => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
onPrev?: () => void;
|
onPrev?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
@ -200,14 +200,14 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReEdit = () => {
|
const handleReEdit = () => {
|
||||||
if (task) {
|
if (task && onReEdit) {
|
||||||
onReEdit(task.id);
|
onReEdit(task.id);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRegenerate = () => {
|
const handleRegenerate = () => {
|
||||||
if (task) {
|
if (task && onRegenerate) {
|
||||||
onRegenerate(task.id);
|
onRegenerate(task.id);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
@ -480,7 +480,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
<span>{task.aspectRatio === 'adaptive' ? '自适应' : task.aspectRatio}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(onReEdit || onRegenerate) && (
|
||||||
<div className={styles.cardActions}>
|
<div className={styles.cardActions}>
|
||||||
|
{onReEdit && (
|
||||||
<button className={styles.cardBtn} onClick={handleReEdit}>
|
<button className={styles.cardBtn} onClick={handleReEdit}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
@ -488,6 +490,8 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
</svg>
|
</svg>
|
||||||
重新编辑
|
重新编辑
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{onRegenerate && (
|
||||||
<button className={styles.cardBtn} onClick={handleRegenerate}>
|
<button className={styles.cardBtn} onClick={handleRegenerate}>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
<polyline points="23 4 23 10 17 10" />
|
<polyline points="23 4 23 10 17 10" />
|
||||||
@ -495,7 +499,9 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
|
|||||||
</svg>
|
</svg>
|
||||||
再次生成
|
再次生成
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type {
|
|||||||
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
|
User, Quota, AuthTokens, AdminStats, AdminUser, AdminUserDetail,
|
||||||
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
|
||||||
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
|
||||||
AuditLog,
|
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { reportError } from './logCenter';
|
import { reportError } from './logCenter';
|
||||||
|
|
||||||
@ -31,6 +31,16 @@ api.interceptors.response.use(
|
|||||||
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
|
const isAuthEndpoint = authEndpoints.some(ep => requestUrl.includes(ep));
|
||||||
|
|
||||||
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
if (error.response?.status === 401 && !originalRequest._retry && !isAuthEndpoint) {
|
||||||
|
// Check if session was kicked by another device login
|
||||||
|
const errorCode = error.response?.data?.code || error.response?.data?.detail?.code;
|
||||||
|
if (errorCode === 'session_expired_other_device') {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
alert('您的账号已在其他设备登录,请重新登录');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
@ -203,6 +213,36 @@ export const adminApi = {
|
|||||||
updateSettings: (settings: SystemSettings) =>
|
updateSettings: (settings: SystemSettings) =>
|
||||||
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
|
api.put<SystemSettings & { updated_at: string }>('/admin/settings', settings),
|
||||||
|
|
||||||
|
// Content Assets
|
||||||
|
getAssetsOverview: () =>
|
||||||
|
api.get<{
|
||||||
|
total_videos: number;
|
||||||
|
total_seconds: number;
|
||||||
|
total_teams: number;
|
||||||
|
teams: AssetTeamSummary[];
|
||||||
|
no_team: { video_count: number; seconds_consumed: number };
|
||||||
|
}>('/admin/assets/overview'),
|
||||||
|
|
||||||
|
getAssetsTeamMembers: (teamId: number) =>
|
||||||
|
api.get<{
|
||||||
|
team_id: number;
|
||||||
|
team_name: string;
|
||||||
|
total_videos: number;
|
||||||
|
total_seconds: number;
|
||||||
|
member_count: number;
|
||||||
|
members: AssetMemberSummary[];
|
||||||
|
}>(`/admin/assets/team/${teamId}/members`),
|
||||||
|
|
||||||
|
getAssetsUserVideos: (userId: number, page: number = 1, pageSize: number = 30) =>
|
||||||
|
api.get<{
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
results: AssetVideo[];
|
||||||
|
}>(`/admin/assets/user/${userId}/videos`, { params: { page, page_size: pageSize } }),
|
||||||
|
|
||||||
getAuditLogs: (params: {
|
getAuditLogs: (params: {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
@ -239,6 +279,27 @@ export const teamApi = {
|
|||||||
|
|
||||||
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
updateMemberStatus: (memberId: number, isActive: boolean) =>
|
||||||
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
|
api.patch(`/team/members/${memberId}/status`, { is_active: isActive }),
|
||||||
|
|
||||||
|
// Content Assets
|
||||||
|
getAssetsOverview: () =>
|
||||||
|
api.get<{
|
||||||
|
team_id: number;
|
||||||
|
team_name: string;
|
||||||
|
total_videos: number;
|
||||||
|
total_seconds: number;
|
||||||
|
member_count: number;
|
||||||
|
members: AssetMemberSummary[];
|
||||||
|
}>('/team/assets/overview'),
|
||||||
|
|
||||||
|
getAssetsMemberVideos: (memberId: number, page: number = 1, pageSize: number = 30) =>
|
||||||
|
api.get<{
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
results: AssetVideo[];
|
||||||
|
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Profile APIs
|
// Profile APIs
|
||||||
|
|||||||
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/dashboard', label: '仪表盘', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
||||||
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
|
{ path: '/admin/teams', label: '团队管理', icon: 'M12 7V3H2v18h20V7H12zM6 19H4v-2h2v2zm0-4H4v-2h2v2zm0-4H4V9h2v2zm0-4H4V5h2v2zm4 12H8v-2h2v2zm0-4H8v-2h2v2zm0-4H8V9h2v2zm0-4H8V5h2v2zm10 12h-8v-2h2v-2h-2v-2h2v-2h-2V9h8v10zm-2-8h-2v2h2v-2zm0 4h-2v2h2v-2z' },
|
||||||
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
{ path: '/admin/users', label: '用户管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
||||||
|
{ path: '/admin/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
|
||||||
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
|
{ path: '/admin/records', label: '消费记录', icon: 'M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-2 10h-4v4h-2v-4H7v-2h4V7h2v4h4v2z' },
|
||||||
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
|
{ path: '/admin/settings', label: '系统设置', icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.488.488 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z' },
|
||||||
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
|
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { AuroraCanvas } from '../components/AuroraCanvas';
|
import { AuroraCanvas } from '../components/AuroraCanvas';
|
||||||
import { LoginModal } from '../components/LoginModal';
|
import { LoginModal } from '../components/LoginModal';
|
||||||
|
import { ForceChangePasswordModal } from '../components/ForceChangePasswordModal';
|
||||||
import logoImg from '../assets/logo_512.png';
|
import logoImg from '../assets/logo_512.png';
|
||||||
import styles from './LandingPage.module.css';
|
import styles from './LandingPage.module.css';
|
||||||
|
|
||||||
@ -13,7 +14,9 @@ interface Props {
|
|||||||
export function LandingPage({ autoLogin }: Props) {
|
export function LandingPage({ autoLogin }: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
|
const mustChangePassword = useAuthStore((s) => s.mustChangePassword);
|
||||||
const [showLogin, setShowLogin] = useState(false);
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
const [showForceChange, setShowForceChange] = useState(false);
|
||||||
const [showSpark, setShowSpark] = useState(false);
|
const [showSpark, setShowSpark] = useState(false);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
@ -26,9 +29,20 @@ export function LandingPage({ autoLogin }: Props) {
|
|||||||
}
|
}
|
||||||
}, [autoLogin, isAuthenticated]);
|
}, [autoLogin, isAuthenticated]);
|
||||||
|
|
||||||
|
// Auto-show force change password modal if authenticated but must change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && mustChangePassword) {
|
||||||
|
setShowForceChange(true);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, mustChangePassword]);
|
||||||
|
|
||||||
const handleAirDrama = () => {
|
const handleAirDrama = () => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
navigate('/app');
|
if (mustChangePassword) {
|
||||||
|
setShowForceChange(true);
|
||||||
|
} else {
|
||||||
|
navigate('/app');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
}
|
}
|
||||||
@ -40,6 +54,15 @@ export function LandingPage({ autoLogin }: Props) {
|
|||||||
|
|
||||||
const handleLoginSuccess = () => {
|
const handleLoginSuccess = () => {
|
||||||
setShowLogin(false);
|
setShowLogin(false);
|
||||||
|
if (mustChangePassword) {
|
||||||
|
setShowForceChange(true);
|
||||||
|
} else {
|
||||||
|
navigate('/app', { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForceChangeSuccess = () => {
|
||||||
|
setShowForceChange(false);
|
||||||
navigate('/app', { replace: true });
|
navigate('/app', { replace: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -161,6 +184,11 @@ export function LandingPage({ autoLogin }: Props) {
|
|||||||
onClose={() => setShowLogin(false)}
|
onClose={() => setShowLogin(false)}
|
||||||
onSuccess={handleLoginSuccess}
|
onSuccess={handleLoginSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Force change password modal (unclosable) */}
|
||||||
|
{showForceChange && (
|
||||||
|
<ForceChangePasswordModal onSuccess={handleForceChangeSuccess} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
|
.cardHeader { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
|
.cardTitle { font-size: 16px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 4px; }
|
||||||
.cardDesc { color: var(--color-text-secondary); font-size: 13px; margin-bottom: 20px; }
|
.cardDesc { color: var(--color-text-secondary); font-size: 13px; margin-bottom: 20px; }
|
||||||
|
.cardHint { color: var(--color-text-secondary); font-size: 12px; margin-bottom: 16px; margin-top: -4px; }
|
||||||
|
|
||||||
.formRow { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
.formRow { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||||
.formGroup { margin-bottom: 16px; }
|
.formGroup { margin-bottom: 16px; }
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export function SettingsPage() {
|
|||||||
default_monthly_seconds_limit: 6000,
|
default_monthly_seconds_limit: 6000,
|
||||||
announcement: '',
|
announcement: '',
|
||||||
announcement_enabled: false,
|
announcement_enabled: false,
|
||||||
|
max_desktop_sessions: 1,
|
||||||
|
max_mobile_sessions: 0,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@ -91,6 +93,35 @@ export function SettingsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.card}>
|
||||||
|
<h2 className={styles.cardTitle}>登录设备限制</h2>
|
||||||
|
<p className={styles.cardDesc}>限制每个用户在不同设备类型上的同时登录数量</p>
|
||||||
|
<div className={styles.formRow}>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>最大桌面端会话数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={settings.max_desktop_sessions}
|
||||||
|
onChange={(e) => setSettings({ ...settings, max_desktop_sessions: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.formGroup}>
|
||||||
|
<label>最大移动端会话数</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={settings.max_mobile_sessions}
|
||||||
|
onChange={(e) => setSettings({ ...settings, max_mobile_sessions: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className={styles.cardHint}>桌面端至少为 1,移动端设为 0 表示暂不启用移动端登录</p>
|
||||||
|
<button className={styles.saveBtn} onClick={handleSaveQuota} disabled={saving}>
|
||||||
|
{saving ? '保存中...' : '保存登录限制'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.cardHeader}>
|
<div className={styles.cardHeader}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
|
||||||
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
|
||||||
|
{ path: '/team/assets', label: '内容资产', icon: 'M4 6H2v14c0 1.1.9 2 2 2h14v-2H4V6zm16-4H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8 12.5v-9l6 4.5-6 4.5z' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TeamAdminLayout() {
|
export function TeamAdminLayout() {
|
||||||
|
|||||||
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;
|
quota: Quota | null;
|
||||||
team: TeamInfo | null;
|
team: TeamInfo | null;
|
||||||
teamDisabled: boolean;
|
teamDisabled: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
refreshAccessToken: () => Promise<void>;
|
refreshAccessToken: () => Promise<void>;
|
||||||
fetchUserInfo: () => Promise<void>;
|
fetchUserInfo: () => Promise<void>;
|
||||||
initialize: () => Promise<void>;
|
initialize: () => Promise<void>;
|
||||||
|
clearMustChangePassword: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
@ -28,6 +30,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
quota: null,
|
quota: null,
|
||||||
team: null,
|
team: null,
|
||||||
teamDisabled: false,
|
teamDisabled: false,
|
||||||
|
mustChangePassword: false,
|
||||||
|
|
||||||
login: async (username, password) => {
|
login: async (username, password) => {
|
||||||
const { data } = await authApi.login(username, password);
|
const { data } = await authApi.login(username, password);
|
||||||
@ -38,6 +41,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
accessToken: data.tokens.access,
|
accessToken: data.tokens.access,
|
||||||
refreshToken: data.tokens.refresh,
|
refreshToken: data.tokens.refresh,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
mustChangePassword: data.user.must_change_password || false,
|
||||||
});
|
});
|
||||||
// Fetch quota after login
|
// Fetch quota after login
|
||||||
await get().fetchUserInfo();
|
await get().fetchUserInfo();
|
||||||
@ -54,6 +58,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
quota: null,
|
quota: null,
|
||||||
team: null,
|
team: null,
|
||||||
teamDisabled: false,
|
teamDisabled: false,
|
||||||
|
mustChangePassword: false,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -75,6 +80,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
team: team || null,
|
team: team || null,
|
||||||
teamDisabled: team_disabled || false,
|
teamDisabled: team_disabled || false,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
mustChangePassword: user.must_change_password || false,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Token invalid
|
// Token invalid
|
||||||
@ -93,4 +99,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearMustChangePassword: () => {
|
||||||
|
set({ mustChangePassword: false });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -74,6 +74,7 @@ export interface User {
|
|||||||
is_team_admin: boolean;
|
is_team_admin: boolean;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
team_name: string | null;
|
team_name: string | null;
|
||||||
|
must_change_password: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamInfo {
|
export interface TeamInfo {
|
||||||
@ -155,6 +156,8 @@ export interface SystemSettings {
|
|||||||
default_monthly_seconds_limit: number;
|
default_monthly_seconds_limit: number;
|
||||||
announcement: string;
|
announcement: string;
|
||||||
announcement_enabled: boolean;
|
announcement_enabled: boolean;
|
||||||
|
max_desktop_sessions: number;
|
||||||
|
max_mobile_sessions: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileOverview {
|
export interface ProfileOverview {
|
||||||
@ -218,6 +221,35 @@ export interface TeamStats {
|
|||||||
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
member_consumption: { user_id: number; username: string; seconds_consumed: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Asset management types
|
||||||
|
export interface AssetTeamSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
video_count: number;
|
||||||
|
seconds_consumed: number;
|
||||||
|
member_count: number;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetMemberSummary {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
is_team_admin: boolean;
|
||||||
|
video_count: number;
|
||||||
|
seconds_consumed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssetVideo {
|
||||||
|
id: number;
|
||||||
|
task_id: string;
|
||||||
|
prompt: string;
|
||||||
|
result_url: string;
|
||||||
|
duration: number;
|
||||||
|
seconds_consumed: number;
|
||||||
|
aspect_ratio: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: number;
|
id: number;
|
||||||
operator_name: string;
|
operator_name: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user