Compare commits

..

9 Commits

Author SHA1 Message Date
seaislee1209
aa538443b6 feat: v0.12.3 种子值支持 + UI 修复
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m10s
①Seed 种子值全链路(后端传入/保存火山返回的seed/API返回,详情弹窗显示)
②前端种子值控件暂禁用(样式待调整)
③空页面文案改为品牌彩蛋 Every frame was once just air.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:22:22 +08:00
seaislee1209
493b30c6b9 fix: source map 禁用 + MD5 改 SHA256
①vite build sourcemap: false,防止源码泄露
②tos_client.py 文件去重哈希从 MD5 改为 SHA256

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:58:52 +08:00
seaislee1209
9a6a8c964a fix: S6 错误信息泄露修复 — str(e) 改为通用中文提示
4处直接返回给用户的 str(e) 改为通用提示,详细错误仅记日志

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:55:58 +08:00
seaislee1209
c381784207 feat: v0.12.2 收藏功能 + UI 修复
①视频收藏(is_favorited + toggle API + 卡片/详情页收藏按钮 + 资产页「我的收藏」筛选)
②联网搜索按钮永久禁用(待开放)
③音频标签加音符符号,hover 不弹预览
④轮询完成后自动更新 token/费用(不用刷新页面)
⑤超管/团管内容资产页视频详情加上下切换箭头

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:36:20 +08:00
seaislee1209
afcff9455f feat: v0.12.1 安全加固补充 + 短信测试按钮
①Refresh Token 轮换(ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION)
②前端 token 刷新时保存新 refresh token(auth store + axios 拦截器)
③短信告警测试按钮(/admin/test-sms + 系统设置页按钮)
④安全审查完成:S2 git 历史无泄露、S4 无攻击面、S7 nginx 已配、S10 全接口有权限

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:38:42 +08:00
seaislee1209
203603f69a feat: v0.12.0 用户总额度 + 并发控制 + 团管消费记录 + 安全加固
①用户总消费额度(User.spending_limit,默认-1不限,花完即停,含冻结中任务)
②团队并发任务控制(Team.max_concurrent_tasks,默认5,超限拒绝)
③额度检查竞态修复(Layer 1-4 全部移入 transaction.atomic + select_for_update)
④查询参数类型保护(_safe_int 替换所有裸 int() 调用,防 500)
⑤团管消费记录页(/team/records,按用户/日期筛选 + CSV 导出)
⑥超管用户页/团管成员页新增总额度列和编辑
⑦超管团队页新增并发列和内联编辑
⑧失败原因 tooltip 改右对齐防裁剪

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:53:56 +08:00
seaislee1209
6a5ddbaf78 feat: v0.11.2 图片缩略图优化 + 素材库修复 + UI 细节
图片缩略图优化:
- 新增 tosThumb() 工具函数,TOS 图片按显示尺寸 2x 加载缩略图
- 所有小图(任务卡片、mention 标签、hover 预览、素材库、输入栏参考图)全部走缩略图
- 原图仅在 ImageLightbox 大图预览和提交生成时使用
- tosThumb 只匹配 airdrama-media 桶,不影响火山内部桶 URL

素材库修复:
- 旧数据图片从火山桶同步到我们 TOS 桶(一次性脚本)
- 素材详情页图片支持点击看大图(ImageLightbox)
- 弹窗高度固定 85vh,三个视图高度一致
- 列表页点击图片进素材组,不触发预览
- 视频敏感内容错误码映射补充

UI 细节:
- 任务卡片参考图 hover 预览(上方弹出)
- 详细信息弹窗延迟关闭(鼠标可移到弹窗上)
- 删除@后 mention 弹窗自动关闭
- 导航箭头禁用时不触发关闭弹窗

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 16:13:04 +08:00
seaislee1209
328cbc147d fix: v0.11.1 隐藏 AirDrama Fast 选项(火山未开通)
- Toolbar modelItems 注释掉 Fast 选项,用户只能选标准版
- 外部团队测试时选 Fast 会报 model not found 错误

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 03:29:54 +08:00
seaislee1209
6c364f4c3f feat: v0.11.0 素材库功能 + 生成页面 UI 优化
素材库(虚拟人像):
- 后端:AssetGroup/Asset 模型 + 火山 Assets API 客户端 + 7 个 API 端点
- 前端:素材库管理弹窗(上传/浏览/追加/改名/状态轮询)
- PromptInput:@ 搜索素材库 + mention 标签(缩略图+名字)
- 提交生成时提取 asset:// 引用并去重
- 打开素材详情时自动检查云端状态,已删除的自动清理
- 后端 reference_snapshots 存储 thumb_url,刷新后标签缩略图和 hover 预览正常

生成页面 UI:
- 提示词 hover 即梦风格:原位展开玻璃底覆盖视频,不弹浮层
- 标签(AirDrama/时长/比例)inline 排列,溢出时 canvas 截断
- 详细信息弹窗支持鼠标移上去不消失(延迟关闭),增加 token/费用信息
- 任务卡片/视频详情页提示词标签化(renderPromptWithMentions)
- 视频详情页底部去掉重复按钮,信息栏 flex-wrap 自动换行

mention 标签:
- 输入框内剪切/复制粘贴保留标签(handlePaste 检测 text/html)
- 拖拽标签跟手(caretRangeFromPoint + drop 位置精确插入)
- 拖拽时 hover 预览自动关闭,InputBar 蓝边仅外部文件拖入时触发

其他:
- 联网搜索按钮(暂禁用,等火山确认 API)
- card max-width 800→1024,参考图缩略图 48→56px 居中对齐
- 导航箭头禁用时不触发关闭(去掉 pointer-events:none)
- API 错误信息附带原始报错便于排查

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 03:11:05 +08:00
49 changed files with 3663 additions and 329 deletions

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-22 10:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0010_billing_data_migration'),
]
operations = [
migrations.AddField(
model_name='team',
name='max_concurrent_tasks',
field=models.IntegerField(default=5, verbose_name='最大并发任务数'),
),
migrations.AddField(
model_name='user',
name='spending_limit',
field=models.DecimalField(decimal_places=2, default=-1, max_digits=12, verbose_name='用户总消费额度(元)'),
),
]

View File

@ -18,6 +18,7 @@ class Team(models.Model):
daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)') daily_member_spending_default = models.DecimalField(max_digits=12, decimal_places=2, default=50, verbose_name='新成员默认每日消费限额(元)')
frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)') frozen_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0, verbose_name='冻结金额(元)')
markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比') markup_percentage = models.DecimalField(max_digits=5, decimal_places=2, default=0, verbose_name='加价百分比')
max_concurrent_tasks = models.IntegerField(default=5, verbose_name='最大并发任务数')
is_active = models.BooleanField(default=True, verbose_name='启用状态') is_active = models.BooleanField(default=True, verbose_name='启用状态')
expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)') expected_regions = models.CharField(max_length=500, blank=True, default='', verbose_name='预期登录城市(逗号分隔)')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源') disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
@ -55,6 +56,7 @@ class User(AbstractUser):
# ── 次数限额v0.10.0 新增) ── # ── 次数限额v0.10.0 新增) ──
daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限') daily_generation_limit = models.IntegerField(default=50, verbose_name='每日生成次数上限')
monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限') monthly_generation_limit = models.IntegerField(default=1500, verbose_name='每月生成次数上限')
spending_limit = models.DecimalField(max_digits=12, decimal_places=2, default=-1, verbose_name='用户总消费额度(元)')
must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码') must_change_password = models.BooleanField(default=True, verbose_name='必须修改密码')
disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源') disabled_by = models.CharField(max_length=10, blank=True, default='', verbose_name='禁用来源')
created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') created_at = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-03-21 09:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('accounts', '0010_billing_data_migration'),
('generation', '0007_billing_system_v010'),
]
operations = [
migrations.CreateModel(
name='AssetGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('remote_group_id', models.CharField(default='', max_length=100, verbose_name='火山Group ID')),
('name', models.CharField(default='', max_length=100, verbose_name='角色名')),
('description', models.CharField(blank=True, default='', max_length=300, verbose_name='描述')),
('thumbnail_url', models.CharField(blank=True, default='', max_length=1000, verbose_name='缩略图URL')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_asset_groups', to=settings.AUTH_USER_MODEL, verbose_name='创建人')),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='asset_groups', to='accounts.team', verbose_name='所属团队')),
],
options={
'verbose_name': '素材组',
'verbose_name_plural': '素材组',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Asset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('remote_asset_id', models.CharField(default='', max_length=100, verbose_name='火山Asset ID')),
('name', models.CharField(default='', max_length=100, verbose_name='素材名称')),
('url', models.CharField(blank=True, default='', max_length=1000, verbose_name='图片URL')),
('status', models.CharField(choices=[('processing', '处理中'), ('active', '可用'), ('failed', '失败')], default='processing', max_length=20, verbose_name='状态')),
('error_message', models.CharField(blank=True, default='', max_length=500, verbose_name='错误信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='generation.assetgroup', verbose_name='所属素材组')),
],
options={
'verbose_name': '素材',
'verbose_name_plural': '素材',
'ordering': ['-created_at'],
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-22 11:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0008_asset_library'),
]
operations = [
migrations.AddField(
model_name='generationrecord',
name='is_favorited',
field=models.BooleanField(default=False, verbose_name='已收藏'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-03-22 14:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('generation', '0009_generationrecord_is_favorited'),
]
operations = [
migrations.AddField(
model_name='generationrecord',
name='seed',
field=models.BigIntegerField(default=-1, verbose_name='种子值'),
),
]

View File

@ -44,6 +44,8 @@ class GenerationRecord(models.Model):
result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL') result_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='生成结果URL')
error_message = models.TextField(blank=True, default='', verbose_name='错误信息') error_message = models.TextField(blank=True, default='', verbose_name='错误信息')
reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息') reference_urls = models.JSONField(default=list, blank=True, verbose_name='参考素材信息')
is_favorited = models.BooleanField(default=False, verbose_name='已收藏')
seed = models.BigIntegerField(default=-1, verbose_name='种子值')
created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间') created_at = models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')
class Meta: class Meta:
@ -99,3 +101,56 @@ class QuotaConfig(models.Model):
def __str__(self): def __str__(self):
return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月' return f'全局配额: {self.default_daily_seconds_limit}s/日, {self.default_monthly_seconds_limit}s/月'
class AssetGroup(models.Model):
"""虚拟人像素材组 — 一个角色对应一个组。"""
team = models.ForeignKey(
'accounts.Team', on_delete=models.CASCADE,
related_name='asset_groups', verbose_name='所属团队',
)
remote_group_id = models.CharField(max_length=100, default='', verbose_name='火山Group ID')
name = models.CharField(max_length=100, default='', verbose_name='角色名')
description = models.CharField(max_length=300, blank=True, default='', verbose_name='描述')
thumbnail_url = models.CharField(max_length=1000, blank=True, default='', verbose_name='缩略图URL')
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL,
null=True, blank=True, related_name='created_asset_groups', verbose_name='创建人',
)
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.team.name} - {self.name}'
class Asset(models.Model):
"""虚拟人像素材 — 单张图片。"""
STATUS_CHOICES = [
('processing', '处理中'),
('active', '可用'),
('failed', '失败'),
]
group = models.ForeignKey(
AssetGroup, on_delete=models.CASCADE,
related_name='assets', verbose_name='所属素材组',
)
remote_asset_id = models.CharField(max_length=100, default='', verbose_name='火山Asset ID')
name = models.CharField(max_length=100, default='', verbose_name='素材名称')
url = models.CharField(max_length=1000, blank=True, default='', verbose_name='图片URL')
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='processing', verbose_name='状态')
error_message = models.CharField(max_length=500, blank=True, default='', verbose_name='错误信息')
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.group.name} - {self.name}'

View File

@ -13,6 +13,7 @@ class VideoGenerateSerializer(serializers.Serializer):
class QuotaUpdateSerializer(serializers.Serializer): class QuotaUpdateSerializer(serializers.Serializer):
daily_generation_limit = serializers.IntegerField(min_value=-1) daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1) monthly_generation_limit = serializers.IntegerField(min_value=-1)
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
class UserStatusSerializer(serializers.Serializer): class UserStatusSerializer(serializers.Serializer):
@ -68,6 +69,7 @@ class TeamCreateSerializer(serializers.Serializer):
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True) markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=True)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1) monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=-1)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50) daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, default=50)
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False, default=5)
expected_regions = serializers.CharField(max_length=500, required=True) expected_regions = serializers.CharField(max_length=500, required=True)
@ -78,6 +80,7 @@ class TeamUpdateSerializer(serializers.Serializer):
markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False) markup_percentage = serializers.DecimalField(max_digits=5, decimal_places=2, min_value=0, required=False)
monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False) monthly_spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False) daily_member_spending_default = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)
max_concurrent_tasks = serializers.IntegerField(min_value=0, required=False)
is_active = serializers.BooleanField(required=False) is_active = serializers.BooleanField(required=False)
expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True) expected_regions = serializers.CharField(max_length=500, required=False, allow_blank=True)
@ -121,3 +124,4 @@ class TeamMemberCreateSerializer(serializers.Serializer):
class MemberQuotaSerializer(serializers.Serializer): class MemberQuotaSerializer(serializers.Serializer):
daily_generation_limit = serializers.IntegerField(min_value=-1) daily_generation_limit = serializers.IntegerField(min_value=-1)
monthly_generation_limit = serializers.IntegerField(min_value=-1) monthly_generation_limit = serializers.IntegerField(min_value=-1)
spending_limit = serializers.DecimalField(max_digits=12, decimal_places=2, required=False)

View File

@ -8,6 +8,7 @@ urlpatterns = [
path('video/generate', views.video_generate_view, name='video_generate'), path('video/generate', views.video_generate_view, name='video_generate'),
path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'), path('video/tasks', views.video_tasks_list_view, name='video_tasks_list'),
path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'), path('video/tasks/<uuid:task_id>', views.video_task_detail_view, name='video_task_detail'),
path('video/tasks/<uuid:task_id>/favorite', views.video_task_toggle_favorite_view, name='video_task_toggle_favorite'),
# Public announcement # Public announcement
path('announcement', views.announcement_view, name='announcement'), path('announcement', views.announcement_view, name='announcement'),
@ -35,9 +36,13 @@ 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: Login Records ──
path('admin/login-records', views.admin_login_records_view, name='admin_login_records'),
# ── Super Admin: Anomaly Detection ── # ── Super Admin: Anomaly Detection ──
path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'), path('admin/anomalies', views.admin_login_anomalies_view, name='admin_login_anomalies'),
path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'), path('admin/test-feishu', views.admin_test_feishu_view, name='admin_test_feishu'),
path('admin/test-sms', views.admin_test_sms_view, name='admin_test_sms'),
path('admin/teams/<int:team_id>/auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'), path('admin/teams/<int:team_id>/auto-learn', views.admin_team_auto_learn_view, name='admin_team_auto_learn'),
path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'), path('admin/teams/<int:team_id>/apply-learned-regions', views.admin_team_apply_learned_regions_view, name='admin_team_apply_learned_regions'),
@ -55,6 +60,9 @@ 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: Consumption Records ──
path('team/records', views.team_records_view, name='team_records'),
# ── Team Admin: Content Assets ── # ── Team Admin: Content Assets ──
path('team/assets/overview', views.team_assets_overview, name='team_assets_overview'), 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'), path('team/assets/member/<int:member_id>/videos', views.team_assets_member_videos, name='team_assets_member_videos'),
@ -62,4 +70,12 @@ urlpatterns = [
# ── 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'),
# ── Assets API (Virtual Avatar Library) ──
path('assets/groups', views.asset_groups_view, name='asset_groups'),
path('assets/groups/<int:group_id>', views.asset_group_detail_view, name='asset_group_detail'),
path('assets/groups/<int:group_id>/assets', views.asset_group_add_asset_view, name='asset_group_add_asset'),
path('assets/<int:asset_id>', views.asset_update_view, name='asset_update'),
path('assets/<int:asset_id>/status', views.asset_poll_status_view, name='asset_poll_status'),
path('assets/search', views.asset_search_view, name='asset_search'),
] ]

View File

