Compare commits
9 Commits
5bb49b5940
...
aa538443b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa538443b6 | ||
|
|
493b30c6b9 | ||
|
|
9a6a8c964a | ||
|
|
c381784207 | ||
|
|
afcff9455f | ||
|
|
203603f69a | ||
|
|
6a5ddbaf78 | ||
|
|
328cbc147d | ||
|
|
6c364f4c3f |
@ -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='用户总消费额度(元)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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='创建时间')
|
||||||
|
|||||||
53
backend/apps/generation/migrations/0008_asset_library.py
Normal file
53
backend/apps/generation/migrations/0008_asset_library.py
Normal 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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='已收藏'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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='种子值'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@ -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}'
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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'),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -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,
|
||||||
|
})
|
||||||
|
|||||||
@ -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 (短信告警)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
177
backend/utils/assets_client.py
Normal file
177
backend/utils/assets_client.py
Normal 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)
|
||||||
@ -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}'
|
||||||
|
|
||||||
|
|||||||
@ -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 />} />
|
||||||
|
|||||||
393
web/src/components/AssetLibraryModal.module.css
Normal file
393
web/src/components/AssetLibraryModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
437
web/src/components/AssetLibraryModal.tsx
Normal file
437
web/src/components/AssetLibraryModal.tsx
Normal 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 });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</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 })}
|
||||||
|
>
|
||||||
|
✎ 改名
|
||||||
|
</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}>支持 JPG、PNG 格式,单张不超过 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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])}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
46
web/src/pages/LoginRecordsPage.module.css
Normal file
46
web/src/pages/LoginRecordsPage.module.css
Normal 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; }
|
||||||
182
web/src/pages/LoginRecordsPage.tsx
Normal file
182
web/src/pages/LoginRecordsPage.tsx
Normal 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)}><</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)}>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
170
web/src/pages/TeamRecordsPage.tsx
Normal file
170
web/src/pages/TeamRecordsPage.tsx
Normal 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)}><</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)}>></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
79
web/src/store/assetLibrary.ts
Normal file
79
web/src/store/assetLibrary.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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': {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user