@ -13,7 +13,7 @@ from django.db.models.functions import TruncDate
from django.db.utils import OperationalError as DbOperationalError from django.db.utils import OperationalError as DbOperationalError
from datetime import timedelta from datetime import timedelta
from .models import GenerationRecord, QuotaConfig from .models import GenerationRecord, QuotaConfig, AssetGroup, Asset
from .serializers import ( from .serializers import (
VideoGenerateSerializer, QuotaUpdateSerializer, VideoGenerateSerializer, QuotaUpdateSerializer,
UserStatusSerializer, SystemSettingsSerializer, UserStatusSerializer, SystemSettingsSerializer,
@ -22,7 +22,7 @@ from .serializers import (
TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer, TeamAdminCreateSerializer, TeamMemberCreateSerializer, MemberQuotaSerializer,
TeamAnomalyConfigSerializer, TeamAnomalyConfigSerializer,
) )
from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession from apps.accounts.models import Team, AdminAuditLog, log_admin_action, TeamAnomalyConfig, LoginAnomaly, ActiveSession, LoginRecord
from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember from apps.accounts.permissions import IsSuperAdmin, IsTeamAdmin, IsTeamMember
from utils.tos_client import upload_file as tos_upload from utils.tos_client import upload_file as tos_upload
from utils.airdrama_client import create_task, query_task, extract_video_url, map_status from utils.airdrama_client import create_task, query_task, extract_video_url, map_status
@ -39,6 +39,15 @@ MAX_IMAGE_SIZE = 30 * 1024 * 1024 # 30MB
MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB MAX_VIDEO_SIZE = 50 * 1024 * 1024 # 50MB
MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB MAX_AUDIO_SIZE = 15 * 1024 * 1024 # 15MB
def _safe_int(value, default=0):
"""Safely convert query parameter to int, returning default on failure."""
try:
return int(value)
except (TypeError, ValueError):
return default
# Columns added in migration 0003; may not exist in production DB yet. # Columns added in migration 0003; may not exist in production DB yet.
_M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls') _M0003_COLS = ('ark_task_id', 'result_url', 'error_message', 'reference_urls')
_m0003_ok = None # None = unknown, True = columns exist, False = missing _m0003_ok = None # None = unknown, True = columns exist, False = missing
@ -112,7 +121,7 @@ def upload_media_view(request):
except Exception as e: except Exception as e:
logger.exception('TOS upload failed') logger.exception('TOS upload failed')
return Response( return Response(
{'error': f'文件上传失败: {str(e)}'}, {'error': '文件上传失败,请稍后重试'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR, status=status.HTTP_500_INTERNAL_SERVER_ERROR,
) )
@ -152,6 +161,8 @@ def video_generate_view(request):
mode = serializer.validated_data['mode'] mode = serializer.validated_data['mode']
model = serializer.validated_data['model'] model = serializer.validated_data['model']
aspect_ratio = serializer.validated_data['aspect_ratio'] aspect_ratio = serializer.validated_data['aspect_ratio']
search_mode = request.data.get('search_mode', 'off')
seed = _safe_int(request.data.get('seed', -1), -1)
# ── 预估 token 和费用 ── # ── 预估 token 和费用 ──
config = QuotaConfig.objects.get_or_create(pk=1)[0] config = QuotaConfig.objects.get_or_create(pk=1)[0]
@ -159,7 +170,11 @@ def video_generate_view(request):
estimated_tokens = estimate_tokens(w, h, duration) estimated_tokens = estimate_tokens(w, h, duration)
estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage) estimated_cost = calculate_cost(estimated_tokens, config.base_token_price, team.markup_percentage)
# ── Layer 1: 用户每日生成次数限额 (skip if -1) ── # ── 所有额度检查在 transaction 内完成select_for_update 串行化同团队请求 ──
with transaction.atomic():
locked_team = Team.objects.select_for_update().get(pk=team.pk)
# Layer 1: 用户每日生成次数限额 (skip if -1)
if user.daily_generation_limit != -1: if user.daily_generation_limit != -1:
daily_count = user.generation_records.filter(created_at__date=today).count() daily_count = user.generation_records.filter(created_at__date=today).count()
if daily_count >= user.daily_generation_limit: if daily_count >= user.daily_generation_limit:
@ -173,7 +188,7 @@ def video_generate_view(request):
).isoformat(), ).isoformat(),
}, status=status.HTTP_429_TOO_MANY_REQUESTS) }, status=status.HTTP_429_TOO_MANY_REQUESTS)
# ── Layer 2: 用户每月生成次数限额 (skip if -1) ── # Layer 2: 用户每月生成次数限额 (skip if -1)
if user.monthly_generation_limit != -1: if user.monthly_generation_limit != -1:
monthly_count = user.generation_records.filter( monthly_count = user.generation_records.filter(
created_at__date__gte=first_of_month created_at__date__gte=first_of_month
@ -186,9 +201,32 @@ def video_generate_view(request):
'monthly_generation_used': monthly_count, 'monthly_generation_used': monthly_count,
}, status=status.HTTP_429_TOO_MANY_REQUESTS) }, status=status.HTTP_429_TOO_MANY_REQUESTS)
# ── Layer 3 & 4: 团队余额检查 + 冻结 (atomic with row lock) ── # Layer 2.5: 用户总消费额度 (skip if -1)
with transaction.atomic(): from decimal import Decimal
locked_team = Team.objects.select_for_update().get(pk=team.pk) if user.spending_limit != Decimal('-1'):
total_spent = GenerationRecord.objects.filter(
user=user,
status__in=['completed', 'processing', 'queued'],
).aggregate(total=Sum('cost_amount'))['total'] or Decimal('0')
if total_spent + estimated_cost > user.spending_limit:
return Response({
'error': 'spending_limit_exceeded',
'message': f'您的总消费已达上限(¥{user.spending_limit}),请联系管理员',
'spending_limit': float(user.spending_limit),
'total_spent': float(total_spent),
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 2.6: 团队并发限制
if locked_team.max_concurrent_tasks > 0:
processing_count = GenerationRecord.objects.filter(
user__team=locked_team,
status__in=['queued', 'processing'],
).count()
if processing_count >= locked_team.max_concurrent_tasks:
return Response({
'error': 'concurrent_limit',
'message': f'团队当前有 {processing_count} 个任务正在处理,已达并发上限 {processing_count}/{locked_team.max_concurrent_tasks}',
}, status=status.HTTP_429_TOO_MANY_REQUESTS)
# Layer 3: 团队月消费限额 # Layer 3: 团队月消费限额
if locked_team.monthly_spending_limit != -1: if locked_team.monthly_spending_limit != -1:
@ -220,29 +258,59 @@ def video_generate_view(request):
references = request.data.get('references', []) references = request.data.get('references', [])
reference_snapshots = [] reference_snapshots = []
content_items = [] content_items = []
seen_urls = set() # 去重:同一个素材只引用一次
from .models import Asset as AssetModel
for ref in references: for ref in references:
url = ref.get('url', '') url = ref.get('url', '')
original_url = url # 保留原始 URL 用于 reference_snapshots
ref_type = ref.get('type', 'image') ref_type = ref.get('type', 'image')
role = ref.get('role', '') role = ref.get('role', '')
label = ref.get('label', '') label = ref.get('label', '')
reference_snapshots.append({ # 跳过重复 URL — 在所有操作之前判断
'url': url, 'type': ref_type, 'role': role, 'label': label, if original_url in seen_urls:
}) continue
seen_urls.add(original_url)
# 快照存原始 URL前端重建 reEdit 需要 asset://group-{id} 格式)
snap = {'url': original_url, 'type': ref_type, 'role': role, 'label': label}
thumb_url = ref.get('thumb_url', '')
if thumb_url:
snap['thumb_url'] = thumb_url
reference_snapshots.append(snap)
# 转换 asset://group-{id} 为火山 Asset://Asset-xxx 格式(仅用于 content_items
resolved_url = url
if url.startswith('asset://group-'):
try:
group_id = int(url.replace('asset://group-', ''))
first_asset = AssetModel.objects.filter(
group_id=group_id, status='active'
).first()
if first_asset and first_asset.remote_asset_id:
aid = first_asset.remote_asset_id
if aid.startswith('asset-'):
aid = 'Asset-' + aid[6:]
resolved_url = f'Asset://{aid}'
else:
logger.warning('No active asset found for group %s', group_id)
except (ValueError, Exception) as e:
logger.warning('Failed to resolve asset group URL %s: %s', url, e)
if ref_type == 'image': if ref_type == 'image':
item = {'type': 'image_url', 'image_url': {'url': url}} item = {'type': 'image_url', 'image_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'video': elif ref_type == 'video':
item = {'type': 'video_url', 'video_url': {'url': url}} item = {'type': 'video_url', 'video_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
elif ref_type == 'audio': elif ref_type == 'audio':
item = {'type': 'audio_url', 'audio_url': {'url': url}} item = {'type': 'audio_url', 'audio_url': {'url': resolved_url}}
if role: if role:
item['role'] = role item['role'] = role
content_items.append(item) content_items.append(item)
@ -262,6 +330,7 @@ def video_generate_view(request):
cost_amount=0, cost_amount=0,
base_cost_amount=0, base_cost_amount=0,
reference_urls=reference_snapshots, reference_urls=reference_snapshots,
seed=seed,
) )
locked_team.frozen_amount = F('frozen_amount') + estimated_cost locked_team.frozen_amount = F('frozen_amount') + estimated_cost
@ -278,6 +347,8 @@ def video_generate_view(request):
content_items=content_items, content_items=content_items,
aspect_ratio=aspect_ratio, aspect_ratio=aspect_ratio,
duration=duration, duration=duration,
search_mode=search_mode,
seed=seed,
) )
ark_task_id = ark_response.get('id', '') ark_task_id = ark_response.get('id', '')
record.ark_task_id = ark_task_id record.ark_task_id = ark_task_id
@ -369,8 +440,8 @@ def video_tasks_list_view(request):
offset: Number of tasks to skip (default 0). offset: Number of tasks to skip (default 0).
""" """
user = request.user user = request.user
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
offset = max(int(request.query_params.get('offset', 0)), 0) offset = max(_safe_int(request.query_params.get('offset', 0), 0), 0)
qs = user.generation_records.order_by('-created_at') qs = user.generation_records.order_by('-created_at')
total = qs.count() total = qs.count()
@ -411,6 +482,11 @@ def video_task_detail_view(request, task_id):
new_status = map_status(ark_resp.get('status', '')) new_status = map_status(ark_resp.get('status', ''))
record.status = new_status record.status = new_status
# 保存火山返回的实际 seed 值
returned_seed = ark_resp.get('seed')
if returned_seed is not None:
record.seed = returned_seed
if new_status == 'completed': if new_status == 'completed':
video_url = extract_video_url(ark_resp) video_url = extract_video_url(ark_resp)
if video_url: if video_url:
@ -445,7 +521,7 @@ def video_task_detail_view(request, task_id):
# Seedance 未计费,释放冻结 # Seedance 未计费,释放冻结
_release_freeze(record) _release_freeze(record)
record.save(update_fields=['status', 'result_url', 'error_message']) record.save(update_fields=['status', 'result_url', 'error_message', 'seed'])
except Exception as e: except Exception as e:
logger.exception('AirDrama API query failed for %s', ark_task_id) logger.exception('AirDrama API query failed for %s', ark_task_id)
@ -472,10 +548,26 @@ def _serialize_task(record):
'result_url': d.get('result_url', ''), 'result_url': d.get('result_url', ''),
'error_message': d.get('error_message', ''), 'error_message': d.get('error_message', ''),
'reference_urls': d.get('reference_urls') or [], 'reference_urls': d.get('reference_urls') or [],
'is_favorited': record.is_favorited,
'seed': record.seed,
'created_at': record.created_at.isoformat(), 'created_at': record.created_at.isoformat(),
} }
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def video_task_toggle_favorite_view(request, task_id):
"""POST /api/v1/video/tasks/<task_id>/favorite — Toggle favorite."""
try:
record = GenerationRecord.objects.get(task_id=task_id, user=request.user)
except GenerationRecord.DoesNotExist:
return Response({'error': '任务不存在'}, status=status.HTTP_404_NOT_FOUND)
record.is_favorited = not record.is_favorited
record.save(update_fields=['is_favorited'])
return Response({'is_favorited': record.is_favorited})
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Admin: Dashboard Stats # Admin: Dashboard Stats
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@ -710,6 +802,10 @@ def admin_teams_list_view(request):
'frozen_amount': float(t.frozen_amount), 'frozen_amount': float(t.frozen_amount),
'markup_percentage': float(t.markup_percentage), 'markup_percentage': float(t.markup_percentage),
'daily_member_limit_default': t.daily_member_limit_default, 'daily_member_limit_default': t.daily_member_limit_default,
'max_concurrent_tasks': t.max_concurrent_tasks,
'current_processing': GenerationRecord.objects.filter(
user__team=t, status__in=['queued', 'processing'],
).count(),
'member_count': t.members.count(), 'member_count': t.members.count(),
'is_active': t.is_active, 'is_active': t.is_active,
'expected_regions': t.expected_regions, 'expected_regions': t.expected_regions,
@ -805,6 +901,7 @@ def admin_team_detail_view(request, team_id):
'markup_percentage': float(team.markup_percentage), 'markup_percentage': float(team.markup_percentage),
'monthly_spending_limit': float(team.monthly_spending_limit), 'monthly_spending_limit': float(team.monthly_spending_limit),
'daily_member_spending_default': float(team.daily_member_spending_default), 'daily_member_spending_default': float(team.daily_member_spending_default),
'max_concurrent_tasks': team.max_concurrent_tasks,
'is_active': team.is_active, 'is_active': team.is_active,
'expected_regions': team.expected_regions, 'expected_regions': team.expected_regions,
'disabled_by': team.disabled_by, 'disabled_by': team.disabled_by,
@ -889,6 +986,10 @@ def admin_team_detail_view(request, team_id):
'frozen_amount': float(team.frozen_amount), 'frozen_amount': float(team.frozen_amount),
'markup_percentage': float(team.markup_percentage), 'markup_percentage': float(team.markup_percentage),
'daily_member_limit_default': team.daily_member_limit_default, 'daily_member_limit_default': team.daily_member_limit_default,
'max_concurrent_tasks': team.max_concurrent_tasks,
'current_processing': GenerationRecord.objects.filter(
user__team=team, status__in=['queued', 'processing'],
).count(),
'member_count': team.members.count(), 'member_count': team.members.count(),
'is_active': team.is_active, 'is_active': team.is_active,
'expected_regions': team.expected_regions, 'expected_regions': team.expected_regions,
@ -1092,8 +1193,8 @@ def admin_users_list_view(request):
today = timezone.now().date() today = timezone.now().date()
first_of_month = today.replace(day=1) first_of_month = today.replace(day=1)
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip() search = request.query_params.get('search', '').strip()
status_filter = request.query_params.get('status', '').strip() status_filter = request.query_params.get('status', '').strip()
team_id = request.query_params.get('team_id', '').strip() team_id = request.query_params.get('team_id', '').strip()
@ -1124,6 +1225,10 @@ def admin_users_list_view(request):
filter=Q(generation_records__created_at__date__gte=first_of_month), filter=Q(generation_records__created_at__date__gte=first_of_month),
), ),
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))), is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
total_spent_all=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__status='completed'),
),
) )
if search: if search:
@ -1133,7 +1238,7 @@ def admin_users_list_view(request):
elif status_filter == 'disabled': elif status_filter == 'disabled':
qs = qs.filter(is_active=False) qs = qs.filter(is_active=False)
if team_id: if team_id:
qs = qs.filter(team_id=int(team_id)) qs = qs.filter(team_id=_safe_int(team_id))
total = qs.count() total = qs.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -1162,6 +1267,8 @@ def admin_users_list_view(request):
'generations_this_month': u.generations_this_month or 0, 'generations_this_month': u.generations_this_month or 0,
'spent_today': float(u.spent_today or 0), 'spent_today': float(u.spent_today or 0),
'spent_this_month': float(u.spent_this_month or 0), 'spent_this_month': float(u.spent_this_month or 0),
'spending_limit': float(u.spending_limit),
'total_spent': float(u.total_spent_all or 0),
'is_online': u.is_online, 'is_online': u.is_online,
}) })
@ -1274,15 +1381,21 @@ def admin_user_quota_view(request, user_id):
before = { before = {
'daily_generation_limit': user.daily_generation_limit, 'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit, 'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
} }
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
user.daily_generation_limit = serializer.validated_data['daily_generation_limit'] user.daily_generation_limit = serializer.validated_data['daily_generation_limit']
user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit'] user.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
user.save(update_fields=['daily_generation_limit', 'monthly_generation_limit']) if 'spending_limit' in serializer.validated_data:
user.spending_limit = serializer.validated_data['spending_limit']
update_fields.append('spending_limit')
user.save(update_fields=update_fields)
log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username, log_admin_action(request, 'user_quota_update', 'user', target_id=user.id, target_name=user.username,
before=before, before=before,
after={ after={
'daily_generation_limit': user.daily_generation_limit, 'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit, 'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
}) })
return Response({ return Response({
@ -1290,6 +1403,7 @@ def admin_user_quota_view(request, user_id):
'username': user.username, 'username': user.username,
'daily_generation_limit': user.daily_generation_limit, 'daily_generation_limit': user.daily_generation_limit,
'monthly_generation_limit': user.monthly_generation_limit, 'monthly_generation_limit': user.monthly_generation_limit,
'spending_limit': float(user.spending_limit),
'daily_seconds_limit': user.daily_seconds_limit, 'daily_seconds_limit': user.daily_seconds_limit,
'monthly_seconds_limit': user.monthly_seconds_limit, 'monthly_seconds_limit': user.monthly_seconds_limit,
'updated_at': timezone.now().isoformat(), 'updated_at': timezone.now().isoformat(),
@ -1399,8 +1513,8 @@ def admin_create_user_view(request):
@permission_classes([IsSuperAdmin]) @permission_classes([IsSuperAdmin])
def admin_records_view(request): def admin_records_view(request):
"""GET /api/v1/admin/records""" """GET /api/v1/admin/records"""
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip() search = request.query_params.get('search', '').strip()
start_date = request.query_params.get('start_date', '').strip() start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip() end_date = request.query_params.get('end_date', '').strip()
@ -1415,7 +1529,7 @@ def admin_records_view(request):
if end_date: if end_date:
qs = qs.filter(created_at__date__lte=end_date) qs = qs.filter(created_at__date__lte=end_date)
if team_id: if team_id:
qs = qs.filter(user__team_id=int(team_id)) qs = qs.filter(user__team_id=_safe_int(team_id))
total = qs.count() total = qs.count()
offset = (page - 1) * page_size offset = (page - 1) * page_size
@ -1449,6 +1563,62 @@ def admin_records_view(request):
}) })
# ──────────────────────────────────────────────
# Team Admin: Consumption Records
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsTeamAdmin])
def team_records_view(request):
"""GET /api/v1/team/records — 团管查看本团队消费记录"""
team = request.user.team
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
qs = GenerationRecord.objects.filter(
user__team=team
).select_related('user').order_by('-created_at')
if search:
qs = qs.filter(user__username__icontains=search)
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
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,
'created_at': r.created_at.isoformat(),
'user_id': r.user_id,
'username': r.user.username,
'seconds_consumed': r.seconds_consumed,
'tokens_consumed': r.tokens_consumed,
'cost_amount': float(r.cost_amount),
'prompt': r.prompt,
'mode': r.mode,
'model': r.model,
'aspect_ratio': r.aspect_ratio,
'status': r.status,
'error_message': r.error_message or '',
})
return Response({
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Admin: System Settings # Admin: System Settings
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
@ -1516,8 +1686,8 @@ def admin_settings_view(request):
@permission_classes([IsSuperAdmin]) @permission_classes([IsSuperAdmin])
def admin_login_anomalies_view(request): def admin_login_anomalies_view(request):
"""GET /api/v1/admin/anomalies — Login anomaly records list.""" """GET /api/v1/admin/anomalies — Login anomaly records list."""
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
team_id = request.query_params.get('team_id', '').strip() team_id = request.query_params.get('team_id', '').strip()
rule = request.query_params.get('rule', '').strip() rule = request.query_params.get('rule', '').strip()
level = request.query_params.get('level', '').strip() level = request.query_params.get('level', '').strip()
@ -1527,7 +1697,7 @@ def admin_login_anomalies_view(request):
qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all() qs = LoginAnomaly.objects.select_related('team', 'user', 'login_record').all()
if team_id: if team_id:
qs = qs.filter(team_id=int(team_id)) qs = qs.filter(team_id=_safe_int(team_id))
if rule: if rule:
qs = qs.filter(rule=rule) qs = qs.filter(rule=rule)
if level: if level:
@ -1587,6 +1757,21 @@ def admin_test_feishu_view(request):
return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST) return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST'])
@permission_classes([IsSuperAdmin])
def admin_test_sms_view(request):
"""POST /api/v1/admin/test-sms — Send a test SMS alert."""
mobile = request.data.get('mobile', '').strip()
if not mobile:
return Response({'error': '请输入手机号'}, status=status.HTTP_400_BAD_REQUEST)
from utils.alert_service import send_sms_test
success, message = send_sms_test(mobile)
if success:
return Response({'message': message})
return Response({'error': message}, status=status.HTTP_400_BAD_REQUEST)
@api_view(['POST']) @api_view(['POST'])
@permission_classes([IsSuperAdmin]) @permission_classes([IsSuperAdmin])
def admin_team_auto_learn_view(request, team_id): def admin_team_auto_learn_view(request, team_id):
@ -1662,8 +1847,8 @@ def admin_team_apply_learned_regions_view(request, team_id):
@permission_classes([IsSuperAdmin]) @permission_classes([IsSuperAdmin])
def admin_audit_logs_view(request): def admin_audit_logs_view(request):
"""GET /api/v1/admin/logs — Query admin audit logs.""" """GET /api/v1/admin/logs — Query admin audit logs."""
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
action = request.query_params.get('action', '').strip() action = request.query_params.get('action', '').strip()
operator = request.query_params.get('operator', '').strip() operator = request.query_params.get('operator', '').strip()
start_date = request.query_params.get('start_date', '').strip() start_date = request.query_params.get('start_date', '').strip()
@ -1864,6 +2049,10 @@ def team_members_list_view(request):
filter=Q(generation_records__created_at__date__gte=first_of_month), filter=Q(generation_records__created_at__date__gte=first_of_month),
), ),
is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))), is_online=Exists(ActiveSession.objects.filter(user_id=OuterRef('pk'))),
total_spent_all=Sum(
'generation_records__cost_amount',
filter=Q(generation_records__status='completed'),
),
).order_by('-date_joined') ).order_by('-date_joined')
return Response({ return Response({
@ -1883,6 +2072,8 @@ def team_members_list_view(request):
'generations_this_month': m.generations_this_month or 0, 'generations_this_month': m.generations_this_month or 0,
'spent_today': float(m.spent_today or 0), 'spent_today': float(m.spent_today or 0),
'spent_this_month': float(m.spent_this_month or 0), 'spent_this_month': float(m.spent_this_month or 0),
'spending_limit': float(m.spending_limit),
'total_spent': float(m.total_spent_all or 0),
'is_online': m.is_online, 'is_online': m.is_online,
'date_joined': m.date_joined.isoformat(), 'date_joined': m.date_joined.isoformat(),
} for m in members], } for m in members],
@ -2028,15 +2219,21 @@ def team_member_quota_view(request, member_id):
before = { before = {
'daily_generation_limit': member.daily_generation_limit, 'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit, 'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
} }
update_fields = ['daily_generation_limit', 'monthly_generation_limit']
member.daily_generation_limit = serializer.validated_data['daily_generation_limit'] member.daily_generation_limit = serializer.validated_data['daily_generation_limit']
member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit'] member.monthly_generation_limit = serializer.validated_data['monthly_generation_limit']
member.save(update_fields=['daily_generation_limit', 'monthly_generation_limit']) if 'spending_limit' in serializer.validated_data:
member.spending_limit = serializer.validated_data['spending_limit']
update_fields.append('spending_limit')
member.save(update_fields=update_fields)
log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username, log_admin_action(request, 'member_quota_update', 'user', target_id=member.id, target_name=member.username,
before=before, before=before,
after={ after={
'daily_generation_limit': member.daily_generation_limit, 'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit, 'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
}) })
return Response({ return Response({
@ -2044,6 +2241,7 @@ def team_member_quota_view(request, member_id):
'username': member.username, 'username': member.username,
'daily_generation_limit': member.daily_generation_limit, 'daily_generation_limit': member.daily_generation_limit,
'monthly_generation_limit': member.monthly_generation_limit, 'monthly_generation_limit': member.monthly_generation_limit,
'spending_limit': float(member.spending_limit),
'daily_seconds_limit': member.daily_seconds_limit, 'daily_seconds_limit': member.daily_seconds_limit,
'monthly_seconds_limit': member.monthly_seconds_limit, 'monthly_seconds_limit': member.monthly_seconds_limit,
}) })
@ -2205,8 +2403,8 @@ def profile_overview_view(request):
def profile_records_view(request): def profile_records_view(request):
"""GET /api/v1/profile/records""" """GET /api/v1/profile/records"""
user = request.user user = request.user
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 20)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
qs = user.generation_records.order_by('-created_at') qs = user.generation_records.order_by('-created_at')
total = qs.count() total = qs.count()
@ -2339,8 +2537,8 @@ def admin_assets_user_videos(request, user_id):
except User.DoesNotExist: except User.DoesNotExist:
return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': '用户不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 30)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
qs = target_user.generation_records.filter(status='completed').order_by('-created_at') qs = target_user.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count() total = qs.count()
@ -2420,8 +2618,8 @@ def team_assets_member_videos(request, member_id):
except User.DoesNotExist: except User.DoesNotExist:
return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND) return Response({'error': '成员不存在'}, status=status.HTTP_404_NOT_FOUND)
page = int(request.query_params.get('page', 1)) page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(int(request.query_params.get('page_size', 30)), 100) page_size = min(_safe_int(request.query_params.get('page_size', 30), 30), 100)
qs = member.generation_records.filter(status='completed').order_by('-created_at') qs = member.generation_records.filter(status='completed').order_by('-created_at')
total = qs.count() total = qs.count()
@ -2450,3 +2648,432 @@ def team_assets_member_videos(request, member_id):
'page_size': page_size, 'page_size': page_size,
'results': results, 'results': results,
}) })
# ──────────────────────────────────────────────
# Admin: Login Records
# ──────────────────────────────────────────────
@api_view(['GET'])
@permission_classes([IsSuperAdmin])
def admin_login_records_view(request):
"""GET /api/v1/admin/login-records"""
page = _safe_int(request.query_params.get('page', 1), 1)
page_size = min(_safe_int(request.query_params.get('page_size', 20), 20), 100)
search = request.query_params.get('search', '').strip()
team_id = request.query_params.get('team_id', '').strip()
start_date = request.query_params.get('start_date', '').strip()
end_date = request.query_params.get('end_date', '').strip()
city = request.query_params.get('city', '').strip()
qs = LoginRecord.objects.select_related('user', 'team').order_by('-created_at')
if search:
qs = qs.filter(user__username__icontains=search)
if team_id:
qs = qs.filter(team_id=_safe_int(team_id))
if start_date:
qs = qs.filter(created_at__date__gte=start_date)
if end_date:
qs = qs.filter(created_at__date__lte=end_date)
if city:
qs = qs.filter(geo_city__icontains=city)
total = qs.count()
offset = (page - 1) * page_size
records = list(qs[offset:offset + page_size])
results = []
for r in records:
results.append({
'id': r.id,
'username': r.user.username,
'user_id': r.user_id,
'team_name': r.team.name if r.team else None,
'ip_address': r.ip_address or '',
'geo_country': r.geo_country,
'geo_province': r.geo_province,
'geo_city': r.geo_city,
'geo_source': r.geo_source,
'user_agent': r.user_agent,
'created_at': r.created_at.isoformat(),
})
return Response({
'total': total,
'page': page,
'page_size': page_size,
'results': results,
})
# ──────────────────────────────────────────────
# Virtual Avatar Asset Library
# ──────────────────────────────────────────────
def _assets_api_call(func, *args, **kwargs):
"""Safely call an assets_client function; returns (result, error_response).
If ASSETS_API_ENABLED is False the remote call is skipped and an empty
placeholder is returned so that local DB records are still created.
"""
from django.conf import settings as django_settings
if not django_settings.ASSETS_API_ENABLED:
return None, None
try:
from utils.assets_client import AssetsAPIError
result = func(*args, **kwargs)
return result, None
except AssetsAPIError as e:
logger.warning('Assets API error: %s', e)
return None, Response(
{'error': 'assets_api_error', 'message': '素材服务暂时不可用,请稍后重试'},
status=status.HTTP_502_BAD_GATEWAY,
)
except Exception as e:
logger.exception('Assets API unexpected error')
return None, Response(
{'error': 'assets_api_error', 'message': f'素材 API 调用失败: {e}'},
status=status.HTTP_502_BAD_GATEWAY,
)
@api_view(['GET', 'POST'])
@permission_classes([IsTeamMember])
@parser_classes([MultiPartParser, JSONParser])
def asset_groups_view(request):
"""GET /api/v1/assets/groups — list groups for current team.
POST /api/v1/assets/groups create a group with an initial image.
"""
team = request.user.team
if request.method == 'GET':
groups = (
AssetGroup.objects
.filter(team=team)
.annotate(asset_count=Count('assets'))
.order_by('-created_at')
)
results = []
for g in groups:
results.append({
'id': g.id,
'name': g.name,
'thumbnail_url': g.thumbnail_url,
'asset_count': g.asset_count,
'remote_group_id': g.remote_group_id,
'created_at': g.created_at.isoformat(),
})
return Response({'results': results})
# ── POST: create group + first asset ──
name = request.data.get('name', '').strip()
if not name:
return Response({'error': '请输入角色名称'}, status=status.HTTP_400_BAD_REQUEST)
file = request.FILES.get('file')
if not file:
return Response({'error': '请上传一张素材图片'}, status=status.HTTP_400_BAD_REQUEST)
# Upload to TOS
try:
tos_url = tos_upload(file, folder='assets')
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Create remote group
from utils import assets_client
remote_group_id = ''
result, err = _assets_api_call(assets_client.create_asset_group, name)
if err:
return err
if result is not None:
remote_group_id = result
# Create remote asset
remote_asset_id = ''
if remote_group_id:
result, err = _assets_api_call(assets_client.create_asset, remote_group_id, tos_url, name)
if err:
return err
if result is not None:
remote_asset_id = result
# Local DB records
group = AssetGroup.objects.create(
team=team,
remote_group_id=remote_group_id,
name=name,
description='',
thumbnail_url=tos_url,
created_by=request.user,
)
Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=tos_url,
status='processing' if remote_asset_id else 'active',
error_message='',
)
return Response({
'id': group.id,
'name': group.name,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
'asset_count': 1,
'created_at': group.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@api_view(['GET', 'PUT'])
@permission_classes([IsTeamMember])
@parser_classes([JSONParser])
def asset_group_detail_view(request, group_id):
"""GET /api/v1/assets/groups/<id> — group info + assets.
PUT /api/v1/assets/groups/<id> update name/description.
"""
team = request.user.team
try:
group = AssetGroup.objects.get(pk=group_id, team=team)
except AssetGroup.DoesNotExist:
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
if request.method == 'GET':
assets_qs = Asset.objects.filter(group=group).order_by('-created_at')
asset_list = []
for a in assets_qs:
asset_list.append({
'id': a.id,
'name': a.name,
'url': a.url,
'status': a.status,
'remote_asset_id': a.remote_asset_id,
'error_message': a.error_message,
'created_at': a.created_at.isoformat(),
})
return Response({
'id': group.id,
'name': group.name,
'description': group.description,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
'created_at': group.created_at.isoformat(),
'assets': asset_list,
})
# ── PUT ──
new_name = request.data.get('name')
new_desc = request.data.get('description')
if new_name is None and new_desc is None:
return Response({'error': '请提供要更新的字段'}, status=status.HTTP_400_BAD_REQUEST)
# Update remote
if group.remote_group_id:
from utils import assets_client
_, err = _assets_api_call(
assets_client.update_asset_group,
group.remote_group_id, name=new_name, description=new_desc,
)
if err:
return err
update_fields = []
if new_name is not None:
group.name = new_name
update_fields.append('name')
if new_desc is not None:
group.description = new_desc
update_fields.append('description')
group.save(update_fields=update_fields)
return Response({
'id': group.id,
'name': group.name,
'description': group.description,
'thumbnail_url': group.thumbnail_url,
'remote_group_id': group.remote_group_id,
})
@api_view(['POST'])
@permission_classes([IsTeamMember])
@parser_classes([MultiPartParser])
def asset_group_add_asset_view(request, group_id):
"""POST /api/v1/assets/groups/<id>/assets — add an image to a group."""
team = request.user.team
try:
group = AssetGroup.objects.get(pk=group_id, team=team)
except AssetGroup.DoesNotExist:
return Response({'error': '素材组不存在'}, status=status.HTTP_404_NOT_FOUND)
file = request.FILES.get('file')
if not file:
return Response({'error': '请上传文件'}, status=status.HTTP_400_BAD_REQUEST)
name = request.data.get('name', '').strip() or file.name
# Upload to TOS
try:
tos_url = tos_upload(file, folder='assets')
except Exception as e:
logger.exception('TOS upload failed for asset')
return Response(
{'error': f'文件上传失败: {e}'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
# Create remote asset
from utils import assets_client
remote_asset_id = ''
if group.remote_group_id:
result, err = _assets_api_call(
assets_client.create_asset, group.remote_group_id, tos_url, name,
)
if err:
return err
if result is not None:
remote_asset_id = result
asset = Asset.objects.create(
group=group,
remote_asset_id=remote_asset_id,
name=name,
url=tos_url,
status='processing' if remote_asset_id else 'active',
error_message='',
)
# If first asset, set thumbnail
if not group.thumbnail_url:
group.thumbnail_url = tos_url
group.save(update_fields=['thumbnail_url'])
return Response({
'id': asset.id,
'name': asset.name,
'url': asset.url,
'status': asset.status,
'remote_asset_id': asset.remote_asset_id,
'created_at': asset.created_at.isoformat(),
}, status=status.HTTP_201_CREATED)
@api_view(['PUT'])
@permission_classes([IsTeamMember])
@parser_classes([JSONParser])
def asset_update_view(request, asset_id):
"""PUT /api/v1/assets/<id> — rename an asset."""
team = request.user.team
try:
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
except Asset.DoesNotExist:
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
new_name = request.data.get('name')
if not new_name:
return Response({'error': '请提供素材名称'}, status=status.HTTP_400_BAD_REQUEST)
if asset.remote_asset_id:
from utils import assets_client
_, err = _assets_api_call(assets_client.update_asset, asset.remote_asset_id, name=new_name)
if err:
return err
asset.name = new_name
asset.save(update_fields=['name'])
return Response({
'id': asset.id,
'name': asset.name,
'url': asset.url,
'status': asset.status,
})
@api_view(['GET'])
@permission_classes([IsTeamMember])
def asset_search_view(request):
"""GET /api/v1/assets/search?q=... — fast search for @ popup."""
team = request.user.team
q = request.query_params.get('q', '').strip()
if not q:
return Response({'results': []})
groups = (
AssetGroup.objects
.filter(team=team, name__icontains=q)
.order_by('-created_at')[:20]
)
results = []
for g in groups:
results.append({
'id': g.id,
'name': g.name,
'thumbnail_url': g.thumbnail_url,
'remote_group_id': g.remote_group_id,
})
return Response({'results': results})
@api_view(['GET'])
@permission_classes([IsTeamMember])
def asset_poll_status_view(request, asset_id):
"""GET /api/v1/assets/<id>/status — poll remote processing status."""
team = request.user.team
try:
asset = Asset.objects.select_related('group').get(pk=asset_id, group__team=team)
except Asset.DoesNotExist:
return Response({'error': '素材不存在'}, status=status.HTTP_404_NOT_FOUND)
if asset.remote_asset_id:
from utils import assets_client
from utils.assets_client import AssetsAPIError
try:
result = assets_client.get_asset(asset.remote_asset_id)
remote_status = result.get('Status', '')
if remote_status == 'Active':
asset.status = 'active'
asset.url = result.get('Url', asset.url)
elif remote_status == 'Failed':
asset.status = 'failed'
asset.error_message = result.get('ErrorMessage', '')
else:
asset.status = 'processing'
asset.save(update_fields=['status', 'url', 'error_message'])
except AssetsAPIError as e:
error_str = str(e)
# 火山返回素材不存在 → 删除本地记录
if 'not found' in error_str.lower() or 'NotFound' in e.code or 'NotExist' in e.code:
asset.delete()
return Response({'status': 'deleted', 'message': '素材在云端已被删除'})
return Response(
{'error': 'assets_api_error', 'message': '素材状态查询失败,请稍后重试'},
status=status.HTTP_502_BAD_GATEWAY,
)
except Exception as e:
error_str = str(e)
# 空响应 / JSON 解析失败 = 火山已删除该素材
if 'Expecting value' in error_str or 'JSONDecodeError' in type(e).__name__:
logger.info('Asset %s appears deleted on remote (empty response), removing local record', asset.remote_asset_id)
asset.delete()
return Response({'status': 'deleted', 'message': '素材在云端已被删除'})
logger.exception('Assets API unexpected error in poll')
return Response(
{'error': 'assets_api_error', 'message': '素材服务异常,请稍后重试'},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response({
'id': asset.id,
'name': asset.name,
'url': asset.url,
'status': asset.status,
'error_message': asset.error_message,
})

View File

@ -42,6 +42,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Third party # Third party
'rest_framework', 'rest_framework',
'rest_framework_simplejwt.token_blacklist',
'corsheaders', 'corsheaders',
# Local apps # Local apps
'apps.accounts', 'apps.accounts',
@ -151,7 +152,8 @@ REST_FRAMEWORK = {
SIMPLE_JWT = { SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
'REFRESH_TOKEN_LIFETIME': timedelta(days=1), 'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
'ROTATE_REFRESH_TOKENS': False, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'AUTH_HEADER_TYPES': ('Bearer',), 'AUTH_HEADER_TYPES': ('Bearer',),
} }
@ -210,6 +212,8 @@ ARK_API_KEY = os.environ.get('ARK_API_KEY', '')
ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3') ARK_BASE_URL = os.environ.get('ARK_BASE_URL', 'https://ark.cn-beijing.volces.com/api/v3')
# Set to True when Seedance model is activated on ARK platform # Set to True when Seedance model is activated on ARK platform
SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true' SEEDANCE_ENABLED = os.environ.get('SEEDANCE_ENABLED', 'false').lower() == 'true'
# Set to True to enable the Assets API (virtual avatar library)
ASSETS_API_ENABLED = os.environ.get('ASSETS_API_ENABLED', 'false').lower() == 'true'
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# Aliyun SMS (短信告警) # Aliyun SMS (短信告警)

View File

@ -7,3 +7,4 @@ gunicorn>=21.2,<23.0
tos>=2.7,<3.0 tos>=2.7,<3.0
requests>=2.31,<3.0 requests>=2.31,<3.0
ip-region>=1.0 ip-region>=1.0
volcengine>=1.0.218

View File

@ -6,26 +6,43 @@ from django.conf import settings
# API error code → user-friendly Chinese message # API error code → user-friendly Chinese message
ERROR_MESSAGES = { ERROR_MESSAGES = {
# Input content moderation # Input content moderation — 人脸/敏感内容
'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,系统不允许处理包含真人面部的图', 'InputImageSensitiveContentDetected.PrivacyInformation': '参考图片中检测到真实人脸,请使用虚拟人像素材替代真人照',
'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试', 'InputImageSensitiveContentDetected': '参考图片包含敏感内容,请更换图片后重试',
'InputVideoSensitiveContentDetected.PrivacyInformation': '参考视频中检测到真实人脸,请使用虚拟人像素材替代真人视频',
'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试', 'InputVideoSensitiveContentDetected': '参考视频包含敏感内容,请更换视频后重试',
'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试', 'InputTextSensitiveContentDetected': '提示词包含敏感内容,请修改后重试',
'InputAudioSensitiveContentDetected': '参考音频包含敏感内容,请更换音频后重试',
# Output content moderation # Output content moderation
'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截', 'OutputVideoSensitiveContentDetected': '生成的视频包含敏感内容,已被系统拦截,请修改提示词后重试',
'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截', 'OutputImageSensitiveContentDetected': '生成的图片包含敏感内容,已被系统拦截',
# Parameter & rate limit errors # Parameter errors
'InvalidParameter': '请求参数无效,请检查输入', 'InvalidParameter': '请求参数无效,请检查输入内容',
'RateLimitExceeded': 'API 调用频率超限,请稍后重试', 'InvalidImage': '图片格式或尺寸不符合要求,请检查后重试',
'ConcurrencyLimitExceeded': '并发数超限,请稍后重试', 'InvalidVideo': '视频格式或尺寸不符合要求,请检查后重试',
'InvalidAudio': '音频格式不符合要求,请检查后重试',
# Rate limit
'RateLimitExceeded': '请求过于频繁,请稍后重试',
'ConcurrencyLimitExceeded': '当前生成任务过多,请稍后重试',
# Account & billing # Account & billing
'InsufficientBalance': '账户余额不足,请联系管理员充值', 'InsufficientBalance': '平台账户余额不足,请联系管理员',
# Asset errors
'AssetNotFound': '引用的素材不存在或已被删除,请检查素材库',
# Server errors # Server errors
'ServerOverloaded': '服务器繁忙,请稍后重试', 'ServerOverloaded': '服务器繁忙,请稍后重试',
'InternalError': '服务内部错误,请稍后重试', 'InternalError': '视频生成服务异常,请稍后重试',
'Timeout': '生成超时,请重试', 'Timeout': '生成超时,请重试',
} }
# 关键词匹配API 返回的 message 中包含这些关键词时,映射为对应中文提示
_MESSAGE_KEYWORDS = {
'face': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'privacy': '检测到真实人脸,请使用虚拟人像素材替代真人照片',
'sensitive': '内容包含敏感信息,请修改后重试',
'not found': '引用的素材不存在或已被删除,请检查素材库',
'not valid': '请求参数无效,请检查输入内容',
}
class AirDramaAPIError(Exception): class AirDramaAPIError(Exception):
"""Raised when video generation API returns an error response.""" """Raised when video generation API returns an error response."""
@ -33,8 +50,16 @@ class AirDramaAPIError(Exception):
self.code = code self.code = code
self.api_message = message self.api_message = message
self.status_code = status_code self.status_code = status_code
# Use friendly message if available, otherwise use API message # 1. 精确匹配 error code
self.user_message = ERROR_MESSAGES.get(code, message) friendly = ERROR_MESSAGES.get(code)
if not friendly:
# 2. 关键词匹配 message 内容
msg_lower = (message or '').lower()
for keyword, hint in _MESSAGE_KEYWORDS.items():
if keyword in msg_lower:
friendly = hint
break
self.user_message = friendly or '生成失败,请重试'
super().__init__(self.user_message) super().__init__(self.user_message)
@ -51,7 +76,8 @@ def _headers():
} }
def create_task(prompt, model, content_items, aspect_ratio, duration, generate_audio=True): def create_task(prompt, model, content_items, aspect_ratio, duration,
generate_audio=True, search_mode='off', seed=-1):
"""Create a video generation task. """Create a video generation task.
Args: Args:
@ -61,6 +87,7 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.). aspect_ratio: Video aspect ratio ('16:9', '9:16', etc.).
duration: Video duration in seconds. duration: Video duration in seconds.
generate_audio: Whether to generate audio with the video. generate_audio: Whether to generate audio with the video.
search_mode: 'smart' to enable internet search, 'off' to disable.
Returns: Returns:
dict: API response with task id and status. dict: API response with task id and status.
@ -79,8 +106,16 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
'ratio': aspect_ratio, 'ratio': aspect_ratio,
'duration': duration, 'duration': duration,
'watermark': False, 'watermark': False,
'seed': seed,
} }
if search_mode and search_mode != 'off':
payload['tools'] = [{'type': 'web_search'}]
import logging
logger = logging.getLogger(__name__)
logger.info('AirDrama API payload: %s', {k: v for k, v in payload.items() if k != 'content'})
resp = requests.post(url, json=payload, headers=_headers(), timeout=60) resp = requests.post(url, json=payload, headers=_headers(), timeout=60)
if resp.status_code != 200: if resp.status_code != 200:
# Extract human-readable error from API response # Extract human-readable error from API response
@ -88,8 +123,10 @@ def create_task(prompt, model, content_items, aspect_ratio, duration, generate_a
err = resp.json().get('error', {}) err = resp.json().get('error', {})
code = err.get('code', '') code = err.get('code', '')
message = err.get('message', resp.text) message = err.get('message', resp.text)
logger.error('AirDrama API error: status=%s code=%s message=%s', resp.status_code, code, message)
except Exception: except Exception:
code, message = '', resp.text code, message = '', resp.text
logger.error('AirDrama API error: status=%s body=%s', resp.status_code, resp.text)
raise AirDramaAPIError(code, message, resp.status_code) raise AirDramaAPIError(code, message, resp.status_code)
return resp.json() return resp.json()

View File

@ -311,6 +311,77 @@ def send_sms_alert(anomaly):
logger.error('SMS alert error for %s: %s', mobile, e) logger.error('SMS alert error for %s: %s', mobile, e)
def send_sms_test(mobile):
"""发送短信测试到指定手机号。Returns (success, message)。"""
from django.conf import settings as django_settings
access_key = django_settings.ALIYUN_SMS_ACCESS_KEY
access_secret = django_settings.ALIYUN_SMS_ACCESS_SECRET
sign_name = django_settings.ALIYUN_SMS_SIGN_NAME
template_code = django_settings.ALIYUN_SMS_TEMPLATE_CODE
if not all([access_key, access_secret, template_code]):
return False, '阿里云短信密钥未配置ALIYUN_SMS_ACCESS_KEY / ALIYUN_SMS_ACCESS_SECRET'
template_param = json.dumps({
'team_name': '测试团队',
'rule_name': '告警测试',
'username': '测试用户',
'city': '测试城市',
'auto_action': '仅测试',
}, ensure_ascii=False)
import hashlib
import hmac
import base64
import urllib.parse
import uuid
from datetime import datetime
def _percent_encode(s):
return urllib.parse.quote(s, safe='', encoding='utf-8')
try:
params = {
'AccessKeyId': access_key,
'Action': 'SendSms',
'Format': 'JSON',
'PhoneNumbers': mobile,
'RegionId': 'cn-hangzhou',
'SignName': sign_name,
'SignatureMethod': 'HMAC-SHA1',
'SignatureNonce': str(uuid.uuid4()),
'SignatureVersion': '1.0',
'TemplateCode': template_code,
'TemplateParam': template_param,
'Timestamp': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
'Version': '2017-05-25',
}
sorted_params = sorted(params.items())
query_string = '&'.join(f'{_percent_encode(k)}={_percent_encode(v)}' for k, v in sorted_params)
string_to_sign = f'GET&{_percent_encode("/")}&{_percent_encode(query_string)}'
sign_key = (access_secret + '&').encode('utf-8')
signature = base64.b64encode(
hmac.new(sign_key, string_to_sign.encode('utf-8'), hashlib.sha1).digest()
).decode('utf-8')
params['Signature'] = signature
resp = requests.get(
'https://dysmsapi.aliyuncs.com/',
params=params,
timeout=10,
)
data = resp.json()
if data.get('Code') == 'OK':
return True, '测试短信已发送'
return False, f'发送失败: {data.get("Message", data.get("Code", "未知错误"))}'
except Exception as e:
return False, str(e)
def send_feishu_test(mobile): def send_feishu_test(mobile):
"""发送测试消息到指定手机号。Returns (success, message)。""" """发送测试消息到指定手机号。Returns (success, message)。"""
try: try:

View File

@ -0,0 +1,177 @@
"""Volcano Engine Assets API client — uses volcengine SDK for AK/SK auth.
All functions are synchronous and raise ``AssetsAPIError`` on API errors.
"""
import json
import logging
from django.conf import settings
from volcengine.ApiInfo import ApiInfo
from volcengine.base.Service import Service
from volcengine.Credentials import Credentials
from volcengine.ServiceInfo import ServiceInfo
logger = logging.getLogger(__name__)
SERVICE = 'ark'
REGION = 'cn-beijing'
API_VERSION = '2024-01-01'
HOST = 'open.volcengineapi.com'
PROJECT_NAME = 'int_dev_Airlabs'
class AssetsAPIError(Exception):
"""Raised when the Assets API returns an error."""
def __init__(self, code, message, status_code=400):
self.code = code
self.api_message = message
self.status_code = status_code
super().__init__(f'[{code}] {message}')
def _get_service():
"""Build a volcengine Service instance with AK/SK credentials."""
ak = settings.TOS_ACCESS_KEY
sk = settings.TOS_SECRET_KEY
if not ak or not sk:
raise AssetsAPIError('ConfigError', 'TOS_ACCESS_KEY / TOS_SECRET_KEY not configured')
service_info = ServiceInfo(
HOST,
{'Accept': 'application/json', 'Content-Type': 'application/json'},
Credentials(ak, sk, SERVICE, REGION),
10, 30,
)
api_info = {
'CreateAssetGroup': ApiInfo('POST', '/', {'Action': 'CreateAssetGroup', 'Version': API_VERSION}, {}, {}),
'CreateAsset': ApiInfo('POST', '/', {'Action': 'CreateAsset', 'Version': API_VERSION}, {}, {}),
'ListAssetGroups': ApiInfo('POST', '/', {'Action': 'ListAssetGroups', 'Version': API_VERSION}, {}, {}),
'ListAssets': ApiInfo('POST', '/', {'Action': 'ListAssets', 'Version': API_VERSION}, {}, {}),
'GetAsset': ApiInfo('POST', '/', {'Action': 'GetAsset', 'Version': API_VERSION}, {}, {}),
'GetAssetGroup': ApiInfo('POST', '/', {'Action': 'GetAssetGroup', 'Version': API_VERSION}, {}, {}),
'UpdateAssetGroup': ApiInfo('POST', '/', {'Action': 'UpdateAssetGroup', 'Version': API_VERSION}, {}, {}),
'UpdateAsset': ApiInfo('POST', '/', {'Action': 'UpdateAsset', 'Version': API_VERSION}, {}, {}),
}
return Service(service_info, api_info)
def _do_request(action: str, body_dict: dict) -> dict:
"""Send a signed POST to the Assets API and return the Result dict."""
service = _get_service()
body = json.dumps(body_dict, ensure_ascii=False)
try:
resp = service.json(action, {}, body)
except Exception as e:
error_str = str(e)
try:
error_data = json.loads(error_str)
err = error_data.get('error', {})
raise AssetsAPIError(err.get('code', 'Unknown'), err.get('message', error_str))
except (json.JSONDecodeError, AssetsAPIError):
raise
except Exception:
raise AssetsAPIError('RequestError', error_str)
data = json.loads(resp) if isinstance(resp, str) else resp
meta = data.get('ResponseMetadata', {})
error = meta.get('Error', {})
if error:
raise AssetsAPIError(
error.get('Code', 'Unknown'),
error.get('Message', str(data)),
)
return data.get('Result', {})
# ──────────────────────────────────────────────
# Public helpers
# ──────────────────────────────────────────────
def create_asset_group(name: str, description: str = '', group_type: str = 'AIGC') -> str:
"""Create an asset group. Returns the remote group id."""
body = {
'Name': name,
'Description': description,
'GroupType': group_type,
'ProjectName': PROJECT_NAME,
}
result = _do_request('CreateAssetGroup', body)
return result.get('Id', '')
def create_asset(group_id: str, image_url: str, name: str = '', asset_type: str = 'Image') -> str:
"""Create an asset inside an existing group. Returns the remote asset id."""
body = {
'GroupId': group_id,
'URL': image_url,
'Name': name,
'AssetType': asset_type,
'ProjectName': PROJECT_NAME,
}
result = _do_request('CreateAsset', body)
return result.get('Id', '')
def list_asset_groups(page: int = 1, page_size: int = 20, name: str = None) -> tuple:
"""List asset groups. Returns (items_list, total_count)."""
filter_dict = {'GroupType': 'AIGC'}
if name:
filter_dict['Name'] = name
body = {
'Filter': filter_dict,
'PageNumber': page,
'PageSize': page_size,
'ProjectName': PROJECT_NAME,
}
result = _do_request('ListAssetGroups', body)
return result.get('Items', []), result.get('TotalCount', 0)
def list_assets(group_ids: list = None, status: str = None,
name: str = None, page: int = 1, page_size: int = 20) -> tuple:
"""List assets with optional filters. Returns (items_list, total_count)."""
filter_dict = {'GroupType': 'AIGC'}
if group_ids:
filter_dict['GroupIds'] = group_ids
if status:
filter_dict['Statuses'] = [status]
if name:
filter_dict['Name'] = name
body = {
'Filter': filter_dict,
'PageNumber': page,
'PageSize': page_size,
'ProjectName': PROJECT_NAME,
}
result = _do_request('ListAssets', body)
return result.get('Items', []), result.get('TotalCount', 0)
def get_asset(asset_id: str) -> dict:
"""Get single asset details including processing status."""
body = {'Id': asset_id, 'ProjectName': PROJECT_NAME}
return _do_request('GetAsset', body)
def update_asset_group(group_id: str, name: str = None, description: str = None):
"""Update an asset group's name and/or description."""
body = {'Id': group_id, 'ProjectName': PROJECT_NAME}
if name is not None:
body['Name'] = name
if description is not None:
body['Description'] = description
_do_request('UpdateAssetGroup', body)
def update_asset(asset_id: str, name: str = None):
"""Update an asset's name."""
body = {'Id': asset_id, 'ProjectName': PROJECT_NAME}
if name is not None:
body['Name'] = name
_do_request('UpdateAsset', body)

View File

@ -47,7 +47,7 @@ def upload_file(file_obj, folder='uploads'):
content = file_obj.read() content = file_obj.read()
# Use content hash as key for dedup # Use content hash as key for dedup
content_hash = hashlib.md5(content).hexdigest() content_hash = hashlib.sha256(content).hexdigest()
key = f'{folder}/{content_hash}.{ext}' key = f'{folder}/{content_hash}.{ext}'
url = f'{settings.TOS_CDN_DOMAIN}/{key}' url = f'{settings.TOS_CDN_DOMAIN}/{key}'

View File

@ -14,12 +14,14 @@ import { RecordsPage } from './pages/RecordsPage';
import { SettingsPage } from './pages/SettingsPage'; import { SettingsPage } from './pages/SettingsPage';
import { AuditLogsPage } from './pages/AuditLogsPage'; import { AuditLogsPage } from './pages/AuditLogsPage';
import { AnomalyLogPage } from './pages/AnomalyLogPage'; import { AnomalyLogPage } from './pages/AnomalyLogPage';
import { LoginRecordsPage } from './pages/LoginRecordsPage';
import { ProfilePage } from './pages/ProfilePage'; import { ProfilePage } from './pages/ProfilePage';
import { AssetsPage } from './pages/AssetsPage'; 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 { TeamRecordsPage } from './pages/TeamRecordsPage';
import { AdminAssetsPage } from './pages/AdminAssetsPage'; import { AdminAssetsPage } from './pages/AdminAssetsPage';
import { TeamAssetsPage } from './pages/TeamAssetsPage'; import { TeamAssetsPage } from './pages/TeamAssetsPage';
@ -79,6 +81,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="security" element={<AnomalyLogPage />} /> <Route path="security" element={<AnomalyLogPage />} />
<Route path="login-records" element={<LoginRecordsPage />} />
<Route path="logs" element={<AuditLogsPage />} /> <Route path="logs" element={<AuditLogsPage />} />
<Route path="assets" element={<AdminAssetsPage />} /> <Route path="assets" element={<AdminAssetsPage />} />
</Route> </Route>
@ -94,6 +97,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="records" element={<TeamRecordsPage />} />
<Route path="assets" element={<TeamAssetsPage />} /> <Route path="assets" element={<TeamAssetsPage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@ -0,0 +1,393 @@
.overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal {
width: 90vw;
max-width: 1400px;
height: 85vh;
background: #16161e;
border: 1px solid var(--color-border-card);
border-radius: 12px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border-card);
flex-shrink: 0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 12px;
}
.backBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
transition: color 0.15s;
}
.backBtn:hover {
color: var(--color-text-primary);
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--color-text-primary);
}
.closeBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
transition: color 0.15s;
}
.closeBtn:hover {
color: var(--color-text-primary);
}
.body {
padding: 20px 24px;
flex: 1;
overflow-y: auto;
}
.actions {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.actionBtn {
padding: 6px 14px;
background: var(--color-primary);
border: none;
border-radius: 8px;
color: #fff;
font-size: 13px;
cursor: pointer;
transition: filter 0.15s;
}
.actionBtn:hover {
filter: brightness(1.15);
}
.actionBtnOutline {
padding: 6px 14px;
background: transparent;
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.actionBtnOutline:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.card {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s;
}
.card:hover {
border-color: var(--color-primary);
transform: translateY(-2px);
}
.cardThumb {
width: 100%;
height: 120px;
object-fit: cover;
display: block;
background: #1a1a2e;
}
.cardInfo {
padding: 10px 12px;
display: flex;
align-items: center;
gap: 6px;
}
.cardName {
flex: 1;
font-size: 13px;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.editBtn {
background: none;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
padding: 2px;
font-size: 12px;
flex-shrink: 0;
transition: color 0.15s;
}
.editBtn:hover {
color: var(--color-text-primary);
}
.inlineEditWrap {
display: flex;
align-items: center;
gap: 4px;
flex: 1;
min-width: 0;
}
.inlineInput {
flex: 1;
min-width: 0;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--color-primary);
border-radius: 4px;
color: var(--color-text-primary);
font-size: 13px;
outline: none;
}
/* Detail view - asset cards */
.assetGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.assetCard {
background: var(--color-bg-card);
border: 1px solid var(--color-border-card);
border-radius: 12px;
overflow: hidden;
}
.assetThumb {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
background: #1a1a2e;
}
.assetInfo {
padding: 10px 12px;
}
.assetName {
font-size: 13px;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.statusBadge {
display: inline-block;
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
}
.statusActive {
color: var(--color-success);
background: rgba(0, 184, 148, 0.12);
}
.statusProcessing {
color: var(--color-warning);
background: rgba(243, 156, 18, 0.12);
}
.statusFailed {
color: var(--color-danger);
background: rgba(231, 76, 60, 0.12);
}
/* Upload view */
.uploadForm {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 560px;
margin: 0 auto;
}
.inputLabel {
font-size: 13px;
color: var(--color-text-secondary);
margin-bottom: 4px;
}
.textInput {
width: 100%;
padding: 10px 14px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--color-border-card);
border-radius: 8px;
color: var(--color-text-primary);
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.textInput:focus {
border-color: var(--color-primary);
}
.dropZone {
border: 2px dashed var(--color-border-card);
border-radius: 12px;
padding: 40px 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropZone:hover {
border-color: var(--color-primary);
background: rgba(108, 99, 255, 0.04);
}
.dropZoneActive {
border-color: var(--color-primary);
background: rgba(108, 99, 255, 0.08);
}
.dropZoneText {
font-size: 14px;
color: var(--color-text-secondary);
margin-bottom: 8px;
}
.dropZoneHint {
font-size: 12px;
color: var(--color-text-disabled);
}
.dropZoneWarning {
font-size: 14px;
font-weight: 600;
color: #ff4d4f;
margin-top: 12px;
padding: 8px 12px;
background: rgba(255, 77, 79, 0.08);
border: 1px solid rgba(255, 77, 79, 0.25);
border-radius: 6px;
}
.dropZonePreview {
max-width: 200px;
max-height: 160px;
object-fit: contain;
border-radius: 8px;
margin-bottom: 8px;
}
.submitBtn {
padding: 10px 0;
background: var(--color-primary);
border: none;
border-radius: 8px;
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.submitBtn:hover {
filter: brightness(1.15);
}
.submitBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 20px;
}
.pageBtn {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--color-border-card);
border-radius: 6px;
color: var(--color-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 13px;
color: var(--color-text-secondary);
}
.empty {
text-align: center;
padding: 40px 0;
color: var(--color-text-secondary);
font-size: 14px;
}

View File

@ -0,0 +1,437 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useAssetLibraryStore } from '../store/assetLibrary';
import { assetsApi, tosThumb } from '../lib/api';
import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox';
import type { AssetGroup, AssetItem } from '../types';
import styles from './AssetLibraryModal.module.css';
interface Props {
open: boolean;
onClose: () => void;
}
export function AssetLibraryModal({ open, onClose }: Props) {
const [view, setView] = useState<'list' | 'detail' | 'upload'>('list');
const [selectedGroup, setSelectedGroup] = useState<AssetGroup | null>(null);
const [groupAssets, setGroupAssets] = useState<AssetItem[]>([]);
const [newName, setNewName] = useState('');
const [uploading, setUploading] = useState(false);
const [editingName, setEditingName] = useState<{ id: number; value: string } | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadPreview, setUploadPreview] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const addFileInputRef = useRef<HTMLInputElement>(null);
const groups = useAssetLibraryStore((s) => s.groups);
const loading = useAssetLibraryStore((s) => s.loading);
const total = useAssetLibraryStore((s) => s.total);
const page = useAssetLibraryStore((s) => s.page);
const loadGroups = useAssetLibraryStore((s) => s.loadGroups);
const createGroup = useAssetLibraryStore((s) => s.createGroup);
const pollAssetStatus = useAssetLibraryStore((s) => s.pollAssetStatus);
const totalPages = Math.ceil(total / 20);
useEffect(() => {
if (open) {
loadGroups(1);
setView('list');
setSelectedGroup(null);
}
}, [open, loadGroups]);
const handleGroupClick = useCallback(async (group: AssetGroup) => {
setSelectedGroup(group);
try {
const { data } = await assetsApi.getGroupDetail(group.id);
const assets: AssetItem[] = data.assets || [];
setGroupAssets(assets);
// 对所有素材检查一次云端状态(处理中的更新状态,被删的清理掉)
let needRefresh = false;
const checks = assets.map((asset) =>
assetsApi.pollStatus(asset.id).then(({ data: statusData }) => {
if (statusData.status !== asset.status || statusData.status as string === 'deleted') {
needRefresh = true;
}
}).catch(() => {})
);
Promise.all(checks).then(() => {
if (needRefresh) {
assetsApi.getGroupDetail(group.id).then(({ data: refreshed }) => {
setGroupAssets(refreshed.assets || []);
}).catch(() => {});
}
});
} catch {
setGroupAssets([]);
}
setView('detail');
}, []);
const handleBackToList = useCallback(() => {
setView('list');
setSelectedGroup(null);
setGroupAssets([]);
setEditingName(null);
loadGroups(page);
}, [loadGroups, page]);
const handleRenameGroup = useCallback(async (id: number, name: string) => {
try {
await assetsApi.updateGroup(id, { name });
showToast('重命名成功');
setEditingName(null);
loadGroups(page);
if (selectedGroup && selectedGroup.id === id) {
setSelectedGroup({ ...selectedGroup, name });
}
} catch {
showToast('重命名失败');
}
}, [loadGroups, page, selectedGroup]);
const handleUploadSubmit = useCallback(async () => {
const trimmed = newName.trim();
if (!trimmed || !uploadFile) return;
if (trimmed.length > 64) { showToast('角色名称不能超过64个字符'); return; }
if (trimmed.includes('&&')) { showToast('角色名称不能包含 &&'); return; }
setUploading(true);
const result = await createGroup(newName.trim(), uploadFile);
setUploading(false);
if (result) {
pollAssetStatus(result.id);
setNewName('');
setUploadFile(null);
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadPreview(null);
handleBackToList();
}
}, [newName, uploadFile, createGroup, pollAssetStatus, uploadPreview, handleBackToList]);
const handleFileSelect = useCallback((file: File) => {
if (uploadPreview) URL.revokeObjectURL(uploadPreview);
setUploadFile(file);
setUploadPreview(URL.createObjectURL(file));
}, [uploadPreview]);
const refreshGroupDetail = useCallback(async () => {
if (!selectedGroup) return;
try {
const { data } = await assetsApi.getGroupDetail(selectedGroup.id);
setGroupAssets(data.assets || []);
} catch { /* ignore */ }
}, [selectedGroup]);
const handleAddAsset = useCallback(async (file: File) => {
if (!selectedGroup) return;
const formData = new FormData();
formData.append('file', file);
try {
const { data } = await assetsApi.addAsset(selectedGroup.id, formData);
setGroupAssets((prev) => [...prev, data]);
// 轮询状态,完成后刷新详情
const pollId = data.id;
const pollInterval = setInterval(async () => {
try {
const { data: statusData } = await assetsApi.pollStatus(pollId);
if (statusData.status !== 'processing') {
clearInterval(pollInterval);
if (statusData.status === 'active') showToast('素材已就绪');
else if (statusData.status === 'deleted') showToast('素材在云端已被删除');
else showToast('素材处理失败');
refreshGroupDetail();
}
} catch {
clearInterval(pollInterval);
}
}, 3000);
showToast('图片已上传,处理中...');
} catch {
showToast('上传失败,请重试');
}
}, [selectedGroup, refreshGroupDetail]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
handleFileSelect(file);
}
}, [handleFileSelect]);
if (!open) return null;
return (
<div className={styles.overlay} onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.headerLeft}>
{view !== 'list' && (
<button className={styles.backBtn} onClick={handleBackToList}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
<span className={styles.title}>
{view === 'list' && '素材库'}
{view === 'detail' && (selectedGroup?.name || '角色详情')}
{view === 'upload' && '上传新角色'}
</span>
</div>
<button className={styles.closeBtn} onClick={onClose}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
{/* Body */}
<div className={styles.body}>
{/* List View */}
{view === 'list' && (
<>
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => setView('upload')}>
+
</button>
</div>
{loading ? (
<div className={styles.empty}>...</div>
) : groups.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.grid}>
{groups.map((group) => (
<div key={group.id} className={styles.card} onClick={() => handleGroupClick(group)}>
<img src={tosThumb(group.thumbnail_url, 300)} alt={group.name} className={styles.cardThumb} />
<div className={styles.cardInfo}>
{editingName && editingName.id === group.id ? (
<div className={styles.inlineEditWrap} onClick={(e) => e.stopPropagation()}>
<input
className={styles.inlineInput}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(group.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.editBtn}
onClick={() => handleRenameGroup(group.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.editBtn}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
) : (
<>
<span className={styles.cardName}>{group.name}</span>
<button
className={styles.editBtn}
onClick={(e) => {
e.stopPropagation();
setEditingName({ id: group.id, value: group.name });
}}
>
&#9998;
</button>
</>
)}
</div>
</div>
))}
</div>
)}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={page <= 1}
onClick={() => loadGroups(page - 1)}
>
</button>
<span className={styles.pageInfo}>{page} / {totalPages}</span>
<button
className={styles.pageBtn}
disabled={page >= totalPages}
onClick={() => loadGroups(page + 1)}
>
</button>
</div>
)}
</>
)}
{/* Detail View */}
{view === 'detail' && selectedGroup && (
<>
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => addFileInputRef.current?.click()}>
+
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName({ id: selectedGroup.id, value: selectedGroup.name })}
>
&#9998;
</button>
<input
ref={addFileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleAddAsset(file);
e.target.value = '';
}}
/>
</div>
{editingName && editingName.id === selectedGroup.id && (
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<input
className={styles.textInput}
style={{ flex: 1 }}
value={editingName.value}
onChange={(e) => setEditingName({ ...editingName, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') handleRenameGroup(selectedGroup.id, editingName.value);
if (e.key === 'Escape') setEditingName(null);
}}
autoFocus
/>
<button
className={styles.actionBtn}
onClick={() => handleRenameGroup(selectedGroup.id, editingName.value)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.actionBtnOutline}
onClick={() => setEditingName(null)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</div>
)}
{groupAssets.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.assetGrid}>
{groupAssets.map((asset) => (
<div key={asset.id} className={styles.assetCard}>
<img
src={tosThumb(asset.url, 300)}
alt={asset.name}
className={styles.assetThumb}
style={{ cursor: 'zoom-in' }}
onClick={() => setLightboxSrc(asset.url)}
/>
<div className={styles.assetInfo}>
<div className={styles.assetName}>{asset.name}</div>
<span className={`${styles.statusBadge} ${
asset.status === 'active' ? styles.statusActive
: asset.status === 'processing' ? styles.statusProcessing
: styles.statusFailed
}`}>
{asset.status === 'active' && '可用'}
{asset.status === 'processing' && '处理中'}
{asset.status === 'failed' && '失败'}
</span>
</div>
</div>
))}
</div>
)}
</>
)}
{/* Upload View */}
{view === 'upload' && (
<div className={styles.uploadForm}>
<div>
<div className={styles.inputLabel}></div>
<input
className={styles.textInput}
placeholder="请输入角色名称,如:林峰"
maxLength={64}
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div>
<div className={styles.inputLabel}></div>
<div
className={`${styles.dropZone} ${dragOver ? styles.dropZoneActive : ''}`}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{uploadPreview ? (
<>
<img src={uploadPreview} alt="预览" className={styles.dropZonePreview} />
<div className={styles.dropZoneHint}></div>
</>
) : (
<>
<div className={styles.dropZoneText}></div>
<div className={styles.dropZoneHint}></div>
<div className={styles.dropZoneHint}> JPGPNG 30MB</div>
</>
)}
<div className={styles.dropZoneWarning}> </div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFileSelect(file);
e.target.value = '';
}}
/>
</div>
<button
className={styles.submitBtn}
disabled={!newName.trim() || !uploadFile || uploading}
onClick={handleUploadSubmit}
>
{uploading ? '上传中...' : '确认上传'}
</button>
</div>
)}
</div>
</div>
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
</div>
);
}

View File

@ -3,7 +3,7 @@
border: none; border: none;
border-radius: 0; border-radius: 0;
padding: 20px 0; padding: 20px 0;
max-width: 800px; max-width: 1024px;
width: 100%; width: 100%;
animation: cardFadeIn 0.3s ease-out; animation: cardFadeIn 0.3s ease-out;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid rgba(255, 255, 255, 0.06);
@ -17,8 +17,10 @@
/* Header */ /* Header */
.header { .header {
display: flex; display: flex;
align-items: center;
gap: 12px; gap: 12px;
margin-bottom: 12px; margin-bottom: 12px;
position: relative;
} }
.refColumn { .refColumn {
@ -40,7 +42,7 @@
} }
.refThumb { .refThumb {
height: 48px; height: 56px;
aspect-ratio: 3 / 4; aspect-ratio: 3 / 4;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
@ -79,63 +81,63 @@
overflow: hidden; overflow: hidden;
} }
.promptTooltip { /* hover 展开黑底:基于 .header 定位,左边距图片 4px */
.promptExpanded {
position: absolute; position: absolute;
top: 100%; top: 0;
left: 0;
right: 0; right: 0;
z-index: 10; z-index: 10;
background: #1e1e2a; font-size: 14px;
border: 1px solid #2a2a38;
border-radius: 10px;
padding: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
animation: tooltipFadeIn 0.15s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipAbove {
top: auto;
bottom: 100%;
margin-bottom: 4px;
animation: tooltipFadeInAbove 0.15s ease-out;
}
@keyframes tooltipFadeInAbove {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.promptTooltipText {
font-size: 13px;
color: var(--color-text-primary); color: var(--color-text-primary);
line-height: 1.6; line-height: 1.6;
margin-bottom: 8px;
word-break: break-word; word-break: break-word;
background: rgba(13, 13, 26, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.10);
padding: 6px 8px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
} }
.copyBtn { .mentionTag {
display: inline-flex; display: inline;
align-items: center; padding: 1px 5px;
padding: 4px 12px; border-radius: 4px;
background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7);
font-size: 13px;
white-space: nowrap;
cursor: default;
}
.mentionPreview {
position: fixed;
z-index: 9999;
transform: translate(-50%, -100%);
background: #1e1e2e;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.mentionPreviewImg {
display: block;
width: 160px;
height: 100px;
object-fit: cover;
border-radius: 6px; border-radius: 6px;
font-size: 12px;
color: var(--color-primary);
background: rgba(108, 99, 255, 0.1);
border: 1px solid rgba(108, 99, 255, 0.2);
cursor: pointer;
transition: background 0.15s;
font-family: inherit;
} }
.copyBtn:hover { .mentionPreviewLabel {
background: rgba(108, 99, 255, 0.18); text-align: center;
color: #8a8a9a;
font-size: 11px;
margin-top: 4px;
} }
/* Inline labels after prompt text */ /* Inline labels after prompt text */
.labelsInline { .labelsInline {
display: inline; display: inline;
@ -143,6 +145,7 @@
white-space: nowrap; white-space: nowrap;
} }
.label { .label {
display: inline-flex; display: inline-flex;
font-size: 12px; font-size: 12px;
@ -233,8 +236,10 @@
inset: 0; inset: 0;
background: transparent; background: transparent;
display: flex; display: flex;
align-items: flex-start; flex-direction: column;
justify-content: flex-end; align-items: flex-end;
justify-content: flex-start;
gap: 8px;
padding: 12px; padding: 12px;
animation: overlayFadeIn 0.15s ease-out; animation: overlayFadeIn 0.15s ease-out;
} }

View File

@ -1,8 +1,10 @@
import { useRef, useState, useEffect, useCallback } from 'react'; import { useRef, useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { GenerationTask } from '../types'; import type { GenerationTask } from '../types';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
import { showToast } from './Toast'; import { showToast } from './Toast';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { tosThumb } from '../lib/api';
import styles from './GenerationCard.module.css'; import styles from './GenerationCard.module.css';
const EditIcon = () => ( const EditIcon = () => (
@ -34,6 +36,83 @@ const DownloadIcon = () => (
</svg> </svg>
); );
// Mention tag with thumbnail + hover preview
function MentionTag({ label, thumbUrl }: { label: string; thumbUrl?: string }) {
const [hover, setHover] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
return (
<>
<span
ref={ref}
className={styles.mentionTag}
onMouseEnter={() => {
if (thumbUrl && ref.current) {
const rect = ref.current.getBoundingClientRect();
setPos({ top: rect.top - 8, left: rect.left + rect.width / 2 });
setHover(true);
}
}}
onMouseLeave={() => setHover(false)}
>
{thumbUrl && (
<img
src={tosThumb(thumbUrl, 28)}
alt=""
style={{ width: 14, height: 14, borderRadius: 3, objectFit: 'cover', verticalAlign: 'middle', marginRight: 3 }}
/>
)}
{label}
</span>
{hover && thumbUrl && createPortal(
<div className={styles.mentionPreview} style={{ top: pos.top, left: pos.left }}>
<img src={tosThumb(thumbUrl, 200)} alt={label} className={styles.mentionPreviewImg} />
<div className={styles.mentionPreviewLabel}>{label}</div>
</div>,
document.body
)}
</>
);
}
// Render prompt text with @mentions as styled tags (thumbnail + hover preview)
export function renderPromptWithMentions(
text: string,
assetMentions: { label: string; thumbUrl?: string }[],
references: { label: string; previewUrl?: string }[]
) {
// Build lookup: label → thumbUrl
const thumbMap = new Map<string, string>();
for (const am of assetMentions) {
if (am.label) thumbMap.set(am.label, am.thumbUrl || '');
}
for (const r of references) {
if (r.label && !thumbMap.has(r.label)) thumbMap.set(r.label, r.previewUrl || '');
}
const labels = [...thumbMap.keys()];
if (labels.length === 0) return text;
// Build regex: match @label patterns, longest first
labels.sort((a, b) => b.length - a.length);
const escaped = labels.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const regex = new RegExp(`(@(?:${escaped.join('|')}))`, 'g');
const parts = text.split(regex);
if (parts.length === 1) return text;
return parts.map((part, i) => {
if (regex.test(part)) {
regex.lastIndex = 0;
const label = part.slice(1); // remove @
return <MentionTag key={i} label={label} thumbUrl={thumbMap.get(label)} />;
}
regex.lastIndex = 0;
return part;
});
}
interface Props { interface Props {
task: GenerationTask; task: GenerationTask;
onOpenDetail?: (task: GenerationTask) => void; onOpenDetail?: (task: GenerationTask) => void;
@ -43,12 +122,14 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const removeTask = useGenerationStore((s) => s.removeTask); const removeTask = useGenerationStore((s) => s.removeTask);
const reEdit = useGenerationStore((s) => s.reEdit); const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate); const regenerate = useGenerationStore((s) => s.regenerate);
const toggleFavorite = useGenerationStore((s) => s.toggleFavorite);
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 promptWrapperRef = useRef<HTMLDivElement>(null);
const labelsRef = useRef<HTMLSpanElement>(null); const labelsRef = useRef<HTMLSpanElement>(null);
const refColumnRef = useRef<HTMLDivElement>(null);
const [videoHover, setVideoHover] = useState(false); const [videoHover, setVideoHover] = useState(false);
const [promptHover, setPromptHover] = useState(false); const [promptHover, setPromptHover] = useState(false);
const [showMore, setShowMore] = useState(false); const [showMore, setShowMore] = useState(false);
@ -56,8 +137,17 @@ 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);
const detailLeaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [refPreview, setRefPreview] = useState<{ url: string; label: string; type: string; top: number; left: number } | null>(null);
const startDetailLeave = useCallback(() => {
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
detailLeaveTimer.current = setTimeout(() => setDetailHover(false), 200);
}, []);
const cancelDetailLeave = useCallback(() => {
if (detailLeaveTimer.current) clearTimeout(detailLeaveTimer.current);
}, []);
// Close more menu on click outside // Close more menu on click outside
useEffect(() => { useEffect(() => {
@ -82,11 +172,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
const style = getComputedStyle(container); const style = getComputedStyle(container);
const font = `${style.fontSize} ${style.fontFamily}`; const font = `${style.fontSize} ${style.fontFamily}`;
const labelsWidth = labelsEl.offsetWidth + 8;
// Measure labels width
const labelsWidth = labelsEl.offsetWidth + 8; // +8 for gap
// Two lines of available width, minus labels on line 2, with safety margin
const totalAvailable = containerWidth * 2 - labelsWidth - 24; const totalAvailable = containerWidth * 2 - labelsWidth - 24;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@ -94,35 +180,28 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ctx.font = font; ctx.font = font;
const prompt = task.prompt || ''; const prompt = task.prompt || '';
let totalWidth = 0;
let needsTruncation = false;
// Check if prompt fits
const fullWidth = ctx.measureText(prompt).width; const fullWidth = ctx.measureText(prompt).width;
if (fullWidth <= totalAvailable) { if (fullWidth <= totalAvailable) {
setTruncatedPrompt(prompt); setTruncatedPrompt(prompt);
return; return;
} }
// Truncate character by character
let truncated = ''; let truncated = '';
let totalWidth = 0;
const ellipsisWidth = ctx.measureText('…').width; const ellipsisWidth = ctx.measureText('…').width;
for (const char of prompt) { for (const char of prompt) {
const charWidth = ctx.measureText(char).width; const charWidth = ctx.measureText(char).width;
if (totalWidth + charWidth + ellipsisWidth > totalAvailable) { if (totalWidth + charWidth + ellipsisWidth > totalAvailable) {
needsTruncation = true;
break; break;
} }
truncated += char; truncated += char;
totalWidth += charWidth; totalWidth += charWidth;
} }
setTruncatedPrompt(truncated + '…');
setTruncatedPrompt(needsTruncation ? truncated + '…' : prompt);
}, [task.prompt]); }, [task.prompt]);
useEffect(() => { useEffect(() => {
computeTruncation(); computeTruncation();
const container = promptLineRef.current; const container = promptLineRef.current;
if (!container) return; if (!container) return;
const ro = new ResizeObserver(() => computeTruncation()); const ro = new ResizeObserver(() => computeTruncation());
@ -194,9 +273,18 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div className={styles.header}> <div className={styles.header}>
{/* Left: reference thumbnails */} {/* Left: reference thumbnails */}
{task.references.length > 0 && ( {task.references.length > 0 && (
<div className={styles.refColumn}> <div ref={refColumnRef} className={styles.refColumn}>
{task.references.map((ref) => ( {task.references.map((ref) => (
<div key={ref.id} className={styles.refThumb}> <div
key={ref.id}
className={styles.refThumb}
onMouseEnter={(e) => {
if (ref.type === 'audio') return;
const rect = e.currentTarget.getBoundingClientRect();
setRefPreview({ url: ref.previewUrl, label: ref.label, type: ref.type, top: rect.top - 8, left: rect.left + rect.width / 2 });
}}
onMouseLeave={() => setRefPreview(null)}
>
{ref.type === 'video' ? ( {ref.type === 'video' ? (
<video src={ref.previewUrl} className={styles.refMedia} muted /> <video src={ref.previewUrl} className={styles.refMedia} muted />
) : ref.type === 'audio' ? ( ) : ref.type === 'audio' ? (
@ -208,7 +296,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
</svg> </svg>
</div> </div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refMedia} /> <img src={tosThumb(ref.previewUrl, 112)} alt={ref.label} className={styles.refMedia} />
)} )}
</div> </div>
))} ))}
@ -219,20 +307,18 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<div <div
ref={promptWrapperRef} ref={promptWrapperRef}
className={styles.promptWrapper} className={styles.promptWrapper}
onMouseLeave={() => setPromptHover(false)} onMouseLeave={() => { setPromptHover(false); startDetailLeave(); }}
> >
{/* 默认状态:截断提示词 + inline 标签 */}
<div ref={promptLineRef} className={styles.promptLine}> <div ref={promptLineRef} className={styles.promptLine}>
<span onMouseEnter={() => setPromptHover(true)}>
{renderPromptWithMentions(truncatedPrompt || '(无文字描述)', task.assetMentions || [], task.references)}
</span>
<span <span
onMouseEnter={() => { ref={labelsRef}
const el = promptWrapperRef.current; className={styles.labelsInline}
if (el) { onMouseEnter={() => setPromptHover(false)}
const rect = el.getBoundingClientRect(); >
setPromptAbove(rect.bottom + 350 > window.innerHeight);
}
setPromptHover(true);
}}
>{truncatedPrompt || '(无文字描述)'}</span>
<span ref={labelsRef} className={styles.labelsInline} onMouseEnter={() => setPromptHover(false)}>
<span className={styles.label}> <span className={styles.label}>
{task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'} {task.model === 'seedance_2.0' ? 'AirDrama' : 'AirDrama Fast'}
</span> </span>
@ -242,6 +328,7 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
ref={detailLinkRef} ref={detailLinkRef}
className={styles.detailLink} className={styles.detailLink}
onMouseEnter={() => { onMouseEnter={() => {
cancelDetailLeave();
const el = detailLinkRef.current; const el = detailLinkRef.current;
if (el) { if (el) {
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
@ -252,11 +339,21 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
} }
setDetailHover(true); setDetailHover(true);
}} }}
onMouseLeave={() => setDetailHover(false)} onMouseLeave={startDetailLeave}
> >
</span>
</span>
</div>
</div>
{/* 详细信息弹窗 — 放在 promptWrapper 外,鼠标可以移到弹窗上 */}
{detailHover && ( {detailHover && (
<div className={styles.detailTooltip} style={{ top: detailPos.top, right: detailPos.right }}> <div
className={styles.detailTooltip}
style={{ top: detailPos.top, right: detailPos.right }}
onMouseEnter={() => { cancelDetailLeave(); setDetailHover(true); }}
onMouseLeave={startDetailLeave}
>
<div className={styles.detailRow}> <div className={styles.detailRow}>
<span></span><span>{task.aspectRatio}</span> <span></span><span>{task.aspectRatio}</span>
</div> </div>
@ -274,20 +371,52 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<span></span> <span></span>
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span> <span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
</div> </div>
{(task.tokensConsumed ?? 0) > 0 && (
<>
<div className={styles.detailRow}>
<span> Tokens</span>
<span>{(task.tokensConsumed ?? 0).toLocaleString()}</span>
</div>
<div className={styles.detailRow}>
<span></span>
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
</div>
</>
)}
{(task.seed ?? -1) > 0 && (
<div className={styles.detailRow}>
<span></span>
<span>{task.seed}</span>
</div> </div>
)} )}
</span>
</span>
</div> </div>
)}
</div>
{/* hover 展开黑底:基于 header 定位,左边距图片 4px */}
{promptHover && task.prompt && ( {promptHover && task.prompt && (
<div className={`${styles.promptTooltip} ${promptAbove ? styles.promptTooltipAbove : ''}`}> <div
<p className={styles.promptTooltipText}>{task.prompt}</p> className={styles.promptExpanded}
<button className={styles.copyBtn} onClick={handleCopyPrompt}></button> style={{ left: refColumnRef.current ? refColumnRef.current.offsetWidth + 4 : 0 }}
onMouseEnter={() => setPromptHover(true)}
onMouseLeave={() => setPromptHover(false)}
>
{renderPromptWithMentions(task.prompt, task.assetMentions || [], task.references)}
</div> </div>
)} )}
</div> </div>
</div>
</div> {/* Reference thumbnail hover preview */}
{refPreview && createPortal(
<div className={styles.mentionPreview} style={{ top: refPreview.top, left: refPreview.left }}>
{refPreview.type === 'video' ? (
<video src={refPreview.url} className={styles.mentionPreviewImg} autoPlay loop muted playsInline />
) : (
<img src={tosThumb(refPreview.url, 300)} alt={refPreview.label} className={styles.mentionPreviewImg} />
)}
<div className={styles.mentionPreviewLabel}>{refPreview.label}</div>
</div>,
document.body
)}
{/* Video / result area */} {/* Video / result area */}
<div className={styles.content}> <div className={styles.content}>
@ -328,6 +457,11 @@ export function GenerationCard({ task, onOpenDetail }: Props) {
<button className={styles.downloadBtn} onClick={handleDownload}> <button className={styles.downloadBtn} onClick={handleDownload}>
<DownloadIcon /> <DownloadIcon />
</button> </button>
<button className={styles.downloadBtn} onClick={(e) => { e.stopPropagation(); toggleFavorite(task.id); }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill={task.isFavorited ? '#faad14' : 'none'} stroke={task.isFavorited ? '#faad14' : 'currentColor'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
</button>
</div> </div>
)} )}
</div> </div>

View File

@ -1,3 +1,10 @@
/* Hide number input spinners */
:global(.hide-spin::-webkit-outer-spin-button),
:global(.hide-spin::-webkit-inner-spin-button) {
-webkit-appearance: none;
margin: 0;
}
.wrapper { .wrapper {
width: 100%; width: 100%;
padding: 8px 16px 20px; padding: 8px 16px 20px;

View File

@ -1,9 +1,10 @@
import { useRef, useCallback, type DragEvent } from 'react'; import { useRef, useState, useCallback, type DragEvent } from 'react';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { UniversalUpload } from './UniversalUpload'; import { UniversalUpload } from './UniversalUpload';
import { KeyframeUpload } from './KeyframeUpload'; import { KeyframeUpload } from './KeyframeUpload';
import { PromptInput } from './PromptInput'; import { PromptInput } from './PromptInput';
import { Toolbar } from './Toolbar'; import { Toolbar } from './Toolbar';
import { AssetLibraryModal } from './AssetLibraryModal';
import { showToast } from './Toast'; import { showToast } from './Toast';
import styles from './InputBar.module.css'; import styles from './InputBar.module.css';
@ -15,7 +16,8 @@ export function InputBar() {
const handleDragOver = useCallback((e: DragEvent) => { const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault(); e.preventDefault();
if (barRef.current) { // 只有外部文件拖入时才显示蓝色边框(内部 mention 标签拖拽不触发)
if (e.dataTransfer.types.includes('Files') && barRef.current) {
barRef.current.style.borderColor = '#00b8e6'; barRef.current.style.borderColor = '#00b8e6';
} }
}, []); }, []);
@ -71,9 +73,69 @@ export function InputBar() {
} }
}, [mode, addReferences, setFirstFrame]); }, [mode, addReferences, setFirstFrame]);
const [assetModalOpen, setAssetModalOpen] = useState(false);
const searchMode = useInputBarStore((s) => s.searchMode);
const setSearchMode = useInputBarStore((s) => s.setSearchMode);
const seed = useInputBarStore((s) => s.seed);
const seedEnabled = useInputBarStore((s) => s.seedEnabled);
const setSeed = useInputBarStore((s) => s.setSeed);
const setSeedEnabled = useInputBarStore((s) => s.setSeedEnabled);
const references = useInputBarStore((s) => s.references);
const editorHtml = useInputBarStore((s) => s.editorHtml);
const firstFrame = useInputBarStore((s) => s.firstFrame);
const lastFrame = useInputBarStore((s) => s.lastFrame);
// 联网搜索暂未开放
const searchDisabled = true;
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.container}> <div className={styles.container}>
{/* 素材库 + 联网搜索按钮 — 输入框上方 */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, paddingLeft: 4 }}>
<button
onClick={() => setAssetModalOpen(true)}
style={{
background: 'transparent', border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: 'var(--color-text-secondary)', cursor: 'pointer',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; }}
>
</button>
<button
onClick={() => { if (!searchDisabled) setSearchMode(searchMode === 'smart' ? 'off' : 'smart'); }}
title={searchDisabled ? '联网搜索仅支持纯文生视频' : ''}
style={{
background: searchMode === 'smart' && !searchDisabled ? 'rgba(108, 99, 255, 0.12)' : 'transparent',
border: `1px solid ${searchMode === 'smart' && !searchDisabled ? 'var(--color-primary)' : 'var(--color-border-card)'}`,
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: searchDisabled ? '#3a3a4a' : searchMode === 'smart' ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: searchDisabled ? 'not-allowed' : 'pointer', transition: 'all 0.15s',
opacity: searchDisabled ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-primary)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-primary)'; } }}
onMouseLeave={(e) => { if (!searchDisabled && searchMode !== 'smart') { (e.currentTarget as HTMLElement).style.borderColor = 'var(--color-border-card)'; (e.currentTarget as HTMLElement).style.color = 'var(--color-text-secondary)'; } }}
>
</button>
<button
disabled
style={{
background: 'transparent',
border: '1px solid var(--color-border-card)',
borderRadius: 6, padding: '4px 12px', fontSize: 12,
color: '#3a3a4a', cursor: 'not-allowed', transition: 'all 0.15s',
opacity: 0.5,
}}
>
</button>
</div>
<div <div
ref={barRef} ref={barRef}
className={styles.bar} className={styles.bar}
@ -94,6 +156,7 @@ export function InputBar() {
<Toolbar /> <Toolbar />
</div> </div>
</div> </div>
<AssetLibraryModal open={assetModalOpen} onClose={() => setAssetModalOpen(false)} />
</div> </div>
); );
} }

View File

@ -41,9 +41,24 @@
background: rgba(108, 99, 255, 0.12); background: rgba(108, 99, 255, 0.12);
color: rgba(108, 99, 255, 0.7); color: rgba(108, 99, 255, 0.7);
font-size: 13px; font-size: 13px;
cursor: default; cursor: grab;
user-select: none; user-select: none;
transition: background 0.15s; transition: background 0.15s, opacity 0.15s;
}
.mentionImg {
width: 16px;
height: 16px;
border-radius: 3px;
object-fit: cover;
vertical-align: middle;
margin-right: 3px;
display: inline-block;
pointer-events: none;
}
.dragging {
opacity: 0.4;
} }
.mention:hover { .mention:hover {

View File

@ -1,7 +1,8 @@
import { useRef, useEffect, useCallback, useState } from 'react'; import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import type { UploadedFile } from '../types'; import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetGroup } from '../types';
import styles from './PromptInput.module.css'; import styles from './PromptInput.module.css';
const placeholders: Record<string, string> = { const placeholders: Record<string, string> = {
@ -25,6 +26,9 @@ export function PromptInput() {
const [highlightedIdx, setHighlightedIdx] = useState(0); const [highlightedIdx, setHighlightedIdx] = useState(0);
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null); const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-focus // Auto-focus
useEffect(() => { useEffect(() => {
@ -36,10 +40,11 @@ export function PromptInput() {
const el = editorRef.current; const el = editorRef.current;
if (!el) return; if (!el) return;
if (el.innerHTML !== editorHtml) { if (el.innerHTML !== editorHtml) {
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] }); el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] });
// If the HTML is plain text but we have references, rebuild mention spans // If the HTML is plain text but we have references or asset mentions, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only) // This handles the case where editorHtml comes from backend (plain text only)
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) { const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
if (editorHtml && !editorHtml.includes('data-ref-id') && (references.length > 0 || currentAssetMentions.length > 0)) {
rebuildMentionSpans(el); rebuildMentionSpans(el);
} }
} }
@ -55,26 +60,88 @@ export function PromptInput() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [insertAtTrigger]); }, [insertAtTrigger]);
// Helper: create a mention span with optional thumbnail
const createMentionSpan = useCallback((opts: {
refId: string; refType: string; label: string; thumbUrl?: string;
assetGroupId?: string; groupName?: string;
}) => {
const span = document.createElement('span');
span.className = styles.mention;
span.contentEditable = 'false';
span.dataset.refId = opts.refId;
span.dataset.refType = opts.refType;
span.draggable = true;
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
if (opts.groupName) span.dataset.groupName = opts.groupName;
if (opts.refType === 'audio') {
const icon = document.createElement('span');
icon.textContent = '\u266B';
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
span.appendChild(icon);
} else if (opts.thumbUrl) {
const img = document.createElement('img');
img.src = tosThumb(opts.thumbUrl, 32);
img.className = styles.mentionImg;
img.setAttribute('width', '16');
img.setAttribute('height', '16');
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
span.appendChild(img);
}
// @ 前缀隐藏textContent 保留用于模式匹配,视觉上不显示)
const atHidden = document.createElement('span');
atHidden.style.cssText = 'font-size:0;width:0;overflow:hidden;display:inline';
atHidden.textContent = '@';
span.appendChild(atHidden);
span.appendChild(document.createTextNode(opts.label));
return span;
}, []);
// Rebuild mention spans from plain text @label patterns // Rebuild mention spans from plain text @label patterns
const rebuildMentionSpans = useCallback((el: HTMLElement) => { const rebuildMentionSpans = useCallback((el: HTMLElement) => {
// Collect all targets to match: references + asset mentions
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string };
const targets: MatchTarget[] = [
...references.map((ref) => ({
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
})),
...currentAssetMentions.map((am) => ({
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
assetGroupId: am.groupId, groupName: am.label,
})),
];
if (targets.length === 0) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; ref: UploadedFile }[] }[] = []; const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
let textNode: Text | null; let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) { while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent || ''; const text = textNode.textContent || '';
const matches: { start: number; end: number; ref: UploadedFile }[] = []; const matches: { start: number; end: number; target: MatchTarget }[] = [];
for (const ref of references) { for (const target of targets) {
const pattern = `@${ref.label}`; const pattern = `@${target.label}`;
let idx = text.indexOf(pattern); let idx = text.indexOf(pattern);
while (idx !== -1) { while (idx !== -1) {
matches.push({ start: idx, end: idx + pattern.length, ref }); matches.push({ start: idx, end: idx + pattern.length, target });
idx = text.indexOf(pattern, idx + pattern.length); idx = text.indexOf(pattern, idx + pattern.length);
} }
} }
if (matches.length > 0) { if (matches.length > 0) {
// Sort by position, remove overlapping matches
matches.sort((a, b) => a.start - b.start); matches.sort((a, b) => a.start - b.start);
replacements.push({ node: textNode, matches }); const filtered: typeof matches = [];
let lastEnd = 0;
for (const m of matches) {
if (m.start >= lastEnd) {
filtered.push(m);
lastEnd = m.end;
}
}
replacements.push({ node: textNode, matches: filtered });
} }
} }
@ -86,12 +153,14 @@ export function PromptInput() {
if (m.start > lastIdx) { if (m.start > lastIdx) {
frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start))); frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start)));
} }
const span = document.createElement('span'); const span = createMentionSpan({
span.className = styles.mention; refId: m.target.refId,
span.contentEditable = 'false'; refType: m.target.refType,
span.dataset.refId = m.ref.id; label: m.target.label,
span.dataset.refType = m.ref.type; thumbUrl: m.target.thumbUrl,
span.textContent = `@${m.ref.label}`; assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
});
frag.appendChild(span); frag.appendChild(span);
lastIdx = m.end; lastIdx = m.end;
} }
@ -104,7 +173,7 @@ export function PromptInput() {
if (replacements.length > 0) { if (replacements.length > 0) {
setEditorHtml(el.innerHTML); setEditorHtml(el.innerHTML);
} }
}, [references, setEditorHtml]); }, [references, setEditorHtml, createMentionSpan]);
const openMentionPopup = useCallback(() => { const openMentionPopup = useCallback(() => {
const el = editorRef.current; const el = editorRef.current;
@ -151,6 +220,7 @@ export function PromptInput() {
}, [setPrompt, setEditorHtml]); }, [setPrompt, setEditorHtml]);
// Remove orphaned mention spans when a reference is deleted // Remove orphaned mention spans when a reference is deleted
// Skip asset-type spans — they are not tied to uploaded references
useEffect(() => { useEffect(() => {
const el = editorRef.current; const el = editorRef.current;
if (!el) return; if (!el) return;
@ -158,6 +228,7 @@ export function PromptInput() {
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]'); const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
let changed = false; let changed = false;
spans.forEach((span) => { spans.forEach((span) => {
if (span.dataset.refType === 'asset') return; // skip asset mentions
if (!refIds.has(span.dataset.refId!)) { if (!refIds.has(span.dataset.refId!)) {
span.replaceWith(''); span.replaceWith('');
changed = true; changed = true;
@ -181,10 +252,45 @@ export function PromptInput() {
const text = node.textContent || ''; const text = node.textContent || '';
const offset = range.startOffset; const offset = range.startOffset;
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
// Keep the @ visible, open popup above it // Find the last @ before cursor
const textBeforeCursor = text.substring(0, offset);
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
if (lastAtIdx < 0) {
// No @ before cursor, close popup
setShowMentionPopup(false);
return;
}
if (lastAtIdx >= 0) {
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
if (textAfterAt.length === 0 && references.length > 0) {
// Just typed @, show reference popup
typedAtRef.current = true; typedAtRef.current = true;
setMentionMode('references');
openMentionPopup(); openMentionPopup();
} else if (/[\u4e00-\u9fff]+/.test(textAfterAt) && !textAfterAt.includes(' ')) {
// Chinese text after @, search assets
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
assetsApi.search(textAfterAt).then((res) => {
if (res.data.results.length > 0) {
setAssetSearchResults(res.data.results);
setMentionMode('assets');
typedAtRef.current = true;
setHighlightedIdx(0);
openMentionPopup();
} else {
setShowMentionPopup(false);
}
}).catch(() => {});
}, 300);
} else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup
setShowMentionPopup(false);
}
} }
}, [extractText, references.length, openMentionPopup]); }, [extractText, references.length, openMentionPopup]);
@ -217,13 +323,13 @@ export function PromptInput() {
range.deleteContents(); range.deleteContents();
// Create mention span // Create mention span with thumbnail
const mention = document.createElement('span'); const mention = createMentionSpan({
mention.className = styles.mention; refId: ref.id,
mention.contentEditable = 'false'; refType: ref.type,
mention.dataset.refId = ref.id; label: ref.label,
mention.dataset.refType = ref.type; thumbUrl: ref.previewUrl,
mention.textContent = `@${ref.label}`; });
// Insert mention + trailing space // Insert mention + trailing space
range.insertNode(mention); range.insertNode(mention);
@ -241,23 +347,85 @@ export function PromptInput() {
extractText(); extractText();
}, [extractText]); }, [extractText]);
const insertAssetMention = useCallback((group: AssetGroup) => {
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
const el = editorRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// Remove the @query text that was typed
if (typedAtRef.current) {
typedAtRef.current = false;
const node = range.startContainer;
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent || '';
const offset = range.startOffset;
const atIdx = text.lastIndexOf('@', offset - 1);
if (atIdx >= 0) {
node.textContent = text.substring(0, atIdx) + text.substring(offset);
range.setStart(node, atIdx);
range.collapse(true);
}
}
}
range.deleteContents();
// Create mention span for asset with thumbnail
const mention = createMentionSpan({
refId: String(group.id),
refType: 'asset',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
});
range.insertNode(mention);
const space = document.createTextNode('\u00A0');
mention.after(space);
const newRange = document.createRange();
newRange.setStartAfter(space);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
extractText();
}, [extractText]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (showMentionPopup) { if (showMentionPopup) {
const items = mentionMode === 'assets' ? assetSearchResults : references;
if (items.length === 0) return;
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
setShowMentionPopup(false); setShowMentionPopup(false);
setMentionMode('references');
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault(); e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % references.length); setHighlightedIdx((prev) => (prev + 1) % items.length);
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault(); e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length); setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
if (mentionMode === 'assets') {
insertAssetMention(assetSearchResults[highlightedIdx]);
} else {
insertMention(references[highlightedIdx]); insertMention(references[highlightedIdx]);
} }
} }
}, [showMentionPopup, references, highlightedIdx, insertMention]); }
}, [showMentionPopup, mentionMode, references, assetSearchResults, highlightedIdx, insertMention, insertAssetMention]);
const handlePaste = useCallback((e: React.ClipboardEvent) => { const handlePaste = useCallback((e: React.ClipboardEvent) => {
e.preventDefault(); e.preventDefault();
@ -276,6 +444,22 @@ export function PromptInput() {
return; return;
} }
// Check if clipboard HTML contains mention spans (from our editor)
const html = e.clipboardData.getData('text/html');
if (html && html.includes('data-ref-id')) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['span', 'br', 'img'],
ALLOWED_ATTR: [
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
'draggable', 'src', 'alt', 'width', 'height', 'style',
],
});
document.execCommand('insertHTML', false, sanitized);
extractText();
return;
}
// Plain text paste — strip @label patterns to prevent duplicate mention tags // Plain text paste — strip @label patterns to prevent duplicate mention tags
let text = e.clipboardData.getData('text/plain'); let text = e.clipboardData.getData('text/plain');
for (const ref of references) { for (const ref of references) {
@ -288,17 +472,38 @@ export function PromptInput() {
extractText(); extractText();
}, [extractText, references]); }, [extractText, references]);
// Mention hover — delegated event // Mention hover — delegated event (supports both reference and asset mentions)
const handleMouseOver = useCallback((e: React.MouseEvent) => { const handleMouseOver = useCallback((e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null; const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (!target) return; if (!target) return;
const refId = target.dataset.refId; const refId = target.dataset.refId;
const ref = references.find((r) => r.id === refId); const refType = target.dataset.refType;
if (!ref) return;
// 音频标签不显示 hover 预览
if (refType === 'audio') return;
// 参考图:从 references 中查找
let found = references.find((r) => r.id === refId);
// 素材库标签:用 data-thumb-url 构造预览数据
if (!found && refType === 'asset') {
const thumbUrl = target.dataset.thumbUrl;
if (thumbUrl) {
found = {
id: refId || '',
type: 'image',
previewUrl: thumbUrl,
label: target.dataset.groupName || target.textContent || '',
};
}
}
if (!found) return;
const rect = target.getBoundingClientRect(); const rect = target.getBoundingClientRect();
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect(); const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
setHoverRef(ref); setHoverRef(found);
setHoverPos({ setHoverPos({
top: rect.top - wrapperRect.top - 8, top: rect.top - wrapperRect.top - 8,
left: rect.left - wrapperRect.left + rect.width / 2, left: rect.left - wrapperRect.left + rect.width / 2,
@ -340,14 +545,64 @@ export function PromptInput() {
onPaste={handlePaste} onPaste={handlePaste}
onMouseOver={handleMouseOver} onMouseOver={handleMouseOver}
onMouseOut={handleMouseOut} onMouseOut={handleMouseOut}
onDragStart={(e) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (target) {
e.dataTransfer.setData('text/html', target.outerHTML);
e.dataTransfer.effectAllowed = 'move';
target.classList.add(styles.dragging);
setHoverRef(null);
}
}}
onDragOver={(e) => {
e.preventDefault();
// 拖拽 mention 标签时让光标跟随鼠标位置
if (!e.dataTransfer.types.includes('Files')) {
const range = document.caretRangeFromPoint(e.clientX, e.clientY);
if (range) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}}
onDrop={(e) => {
e.preventDefault();
const html = e.dataTransfer.getData('text/html');
if (html && html.includes('data-ref-id')) {
// 1. 先用鼠标坐标算出目标位置,插入临时 marker此时 DOM 还没变)
const dropRange = document.caretRangeFromPoint(e.clientX, e.clientY);
if (!dropRange) return;
const marker = document.createTextNode('\u200B');
dropRange.insertNode(marker);
// 2. 再删除原始标签DOM 重排不影响 marker 位置)
const dragging = editorRef.current?.querySelector(`.${styles.dragging}`);
if (dragging) dragging.remove();
// 3. 在 marker 位置插入标签
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstChild;
if (node) {
marker.parentNode?.insertBefore(node, marker);
}
marker.remove();
editorRef.current?.normalize();
extractText();
}
}}
/> />
{/* Mention popup */} {/* Mention popup */}
{showMentionPopup && references.length > 0 && ( {showMentionPopup && (
<div <div
className={styles.mentionPopup} className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }} style={{ top: mentionPos.top, left: mentionPos.left }}
> >
{mentionMode === 'references' && references.length > 0 && (
<>
<div className={styles.mentionHeader}>@的内容</div> <div className={styles.mentionHeader}>@的内容</div>
{references.map((ref, idx) => ( {references.map((ref, idx) => (
<button <button
@ -362,7 +617,7 @@ export function PromptInput() {
{ref.type === 'video' ? ( {ref.type === 'video' ? (
<video src={ref.previewUrl} muted className={styles.thumbMedia} /> <video src={ref.previewUrl} muted className={styles.thumbMedia} />
) : ( ) : (
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} /> <img src={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
)} )}
</div> </div>
<span className={styles.mentionLabel}>{ref.label}</span> <span className={styles.mentionLabel}>{ref.label}</span>
@ -371,6 +626,29 @@ export function PromptInput() {
</span> </span>
</button> </button>
))} ))}
</>
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<button
key={group.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(group);
}}
>
<div className={styles.mentionThumb}>
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></span>
</button>
))}
</>
)}
</div> </div>
)} )}
@ -391,7 +669,7 @@ export function PromptInput() {
/> />
) : ( ) : (
<img <img
src={hoverRef.previewUrl} src={tosThumb(hoverRef.previewUrl, 200)}
alt={hoverRef.label} alt={hoverRef.label}
className={styles.previewMedia} className={styles.previewMedia}
/> />

View File

@ -72,7 +72,8 @@ const generationTypeItems = [
const modelItems = [ const modelItems = [
{ label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> }, { label: 'AirDrama', value: 'seedance_2.0' as ModelOption, icon: <DiamondIcon /> },
{ label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> }, // Fast 暂未开通,隐藏选项
// { label: 'AirDrama Fast', value: 'seedance_2.0_fast' as ModelOption, icon: <LightningIcon /> },
]; ];
const modeItems = [ const modeItems = [
@ -264,6 +265,7 @@ export function Toolbar() {
<polyline points="5 12 12 5 19 12" /> <polyline points="5 12 12 5 19 12" />
</svg> </svg>
</button> </button>
</div> </div>
); );
} }

View File

@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { showToast } from './Toast'; import { showToast } from './Toast';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';
import { tosThumb } from '../lib/api';
import styles from './UniversalUpload.module.css'; import styles from './UniversalUpload.module.css';
const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc const MAX_IMAGE_SIZE = 30 * 1024 * 1024; // 30MB per API doc
@ -124,7 +125,7 @@ export function UniversalUpload() {
<AudioIcon /> <AudioIcon />
</div> </div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} /> <img src={tosThumb(ref.previewUrl, 200)} alt={ref.label} className={styles.thumbMedia} style={{ cursor: 'zoom-in' }} onClick={(e) => { e.stopPropagation(); setLightboxSrc(ref.previewUrl); }} />
)} )}
<div <div
className={styles.thumbClose} className={styles.thumbClose}

View File

@ -60,6 +60,35 @@
background: rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.12);
} }
.floatingActions {
position: absolute;
top: 68px;
right: 20px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 8px;
}
.floatingBtn {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: none;
color: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.floatingBtn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.15);
}
/* Video area — centres the player */ /* Video area — centres the player */
.videoArea { .videoArea {
flex: 1; flex: 1;
@ -236,7 +265,7 @@
.navArrowDisabled { .navArrowDisabled {
opacity: 0.3; opacity: 0.3;
pointer-events: none; cursor: default;
} }
/* /*
@ -428,7 +457,7 @@
.infoBar { .infoBar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
gap: 8px; gap: 8px;
padding: 12px 16px; padding: 12px 16px;
border-radius: 10px; border-radius: 10px;

View File

@ -5,6 +5,8 @@ import { AmbientBackground } from './AmbientBackground';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { ImageLightbox } from './ImageLightbox'; import { ImageLightbox } from './ImageLightbox';
import { useInputBarStore } from '../store/inputBar'; import { useInputBarStore } from '../store/inputBar';
import { renderPromptWithMentions } from './GenerationCard';
import { tosThumb } from '../lib/api';
import styles from './VideoDetailModal.module.css'; import styles from './VideoDetailModal.module.css';
interface Props { interface Props {
@ -13,6 +15,7 @@ interface Props {
onReEdit?: (id: string) => void; onReEdit?: (id: string) => void;
onRegenerate?: (id: string) => void; onRegenerate?: (id: string) => void;
onDelete?: (id: string) => void; onDelete?: (id: string) => void;
onToggleFavorite?: (id: string) => void;
hideReEdit?: boolean; hideReEdit?: boolean;
onPrev?: () => void; onPrev?: () => void;
onNext?: () => void; onNext?: () => void;
@ -20,7 +23,7 @@ interface Props {
hasNext?: boolean; hasNext?: boolean;
} }
export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onPrev, onNext, hasPrev, hasNext, hideReEdit }: Props) { export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDelete, onToggleFavorite, onPrev, onNext, hasPrev, hasNext, hideReEdit }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const videoContainerRef = useRef<HTMLDivElement>(null); const videoContainerRef = useRef<HTMLDivElement>(null);
@ -436,8 +439,12 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</button> </button>
<div className={styles.headerIcons}> <div className={styles.headerIcons}>
<button className={styles.iconBtn} title="收藏"> <button
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> className={styles.iconBtn}
title={task.isFavorited ? '取消收藏' : '收藏'}
onClick={() => task && onToggleFavorite?.(task.id)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill={task.isFavorited ? '#faad14' : 'none'} stroke={task.isFavorited ? '#faad14' : 'currentColor'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /> <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg> </svg>
</button> </button>
@ -468,7 +475,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<div className={styles.infoPanelContent}> <div className={styles.infoPanelContent}>
<div className={styles.promptSection}> <div className={styles.promptSection}>
<div className={styles.sectionLabel}></div> <div className={styles.sectionLabel}></div>
<p className={styles.promptText}>{task.prompt || '(无文字描述)'}</p> <p className={styles.promptText}>{renderPromptWithMentions(task.prompt || '(无文字描述)', task.assetMentions || [], task.references)}</p>
</div> </div>
{task.references.length > 0 && ( {task.references.length > 0 && (
@ -487,7 +494,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</svg> </svg>
</div> </div>
) : ( ) : (
<img src={ref.previewUrl} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} /> <img src={tosThumb(ref.previewUrl, 300)} alt={ref.label} className={styles.refImg} style={{ cursor: 'zoom-in' }} onClick={() => setLightboxSrc(ref.previewUrl)} />
)} )}
<span className={styles.refLabel}>{ref.label}</span> <span className={styles.refLabel}>{ref.label}</span>
</div> </div>
@ -498,7 +505,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
</div> </div>
{/* Re-edit button above info bar */} {/* Re-edit button above info bar */}
{!hideReEdit && <div style={{ padding: '0 20px 12px', borderBottom: '1px solid rgba(255,255,255,0.08)' }}> {!hideReEdit && <div style={{ padding: '16px 24px 12px' }}>
<button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}> <button className={styles.cardBtn} onClick={handleReEdit} style={{ width: '100%', justifyContent: 'center' }}>
<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" />
@ -510,7 +517,7 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
{/* Fixed bottom: info bar + actions card */} {/* Fixed bottom: info bar + actions card */}
<div className={styles.infoPanelBottom}> <div className={styles.infoPanelBottom}>
<div className={styles.infoBar}> <div className={styles.infoBar} style={{ flexWrap: 'wrap', rowGap: 6 }}>
<span>{modeLabel}</span> <span>{modeLabel}</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>{modelLabel}</span> <span>{modelLabel}</span>
@ -520,36 +527,19 @@ export function VideoDetailModal({ task, onClose, onReEdit, onRegenerate, onDele
<span>{task.aspectRatio}</span> <span>{task.aspectRatio}</span>
{(task.tokensConsumed ?? 0) > 0 && ( {(task.tokensConsumed ?? 0) > 0 && (
<> <>
<span className={styles.infoBarDot} />
<span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span> <span>{(task.tokensConsumed ?? 0).toLocaleString()} tokens</span>
<span className={styles.infoBarDot} /> <span className={styles.infoBarDot} />
<span>¥{(task.costAmount ?? 0).toFixed(2)}</span> <span>¥{(task.costAmount ?? 0).toFixed(2)}</span>
</> </>
)} )}
{(task.seed ?? -1) > 0 && (
<>
<span className={styles.infoBarDot} />
<span>: {task.seed}</span>
</>
)}
</div> </div>
{(onReEdit || onRegenerate) && (
<div className={styles.cardActions}>
{onReEdit && (
<button className={styles.cardBtn} onClick={handleReEdit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
{onRegenerate && (
<button className={styles.cardBtn} onClick={handleRegenerate}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</button>
)}
</div>
)}
</div> </div>
</div> </div>

View File

@ -23,7 +23,9 @@ export function VideoGenerationPage() {
const initialLoadRef = useRef(true); const initialLoadRef = useRef(true);
const savedScrollTop = useGenerationStore((s) => s.savedScrollTop); const savedScrollTop = useGenerationStore((s) => s.savedScrollTop);
const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition); const saveScrollPosition = useGenerationStore((s) => s.saveScrollPosition);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null); const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
// Load tasks from backend on mount (persist across page refresh) // Load tasks from backend on mount (persist across page refresh)
useEffect(() => { useEffect(() => {
@ -122,7 +124,7 @@ export function VideoGenerationPage() {
<div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}> <div className={styles.contentArea} ref={scrollRef} onScroll={handleScroll}>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div className={styles.emptyArea}> <div className={styles.emptyArea}>
<p className={styles.emptyHint}> AI </p> <p className={styles.emptyHint}>Every frame was once just air.</p>
</div> </div>
) : ( ) : (
<div className={styles.taskList}> <div className={styles.taskList}>
@ -149,6 +151,7 @@ export function VideoGenerationPage() {
onReEdit={handleReEdit} onReEdit={handleReEdit}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onDelete={handleDelete} onDelete={handleDelete}
onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }}
hasPrev={detailIdx > 0} hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1} hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])} onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])}

View File

@ -4,7 +4,7 @@ import type {
AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse, AdminRecord, SystemSettings, ProfileOverview, PaginatedResponse,
BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats, BackendTask, TeamInfo, Team, TeamDetail, TeamMember, TeamStats,
AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo, AuditLog, AssetTeamSummary, AssetMemberSummary, AssetVideo,
LoginAnomaly, TeamAnomalyConfig, LoginAnomaly, TeamAnomalyConfig, AssetGroup, AssetItem,
} from '../types'; } from '../types';
import { reportError } from './logCenter'; import { reportError } from './logCenter';
@ -67,6 +67,9 @@ api.interceptors.response.use(
refresh: refreshToken, refresh: refreshToken,
}); });
localStorage.setItem('access_token', data.access); localStorage.setItem('access_token', data.access);
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
}
originalRequest.headers.Authorization = `Bearer ${data.access}`; originalRequest.headers.Authorization = `Bearer ${data.access}`;
return api(originalRequest); return api(originalRequest);
} catch { } catch {
@ -96,7 +99,7 @@ export const authApi = {
api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }), api.post<{ user: User; tokens: AuthTokens }>('/auth/login', { username, password }),
refreshToken: (refresh: string) => refreshToken: (refresh: string) =>
api.post<{ access: string }>('/auth/token/refresh', { refresh }), api.post<{ access: string; refresh?: string }>('/auth/token/refresh', { refresh }),
getMe: () => getMe: () =>
api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'), api.get<User & { quota: Quota; team: TeamInfo | null; team_disabled: boolean }>('/auth/me'),
@ -131,7 +134,9 @@ export const videoApi = {
model: string; model: string;
aspect_ratio: string; aspect_ratio: string;
duration: number; duration: number;
references: { url: string; type: string; role: string; label: string }[]; references: { url: string; type: string; role: string; label: string; thumb_url?: string }[];
search_mode?: string;
seed?: number;
}) => }) =>
api.post<{ api.post<{
task_id: string; task_id: string;
@ -151,6 +156,9 @@ export const videoApi = {
deleteTask: (taskId: string) => deleteTask: (taskId: string) =>
api.delete(`/video/tasks/${taskId}`), api.delete(`/video/tasks/${taskId}`),
toggleFavorite: (taskId: string) =>
api.post<{ is_favorited: boolean }>(`/video/tasks/${taskId}/favorite`),
getAnnouncement: () => getAnnouncement: () =>
api.get<{ announcement: string; enabled: boolean }>('/announcement'), api.get<{ announcement: string; enabled: boolean }>('/announcement'),
}; };
@ -170,7 +178,7 @@ export const adminApi = {
getTeamDetail: (teamId: number) => getTeamDetail: (teamId: number) =>
api.get<TeamDetail>(`/admin/teams/${teamId}`), api.get<TeamDetail>(`/admin/teams/${teamId}`),
updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) => updateTeam: (teamId: number, data: { name?: string; monthly_seconds_limit?: number; monthly_spending_limit?: number; daily_member_limit_default?: number; markup_percentage?: number; max_concurrent_tasks?: number; is_active?: boolean; expected_regions?: string; anomaly_config?: Partial<TeamAnomalyConfig> }) =>
api.put(`/admin/teams/${teamId}`, data), api.put(`/admin/teams/${teamId}`, data),
topUpTeam: (teamId: number, amount: number) => topUpTeam: (teamId: number, amount: number) =>
@ -205,10 +213,11 @@ export const adminApi = {
getUserDetail: (userId: number) => getUserDetail: (userId: number) =>
api.get<AdminUserDetail>(`/admin/users/${userId}`), api.get<AdminUserDetail>(`/admin/users/${userId}`),
updateUserQuota: (userId: number, daily: number, monthly: number) => updateUserQuota: (userId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/admin/users/${userId}/quota`, { api.put(`/admin/users/${userId}/quota`, {
daily_generation_limit: daily, daily_generation_limit: daily,
monthly_generation_limit: monthly, monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}), }),
updateUserStatus: (userId: number, isActive: boolean) => updateUserStatus: (userId: number, isActive: boolean) =>
@ -278,6 +287,9 @@ export const adminApi = {
testFeishu: (mobile: string) => testFeishu: (mobile: string) =>
api.post<{ message: string }>('/admin/test-feishu', { mobile }), api.post<{ message: string }>('/admin/test-feishu', { mobile }),
testSms: (mobile: string) =>
api.post<{ message: string }>('/admin/test-sms', { mobile }),
teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) => teamAutoLearn: (teamId: number, days: number = 30, minCount: number = 3) =>
api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>( api.post<{ team_id: number; team_name: string; learned_cities: string[]; days: number; min_count: number; current_expected_regions: string }>(
`/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount } `/admin/teams/${teamId}/auto-learn`, { days, min_count: minCount }
@ -286,6 +298,17 @@ export const adminApi = {
teamApplyLearnedRegions: (teamId: number, cities: string[]) => teamApplyLearnedRegions: (teamId: number, cities: string[]) =>
api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }), api.post(`/admin/teams/${teamId}/apply-learned-regions`, { cities }),
getLoginRecords: (params: {
page?: number;
page_size?: number;
search?: string;
team_id?: string;
start_date?: string;
end_date?: string;
city?: string;
} = {}) =>
api.get('/admin/login-records', { params }),
getAuditLogs: (params: { getAuditLogs: (params: {
page?: number; page?: number;
page_size?: number; page_size?: number;
@ -314,10 +337,11 @@ export const teamApi = {
getMemberDetail: (memberId: number) => getMemberDetail: (memberId: number) =>
api.get('/team/members/' + memberId), api.get('/team/members/' + memberId),
updateMemberQuota: (memberId: number, daily: number, monthly: number) => updateMemberQuota: (memberId: number, daily: number, monthly: number, spendingLimit?: number) =>
api.put(`/team/members/${memberId}/quota`, { api.put(`/team/members/${memberId}/quota`, {
daily_generation_limit: daily, daily_generation_limit: daily,
monthly_generation_limit: monthly, monthly_generation_limit: monthly,
...(spendingLimit !== undefined && { spending_limit: spendingLimit }),
}), }),
updateMemberStatus: (memberId: number, isActive: boolean) => updateMemberStatus: (memberId: number, isActive: boolean) =>
@ -343,6 +367,16 @@ export const teamApi = {
page_size: number; page_size: number;
results: AssetVideo[]; results: AssetVideo[];
}>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }), }>(`/team/assets/member/${memberId}/videos`, { params: { page, page_size: pageSize } }),
// Consumption Records
getRecords: (params: {
page?: number;
page_size?: number;
search?: string;
start_date?: string;
end_date?: string;
} = {}) =>
api.get<{ total: number; page: number; page_size: number; results: AdminRecord[] }>('/team/records', { params }),
}; };
// Profile APIs // Profile APIs
@ -356,4 +390,36 @@ export const profileApi = {
}), }),
}; };
export const assetsApi = {
getGroups: (params: { page?: number; page_size?: number } = {}) =>
api.get<{ results: AssetGroup[]; total: number }>('/assets/groups', { params }),
createGroup: (data: FormData) =>
api.post<AssetGroup>('/assets/groups', data, { headers: { 'Content-Type': 'multipart/form-data' } }),
getGroupDetail: (id: number) =>
api.get<AssetGroup & { assets: AssetItem[] }>(`/assets/groups/${id}`),
updateGroup: (id: number, data: { name?: string; description?: string }) =>
api.put(`/assets/groups/${id}`, data),
addAsset: (groupId: number, data: FormData) =>
api.post<AssetItem>(`/assets/groups/${groupId}/assets`, data, { headers: { 'Content-Type': 'multipart/form-data' } }),
updateAsset: (id: number, data: { name: string }) =>
api.put(`/assets/${id}`, data),
search: (q: string) =>
api.get<{ results: AssetGroup[] }>('/assets/search', { params: { q } }),
pollStatus: (id: number) =>
api.get<{ id: number; status: string; url: string; error_message: string }>(`/assets/${id}/status`),
};
/**
* Append TOS image resize parameter to reduce loading size.
* Only applies to TOS image URLs (volces.com with image extensions).
*/
export function tosThumb(url: string | undefined, height: number): string {
if (!url) return '';
// 只对我们自己的 TOS 桶生效airdrama-media不处理火山内部桶ark-media-asset 等)
if (!url.includes('airdrama-media')) return url;
if (!/\.(png|jpg|jpeg|webp|gif)/i.test(url)) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}x-tos-process=image/resize,h_${height}`;
}
export default api; export default api;

View File

@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references,
assetMentions: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,
@ -235,6 +236,18 @@ export function AdminAssetsPage() {
task={detailTask} task={detailTask}
onClose={() => setDetailTask(null)} onClose={() => setDetailTask(null)}
hideReEdit hideReEdit
{...(() => {
if (!detailTask || !expandedMember || !memberVideos[expandedMember]) return {};
const vids = memberVideos[expandedMember].videos;
const idx = vids.findIndex((v) => String(v.id) === detailTask.id);
if (idx < 0) return {};
return {
hasPrev: idx > 0,
hasNext: idx < vids.length - 1,
onPrev: () => idx > 0 && setDetailTask(assetVideoToTask(vids[idx - 1])),
onNext: () => idx < vids.length - 1 && setDetailTask(assetVideoToTask(vids[idx + 1])),
};
})()}
/> />
</div> </div>
); );

View File

@ -13,6 +13,7 @@ const navItems = [
{ 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/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' }, { path: '/admin/security', label: '安全日志', icon: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z' },
{ path: '/admin/login-records', label: '登录记录', icon: 'M11 7L9.6 8.4l2.6 2.6H2v2h10.2l-2.6 2.6L11 17l5-5-5-5zm9 12h-8v2h8c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2h-8v2h8v14z' },
{ path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' }, { path: '/admin/logs', label: '操作日志', icon: 'M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z' },
]; ];

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useMemo } from 'react'; import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { Sidebar } from '../components/Sidebar'; import { Sidebar } from '../components/Sidebar';
import { VideoDetailModal } from '../components/VideoDetailModal'; import { VideoDetailModal } from '../components/VideoDetailModal';
import { useGenerationStore } from '../store/generation'; import { useGenerationStore } from '../store/generation';
@ -91,19 +91,28 @@ export function AssetsPage() {
const reEdit = useGenerationStore((s) => s.reEdit); const reEdit = useGenerationStore((s) => s.reEdit);
const regenerate = useGenerationStore((s) => s.regenerate); const regenerate = useGenerationStore((s) => s.regenerate);
const removeTask = useGenerationStore((s) => s.removeTask); const removeTask = useGenerationStore((s) => s.removeTask);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null); const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const detailTask = useMemo(() => detailTaskId ? tasks.find((t) => t.id === detailTaskId) || null : null, [detailTaskId, tasks]);
const setDetailTask = useCallback((t: GenerationTask | null) => setDetailTaskId(t?.id || null), []);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadTasks(); loadTasks();
}, [loadTasks]); }, [loadTasks]);
const [subTab, setSubTab] = useState<'all' | 'favorites'>('all');
const completedTasks = useMemo( const completedTasks = useMemo(
() => tasks.filter((t) => t.status === 'completed'), () => tasks.filter((t) => t.status === 'completed'),
[tasks], [tasks],
); );
const dateGroups = useMemo(() => groupByDate(completedTasks), [completedTasks]); const displayTasks = useMemo(
() => subTab === 'favorites' ? completedTasks.filter((t) => t.isFavorited) : completedTasks,
[completedTasks, subTab],
);
const dateGroups = useMemo(() => groupByDate(displayTasks), [displayTasks]);
const handleReEdit = (id: string) => { const handleReEdit = (id: string) => {
reEdit(id); reEdit(id);
@ -127,7 +136,7 @@ export function AssetsPage() {
setConfirmDeleteId(null); setConfirmDeleteId(null);
}; };
const detailIdx = detailTask ? completedTasks.findIndex((t) => t.id === detailTask.id) : -1; const detailIdx = detailTask ? displayTasks.findIndex((t) => t.id === detailTask.id) : -1;
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
@ -139,16 +148,16 @@ export function AssetsPage() {
<span className={`${styles.tab} ${styles.tabActive}`}></span> <span className={`${styles.tab} ${styles.tabActive}`}></span>
</div> </div>
<div className={styles.subTabs}> <div className={styles.subTabs}>
<span className={`${styles.subTab} ${styles.subTabActive}`}></span> <span className={`${styles.subTab} ${subTab === 'all' ? styles.subTabActive : ''}`} onClick={() => setSubTab('all')} style={{ cursor: 'pointer' }}></span>
<span className={styles.subTab}></span> <span className={`${styles.subTab} ${subTab === 'favorites' ? styles.subTabActive : ''}`} onClick={() => setSubTab('favorites')} style={{ cursor: 'pointer' }}></span>
</div> </div>
</div> </div>
{/* Video grid by date */} {/* Video grid by date */}
<div className={styles.content}> <div className={styles.content}>
{completedTasks.length === 0 ? ( {displayTasks.length === 0 ? (
<div className={styles.empty}> <div className={styles.empty}>
<p></p> <p>{subTab === 'favorites' ? '暂无收藏的视频' : '暂无已完成的视频'}</p>
</div> </div>
) : ( ) : (
dateGroups.map((group) => ( dateGroups.map((group) => (
@ -175,10 +184,11 @@ export function AssetsPage() {
onReEdit={handleReEdit} onReEdit={handleReEdit}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onDelete={handleDelete} onDelete={handleDelete}
onToggleFavorite={(id) => { useGenerationStore.getState().toggleFavorite(id); }}
hasPrev={detailIdx > 0} hasPrev={detailIdx > 0}
hasNext={detailIdx >= 0 && detailIdx < completedTasks.length - 1} hasNext={detailIdx >= 0 && detailIdx < displayTasks.length - 1}
onPrev={() => detailIdx > 0 && setDetailTask(completedTasks[detailIdx - 1])} onPrev={() => detailIdx > 0 && setDetailTask(displayTasks[detailIdx - 1])}
onNext={() => detailIdx < completedTasks.length - 1 && setDetailTask(completedTasks[detailIdx + 1])} onNext={() => detailIdx < displayTasks.length - 1 && setDetailTask(displayTasks[detailIdx + 1])}
/> />
<ConfirmModal <ConfirmModal

View File

@ -0,0 +1,46 @@
.page { max-width: none; }
.title { font-size: 22px; font-weight: 600; color: var(--color-text-primary); margin-bottom: 20px; }
.filters { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
.searchInput {
padding: 8px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 8px; color: var(--color-text-primary); font-size: 13px; width: 160px; outline: none;
}
.searchInput:focus { border-color: var(--color-primary); }
.dateSep { color: var(--color-text-secondary); font-size: 13px; }
.searchBtn { padding: 8px 16px; background: var(--color-primary); border: none; border-radius: 8px; color: #fff; font-size: 13px; cursor: pointer; }
.searchBtn:hover { opacity: 0.9; }
.refreshBtn {
padding: 8px 16px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: all 0.15s;
background: transparent; border: 1px solid var(--color-border-card); color: var(--color-text-secondary);
}
.refreshBtn:hover { background: var(--color-sidebar-hover); }
.tableWrapper {
background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: var(--radius-card); overflow-x: auto;
}
.table { width: 100%; border-collapse: collapse; font-size: 13px; max-width: none; }
.table th { padding: 12px 16px; text-align: left; color: var(--color-text-secondary); font-weight: 500; border-bottom: 1px solid var(--color-border-card); white-space: nowrap; }
.table td { padding: 12px 16px; color: var(--color-text-primary); border-bottom: 1px solid rgba(42, 42, 56, 0.5); white-space: nowrap; }
.table tr:last-child td { border-bottom: none; }
.table tr:hover td { background: rgba(255, 255, 255, 0.02); }
.timeCell { white-space: nowrap; font-size: 12px; color: var(--color-text-secondary); }
.ipCell { font-family: 'JetBrains Mono', monospace; font-size: 12px; color: var(--color-text-secondary); white-space: nowrap; }
.sourceBadge { padding: 2px 8px; border-radius: 4px; font-size: 12px; background: rgba(0, 184, 230, 0.12); color: var(--color-primary); white-space: nowrap; }
.empty { text-align: center; color: var(--color-text-secondary); padding: 40px; }
.skeletonCell { height: 16px; background: var(--color-border-card); border-radius: 4px; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
.pagination { display: flex; justify-content: space-between; align-items: center; margin-top: 16px; }
.pageInfo { color: var(--color-text-secondary); font-size: 13px; }
.pageButtons { display: flex; gap: 4px; }
.pageButtons button {
padding: 6px 12px; background: var(--color-bg-card); border: 1px solid var(--color-border-card);
border-radius: 6px; color: var(--color-text-secondary); font-size: 13px; cursor: pointer;
}
.pageButtons button:hover:not(:disabled) { background: var(--color-sidebar-hover); color: var(--color-text-primary); }
.pageButtons button:disabled { opacity: 0.4; cursor: not-allowed; }
.activePage { background: var(--color-primary) !important; color: #fff !important; border-color: var(--color-primary) !important; }

View File

@ -0,0 +1,182 @@
import { useEffect, useState, useCallback } from 'react';
import { adminApi } from '../lib/api';
import type { Team } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import { Select } from '../components/Select';
import styles from './LoginRecordsPage.module.css';
interface LoginRecord {
id: number;
username: string;
user_id: number;
team_name: string | null;
ip_address: string;
geo_country: string;
geo_province: string;
geo_city: string;
geo_source: string;
user_agent: string;
created_at: string;
}
const GEO_SOURCE_MAP: Record<string, string> = {
online: '在线API',
offline: '离线库',
skip: '跳过',
failed: '失败',
};
function formatGeoSource(source: string): string {
if (!source) return '-';
return GEO_SOURCE_MAP[source] || source;
}
export function LoginRecordsPage() {
const [records, setRecords] = useState<LoginRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [teamFilter, setTeamFilter] = useState('');
const [citySearch, setCitySearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
const pageSize = 20;
// Load teams for filter dropdown
useEffect(() => {
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
}, []);
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const { data } = await adminApi.getLoginRecords({
page, page_size: pageSize,
search: search || undefined,
team_id: teamFilter || undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
city: citySearch || undefined,
});
setRecords(data.results);
setTotal(data.total);
} catch {
showToast('加载登录记录失败');
} finally {
setLoading(false);
}
}, [page, search, teamFilter, startDate, endDate, citySearch]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleSearch = () => {
setPage(1);
fetchRecords();
};
const totalPages = Math.ceil(total / pageSize);
return (
<div className={styles.page}>
<h1 className={styles.title}></h1>
<div className={styles.filters}>
<input
type="text"
className={styles.searchInput}
placeholder="搜索用户名..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Select
value={teamFilter}
onChange={(v) => { setTeamFilter(v); setPage(1); }}
placeholder="全部团队"
options={[{ label: '全部团队', value: '' }, ...teams.map((t) => ({ label: t.name, value: String(t.id) }))]}
/>
<input
type="text"
className={styles.searchInput}
placeholder="搜索城市..."
value={citySearch}
onChange={(e) => setCitySearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
<button className={styles.refreshBtn} onClick={fetchRecords}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th>IP地址</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<td>{r.username}</td>
<td>{r.team_name || '-'}</td>
<td className={styles.ipCell}>{r.ip_address || '-'}</td>
<td>{r.geo_country || '-'}</td>
<td>{r.geo_province || '-'}</td>
<td>{r.geo_city || '-'}</td>
<td><span className={styles.sourceBadge}>{formatGeoSource(r.geo_source)}</span></td>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}

View File

@ -42,12 +42,12 @@
.completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); } .completed { background: rgba(0, 184, 148, 0.15); color: var(--color-success); }
.failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); } .failed { background: rgba(231, 76, 60, 0.15); color: var(--color-danger); }
.statusCell { position: relative; } .statusCell { position: relative; }
.statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); } .statusCell:hover .errorTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
.errorTooltip { .errorTooltip {
position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%) translateY(4px); position: absolute; bottom: calc(100% + 4px); right: 0; transform: translateY(4px);
background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px; background: #16161e; border: 1px solid var(--color-border-card); border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: nowrap; padding: 6px 10px; font-size: 12px; color: var(--color-danger); white-space: normal;
max-width: 300px; overflow: hidden; text-overflow: ellipsis; max-width: 360px; width: max-content;
opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10; opacity: 0; visibility: hidden; transition: all 0.15s; z-index: 10;
pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3); pointer-events: none; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }

View File

@ -33,6 +33,7 @@ export function SettingsPage() {
alert_cooldown_seconds: 1800, alert_cooldown_seconds: 1800,
}); });
const [testingFeishu, setTestingFeishu] = useState(false); const [testingFeishu, setTestingFeishu] = useState(false);
const [testingSms, setTestingSms] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -87,6 +88,20 @@ export function SettingsPage() {
} }
}; };
const handleTestSms = async () => {
const mobiles = settings.sms_alert_mobiles.split(',').map(s => s.trim()).filter(Boolean);
if (mobiles.length === 0) { showToast('请先填写短信告警手机号'); return; }
setTestingSms(true);
try {
await adminApi.testSms(mobiles[0]);
showToast('测试短信已发送');
} catch (err: any) {
showToast(err.response?.data?.error || '发送失败');
} finally {
setTestingSms(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className={styles.page}> <div className={styles.page}>
@ -330,12 +345,23 @@ export function SettingsPage() {
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label></label> <label></label>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input <input
type="text" type="text"
value={settings.sms_alert_mobiles} value={settings.sms_alert_mobiles}
onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })} onChange={(e) => setSettings({ ...settings, sms_alert_mobiles: e.target.value })}
placeholder="多个手机号用逗号分隔,如 13800138000,13900139000" placeholder="多个手机号用逗号分隔,如 13800138000,13900139000"
style={{ flex: 1 }}
/> />
<button
className={styles.saveBtn}
onClick={handleTestSms}
disabled={testingSms}
style={{ whiteSpace: 'nowrap', padding: '10px 16px' }}
>
{testingSms ? '发送中...' : '测试'}
</button>
</div>
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>

View File

@ -7,6 +7,7 @@ import styles from './AdminLayout.module.css';
const navItems = [ const navItems = [
{ path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' }, { path: '/team/dashboard', label: '概览', icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },
{ path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' }, { path: '/team/members', label: '成员管理', icon: 'M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z' },
{ path: '/team/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 10H7v-2h10v2zm0-4H7V7h10v2z' },
{ 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' }, { 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' },
]; ];

View File

@ -49,6 +49,7 @@ function assetVideoToTask(v: AssetVideo): GenerationTask {
aspectRatio: (v.aspect_ratio as any) || '16:9', aspectRatio: (v.aspect_ratio as any) || '16:9',
duration: v.duration as any, duration: v.duration as any,
references, references,
assetMentions: [],
status: 'completed', status: 'completed',
progress: 100, progress: 100,
resultUrl: v.result_url, resultUrl: v.result_url,
@ -182,6 +183,18 @@ export function TeamAssetsPage() {
<VideoDetailModal <VideoDetailModal
task={detailTask} task={detailTask}
onClose={() => setDetailTask(null)} onClose={() => setDetailTask(null)}
{...(() => {
if (!detailTask || !expandedMember || !memberVideos[expandedMember]) return {};
const vids = memberVideos[expandedMember].videos;
const idx = vids.findIndex((v) => String(v.id) === detailTask.id);
if (idx < 0) return {};
return {
hasPrev: idx > 0,
hasNext: idx < vids.length - 1,
onPrev: () => idx > 0 && setDetailTask(assetVideoToTask(vids[idx - 1])),
onNext: () => idx < vids.length - 1 && setDetailTask(assetVideoToTask(vids[idx + 1])),
};
})()}
/> />
</div> </div>
); );

View File

@ -24,6 +24,7 @@ export function TeamMembersPage() {
const [editMember, setEditMember] = useState<TeamMember | null>(null); const [editMember, setEditMember] = useState<TeamMember | null>(null);
const [editDaily, setEditDaily] = useState(''); const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState(''); const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
const fetchMembers = useCallback(async () => { const fetchMembers = useCallback(async () => {
setLoading(true); setLoading(true);
@ -56,12 +57,13 @@ export function TeamMembersPage() {
setEditMember(member); setEditMember(member);
setEditDaily(String(member.daily_generation_limit ?? 50)); setEditDaily(String(member.daily_generation_limit ?? 50));
setEditMonthly(String(member.monthly_generation_limit ?? 500)); setEditMonthly(String(member.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(member.spending_limit ?? -1));
}; };
const handleSaveQuota = async () => { const handleSaveQuota = async () => {
if (!editMember) return; if (!editMember) return;
try { try {
await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly)); await teamApi.updateMemberQuota(editMember.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast('配额已更新'); showToast('配额已更新');
setEditMember(null); setEditMember(null);
fetchMembers(); fetchMembers();
@ -119,6 +121,7 @@ export function TeamMembersPage() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th>/</th> <th>/</th>
<th>/</th> <th>/</th>
<th></th> <th></th>
@ -128,13 +131,13 @@ export function TeamMembersPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{Array.from({ length: 8 }).map((_, j) => ( {Array.from({ length: 9 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td> <td key={j}><div className={styles.skeletonCell} /></td>
))} ))}
</tr> </tr>
)) ))
) : members.length === 0 ? ( ) : members.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr> <tr><td colSpan={9} className={styles.empty}></td></tr>
) : ( ) : (
members.map((m) => ( members.map((m) => (
<tr key={m.id}> <tr key={m.id}>
@ -160,6 +163,7 @@ export function TeamMembersPage() {
</td> </td>
<td>{formatLimit(m.daily_generation_limit)}</td> <td>{formatLimit(m.daily_generation_limit)}</td>
<td>{formatLimit(m.monthly_generation_limit)}</td> <td>{formatLimit(m.monthly_generation_limit)}</td>
<td>{m.spending_limit === -1 ? '不限' : `¥${m.total_spent.toFixed(2)} / ¥${m.spending_limit.toFixed(2)}`}</td>
<td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td> <td>{(m.generations_today || 0) + '次 / ' + fmtMoney(m.spent_today)}</td>
<td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td> <td>{(m.generations_this_month || 0) + '次 / ' + fmtMoney(m.spent_this_month)}</td>
<td> <td>
@ -205,6 +209,10 @@ export function TeamMembersPage() {
<label>-1 </label> <label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} /> <input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div> </div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
<div className={styles.modalActions}> <div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button> <button className={styles.cancelBtn} onClick={() => setEditMember(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button> <button className={styles.saveBtn} onClick={handleSaveQuota}></button>

View File

@ -0,0 +1,170 @@
import { useEffect, useState, useCallback } from 'react';
import { teamApi } from '../lib/api';
import type { AdminRecord } from '../types';
import { showToast } from '../components/Toast';
import { DatePicker } from '../components/DatePicker';
import styles from './RecordsPage.module.css';
export function TeamRecordsPage() {
const [records, setRecords] = useState<AdminRecord[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [loading, setLoading] = useState(true);
const pageSize = 20;
const fetchRecords = useCallback(async () => {
setLoading(true);
try {
const { data } = await teamApi.getRecords({
page, page_size: pageSize, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
setRecords(data.results);
setTotal(data.total);
} catch {
showToast('加载消费记录失败');
} finally {
setLoading(false);
}
}, [page, search, startDate, endDate]);
useEffect(() => { fetchRecords(); }, [fetchRecords]);
const handleSearch = () => {
setPage(1);
fetchRecords();
};
const handleExportCSV = async () => {
try {
const { data } = await teamApi.getRecords({
page: 1, page_size: 10000, search,
start_date: startDate || undefined,
end_date: endDate || undefined,
});
const header = '时间,用户名,消费秒数,Tokens,费用(元),提示词,生成模式,状态,失败原因\n';
const rows = data.results.map((r) => {
const prompt = r.prompt.replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
const modeLabel = r.mode === 'universal' ? '全能参考' : '首尾帧';
const statusLabel = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' }[r.status];
const errorMsg = (r.error_message || '').replace(/"/g, '""').replace(/^[=+\-@]/, "'$&");
return `${r.created_at},${r.username},"${r.seconds_consumed}","${r.tokens_consumed || 0}","${(r.cost_amount || 0).toFixed(2)}","${prompt}","${modeLabel}","${statusLabel}","${errorMsg}"`;
}).join('\n');
const blob = new Blob(['\uFEFF' + header + rows], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `团队消费记录_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast('导出成功');
} catch {
showToast('导出失败');
}
};
const totalPages = Math.ceil(total / pageSize);
const statusMap: Record<string, string> = { queued: '排队中', processing: '生成中', completed: '已完成', failed: '失败' };
return (
<div className={styles.page}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
<button className={styles.exportBtn} onClick={handleExportCSV}> CSV</button>
</div>
<div className={styles.filters}>
<input
type="text"
className={styles.searchInput}
placeholder="按用户名搜索..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
<span className={styles.dateSep}>~</span>
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
<button className={styles.searchBtn} onClick={handleSearch}></button>
</div>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>Tokens</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td>
))}
</tr>
))
) : records.length === 0 ? (
<tr><td colSpan={8} className={styles.empty}></td></tr>
) : (
records.map((r) => (
<tr key={r.id}>
<td className={styles.timeCell}>{new Date(r.created_at).toLocaleString('zh-CN')}</td>
<td>{r.username}</td>
<td><span className={styles.secondsBadge}>{r.seconds_consumed.toLocaleString()}s</span></td>
<td>{(r.tokens_consumed || 0).toLocaleString()}</td>
<td>¥{(r.cost_amount || 0).toFixed(2)}</td>
<td className={styles.promptCell}>{r.prompt ? (r.prompt.slice(0, 40) + (r.prompt.length > 40 ? '...' : '')) : '-'}</td>
<td>{r.mode === 'universal' ? '全能参考' : '首尾帧'}</td>
<td className={r.status === 'failed' && r.error_message ? styles.statusCell : undefined}>
<span className={`${styles.statusBadge} ${styles[r.status]}`}>
{statusMap[r.status]}
</span>
{r.status === 'failed' && r.error_message && (
<span className={styles.errorTooltip}>{r.error_message}</span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<span className={styles.pageInfo}> {total} </span>
<div className={styles.pageButtons}>
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>&lt;</button>
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
let p: number;
if (totalPages <= 5) p = i + 1;
else if (page <= 3) p = i + 1;
else if (page >= totalPages - 2) p = totalPages - 4 + i;
else p = page - 2 + i;
return (
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
{p}
</button>
);
})}
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>&gt;</button>
</div>
</div>
)}
</div>
);
}

View File

@ -87,6 +87,8 @@ export function TeamsPage() {
const [editMarkupValue, setEditMarkupValue] = useState(''); const [editMarkupValue, setEditMarkupValue] = useState('');
const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false); const [editingAnomalyConfig, setEditingAnomalyConfig] = useState(false);
const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({}); const [anomalyConfigDraft, setAnomalyConfigDraft] = useState<Record<string, any>>({});
const [editingMaxConcurrent, setEditingMaxConcurrent] = useState(false);
const [editMaxConcurrentValue, setEditMaxConcurrentValue] = useState('');
const resetCreateForm = () => { const resetCreateForm = () => {
setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50'); setNewName(''); setNewMonthlyLimit('10000'); setNewDailyMemberLimit('50');
@ -212,7 +214,7 @@ export function TeamsPage() {
} }
}; };
const colCount = 9; const colCount = 10;
return ( return (
<div className={styles.page}> <div className={styles.page}>
@ -239,6 +241,7 @@ export function TeamsPage() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th></th> <th></th>
<th></th> <th></th>
</tr> </tr>
@ -268,6 +271,7 @@ export function TeamsPage() {
<td>{fmtMoney(t.monthly_spending_limit)}</td> <td>{fmtMoney(t.monthly_spending_limit)}</td>
<td>{fmtMoney(t.monthly_spent)}</td> <td>{fmtMoney(t.monthly_spent)}</td>
<td>{t.member_count}</td> <td>{t.member_count}</td>
<td>{t.current_processing ?? 0}/{t.max_concurrent_tasks}</td>
<td> <td>
<span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}> <span className={`${styles.statusBadge} ${t.is_active ? styles.active : styles.disabled}`}>
{t.is_active ? '启用' : '禁用'} {t.is_active ? '启用' : '禁用'}
@ -322,7 +326,7 @@ export function TeamsPage() {
</div> </div>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<label></label> <label></label>
<input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州,深圳,北京" /> <input type="text" value={newExpectedRegions} onChange={(e) => setNewExpectedRegions(e.target.value)} placeholder="广州,深圳,北京" />
</div> </div>
{createError && <div className={styles.formError}>{createError}</div>} {createError && <div className={styles.formError}>{createError}</div>}
<div className={styles.modalActions}> <div className={styles.modalActions}>
@ -556,6 +560,58 @@ export function TeamsPage() {
<span className={styles.detailLabel}></span> <span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{detailTeam.member_count}</span> <span className={styles.detailValue}>{detailTeam.member_count}</span>
</div> </div>
<div className={styles.detailItem}>
<span className={styles.detailLabel}></span>
<span className={styles.detailValue}>
{editingMaxConcurrent ? (
<span style={{ display: 'inline-flex', gap: 6, alignItems: 'center', flexWrap: 'nowrap' }}>
<input
type="number"
value={editMaxConcurrentValue}
onChange={(e) => setEditMaxConcurrentValue(e.target.value)}
style={{ width: 80, padding: '3px 6px', borderRadius: 4, border: '1px solid var(--color-border-card)', background: 'var(--color-bg-page)', color: 'var(--color-text-primary)', fontSize: 13 }}
/>
<button
className={styles.topupBtn}
onClick={async () => {
const val = Number(editMaxConcurrentValue);
if (isNaN(val) || val < 1) { showToast('请输入大于0的整数'); return; }
try {
await adminApi.updateTeam(detailTeam.id, { max_concurrent_tasks: val });
setDetailTeam({ ...detailTeam, max_concurrent_tasks: val });
setTeams(teams.map(t => t.id === detailTeam.id ? { ...t, max_concurrent_tasks: val } : t));
setEditingMaxConcurrent(false);
showToast('并发上限已更新');
} catch (e: any) {
showToast(e.response?.data?.error || '保存失败');
}
}}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
<button
className={styles.topupBtn}
onClick={() => setEditingMaxConcurrent(false)}
style={{ fontSize: 12, padding: '4px 10px', whiteSpace: 'nowrap' }}
>
</button>
</span>
) : (
<>
{detailTeam.max_concurrent_tasks}
<button
className={styles.topupBtn}
onClick={() => { setEditingMaxConcurrent(true); setEditMaxConcurrentValue(String(detailTeam.max_concurrent_tasks || 1)); }}
style={{ fontSize: 12, padding: '4px 10px', marginLeft: 8 }}
>
</button>
</>
)}
</span>
</div>
<div className={styles.detailItem}> <div className={styles.detailItem}>
<span className={styles.detailLabel}></span> <span className={styles.detailLabel}></span>
<span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span> <span className={styles.detailValue}>{new Date(detailTeam.created_at).toLocaleDateString('zh-CN')}</span>

View File

@ -21,6 +21,7 @@ export function UsersPage() {
const [editUser, setEditUser] = useState<AdminUser | null>(null); const [editUser, setEditUser] = useState<AdminUser | null>(null);
const [editDaily, setEditDaily] = useState(''); const [editDaily, setEditDaily] = useState('');
const [editMonthly, setEditMonthly] = useState(''); const [editMonthly, setEditMonthly] = useState('');
const [editSpendingLimit, setEditSpendingLimit] = useState('');
// User detail drawer // User detail drawer
const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null); const [detailUser, setDetailUser] = useState<AdminUserDetail | null>(null);
@ -86,12 +87,13 @@ export function UsersPage() {
setEditUser(user); setEditUser(user);
setEditDaily(String(user.daily_generation_limit ?? 50)); setEditDaily(String(user.daily_generation_limit ?? 50));
setEditMonthly(String(user.monthly_generation_limit ?? 500)); setEditMonthly(String(user.monthly_generation_limit ?? 500));
setEditSpendingLimit(String(user.spending_limit ?? -1));
}; };
const handleSaveQuota = async () => { const handleSaveQuota = async () => {
if (!editUser) return; if (!editUser) return;
try { try {
await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly)); await adminApi.updateUserQuota(editUser.id, Number(editDaily), Number(editMonthly), Number(editSpendingLimit));
showToast('配额已更新'); showToast('配额已更新');
setEditUser(null); setEditUser(null);
fetchUsers(); fetchUsers();
@ -205,6 +207,7 @@ export function UsersPage() {
<th></th> <th></th>
<th></th> <th></th>
<th></th> <th></th>
<th></th>
<th>/</th> <th>/</th>
<th>/</th> <th>/</th>
<th></th> <th></th>
@ -214,13 +217,13 @@ export function UsersPage() {
{loading ? ( {loading ? (
Array.from({ length: 5 }).map((_, i) => ( Array.from({ length: 5 }).map((_, i) => (
<tr key={i}> <tr key={i}>
{Array.from({ length: 10 }).map((_, j) => ( {Array.from({ length: 11 }).map((_, j) => (
<td key={j}><div className={styles.skeletonCell} /></td> <td key={j}><div className={styles.skeletonCell} /></td>
))} ))}
</tr> </tr>
)) ))
) : users.length === 0 ? ( ) : users.length === 0 ? (
<tr><td colSpan={10} className={styles.empty}></td></tr> <tr><td colSpan={11} className={styles.empty}></td></tr>
) : ( ) : (
users.map((u) => ( users.map((u) => (
<tr key={u.id}> <tr key={u.id}>
@ -249,6 +252,7 @@ export function UsersPage() {
</td> </td>
<td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td> <td>{(u.daily_generation_limit ?? -1) === -1 ? '不限' : u.daily_generation_limit + '次'}</td>
<td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td> <td>{(u.monthly_generation_limit ?? -1) === -1 ? '不限' : u.monthly_generation_limit + '次'}</td>
<td>{(u.spending_limit ?? -1) === -1 ? '不限' : '¥' + (u.total_spent || 0).toFixed(2) + ' / ¥' + (u.spending_limit).toFixed(2)}</td>
<td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td> <td>{(u.generations_today || 0) + '次 / ¥' + (u.spent_today || 0).toFixed(2)}</td>
<td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td> <td>{(u.generations_this_month || 0) + '次 / ¥' + (u.spent_this_month || 0).toFixed(2)}</td>
<td> <td>
@ -317,6 +321,10 @@ export function UsersPage() {
<label>-1 </label> <label>-1 </label>
<input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} /> <input type="number" value={editMonthly} onChange={(e) => setEditMonthly(e.target.value)} />
</div> </div>
<div className={styles.formGroup}>
<label>-1 </label>
<input type="number" value={editSpendingLimit} onChange={(e) => setEditSpendingLimit(e.target.value)} />
</div>
<div className={styles.modalActions}> <div className={styles.modalActions}>
<button className={styles.cancelBtn} onClick={() => setEditUser(null)}></button> <button className={styles.cancelBtn} onClick={() => setEditUser(null)}></button>
<button className={styles.saveBtn} onClick={handleSaveQuota}></button> <button className={styles.saveBtn} onClick={handleSaveQuota}></button>

View File

@ -0,0 +1,79 @@
import { create } from 'zustand';
import { assetsApi } from '../lib/api';
import type { AssetGroup } from '../types';
import { showToast } from '../components/Toast';
interface AssetLibraryState {
groups: AssetGroup[];
loading: boolean;
total: number;
page: number;
searchResults: AssetGroup[];
searching: boolean;
loadGroups: (page?: number) => Promise<void>;
searchAssets: (query: string) => Promise<void>;
createGroup: (name: string, file: File) => Promise<AssetGroup | null>;
pollAssetStatus: (assetId: number) => void;
}
export const useAssetLibraryStore = create<AssetLibraryState>((set) => ({
groups: [],
loading: false,
total: 0,
page: 1,
searchResults: [],
searching: false,
loadGroups: async (page = 1) => {
set({ loading: true });
try {
const { data } = await assetsApi.getGroups({ page, page_size: 20 });
set({ groups: data.results, total: data.total, page, loading: false });
} catch {
set({ loading: false });
}
},
searchAssets: async (query: string) => {
set({ searching: true });
try {
const { data } = await assetsApi.search(query);
set({ searchResults: data.results, searching: false });
} catch {
set({ searching: false });
}
},
createGroup: async (name: string, file: File) => {
const formData = new FormData();
formData.append('name', name);
formData.append('file', file);
try {
const { data } = await assetsApi.createGroup(formData);
showToast('角色创建成功');
return data;
} catch {
showToast('创建失败,请重试');
return null;
}
},
pollAssetStatus: (assetId: number) => {
const poll = async () => {
try {
const { data } = await assetsApi.pollStatus(assetId);
if (data.status === 'processing') {
setTimeout(poll, 3000);
} else if (data.status === 'active') {
showToast('素材处理完成');
} else if (data.status === 'failed') {
showToast(data.error_message || '素材处理失败');
}
} catch {
setTimeout(poll, 3000);
}
};
setTimeout(poll, 3000);
},
}));

View File

@ -75,7 +75,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!refresh) throw new Error('No refresh token'); if (!refresh) throw new Error('No refresh token');
const { data } = await authApi.refreshToken(refresh); const { data } = await authApi.refreshToken(refresh);
localStorage.setItem('access_token', data.access); localStorage.setItem('access_token', data.access);
if (data.refresh) {
localStorage.setItem('refresh_token', data.refresh);
set({ accessToken: data.access, refreshToken: data.refresh });
} else {
set({ accessToken: data.access }); set({ accessToken: data.access });
}
}, },
fetchUserInfo: async () => { fetchUserInfo: async () => {

View File

@ -56,7 +56,15 @@ function mapProgress(backendStatus: string): number {
// Convert a BackendTask to a frontend GenerationTask // Convert a BackendTask to a frontend GenerationTask
function backendToFrontend(bt: BackendTask): GenerationTask { function backendToFrontend(bt: BackendTask): GenerationTask {
const references: ReferenceSnapshot[] = (bt.reference_urls || []).map((ref, i) => ({ const allRefs = bt.reference_urls || [];
// 普通引用(参考图/视频/音频)— 可显示的缩略图
const references: ReferenceSnapshot[] = allRefs
.filter((ref) => {
const url = ref.url || '';
return !url.startsWith('asset://') && !url.startsWith('Asset://');
})
.map((ref, i) => ({
id: `ref_${bt.task_id}_${i}`, id: `ref_${bt.task_id}_${i}`,
type: (ref.type || 'image') as 'image' | 'video', type: (ref.type || 'image') as 'image' | 'video',
previewUrl: ref.url, previewUrl: ref.url,
@ -64,6 +72,21 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
role: ref.role, role: ref.role,
})); }));
// Asset 引用 — 仅用于 reEdit/regenerate 重建 mention span
const assetMentions = allRefs
.filter((ref) => {
const url = ref.url || '';
return url.startsWith('asset://') || url.startsWith('Asset://');
})
.map((ref) => {
const url = ref.url || '';
let groupId = '';
if (url.startsWith('asset://group-')) {
groupId = url.replace('asset://group-', '');
}
return { groupId, label: ref.label || '', thumbUrl: (ref as Record<string, string>).thumb_url || '' };
});
return { return {
id: `backend_${bt.task_id}`, id: `backend_${bt.task_id}`,
taskId: bt.task_id, taskId: bt.task_id,
@ -74,6 +97,7 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'], aspectRatio: bt.aspect_ratio as GenerationTask['aspectRatio'],
duration: bt.duration as GenerationTask['duration'], duration: bt.duration as GenerationTask['duration'],
references, references,
assetMentions,
status: mapStatus(bt.status), status: mapStatus(bt.status),
progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status), progress: bt.status === 'processing' ? Number(sessionStorage.getItem(`progress_${bt.task_id}`) || mapProgress(bt.status)) : mapProgress(bt.status),
resultUrl: bt.result_url || undefined, resultUrl: bt.result_url || undefined,
@ -81,6 +105,8 @@ function backendToFrontend(bt: BackendTask): GenerationTask {
createdAt: new Date(bt.created_at).getTime(), createdAt: new Date(bt.created_at).getTime(),
tokensConsumed: bt.tokens_consumed || 0, tokensConsumed: bt.tokens_consumed || 0,
costAmount: bt.cost_amount || 0, costAmount: bt.cost_amount || 0,
isFavorited: bt.is_favorited || false,
seed: bt.seed ?? -1,
}; };
} }
@ -141,6 +167,9 @@ function startPolling(taskId: string, frontendId: string) {
progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress, progress: newStatus === 'completed' ? 100 : newStatus === 'failed' ? 0 : t.progress,
resultUrl: data.result_url || t.resultUrl, resultUrl: data.result_url || t.resultUrl,
errorMessage: mapErrorMessage(data.error_message) || t.errorMessage, errorMessage: mapErrorMessage(data.error_message) || t.errorMessage,
tokensConsumed: data.tokens_consumed ?? t.tokensConsumed,
costAmount: data.cost_amount ?? t.costAmount,
seed: data.seed ?? t.seed,
} }
: t : t
), ),
@ -184,6 +213,7 @@ interface GenerationState {
savedScrollTop: number | null; savedScrollTop: number | null;
addTask: () => Promise<string | null>; addTask: () => Promise<string | null>;
removeTask: (id: string) => void; removeTask: (id: string) => void;
toggleFavorite: (id: string) => Promise<void>;
reEdit: (id: string) => void; reEdit: (id: string) => void;
regenerate: (id: string) => void; regenerate: (id: string) => void;
loadTasks: () => Promise<void>; loadTasks: () => Promise<void>;
@ -303,6 +333,31 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
}, },
].filter(Boolean) as ReferenceSnapshot[]; ].filter(Boolean) as ReferenceSnapshot[];
// Extract asset mentions for placeholder display
const placeholderAssetMentions: { groupId: string; label: string; thumbUrl: string }[] = [];
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
const spans = doc.querySelectorAll('[data-ref-type="asset"]');
const seen = new Set<string>();
spans.forEach((span) => {
const el = span as HTMLElement;
const gid = el.dataset.assetGroupId;
if (gid && !seen.has(gid)) {
seen.add(gid);
placeholderAssetMentions.push({
groupId: gid,
label: el.dataset.groupName || el.textContent?.replace('@', '') || '',
thumbUrl: el.dataset.thumbUrl || '',
});
}
});
}
// Fallback: from inputBar store (regenerate 场景 editorHtml 是纯文本)
if (placeholderAssetMentions.length === 0 && input.assetMentions?.length) {
placeholderAssetMentions.push(...input.assetMentions);
}
// Create a placeholder task immediately for UI feedback // Create a placeholder task immediately for UI feedback
const tempId = `temp_${Date.now()}`; const tempId = `temp_${Date.now()}`;
const placeholderTask: GenerationTask = { const placeholderTask: GenerationTask = {
@ -315,9 +370,12 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspectRatio: input.aspectRatio, aspectRatio: input.aspectRatio,
duration: input.duration, duration: input.duration,
references: localRefs, references: localRefs,
assetMentions: placeholderAssetMentions,
status: 'generating', status: 'generating',
progress: 0, progress: 0,
createdAt: Date.now(), createdAt: Date.now(),
isFavorited: false,
seed: input.seed ?? -1,
}; };
set((s) => ({ tasks: [...s.tasks, placeholderTask] })); set((s) => ({ tasks: [...s.tasks, placeholderTask] }));
@ -330,13 +388,14 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
prompt: '', prompt: '',
editorHtml: '', editorHtml: '',
references: [], references: [],
assetMentions: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
}); });
try { try {
// Upload files to TOS (or reuse existing TOS URLs) // Upload files to TOS (or reuse existing TOS URLs)
const uploadedRefs: { url: string; type: string; role: string; label: string }[] = []; const uploadedRefs: { url: string; type: string; role: string; label: string; thumb_url?: string }[] = [];
for (const item of filesToUpload) { for (const item of filesToUpload) {
if (item.tosUrl) { if (item.tosUrl) {
@ -347,6 +406,44 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
} }
} }
// Extract asset mentions from editor HTML — deduplicate by groupId
const seenGroupIds = new Set<string>();
if (input.editorHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(input.editorHtml, 'text/html');
const assetSpans = doc.querySelectorAll('[data-ref-type="asset"]');
assetSpans.forEach((span) => {
const el = span as HTMLElement;
const groupId = el.dataset.assetGroupId;
const groupName = el.dataset.groupName || el.textContent?.replace('@', '') || '';
if (groupId && !seenGroupIds.has(groupId)) {
seenGroupIds.add(groupId);
uploadedRefs.push({
url: `asset://group-${groupId}`,
type: 'image',
role: 'reference_image',
label: groupName,
thumb_url: el.dataset.thumbUrl || '',
});
}
});
}
// Fallback: also add from inputBar assetMentions (for regenerate scenario)
const inputAssetMentions = input.assetMentions || [];
for (const am of inputAssetMentions) {
if (am.groupId && !seenGroupIds.has(am.groupId)) {
seenGroupIds.add(am.groupId);
uploadedRefs.push({
url: `asset://group-${am.groupId}`,
type: 'image',
role: 'reference_image',
label: am.label,
thumb_url: am.thumbUrl || '',
});
}
}
// Call generate API // Call generate API
const { data: genResult } = await videoApi.generate({ const { data: genResult } = await videoApi.generate({
prompt: input.prompt, prompt: input.prompt,
@ -355,6 +452,8 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspect_ratio: input.aspectRatio, aspect_ratio: input.aspectRatio,
duration: input.duration, duration: input.duration,
references: uploadedRefs, references: uploadedRefs,
search_mode: input.searchMode || 'off',
seed: input.seed ?? -1,
}); });
// Update task with real backend IDs // Update task with real backend IDs
@ -396,14 +495,15 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
return frontendId; return frontendId;
} catch (err: unknown) { } catch (err: unknown) {
const error = err as { response?: { status?: number; data?: { message?: string } } }; const error = err as { response?: { status?: number; data?: { message?: string; error_message?: string } } };
const msg = error.response?.data?.message; const msg = error.response?.data?.error_message || error.response?.data?.message || '生成失败,请重试';
showToast(msg || '生成失败,请重试'); const displayMsg = mapErrorMessage(msg) || msg;
showToast(displayMsg);
// Mark task as failed // Mark task as failed with error message
set((s) => ({ set((s) => ({
tasks: s.tasks.map((t) => tasks: s.tasks.map((t) =>
t.id === tempId ? { ...t, status: 'failed' as const, progress: 0 } : t t.id === tempId ? { ...t, status: 'failed' as const, progress: 0, errorMessage: displayMsg } : t
), ),
})); }));
@ -420,6 +520,23 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
} }
}, },
toggleFavorite: async (id) => {
const task = get().tasks.find((t) => t.id === id);
if (!task?.taskId) return;
// Optimistic update
set((s) => ({
tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t),
}));
try {
await videoApi.toggleFavorite(task.taskId);
} catch {
// Revert on failure
set((s) => ({
tasks: s.tasks.map((t) => t.id === id ? { ...t, isFavorited: !t.isFavorited } : t),
}));
}
},
reEdit: (id) => { reEdit: (id) => {
const task = get().tasks.find((t) => t.id === id); const task = get().tasks.find((t) => t.id === id);
if (!task) return; if (!task) return;
@ -431,6 +548,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
} }
if (task.mode === 'universal') { if (task.mode === 'universal') {
// task.references only contains file refs (assets filtered in backendToFrontend)
const references: UploadedFile[] = task.references.map((r) => ({ const references: UploadedFile[] = task.references.map((r) => ({
id: r.id, id: r.id,
type: r.type, type: r.type,
@ -438,12 +556,17 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
label: r.label, label: r.label,
tosUrl: r.previewUrl, tosUrl: r.previewUrl,
})); }));
const taskSeed = task.seed ?? -1;
const currentSeedEnabled = useInputBarStore.getState().seedEnabled;
useInputBarStore.setState({ useInputBarStore.setState({
prompt: task.prompt, prompt: task.prompt,
editorHtml: task.editorHtml || task.prompt, editorHtml: task.editorHtml || task.prompt,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
references, references,
assetMentions: task.assetMentions || [],
// 如果 seed 开关打开且 task 有有效 seed填入否则不动
...(currentSeedEnabled && taskSeed > 0 ? { seed: taskSeed } : {}),
}); });
} else { } else {
// Keyframe mode: restore firstFrame and lastFrame // Keyframe mode: restore firstFrame and lastFrame
@ -454,6 +577,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
editorHtml: task.editorHtml || task.prompt, editorHtml: task.editorHtml || task.prompt,
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
assetMentions: [],
firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null, firstFrame: firstRef ? { id: firstRef.id, type: firstRef.type, previewUrl: firstRef.previewUrl, label: '首帧', tosUrl: firstRef.previewUrl } : null,
lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null, lastFrame: lastRef ? { id: lastRef.id, type: lastRef.type, previewUrl: lastRef.previewUrl, label: '尾帧', tosUrl: lastRef.previewUrl } : null,
}); });
@ -478,7 +602,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
type: r.type, type: r.type,
previewUrl: r.previewUrl, previewUrl: r.previewUrl,
label: r.label, label: r.label,
tosUrl: r.previewUrl, // TOS URL from previous upload tosUrl: r.previewUrl,
})); }));
useInputBarStore.setState({ useInputBarStore.setState({
@ -488,6 +612,7 @@ export const useGenerationStore = create<GenerationState>((set, get) => ({
aspectRatio: task.aspectRatio, aspectRatio: task.aspectRatio,
duration: task.duration, duration: task.duration,
references: task.mode === 'universal' ? references : [], references: task.mode === 'universal' ? references : [],
assetMentions: task.assetMentions || [],
}); });
// Trigger generation // Trigger generation

View File

@ -56,6 +56,19 @@ interface InputBarState {
// Computed // Computed
canSubmit: () => boolean; canSubmit: () => boolean;
// Search mode (联网搜索)
searchMode: 'smart' | 'off';
setSearchMode: (mode: 'smart' | 'off') => void;
// Seed (种子值)
seed: number;
seedEnabled: boolean;
setSeed: (seed: number) => void;
setSeedEnabled: (enabled: boolean) => void;
// Asset mentions (for reEdit/regenerate to pass asset data to PromptInput rebuild)
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
// @ trigger (for toolbar button to insert @ in contentEditable) // @ trigger (for toolbar button to insert @ in contentEditable)
insertAtTrigger: number; insertAtTrigger: number;
triggerInsertAt: () => void; triggerInsertAt: () => void;
@ -202,6 +215,16 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
return true; return true;
}, },
searchMode: 'off',
setSearchMode: (searchMode) => set({ searchMode }),
seed: -1,
seedEnabled: false,
setSeed: (seed) => set({ seed }),
setSeedEnabled: (seedEnabled) => set({ seedEnabled, seed: -1 }),
assetMentions: [],
insertAtTrigger: 0, insertAtTrigger: 0,
triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })), triggerInsertAt: () => set((s) => ({ insertAtTrigger: s.insertAtTrigger + 1 })),
@ -254,6 +277,7 @@ export const useInputBarStore = create<InputBarState>((set, get) => ({
editorHtml: '', editorHtml: '',
references: [], references: [],
prevReferences: [], prevReferences: [],
assetMentions: [],
firstFrame: null, firstFrame: null,
lastFrame: null, lastFrame: null,
generationType: 'video', generationType: 'video',

View File

@ -41,6 +41,7 @@ export interface GenerationTask {
aspectRatio: AspectRatio; aspectRatio: AspectRatio;
duration: Duration; duration: Duration;
references: ReferenceSnapshot[]; references: ReferenceSnapshot[];
assetMentions: { groupId: string; label: string; thumbUrl: string }[];
status: TaskStatus; status: TaskStatus;
progress: number; progress: number;
resultUrl?: string; resultUrl?: string;
@ -48,6 +49,8 @@ export interface GenerationTask {
createdAt: number; createdAt: number;
tokensConsumed?: number; tokensConsumed?: number;
costAmount?: number; costAmount?: number;
isFavorited?: boolean;
seed?: number;
} }
export interface BackendTask { export interface BackendTask {
@ -67,6 +70,8 @@ export interface BackendTask {
result_url: string; result_url: string;
error_message: string; error_message: string;
reference_urls: { url: string; type: string; role: string; label: string }[]; reference_urls: { url: string; type: string; role: string; label: string }[];
is_favorited: boolean;
seed: number;
created_at: string; created_at: string;
} }
@ -161,6 +166,8 @@ export interface AdminUser {
generations_this_month: number; generations_this_month: number;
spent_today: number; spent_today: number;
spent_this_month: number; spent_this_month: number;
spending_limit: number;
total_spent: number;
is_online?: boolean; is_online?: boolean;
} }
@ -271,6 +278,8 @@ export interface Team {
frozen_amount: number; frozen_amount: number;
markup_percentage: number; markup_percentage: number;
daily_member_limit_default: number; daily_member_limit_default: number;
max_concurrent_tasks: number;
current_processing?: number;
member_count: number; member_count: number;
is_active: boolean; is_active: boolean;
expected_regions: string; expected_regions: string;
@ -315,6 +324,8 @@ export interface TeamMember {
generations_this_month: number; generations_this_month: number;
spent_today: number; spent_today: number;
spent_this_month: number; spent_this_month: number;
spending_limit: number;
total_spent: number;
is_online?: boolean; is_online?: boolean;
date_joined: string; date_joined: string;
} }
@ -390,3 +401,23 @@ export interface AuditLog {
ip_address: string | null; ip_address: string | null;
created_at: string; created_at: string;
} }
export interface AssetGroup {
id: number;
name: string;
thumbnail_url: string;
asset_count: number;
remote_group_id: string;
description: string;
created_at: string;
}
export interface AssetItem {
id: number;
name: string;
url: string;
status: 'processing' | 'active' | 'failed';
remote_asset_id: string;
error_message: string;
created_at: string;
}

View File

@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
build: {
sourcemap: false,
},
server: { server: {
proxy: { proxy: {
'/api': { '/api': {