Compare commits

...

2 Commits

Author SHA1 Message Date
zyc
0873e724bf feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m51s
Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:46:42 +08:00
zyc
92826dec14 feat(core/backend): pipeline continuity + threaded ffmpeg burn-in export + upload/save-timeline
Video pipeline (script→assets→storyboard→video→stitch):
- robust split_script_into_segments (4 non-empty scenes), scene-aware storyboard/video prompts
- link VideoSegment→ScriptSegment + storyboard-frame reference image (graceful text fallback)
- idempotent poll_video_segment (no double-charge on repeated polling)
- threaded export (no Celery worker needed) + poll-export endpoint
- run_export_job rewritten to filter_complex: per-clip trim, xfade transitions,
  subtitle burn-in (Pillow PNG overlay; this ffmpeg lacks libass), BGM mix
- upload-video-segment / upload-bgm / save-timeline endpoints
- serializers embed asset preview URLs (beat assets pagination); Pillow added to requirements

Also includes prior uncommitted backend work: account preferences/sessions,
billing trend, product/asset endpoints, accounts 0002 migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:46:16 +08:00
42 changed files with 3622 additions and 520 deletions

View File

@ -0,0 +1,94 @@
# Generated by Django 5.1.15 on 2026-06-08 09:48
import apps.accounts.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="LoginSession",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("user_agent", models.CharField(blank=True, max_length=400)),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("last_seen_at", models.DateTimeField(auto_now=True)),
("revoked_at", models.DateTimeField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="login_sessions",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ["-last_seen_at"],
},
),
migrations.CreateModel(
name="UserPreference",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notify",
models.JSONField(
blank=True, default=apps.accounts.models._default_notify
),
),
("two_factor_enabled", models.BooleanField(default=False)),
(
"creation_defaults",
models.JSONField(
blank=True, default=apps.accounts.models._default_creation
),
),
(
"display",
models.JSONField(
blank=True, default=apps.accounts.models._default_display
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="preference",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -34,6 +34,44 @@ class Team(TimeStampedModel):
return self.name
def _default_notify() -> dict:
return {"n-export": True, "n-fail": True, "n-quota": True, "n-login": True}
def _default_creation() -> dict:
return {"template": "pain", "duration": "60", "subtitle": "big-variety", "bgm": "kapian", "transition": "fade"}
def _default_display() -> dict:
return {"appearance": "system", "language": "zh", "density": "standard"}
class UserPreference(TimeStampedModel):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化(替代前端 localStorage)。"""
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="preference")
notify = models.JSONField(default=_default_notify, blank=True)
two_factor_enabled = models.BooleanField(default=False)
creation_defaults = models.JSONField(default=_default_creation, blank=True)
display = models.JSONField(default=_default_display, blank=True)
def __str__(self) -> str:
return f"prefs/{self.user}"
class LoginSession(TimeStampedModel):
"""登录会话记录:每次登录写一条(设备 UA / IP / 时间),供设置页「在用设备」展示与下线。"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="login_sessions")
user_agent = models.CharField(max_length=400, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
last_seen_at = models.DateTimeField(auto_now=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-last_seen_at"]
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"

View File

@ -2,7 +2,7 @@ from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import Team, TeamMember, User
from .models import LoginSession, Team, TeamMember, User, UserPreference
class UserSerializer(serializers.ModelSerializer):
@ -12,6 +12,26 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "status"]
class UserPreferenceSerializer(serializers.ModelSerializer):
class Meta:
model = UserPreference
fields = ["notify", "two_factor_enabled", "creation_defaults", "display", "updated_at"]
read_only_fields = ["updated_at"]
class LoginSessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
class Meta:
model = LoginSession
fields = ["id", "user_agent", "ip_address", "last_seen_at", "created_at", "is_current"]
read_only_fields = fields
def get_is_current(self, obj) -> bool:
ctx = self.context or {}
return bool(obj.ip_address and obj.ip_address == ctx.get("current_ip") and obj.user_agent == ctx.get("current_ua"))
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team

View File

@ -3,9 +3,13 @@ from django.urls import path
from .views import (
change_password,
login,
login_sessions,
logout,
me,
preferences,
register,
revoke_login_session,
revoke_other_sessions,
team_member_detail,
team_member_password,
team_members,
@ -20,6 +24,10 @@ urlpatterns = [
path("me/", me, name="auth-me"),
path("me/password/", change_password, name="auth-change-password"),
path("me/avatar/", update_avatar, name="auth-avatar"),
path("me/preferences/", preferences, name="auth-preferences"),
path("me/sessions/", login_sessions, name="auth-sessions"),
path("me/sessions/revoke-others/", revoke_other_sessions, name="auth-sessions-revoke-others"),
path("me/sessions/<uuid:session_id>/revoke/", revoke_login_session, name="auth-session-revoke"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),

View File

@ -12,8 +12,16 @@ from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import TeamMember, User
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
from .models import LoginSession, TeamMember, User, UserPreference
from .serializers import (
LoginSerializer,
LoginSessionSerializer,
RegisterSerializer,
TeamMemberSerializer,
TeamSerializer,
UserPreferenceSerializer,
UserSerializer,
)
def auth_payload(user, team, token):
@ -24,6 +32,25 @@ def auth_payload(user, team, token):
}
def _client_ip(request):
forwarded = request.META.get("HTTP_X_FORWARDED_FOR", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.META.get("REMOTE_ADDR") or None
def record_login_session(request, user):
"""登录成功后记录一条会话(设备 UA / IP),供设置页「在用设备」展示。"""
try:
LoginSession.objects.create(
user=user,
user_agent=(request.META.get("HTTP_USER_AGENT") or "")[:400],
ip_address=_client_ip(request),
)
except Exception: # noqa: BLE001 — 会话记录失败不应阻断登录
pass
@api_view(["POST"])
@permission_classes([])
def register(request):
@ -31,6 +58,7 @@ def register(request):
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
record_login_session(request, data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@ -48,6 +76,7 @@ def login(request):
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
record_login_session(request, user)
return Response(auth_payload(user, team, token))
@ -97,12 +126,19 @@ def change_password(request):
return Response({"token": token.key})
@api_view(["POST"])
@api_view(["POST", "DELETE"])
@parser_classes([MultiPartParser, FormParser])
@permission_classes([IsAuthenticated])
def update_avatar(request):
from apps.assets.storage import TosStorage
# DELETE = 恢复默认头像(清空 avatar_url,前端回退到首字母占位)
if request.method == "DELETE":
user = request.user
user.avatar_url = ""
user.save(update_fields=["avatar_url"])
return Response(UserSerializer(user).data)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
@ -223,3 +259,52 @@ def team_member_password(request, member_id):
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET", "PUT", "PATCH"])
@permission_classes([IsAuthenticated])
def preferences(request):
"""用户设置:通知策略 / 两步验证 / 创作默认 / 显示偏好。服务端持久化。"""
pref, _ = UserPreference.objects.get_or_create(user=request.user)
if request.method in ("PUT", "PATCH"):
serializer = UserPreferenceSerializer(pref, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()
pref.refresh_from_db()
return Response(UserPreferenceSerializer(pref).data)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def login_sessions(request):
"""在用设备:返回未下线的登录会话(最近 20 条)。"""
sessions = LoginSession.objects.filter(user=request.user, revoked_at__isnull=True)[:20]
current_ip = _client_ip(request)
current_ua = (request.META.get("HTTP_USER_AGENT") or "")[:400]
data = LoginSessionSerializer(sessions, many=True, context={"current_ip": current_ip, "current_ua": current_ua}).data
return Response(data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_login_session(request, session_id):
"""下线单个设备会话。"""
from django.utils import timezone
updated = LoginSession.objects.filter(user=request.user, id=session_id, revoked_at__isnull=True).update(
revoked_at=timezone.now()
)
return Response({"revoked": updated})
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def revoke_other_sessions(request):
"""下线除当前外的所有其他设备:旋转 token(令其他端 token 失效)+ 标记会话已下线。"""
from django.utils import timezone
LoginSession.objects.filter(user=request.user, revoked_at__isnull=True).update(revoked_at=timezone.now())
Token.objects.filter(user=request.user).delete()
token, _ = Token.objects.get_or_create(user=request.user)
record_login_session(request, request.user)
return Response({"token": token.key})

View File

@ -1,4 +1,6 @@
import re
import uuid
from datetime import timedelta
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
@ -59,13 +61,43 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def split_script_into_segments(content: str) -> list[str]:
blocks = [line.strip() for line in content.splitlines() if line.strip()]
if len(blocks) >= 4:
return blocks[:4]
if not content.strip():
return [""] * 4
return [content.strip()] + [""] * (4 - len(blocks or [content]))
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
原实现按行 `[:4]`,ARK 返回整段散文时常变成第1镜有词2/3/4镜全空,
导致后续故事板帧 / 视频段拿到空提示词,前后内容断裂这里改为:
优先按空行/标号块切,块数够就把全部块均匀分桶;块不够再按句子切;仍不够则补齐
"""
def _bucketize(items: list[str], joiner: str) -> list[str]:
buckets: list[list[str]] = [[] for _ in range(count)]
per = len(items) / count
for index, item in enumerate(items):
buckets[min(count - 1, int(index / per))].append(item)
return [joiner.join(bucket).strip() for bucket in buckets]
text = (content or "").strip()
if not text:
return [""] * count
# 1) 优先按空行分段;只有一段时退回按行分
blocks = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
if len(blocks) < 2:
blocks = [line.strip() for line in text.splitlines() if line.strip()]
if len(blocks) >= count:
return _bucketize(blocks, "\n")
# 2) 段落不足:按中英文句末标点切句,再均匀分桶
sentences = [s.strip() for s in re.split(r"(?<=[。!?!?.;\n])", text) if s.strip()]
if len(sentences) >= count:
return _bucketize(sentences, " ")
# 3) 仍不足:用已有块/句补齐到 count,绝不留空镜
base = blocks or sentences or [text]
filled = list(base)
while len(filled) < count:
filled.append(base[-1])
return filled[:count]
@transaction.atomic
@ -242,30 +274,98 @@ def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGr
raise
def generate_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
def _scene_context(project) -> str:
"""从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。"""
product = project.product
parts = [f"商品:{product.title}"]
if product.brand:
parts.append(f"品牌:{product.brand}")
if product.category:
parts.append(f"类目:{product.category}")
if getattr(product, "target_audience", ""):
parts.append(f"人群:{product.target_audience}")
adopted_kinds = set(
project.base_asset_groups.filter(adopted_asset__isnull=False).values_list("kind", flat=True)
)
if BaseAssetGroup.Kind.PERSON in adopted_kinds:
parts.append("真人出镜,保持人物一致")
if BaseAssetGroup.Kind.SCENE in adopted_kinds:
parts.append("统一场景与色调")
return " · ".join(parts)
def build_storyboard_frame_prompt(project, version, segment) -> str:
"""单帧故事板提示词:风格锚点 + 本镜画面(回退旁白)+ 版本统一指令。"""
visual = (segment.visual_prompt or segment.narration or "").strip()
lines = [
_scene_context(project),
f"{segment.sort_order + 1} 镜画面:{visual}" if visual else f"{segment.sort_order + 1}",
]
if version.prompt:
lines.append(version.prompt.strip())
lines.append("电商竖屏分镜图,构图清晰,可直接指导视频生成")
return "\n".join(line for line in lines if line)
def build_video_segment_prompt(project, video_segment, scene, user_prompt: str) -> str:
"""单段视频提示词:把本镜旁白 + 画面 + 风格锚点织进去,让每个视频片段跟住对应脚本/故事板。"""
lines = [_scene_context(project)]
if scene is not None:
if scene.narration:
lines.append(f"旁白:{scene.narration.strip()}")
visual = (scene.visual_prompt or scene.narration or "").strip()
if visual:
lines.append(f"画面:{visual}")
if user_prompt:
lines.append(user_prompt.strip())
lines.append(
f"{video_segment.sort_order + 1} 段 · {video_segment.target_duration_seconds}s · "
"9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感"
)
return "\n".join(line for line in lines if line)
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
"""异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。"""
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard")
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
if get_default_model(ModelConfig.Capability.IMAGE) is None:
raise ValueError("no active image model configured")
# 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
if version is None:
version = StoryboardVersion.objects.create(project=project, prompt=prompt)
elif prompt and version.prompt != prompt:
version.prompt = prompt
version.save(update_fields=["prompt", "updated_at"])
return version
storyboard = StoryboardVersion.objects.create(project=project, prompt=prompt)
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
for segment in adopted_script.segments.all():
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": segment.visual_prompt},
)
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
import threading # noqa: F401 — 仅标注此函数运行在独立线程
from django.db import connections
from apps.accounts.models import User
try:
task = AITask.objects.select_related("model_config__provider").get(id=task_id)
version = StoryboardVersion.objects.select_related("project__team").get(id=version_id)
segment = ScriptSegment.objects.get(id=segment_id)
user = User.objects.get(id=user_id)
project = version.project
model_config = task.model_config
reservation = task.credit_reservation
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=f"{prompt}\n{segment.visual_prompt}".strip(),
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
@ -285,22 +385,141 @@ def generate_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion
asset_type=Asset.Type.IMAGE,
)
StoryboardFrame.objects.create(
storyboard=storyboard,
storyboard=version,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
except Exception as exc:
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
storyboard.is_adopted = True
storyboard.save(update_fields=["is_adopted", "updated_at"])
return storyboard
finally:
connections.close_all() # 释放该线程的 DB 连接
def generate_storyboard_frame(*, project, user) -> dict:
"""异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。
返回 {status: generating|succeeded|failed, done, total, version_id}全部完成采用版本"""
import threading
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if version is None or adopted_script is None:
latest = project.storyboard_versions.order_by("-created_at").first()
n = latest.frames.count() if latest else 0
return {"status": "succeeded", "done": n, "total": n, "version_id": str(latest.id) if latest else ""}
segments = list(adopted_script.segments.all().order_by("sort_order"))
total = len(segments)
done_segment_ids = set(version.frames.values_list("script_segment_id", flat=True))
done = len(done_segment_ids)
if done >= total:
_finalize_storyboard(project, version)
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
# 该版本内是否已有帧在后台生成中(RESERVED/SUBMITTED 的故事板任务即为「占位锁」)。
# 仅算「近 3 分钟内」的任务:若进程/线程意外中断留下僵尸任务,超时后不再视为在生成,允许重新发起。
stale_cutoff = timezone.now() - timedelta(minutes=3)
inflight = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
request_payload__storyboard_version=str(version.id),
created_at__gte=stale_cutoff,
).exists()
if inflight:
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
pending = [s for s in segments if s.id not in done_segment_ids]
segment = pending[0]
# 单帧失败次数上限,避免持续失败时无限重试
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id),
).count()
if failed_for_segment >= 2:
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
model_config = get_default_model(ModelConfig.Capability.IMAGE)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": build_storyboard_frame_prompt(project, version, segment),
"storyboard_version": str(version.id),
"storyboard_segment": str(segment.id),
},
)
threading.Thread(
target=_storyboard_frame_worker,
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
daemon=True,
).start()
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
def _finalize_storyboard(project, version) -> None:
"""全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)。"""
project.storyboard_versions.exclude(id=version.id).update(is_adopted=False)
if not version.is_adopted:
version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"])
def _asset_preview_url(asset) -> str:
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
if asset is None:
return ""
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
return ""
if primary.preview_url:
return primary.preview_url
try:
return TosStorage().presigned_get_url(object_key=primary.object_key)
except Exception:
return ""
def _video_reference_images(project, video_segment) -> list[str]:
"""为本视频段挑一张视觉参考图:优先本镜故事板帧,兜底已采用商品基础资产。"""
version = (
project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first()
or project.storyboard_versions.order_by("-created_at").first()
)
if version is not None:
frame = (
version.frames.filter(sort_order=video_segment.sort_order).first()
or version.frames.order_by("sort_order").first()
)
if frame is not None:
url = _asset_preview_url(frame.asset)
if url:
return [url]
product_group = (
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False)
.order_by("-created_at")
.first()
)
if product_group is not None:
url = _asset_preview_url(product_group.adopted_asset)
if url:
return [url]
return []
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
@ -308,6 +527,20 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
# 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。
scene = None
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is not None:
scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first()
if scene is not None and video_segment.script_segment_id != scene.id:
video_segment.script_segment = scene
video_segment.save(update_fields=["script_segment", "updated_at"])
final_prompt = build_video_segment_prompt(project, video_segment, scene, prompt)
# 参考图:优先用本镜故事板帧,其次商品/人物基础资产,给视频做视觉锚点(衔接故事板→视频)。
reference_images = _video_reference_images(project, video_segment)
task = create_ai_task(
project=project,
user=user,
@ -316,22 +549,38 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": prompt,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
)
try:
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=final_prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
reference_images=reference_images or None,
)
except Exception:
# 降级:带参考图被拒时退回纯文生视频(文本里已含本镜旁白/画面,衔接不丢)
if not reference_images:
raise
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=final_prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
reference_images=None,
)
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
task.response_payload = response
task.status = AITask.Status.SUBMITTED
@ -353,6 +602,12 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None:
# 幂等:已完成的段直接回采用版;已失败的段不再 poll。避免对已成功 task 再 poll → 二次建版 / 二次扣费。
if video_segment.status == VideoSegment.Status.SUCCEEDED:
return video_segment.adopted_version or video_segment.versions.order_by("-created_at").first()
if video_segment.status == VideoSegment.Status.FAILED:
return None
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
@ -366,6 +621,12 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if ai_task is None:
raise ValueError("no active video generation task")
# task 已终态(可能被并发的 worker / 另一次 poll 处理过):直接回已有版,不再调 ARK。
if ai_task.status == AITask.Status.SUCCEEDED:
return video_segment.versions.filter(task=ai_task).order_by("-created_at").first()
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
return None
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")

View File

@ -1,9 +1,10 @@
from django.urls import path
from .views import ledgers, recharge, summary
from .views import ledgers, recharge, summary, trend
urlpatterns = [
path("summary/", summary, name="billing-summary"),
path("ledgers/", ledgers, name="billing-ledgers"),
path("recharge/", recharge, name="billing-recharge"),
path("trend/", trend, name="billing-trend"),
]

View File

@ -1,17 +1,33 @@
from datetime import timedelta
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import TruncDate
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.ai.models import AITask
from apps.common.api import get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
# AITask.task_type → 账户页「按阶段分布」的 4 个聚合桶
_STAGE_BUCKET = {
AITask.Type.SCRIPT_GENERATION: "script",
AITask.Type.SCRIPT_OPTIMIZATION: "script",
AITask.Type.PRODUCT_IMAGE: "base",
AITask.Type.PERSON_IMAGE: "base",
AITask.Type.SCENE_IMAGE: "base",
AITask.Type.STORYBOARD: "storyboard",
AITask.Type.VIDEO_SEGMENT: "video",
AITask.Type.EXPORT: "video",
}
@api_view(["GET"])
@permission_classes([IsAuthenticated])
@ -78,3 +94,81 @@ def recharge(request):
},
status=status.HTTP_201_CREATED,
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def trend(request):
"""账户页消费分析:消费趋势(日/周/月可切)+ 本月按阶段/按项目分布。全部来自真实 CHARGE 流水。"""
team = get_current_team(request.user)
today = timezone.localdate()
rng = request.query_params.get("range", "day")
charges = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE)
def _daily_amounts(win_start):
rows = (
charges.filter(created_at__date__gte=win_start)
.annotate(day=TruncDate("created_at"))
.values("day")
.annotate(amount=Sum("amount"))
)
return {row["day"]: row["amount"] or Decimal("0") for row in rows}
# 按 range 选窗口与分桶:日=近 14 天 / 周=近 8 周 / 月=近 6 个自然月(缺口补 0)
series = []
if rng == "week":
monday = today - timedelta(days=today.weekday())
starts = [monday - timedelta(weeks=(7 - i)) for i in range(8)]
amt_by_day = _daily_amounts(starts[0])
for s in starts:
total = sum((amt_by_day.get(s + timedelta(days=k), Decimal("0")) for k in range(7)), Decimal("0"))
series.append({"date": s.isoformat(), "label": s.strftime("%m/%d"), "amount": str(total)})
elif rng == "month":
seq = []
y, m = today.year, today.month
for _ in range(6):
seq.append((y, m))
m -= 1
if m == 0:
m, y = 12, y - 1
seq.reverse()
amt_by_day = _daily_amounts(today.replace(year=seq[0][0], month=seq[0][1], day=1))
for yy, mm in seq:
total = sum((v for d, v in amt_by_day.items() if d.year == yy and d.month == mm), Decimal("0"))
series.append({"date": f"{yy}-{mm:02d}-01", "label": f"{mm}", "amount": str(total)})
else:
start = today - timedelta(days=13)
amt_by_day = _daily_amounts(start)
for i in range(14):
d = start + timedelta(days=i)
series.append({"date": d.isoformat(), "label": d.strftime("%m/%d"), "amount": str(amt_by_day.get(d, Decimal("0")))})
daily = series
total_14d = sum((Decimal(s["amount"]) for s in series), Decimal("0"))
peak = max((Decimal(s["amount"]) for s in series), default=Decimal("0"))
avg = (total_14d / len(series)).quantize(Decimal("0.0001")) if series else Decimal("0")
# 本月按阶段分布(task.task_type → 4 桶)
month_start = today.replace(day=1)
month_charges = charges.filter(created_at__date__gte=month_start).select_related("task")
by_stage = {"script": Decimal("0"), "base": Decimal("0"), "storyboard": Decimal("0"), "video": Decimal("0")}
project_amounts: dict[str, Decimal] = {}
for row in month_charges:
task = row.task
bucket = _STAGE_BUCKET.get(task.task_type) if task else None
if bucket:
by_stage[bucket] += row.amount
pid = str(row.project_id) if row.project_id else None
if pid:
project_amounts[pid] = project_amounts.get(pid, Decimal("0")) + row.amount
return Response(
{
"daily": daily,
"total_14d": str(total_14d),
"avg": str(avg),
"peak": str(peak),
"by_stage": {k: str(v) for k, v in by_stage.items()},
"by_project": {k: str(v) for k, v in project_amounts.items()},
}
)

View File

@ -1,8 +1,18 @@
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Product
from .models import Product, ProductImage
from .serializers import ProductSerializer
@ -12,3 +22,57 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"]
@action(detail=True, methods=["post"], url_path="images", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_image(self, request, pk=None):
"""上传商品图:file → TOS → Asset(category=product_image) → ProductImage 关联。"""
product = self.get_object()
team = get_current_team(request.user)
upload = request.FILES.get("file")
if upload is None:
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
suffix = Path(upload.name).suffix.lower() or ".png"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/products/{product.id}/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "image/png",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=request.data.get("name") or upload.name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
next_order = product.images.count()
ProductImage.objects.create(
product=product,
asset=asset,
sort_order=next_order,
is_primary=next_order == 0,
)
product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["delete"], url_path=r"images/(?P<image_id>[^/.]+)")
def delete_image(self, request, pk=None, image_id=None):
"""移除商品图(删 ProductImage 关联,保留底层 Asset)。"""
product = self.get_object()
deleted, _ = ProductImage.objects.filter(product=product, id=image_id).delete()
if not deleted:
return Response({"detail": "image not found"}, status=status.HTTP_404_NOT_FOUND)
product.refresh_from_db()
return Response(ProductSerializer(product).data, status=status.HTTP_200_OK)

View File

@ -1,5 +1,7 @@
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
from .models import (
BaseAssetGroup,
BgmTrack,
@ -18,6 +20,16 @@ from .models import (
)
def _asset_preview_url(asset) -> str:
"""资产主文件的可播放/可显示 URL(主图优先,其次首张),内嵌进各阶段序列化,
让前端缩略图不再依赖(分页 20 条的)团队 assets 列表解析团队资产 >20 时新生成的图本会丢"""
if asset is None:
return ""
files = list(asset.files.all())
primary = next((f for f in files if f.is_primary), files[0] if files else None)
return AssetFileSerializer().get_preview_url(primary) if primary else ""
class ProjectStageSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectStage
@ -27,10 +39,11 @@ class ProjectStageSerializer(serializers.ModelSerializer):
class VideoSegmentSerializer(serializers.ModelSerializer):
adopted_asset = serializers.SerializerMethodField()
adopted_asset_url = serializers.SerializerMethodField()
class Meta:
model = VideoSegment
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset"]
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url"]
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
def get_adopted_asset(self, obj):
@ -38,22 +51,39 @@ class VideoSegmentSerializer(serializers.ModelSerializer):
version = obj.adopted_version
return str(version.asset_id) if version and version.asset_id else None
def get_adopted_asset_url(self, obj) -> str:
version = obj.adopted_version
return _asset_preview_url(version.asset) if version is not None else ""
class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
adopted_asset_url = serializers.SerializerMethodField()
candidate_asset_urls = serializers.SerializerMethodField()
class Meta:
model = BaseAssetGroup
fields = ["id", "kind", "prompt", "adopted_asset", "candidate_assets", "version", "metadata", "created_at"]
fields = ["id", "kind", "prompt", "adopted_asset", "adopted_asset_url", "candidate_assets", "candidate_asset_urls", "version", "metadata", "created_at"]
read_only_fields = fields
def get_adopted_asset_url(self, obj) -> str:
return _asset_preview_url(obj.adopted_asset)
def get_candidate_asset_urls(self, obj) -> dict:
return {str(asset.id): _asset_preview_url(asset) for asset in obj.candidate_assets.all()}
class StoryboardFrameSerializer(serializers.ModelSerializer):
asset_url = serializers.SerializerMethodField()
class Meta:
model = StoryboardFrame
fields = ["id", "script_segment", "asset", "sort_order", "prompt"]
fields = ["id", "script_segment", "asset", "asset_url", "sort_order", "prompt"]
read_only_fields = fields
def get_asset_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
class StoryboardVersionSerializer(serializers.ModelSerializer):
frames = StoryboardFrameSerializer(many=True, read_only=True)
@ -72,10 +102,34 @@ class VideoSegmentVersionSerializer(serializers.ModelSerializer):
class TimelineClipSerializer(serializers.ModelSerializer):
# 直接内嵌片段资产的可播放 URL + 是否视频,前端播放器无需再依赖(分页的)团队 assets 列表解析
asset_url = serializers.SerializerMethodField()
asset_is_video = serializers.SerializerMethodField()
class Meta:
model = TimelineClip
fields = ["id", "asset", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
read_only_fields = ["id"]
fields = ["id", "asset", "asset_url", "asset_is_video", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
read_only_fields = ["id", "asset_url", "asset_is_video"]
def _primary_file(self, obj):
asset = obj.asset
if asset is None:
return None
files = list(asset.files.all())
return next((f for f in files if f.is_primary), files[0] if files else None)
def get_asset_url(self, obj) -> str:
f = self._primary_file(obj)
return AssetFileSerializer().get_preview_url(f) if f else ""
def get_asset_is_video(self, obj) -> bool:
asset = obj.asset
if asset is None:
return False
if asset.asset_type == "video":
return True
f = self._primary_file(obj)
return bool(f and "video/" in (f.content_type or ""))
class TimelineExportJobSerializer(serializers.ModelSerializer):
@ -93,11 +147,20 @@ class SubtitleTrackSerializer(serializers.ModelSerializer):
class BgmTrackSerializer(serializers.ModelSerializer):
asset_url = serializers.SerializerMethodField()
asset_name = serializers.SerializerMethodField()
class Meta:
model = BgmTrack
fields = ["id", "asset", "volume", "start_ms"]
fields = ["id", "asset", "asset_url", "asset_name", "volume", "start_ms"]
read_only_fields = fields
def get_asset_url(self, obj) -> str:
return _asset_preview_url(obj.asset)
def get_asset_name(self, obj) -> str:
return obj.asset.name if obj.asset_id else ""
class TimelineSerializer(serializers.ModelSerializer):
clips = TimelineClipSerializer(many=True, read_only=True)

View File

@ -1,15 +1,39 @@
from pathlib import Path
import subprocess
import tempfile
import threading
import requests
from django.db import transaction
from django.db import connections, transaction
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.projects.models import ExportJob
# 字幕样式(对齐 stage5 四个 swatch)。本机 ffmpeg 无 libass/drawtext,改用 Pillow 渲染 PNG 再 overlay 烧入。
# RGBA 颜色;box 为半透明黑底(影视),stroke 为描边色。
SUBTITLE_STYLES: dict[str, dict] = {
"plain": {"size": 58, "fill": (255, 255, 255, 255), "stroke": (0, 0, 0, 255), "stroke_w": 4, "box": None}, # 朴素白底
"cinema": {"size": 56, "fill": (255, 255, 255, 255), "stroke": (0, 0, 0, 0), "stroke_w": 0, "box": (0, 0, 0, 165)}, # 影视黑底
"handwrite": {"size": 60, "fill": (255, 255, 255, 255), "stroke": (250, 93, 25, 255), "stroke_w": 7, "box": None}, # 手写描边(主橙 #fa5d19)
"variety": {"size": 60, "fill": (255, 220, 60, 255), "stroke": (0, 0, 0, 255), "stroke_w": 6, "box": None}, # 综艺暖黄
}
# 候选 CJK 字体(mac 优先,Linux 兜底)
_FONT_CANDIDATES = [
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/PingFang.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
]
# 转场(UI 选项)→ ffmpeg xfade transition 名。"none" 表示纯拼接。
XFADE_MAP: dict[str, str] = {
"fade": "fade", "dissolve": "dissolve", "slide": "slideleft", "slideleft": "slideleft",
"slideright": "slideright", "wipe": "wiperight", "wiperight": "wiperight", "circle": "circleopen", "smooth": "smoothleft",
}
def _download_asset_primary_file(asset, target_path: Path) -> None:
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
@ -20,6 +44,174 @@ def _download_asset_primary_file(asset, target_path: Path) -> None:
target_path.write_bytes(response.content)
def _load_font(size: int):
from PIL import ImageFont
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size, index=0)
except Exception: # noqa: BLE001
continue
return ImageFont.load_default()
def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
"""按像素宽折行(中文逐字、英文整体不强拆)。"""
lines: list[str] = []
line = ""
for ch in text:
trial = line + ch
if draw.textlength(trial, font=font) <= max_width or not line:
line = trial
else:
lines.append(line)
line = ch
if line:
lines.append(line)
return lines[:3] # 最多 3 行,够长截断
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。"""
from PIL import Image, ImageDraw
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
canvas_w = 1080
margin_x = 90
font = _load_font(st["size"])
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
line_h = st["size"] + 16
pad = 22
text_h = line_h * len(lines)
canvas_h = text_h + 2 * pad
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if st["box"]:
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
box_w = int(widest) + 2 * pad + 24
x0 = (canvas_w - box_w) // 2
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
y = pad
for ln in lines:
w = draw.textlength(ln, font=font)
x = (canvas_w - w) / 2
draw.text((x, y), ln, font=font, fill=st["fill"],
stroke_width=st["stroke_w"], stroke_fill=st["stroke"])
y += line_h
img.save(path)
return canvas_w, canvas_h
def _clip_specs(clips) -> list[dict]:
"""每个 clip 的入点/出点/时长(秒),考虑 trim。"""
specs = []
for clip in clips:
ts = (clip.trim_start_ms or 0) / 1000.0
te = (clip.trim_end_ms / 1000.0) if clip.trim_end_ms else ts + (clip.duration_ms or 15000) / 1000.0
specs.append({"ts": ts, "te": te, "dur": max(0.1, te - ts)})
return specs
def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]:
"""每个 clip 在输出时间轴上的起点 + 输出总时长(xfade 会压缩总长)。"""
starts, cum = [], 0.0
for i, s in enumerate(specs):
starts.append(0.0 if i == 0 else max(0.0, cum - i * xfade))
cum += s["dur"]
total = sum(s["dur"] for s in specs) - (len(specs) - 1) * xfade if xfade > 0 else sum(s["dur"] for s in specs)
return starts, max(0.1, total)
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float) -> list[str]:
parts: list[str] = []
for i, s in enumerate(specs):
parts.append(
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
"scale=1080:1920:force_original_aspect_ratio=decrease,"
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
prev = "v0"
for i in range(1, n):
out = "vbase" if i == n - 1 else f"x{i}"
parts.append(f"[{prev}][v{i}]xfade=transition={xname}:duration=0.5:offset={starts[i]:.3f}[{out}]")
prev = out
else:
parts.append("".join(f"[v{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0[vbase]")
# 字幕:每条一张 PNG,按时间窗 overlay 到底部居中(本机 ffmpeg 无 libass,用图片烧入)
sub_base = n + (1 if bgm_name else 0)
vlabel = "vbase"
for j, (_png, start, end) in enumerate(sub_overlays):
idx = sub_base + j
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
parts.append(
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS[aout]")
cmd = ["ffmpeg", "-y"]
for i in range(n):
cmd += ["-i", f"clip{i}.mp4"]
if bgm_name:
cmd += ["-stream_loop", "-1", "-i", bgm_name]
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if bgm_name:
cmd += ["-map", "[aout]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
if bgm_name:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
return cmd
def run_export_job_in_thread(export_job_id: str) -> None:
"""后台线程跑拼接导出。本机无 Celery worker(dev),故事板/视频已用线程模式,导出沿用同一打法:
HTTP 秒回,真实 ffmpeg 拼接在线程里跑,前端轮询 poll-export 看进度 / 取成片失败落库供轮询上报"""
def _worker() -> None:
try:
run_export_job(export_job_id)
except Exception as exc: # noqa: BLE001 — 失败落库,poll-export 据此上报
job = ExportJob.objects.filter(id=export_job_id).first()
if job is not None:
job.status = ExportJob.Status.FAILED
job.error_message = str(exc)
job.save(update_fields=["status", "error_message", "updated_at"])
finally:
connections.close_all()
threading.Thread(target=_worker, daemon=True).start()
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目:文本取 SubtitleTrack.content,空则回退脚本旁白;时间按输出布局(对 xfade 也对齐)。"""
track = timeline.subtitle_tracks.filter(enabled=True).first() or timeline.subtitle_tracks.first()
if track is None or track.enabled is False:
return []
texts: list[str] = [str((c or {}).get("text", "")) for c in (track.content or [])]
if not any(t.strip() for t in texts):
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if script is not None:
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")]
cues: list[tuple[float, float, str]] = []
for i in range(len(specs)):
text = texts[i] if i < len(texts) else ""
start = starts[i]
end = starts[i + 1] if i + 1 < len(starts) else total
if text and text.strip():
cues.append((start, max(start + 0.5, end), text))
return cues
def run_export_job(export_job_id: str) -> ExportJob:
export_job = ExportJob.objects.select_related("timeline", "timeline__project").get(id=export_job_id)
timeline = export_job.timeline
@ -32,43 +224,45 @@ def run_export_job(export_job_id: str) -> ExportJob:
export_job.progress = 10
export_job.save(update_fields=["status", "progress", "updated_at"])
transition = str((timeline.metadata or {}).get("transition", {}).get("type", "none"))
bgm_track = timeline.bgm_tracks.select_related("asset").first()
subtitle_track = timeline.subtitle_tracks.filter(enabled=True).first()
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
specs = _clip_specs(clips)
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
starts, total = _output_starts(specs, xfade)
with tempfile.TemporaryDirectory(prefix="airshelf-export-") as tmp_dir:
tmp = Path(tmp_dir)
concat_file = tmp / "concat.txt"
downloaded_files: list[Path] = []
for index, clip in enumerate(clips):
clip_path = tmp / f"clip-{index}.mp4"
_download_asset_primary_file(clip.asset, clip_path)
downloaded_files.append(clip_path)
concat_file.write_text(
"\n".join(f"file '{path.as_posix()}'" for path in downloaded_files),
encoding="utf-8",
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
primary = bgm_track.asset.files.filter(is_primary=True).first() or bgm_track.asset.files.first()
suffix = Path(primary.object_key).suffix or ".mp3" if primary else ".mp3"
bgm_name = f"bgm{suffix}"
_download_asset_primary_file(bgm_track.asset, tmp / bgm_name)
cues = _subtitle_cues(timeline, project, specs, starts, total)
sub_overlays: list[tuple[str, float, float]] = []
for i, (start, end, text) in enumerate(cues):
png = f"sub{i}.png"
_render_subtitle_png(text, style_key, tmp / png)
sub_overlays.append((png, start, end))
export_job.progress = 35
export_job.save(update_fields=["progress", "updated_at"])
command = _build_export_command(
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg export failed: {proc.stderr.decode('utf-8', 'ignore')[-1200:]}")
output_path = tmp / "output.mp4"
command = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-vf",
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
"-r",
"30",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output_path),
]
subprocess.run(command, check=True, capture_output=True)
export_job.progress = 85
export_job.save(update_fields=["progress", "updated_at"])

View File

@ -1,6 +1,11 @@
import logging
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
@ -8,13 +13,29 @@ from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_project_script,
generate_storyboard,
generate_storyboard_frame,
poll_video_segment,
submit_storyboard,
submit_video_segment,
)
from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from .models import BaseAssetGroup, Project, ProjectStage, ScriptVersion, Timeline, TimelineClip, VideoSegment
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
from .serializers import (
BaseAssetGroupSerializer,
ExportJobSerializer,
@ -23,8 +44,32 @@ from .serializers import (
StoryboardVersionSerializer,
VideoSegmentVersionSerializer,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .tasks import poll_video_segment_task, run_export_job_task
from .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id, team=team, created_by=user, name=name,
asset_type=asset_type, source=Asset.Source.UPLOAD, category=category,
)
AssetFile.objects.create(
asset=asset, object_key=stored.object_key, bucket=stored.bucket,
content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True,
)
return asset
def promote_base_asset_stage_if_ready(project: Project) -> bool:
@ -46,13 +91,15 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Project.objects.select_related("product", "timeline").prefetch_related(
"stages",
"video_segments",
"video_segments__adopted_version__asset__files",
"script_versions",
"script_versions__segments",
"base_asset_groups",
"base_asset_groups__candidate_assets",
"base_asset_groups__adopted_asset__files",
"base_asset_groups__candidate_assets__files",
"storyboard_versions",
"storyboard_versions__frames",
"timeline__clips",
"storyboard_versions__frames__asset__files",
"timeline__clips__asset__files",
).all()
serializer_class = ProjectSerializer
search_fields = ["name", "product__title"]
@ -122,15 +169,28 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
project = self.get_object()
storyboard = generate_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_201_CREATED)
return Response(StoryboardVersionSerializer(storyboard).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-storyboard")
def poll_storyboard_action(self, request, pk=None):
"""异步故事板·轮询:每次生成下一帧(单次 ARK 调用 ~20s)。全部完成 → 推进到 VIDEO 阶段。"""
project = self.get_object()
result = generate_storyboard_frame(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)
@action(detail=True, methods=["post"], url_path="skip-storyboard")
@transaction.atomic
@ -149,7 +209,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", ""))
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
# 有 Celery worker 时由它自动轮询;无 worker(本机 dev)则前端驱动 poll-video-segment。
# 队列不可用不应让提交 500——已提交到 ARK,轮询是次要路径。
try:
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
except Exception: # noqa: BLE001
logger.warning("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post"], url_path="poll-video-segment")
@ -188,11 +253,155 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
start_ms += segment.target_duration_seconds * 1000
export_job = create_export_job(timeline=timeline, user=request.user)
run_export_job_task.delay(str(export_job.id))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
stage.status = ProjectStage.Status.QUEUED
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.EXPORT
project.status = Project.Status.EXPORTING
project.save(update_fields=["current_stage", "status", "updated_at"])
# 后台线程跑真实 ffmpeg 拼接(无需 Celery worker);前端轮询 poll-export 取进度/成片。
transaction.on_commit(lambda: run_export_job_in_thread(str(export_job.id)))
return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED)
@action(detail=True, methods=["post", "get"], url_path="poll-export")
def poll_export_action(self, request, pk=None):
"""拼接导出·轮询:回最新导出任务的状态/进度/成片 URL。成片就绪时把 EXPORT 阶段标记成功。"""
project = self.get_object()
timeline = getattr(project, "timeline", None)
export_job = timeline.export_jobs.order_by("-created_at").first() if timeline is not None else None
if export_job is None:
return Response({"status": "not_started", "progress": 0, "output_url": ""})
output_url = ""
output = export_job.output_asset
if output is not None:
primary = output.files.filter(is_primary=True).first() or output.files.first()
if primary is not None:
output_url = AssetFileSerializer().get_preview_url(primary)
if export_job.status == ExportJob.Status.SUCCEEDED:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
if stage.status != ProjectStage.Status.SUCCEEDED:
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
elif export_job.status == ExportJob.Status.FAILED:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
if stage.status != ProjectStage.Status.FAILED:
stage.status = ProjectStage.Status.FAILED
stage.error_message = export_job.error_message
stage.save(update_fields=["status", "error_message", "updated_at"])
return Response({
"status": export_job.status,
"progress": export_job.progress,
"output_asset": str(output.id) if output is not None else None,
"output_url": output_url,
"error_message": export_job.error_message,
})
@action(detail=True, methods=["post"], url_path="upload-video-segment", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_video_segment_action(self, request, pk=None):
"""上传自带视频替换某段:落 TOS → Asset(video) → VideoSegmentVersion → 采用并标记完成。"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
segment = VideoSegment.objects.filter(project=project, id=request.data.get("video_segment_id")).first()
if segment is None:
return Response({"detail": "video segment not found"}, status=status.HTTP_404_NOT_FOUND)
asset = _store_uploaded_asset(
team=project.team, user=request.user, upload=upload,
asset_type=Asset.Type.VIDEO, category=Asset.Category.VIDEO_CLIP,
name=f"{project.name}-上传-{segment.sort_order + 1}",
)
version = VideoSegmentVersion.objects.create(
video_segment=segment, asset=asset, prompt="用户上传", is_adopted=True,
metadata={"source": "upload"},
)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.error_message = ""
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_bgm_action(self, request, pk=None):
"""上传 BGM 音频:落 TOS → Asset(audio) → 设为 timeline 的(唯一)BGM 轨。"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
asset = _store_uploaded_asset(
team=project.team, user=request.user, upload=upload,
asset_type=Asset.Type.AUDIO, category=Asset.Category.UPLOAD, name=f"{project.name}-BGM",
)
volume = int(request.data.get("volume") or 60)
timeline.bgm_tracks.all().delete()
BgmTrack.objects.create(timeline=timeline, asset=asset, volume=max(0, min(100, volume)), start_ms=0)
return Response(ProjectSerializer(self.get_object()).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post", "put"], url_path="save-timeline")
@transaction.atomic
def save_timeline_action(self, request, pk=None):
"""保存草稿:整体持久化时间轴编辑态(片段顺序/裁剪、字幕样式与内容、BGM 音量、转场、草稿元数据)。"""
project = self.get_object()
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
data = request.data
clips = data.get("clips")
if isinstance(clips, list):
valid_asset_ids = set(
Asset.objects.filter(team=project.team, id__in=[c.get("asset") for c in clips if c.get("asset")])
.values_list("id", flat=True)
)
timeline.clips.all().delete()
start_ms = 0
for index, clip in enumerate(clips):
asset_id = clip.get("asset")
if not asset_id or str(asset_id) not in {str(a) for a in valid_asset_ids}:
continue
duration = int(clip.get("duration_ms") or 15000)
TimelineClip.objects.create(
timeline=timeline, asset_id=asset_id, sort_order=index, start_ms=start_ms,
duration_ms=duration, trim_start_ms=int(clip.get("trim_start_ms") or 0),
trim_end_ms=clip.get("trim_end_ms"),
)
start_ms += duration
timeline.duration_seconds = max(1, round(start_ms / 1000))
subtitle = data.get("subtitle")
if isinstance(subtitle, dict):
track = timeline.subtitle_tracks.first() or SubtitleTrack(timeline=timeline)
track.enabled = bool(subtitle.get("enabled", True))
style = dict(track.style or {})
if subtitle.get("style_key"):
style["key"] = subtitle["style_key"]
track.style = style
if isinstance(subtitle.get("content"), list):
track.content = subtitle["content"]
track.save()
bgm = data.get("bgm")
if isinstance(bgm, dict):
track = timeline.bgm_tracks.first()
if bgm.get("clear"):
timeline.bgm_tracks.all().delete()
elif track is not None and bgm.get("volume") is not None:
track.volume = max(0, min(100, int(bgm["volume"])))
track.save(update_fields=["volume", "updated_at"])
metadata = dict(timeline.metadata or {})
if isinstance(data.get("transition"), dict):
metadata["transition"] = {"type": str(data["transition"].get("type", "none"))}
if isinstance(data.get("draft"), dict):
metadata["draft"] = data["draft"]
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
return Response(ProjectSerializer(self.get_object()).data)

View File

@ -10,3 +10,4 @@ requests>=2.31,<3.0
gunicorn>=21.2,<23.0
whitenoise>=6.6,<7.0
Pillow>=10.0

View File

@ -13,7 +13,8 @@
src: local('Alibaba PuHuiTi 3.0'),
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular');
local('AlibabaPuHuiTi-2-55-Regular'),
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +23,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium');
local('AlibabaPuHuiTi-2-65-Medium'),
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -30,7 +32,8 @@
font-style: normal;
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold');
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -39,7 +42,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold');
local('AlibabaPuHuiTi-2-85-Bold'),
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -1,18 +1,22 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
Asset,
BillingSummary,
BillingTrend,
ExportPoll,
Ledger,
LoginSession,
ModelConfig,
Notification,
Product,
Project,
Team,
TeamMember,
User
User,
UserPreference
} from "./types";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "./components/app-shell";
import {
@ -41,7 +45,7 @@ const crumbLabels: Partial<Record<Page, string>> = {
dashboard: "工作台",
products: "商品库",
productDetail: "商品详情",
productCreateUpload: "新建商品",
productCreateUpload: "商品",
projects: "视频项目",
projectWizard: "新建视频项目",
pipeline: "生产管线",
@ -76,9 +80,13 @@ export function App() {
const [aiTasks, setAiTasks] = useState<AITask[]>([]);
const [billing, setBilling] = useState<BillingSummary | null>(null);
const [ledgers, setLedgers] = useState<Ledger[]>([]);
const [billingTrend, setBillingTrend] = useState<BillingTrend | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [projectDetail, setProjectDetail] = useState<Project | null>(null);
const [exportResult, setExportResult] = useState<ExportPoll | null>(null);
const [preferences, setPreferences] = useState<UserPreference | null>(null);
const [sessions, setSessions] = useState<LoginSession[]>([]);
const [activeProductId, setActiveProductId] = useState(route.productId || "");
const [activeProjectId, setActiveProjectId] = useState(route.projectId || "");
@ -95,13 +103,14 @@ export function App() {
);
const loadData = useCallback(async () => {
const [productData, projectData, assetData, billingData, ledgerData, memberData, modelData, taskData, notificationData] =
const [productData, projectData, assetData, billingData, ledgerData, trendData, memberData, modelData, taskData, notificationData] =
await Promise.all([
api.products(),
api.projects(),
api.assets(),
api.billingSummary().catch(() => null),
api.ledgers().catch(() => []),
api.billingTrend().catch(() => null),
api.teamMembers().catch(() => []),
api.modelConfigs().catch(() => null),
api.aiTasks().catch(() => null),
@ -115,6 +124,7 @@ export function App() {
setAiTasks(taskData?.results || []);
if (billingData) setBilling(billingData);
setLedgers(ledgerData);
setBillingTrend(trendData);
if (notificationData) {
setNotifications(notificationData.results);
setUnreadCount(notificationData.unread_count);
@ -123,6 +133,33 @@ export function App() {
setActiveProductId((current) => current || productData.results[0]?.id || "");
}, []);
// 设置页数据:偏好 + 登录会话(进入设置页时按需加载)
const loadSettingsData = useCallback(async () => {
const [pref, sess] = await Promise.all([
api.preferences().catch(() => null),
api.loginSessions().catch(() => [])
]);
if (pref) setPreferences(pref);
setSessions(sess);
}, []);
async function savePreferences(payload: Partial<UserPreference>) {
const next = await api.updatePreferences(payload).catch(() => null);
if (next) setPreferences(next);
return next;
}
async function revokeSession(id: string) {
await action(() => api.revokeSession(id), "设备已下线");
setSessions(await api.loginSessions().catch(() => []));
}
async function revokeOtherSessions() {
const res = await action(() => api.revokeOtherSessions(), "其他设备已全部下线");
if (res?.token) setToken(res.token);
setSessions(await api.loginSessions().catch(() => []));
}
const reloadNotifications = useCallback(async () => {
const data = await api.listNotifications().catch(() => null);
if (data) {
@ -170,6 +207,12 @@ export function App() {
return () => window.removeEventListener("popstate", syncRouteFromHistory);
}, []);
// Load preferences + sessions when entering settings.
useEffect(() => {
if (!authed || (page !== "settings" && page !== "settingsNotify")) return;
loadSettingsData();
}, [authed, page, loadSettingsData]);
// Load full project detail when entering the pipeline.
useEffect(() => {
if (!authed || page !== "pipeline" || !activeProjectId) {
@ -177,6 +220,7 @@ export function App() {
return;
}
let cancelled = false;
setExportResult(null); // 切项目/进管线时清空上个项目的导出态
api
.project(activeProjectId)
.then((detail) => {
@ -188,6 +232,30 @@ export function App() {
};
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
const pollVideosQuiet = useCallback(async () => {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
if (!detail) return;
const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) {
setProjectDetail(detail);
return;
}
for (const segment of active) {
await api.pollVideo(activeProjectId, segment.id).catch(() => undefined);
}
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
// 静默刷新导出任务状态(进入拼接页 / 导出后回填成片),不弹 toast。
const refreshExport = useCallback(async () => {
if (!activeProjectId) return;
const res = await api.pollExport(activeProjectId).catch(() => null);
if (res) setExportResult(res);
}, [activeProjectId]);
function navigate(next: Page, options: NavigateOptions = {}) {
const productId = options.productId ?? activeProductId;
const projectId = options.projectId ?? activeProjectId;
@ -209,7 +277,16 @@ export function App() {
if (detail) setProjectDetail(detail);
}
// 防重复提交:已有操作在途时,后续 action 直接忽略(双击/连点/未及时置灰的按钮都安全)。
// 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。
const actionInFlightRef = useRef(false);
async function action<T>(work: () => Promise<T>, successText: string): Promise<T | null> {
if (actionInFlightRef.current) {
setNotice({ type: "error", text: "操作进行中,请稍候…" });
return null;
}
actionInFlightRef.current = true;
setLoading(true);
setNotice(null);
try {
@ -223,6 +300,7 @@ export function App() {
return null;
} finally {
setLoading(false);
actionInFlightRef.current = false;
}
}
@ -254,6 +332,11 @@ export function App() {
if (res) setUser(res);
}
async function resetOwnAvatar() {
const res = await action(() => api.resetAvatar(), "已恢复默认头像");
if (res) setUser(res);
}
function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) {
return action(() => api.generateImage(payload), "图片已生成");
}
@ -322,23 +405,31 @@ export function App() {
return (
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
/>
);
case "productCreateUpload":
// 设计稿:新建商品是商品库页上的右侧 Drawer(非独立整页),进入即自动打开
return (
<ProductCreateUploadPage
<ProductsPage
products={products}
projects={projects}
navigate={navigate}
openProduct={(productId) => navigate("productDetail", { productId })}
onCreate={async (payload) => {
const created = await action(() => api.createProduct(payload), "商品已创建");
if (created) navigate("productDetail", { productId: created.id });
}}
onBack={() => navigate("products")}
onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")}
autoOpenCreate
/>
);
case "productDetail":
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} />;
if (!activeProduct) return <ProductsPage products={products} navigate={navigate} openProduct={(productId) => navigate("productDetail", { productId })} onCreate={(payload) => action(() => api.createProduct(payload), "商品已创建")} onDelete={(productId) => action(() => api.deleteProduct(productId), "商品已删除")} />;
return (
<ProductDetailPage
product={activeProduct}
@ -346,6 +437,9 @@ export function App() {
assets={assets}
navigate={navigate}
onUpdate={(payload) => action(() => api.updateProduct(activeProduct.id, payload), "商品已更新")}
onUploadImage={(formData) => action(() => api.uploadProductImage(activeProduct.id, formData), "商品图已上传")}
onDeleteImage={(imageId) => action(() => api.deleteProductImage(activeProduct.id, imageId), "商品图已移除")}
onGenerateImages={generateImages}
/>
);
case "projects":
@ -388,12 +482,13 @@ export function App() {
</div>
);
case "library":
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} />;
return <LibraryPage assets={assets} onUpload={(formData) => action(() => api.uploadAsset(formData), "资产已上传")} onDelete={(id) => action(() => api.deleteAsset(id), "资产已删除")} />;
case "account":
return (
<AccountPage
billing={billing}
ledgers={ledgers}
trend={billingTrend}
projects={projects}
teamMembers={teamMembers}
onRecharge={(amount, bonus) => action(() => api.recharge({ amount, bonus }), "充值成功")}
@ -406,6 +501,7 @@ export function App() {
user={currentUser}
members={teamMembers}
billing={billing}
notifications={notifications}
navigate={navigate}
onCreateMember={(payload) => action(() => api.createTeamMember(payload), "成员账户已创建")}
onUpdateMember={(id, payload) => action(() => api.updateTeamMember(id, payload), "成员已更新")}
@ -433,13 +529,13 @@ export function App() {
case "platformCover":
return <ImageWorkbenchPage mode="cover" products={products} assets={assets} modelConfigs={modelConfigs} onBack={() => navigate("assetFactory")} navigate={navigate} onGenerate={generateImages} />;
case "modelPhotoDemoA":
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="A" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} />;
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
return <SettingsPage user={currentUser} team={currentTeam} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
return <SettingsPage user={currentUser} team={currentTeam} preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} />;
case "settingsNotify":
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} />;
return <SettingsPage user={currentUser} team={currentTeam} initialSection="notify" preferences={preferences} sessions={sessions} onSavePreferences={savePreferences} onRevokeSession={revokeSession} onRevokeOthers={revokeOtherSessions} onSaveProfile={saveProfile} onChangePassword={changeOwnPassword} onUploadAvatar={uploadOwnAvatar} onResetAvatar={resetOwnAvatar} onNotify={(text) => setNotice({ type: "success", text })} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@ -470,9 +566,21 @@ export function App() {
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) => action(() => api.generateStoryboard(pipelineProject.id, { prompt }), "故事板已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
// 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待
await api.generateStoryboard(pipelineProject.id, { prompt });
for (let i = 0; i < 60; i += 1) {
const res = await api.pollStoryboard(pipelineProject.id);
if (res.status === "succeeded") break;
if (res.status === "failed") throw new Error("故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
}, "故事板已生成")
}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交")}
onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")}
onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")}
onSubmitAllVideos={(prompt) =>
action(async () => {
@ -484,8 +592,9 @@ export function App() {
});
}
return targets.length;
}, "60s 多段视频任务已提交")
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
@ -495,7 +604,27 @@ export function App() {
return targets.length;
}, "视频片段状态已刷新")
}
onSubmitExport={() => action(() => api.submitExport(pipelineProject.id), "导出任务已提交")}
exportResult={exportResult}
onRefreshExport={refreshExport}
onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")}
onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")}
onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")}
onSubmitExport={(payload) =>
action(async () => {
// 导出前先落盘当前编辑态(片段/字幕/转场/BGM),成片即所见
if (payload) await api.saveTimeline(pipelineProject.id, payload);
await api.submitExport(pipelineProject.id);
// 后端在后台线程跑 ffmpeg 拼接,这里轮询 poll-export 直到成片/失败,实时回填进度
for (let i = 0; i < 160; i += 1) {
const res = await api.pollExport(pipelineProject.id);
setExportResult(res);
if (res.status === "succeeded") return res;
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
}, "成片已导出")
}
/>
);
}

View File

@ -239,6 +239,31 @@
color: var(--black-alpha-48); letter-spacing: .06em;
text-transform: uppercase;
}
/* 新建商品 主 CTA(转写自 model-photo.html .mp-list-h .new-prod) */
.image-workbench .iw-list-h .new-prod {
margin-left: auto;
height: 28px; padding: 0 12px 0 10px;
display: inline-flex; align-items: center; gap: 6px;
background: var(--heat); color: #fff;
border: 1px solid var(--heat);
border-radius: var(--r-sm);
font-size: 12px; font-weight: 600;
font-family: inherit; cursor: pointer;
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.12),
0 2px 4px rgba(250, 93, 25, 0.10);
transition: filter var(--t-base), transform var(--t-fast), box-shadow var(--t-base);
}
.image-workbench .iw-list-h .new-prod:hover {
filter: brightness(.96);
box-shadow:
inset 0 -2px 4px rgba(250, 93, 25, 0.20),
0 1px 1px rgba(250, 93, 25, 0.16),
0 4px 8px rgba(250, 93, 25, 0.20);
}
.image-workbench .iw-list-h .new-prod:active { transform: scale(.98); }
.image-workbench .iw-list-h .new-prod svg { width: 12px; height: 12px; }
.image-workbench .iw-ps-search {
position: relative; height: 32px;
margin: 12px 14px 10px;
@ -359,6 +384,9 @@
transition: border-color var(--t-base), color var(--t-base);
}
.image-workbench .iw-main-h .tb-chip:hover { border-color: var(--heat-20); color: var(--heat); }
.image-workbench .iw-main-h .tb-chip svg { width: 10px; height: 10px; opacity: .6; }
.image-workbench .iw-main-h .tb-menu-wrap { position: relative; }
.image-workbench .iw-main-h .tb-search-wrap { display: inline-flex; align-items: center; }
.image-workbench .iw-main-body {
flex: 1; min-height: 0;
display: grid;
@ -1493,3 +1521,16 @@
box-shadow: var(--shadow-cta);
}
.model-demo.dm-b .dm-param .gen-btn svg { width: 14px; height: 14px; }
/* ─── 任务中心 · 网格视图(转写自 asset-factory.html .history-grid/.history-card)─── */
.asset-factory .history-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media (max-width: 1280px) { .asset-factory .history-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 960px) { .asset-factory .history-grid { grid-template-columns: repeat(2, 1fr); } }
.asset-factory .history-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px; display: grid; grid-template-columns: 78px 1fr; gap: 14px; align-items: center; transition: background var(--t-base); position: relative; }
.asset-factory .history-card:hover { background: var(--black-alpha-4); }
.asset-factory .history-card .placeholder { width: 78px; height: 78px; }
.asset-factory .history-body { min-width: 0; }
.asset-factory .history-name { font-weight: 600; color: var(--accent-black); font-size: 13.5px; }
.asset-factory .history-type { font-size: 11.5px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-factory .history-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; }
.asset-factory .history-foot .mono { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }

View File

@ -3,7 +3,9 @@ import type {
Asset,
AuthPayload,
BillingSummary,
BillingTrend,
Ledger,
LoginSession,
ModelConfig,
Notification,
NotificationList,
@ -14,7 +16,8 @@ import type {
ScriptVersion,
Team,
TeamMember,
User
User,
UserPreference
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
@ -75,6 +78,27 @@ export const api = {
uploadAvatar(formData: FormData) {
return request<User>("/api/auth/me/avatar/", { method: "POST", body: formData });
},
resetAvatar() {
return request<User>("/api/auth/me/avatar/", { method: "DELETE" });
},
deleteAsset(id: string) {
return request<void>(`/api/assets/${id}/`, { method: "DELETE" });
},
preferences() {
return request<UserPreference>("/api/auth/me/preferences/");
},
updatePreferences(payload: Partial<UserPreference>) {
return request<UserPreference>("/api/auth/me/preferences/", { method: "PUT", body: JSON.stringify(payload) });
},
loginSessions() {
return request<LoginSession[]>("/api/auth/me/sessions/");
},
revokeSession(id: string) {
return request<{ revoked: number }>(`/api/auth/me/sessions/${id}/revoke/`, { method: "POST" });
},
revokeOtherSessions() {
return request<{ token: string }>("/api/auth/me/sessions/revoke-others/", { method: "POST" });
},
logout() {
return request<void>("/api/auth/logout/", { method: "POST" });
},
@ -123,6 +147,12 @@ export const api = {
updateProduct(id: string, payload: Partial<Product>) {
return request<Product>(`/api/products/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
uploadProductImage(productId: string, formData: FormData) {
return request<Product>(`/api/products/${productId}/images/`, { method: "POST", body: formData });
},
deleteProductImage(productId: string, imageId: string) {
return request<Product>(`/api/products/${productId}/images/${imageId}/`, { method: "DELETE" });
},
deleteProduct(id: string) {
return request<void>(`/api/products/${id}/`, { method: "DELETE" });
},
@ -132,7 +162,7 @@ export const api = {
project(id: string) {
return request<Project>(`/api/projects/${id}/`);
},
createProject(payload: { name: string; product: string }) {
createProject(payload: { name: string; product: string; metadata?: Record<string, unknown> }) {
return request<Project>("/api/projects/", { method: "POST", body: JSON.stringify(payload) });
},
deleteProject(id: string) {
@ -159,6 +189,12 @@ export const api = {
generateStoryboard(projectId: string, payload: { prompt: string }) {
return request(`/api/projects/${projectId}/generate-storyboard/`, { method: "POST", body: JSON.stringify(payload) });
},
pollStoryboard(projectId: string) {
return request<{ status: "generating" | "succeeded" | "failed"; done: number; total: number; version_id: string; error?: string }>(
`/api/projects/${projectId}/poll-storyboard/`,
{ method: "POST" }
);
},
skipStoryboard(projectId: string) {
return request<Project>(`/api/projects/${projectId}/skip-storyboard/`, { method: "POST" });
},
@ -174,6 +210,24 @@ export const api = {
submitExport(projectId: string) {
return request(`/api/projects/${projectId}/submit-export/`, { method: "POST" });
},
pollExport(projectId: string) {
return request<import("./types").ExportPoll>(`/api/projects/${projectId}/poll-export/`, { method: "POST" });
},
uploadVideoSegment(projectId: string, segmentId: string, file: File) {
const form = new FormData();
form.append("video_segment_id", segmentId);
form.append("file", file);
return request<Project>(`/api/projects/${projectId}/upload-video-segment/`, { method: "POST", body: form });
},
uploadBgm(projectId: string, file: File, volume?: number) {
const form = new FormData();
form.append("file", file);
if (volume != null) form.append("volume", String(volume));
return request<Project>(`/api/projects/${projectId}/upload-bgm/`, { method: "POST", body: form });
},
saveTimeline(projectId: string, payload: import("./types").TimelineSavePayload) {
return request<Project>(`/api/projects/${projectId}/save-timeline/`, { method: "POST", body: JSON.stringify(payload) });
},
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
@ -186,6 +240,9 @@ export const api = {
ledgers() {
return request<Ledger[]>("/api/billing/ledgers/");
},
billingTrend(range?: "day" | "week" | "month") {
return request<BillingTrend>(`/api/billing/trend/${range ? `?range=${range}` : ""}`);
},
modelConfigs() {
return request<Paginated<ModelConfig>>("/api/ai/models/");
},

View File

@ -1,8 +1,97 @@
import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
const SIDEBAR_COLLAPSED_KEY = "airshelf:sidebar-collapsed";
// 全局命令面板(Ctrl K / 点搜索框)—— 忠实搬设计稿 SHELL_COMMANDS,href 改成真路由导航
type Command = { id: string; group: string; label: string; sub: string; page: Page; icon: string; key?: string };
const SHELL_COMMANDS: Command[] = [
{ id: "dashboard", group: "导航", label: "工作台", sub: "任务队列、今日消耗、项目进度", page: "dashboard", icon: "dashboard", key: "D" },
{ id: "products", group: "导航", label: "商品库", sub: "管理 SKU、商品图册、卖点信息", page: "products", icon: "package", key: "P" },
{ id: "projects", group: "导航", label: "视频项目", sub: "查看五阶段短视频流水线", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
{ id: "library", group: "导航", label: "资产库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "消费", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
{ id: "settings", group: "导航", label: "设置", sub: "个人信息、通知、安全、偏好", page: "settings", icon: "settings" },
{ id: "messages", group: "常用动作", label: "消息中心", sub: "任务提醒、协作评论、系统通知", page: "messages", icon: "bell", key: "M" },
{ id: "new-product", group: "常用动作", label: "新建商品", sub: "从商品信息开始生成素材与视频", page: "productCreateUpload", icon: "productPlus" },
{ id: "new-project", group: "常用动作", label: "新建视频项目", sub: "选择商品并进入脚本配置", page: "projectWizard", icon: "clapperboard" },
{ id: "model-photo", group: "常用动作", label: "生成模特上身图", sub: "快速生成 3:4 商品展示素材", page: "modelPhoto", icon: "users" },
{ id: "platform-cover", group: "常用动作", label: "生成平台套图", sub: "适配电商平台封面与详情图", page: "platformCover", icon: "images" },
{ id: "image-optimize", group: "常用动作", label: "图片创作", sub: "对话式生成、编辑、加入资产库", page: "imageOptimize", icon: "images" }
];
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
return SHELL_COMMANDS.filter((cmd) => !q || [cmd.label, cmd.sub, cmd.group, cmd.id].join(" ").toLowerCase().includes(q));
}, [query]);
const run = (cmd: Command) => { onClose(); navigate(cmd.page); };
if (!open) return null;
let lastGroup = "";
return createPortal(
<div
id="shell-command-bg"
className="show"
aria-hidden="false"
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}
>
<div className="shell-command" role="dialog" aria-modal="true" aria-label="命令面板">
<div className="shell-command-head">
<IconKitSvg name="search" />
<input
id="shell-command-input"
autoFocus
placeholder="搜索页面、动作…"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") { event.preventDefault(); onClose(); }
else if (event.key === "Enter" && items[0]) { event.preventDefault(); run(items[0]); }
}}
/>
<span id="shell-command-count" className="shell-command-count">{items.length} </span>
<button id="shell-command-close" type="button" className="shell-command-close" aria-label="关闭" onClick={onClose}>Esc</button>
</div>
<div id="shell-command-list" className="shell-command-list">
{items.length === 0 && (
<div className="shell-command-empty">
<IconKitSvg name="search" />
<span></span>
<span className="shell-command-section">// 换个关键词试试</span>
</div>
)}
{items.map((cmd, i) => {
const section = cmd.group !== lastGroup ? <div className="shell-command-section">{cmd.group}</div> : null;
lastGroup = cmd.group;
return (
<div key={cmd.id}>
{section}
<button className={`shell-command-item${i === 0 ? " active" : ""}`} type="button" onClick={() => run(cmd)}>
<span className="cmd-ic"><IconKitSvg name={cmd.icon} /></span>
<span className="cmd-main">
<span className="cmd-title">{cmd.label}</span>
<span className="cmd-sub">{cmd.sub}</span>
</span>
{cmd.key && <span className="cmd-key">{cmd.key}</span>}
</button>
</div>
);
})}
</div>
</div>
</div>,
document.body
);
}
type Navigate = (page: Page) => void;
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
@ -50,18 +139,50 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
const activeNav = PAGE_TO_NAV[page];
const badges: Partial<Record<string, number>> = { products: products.length, projects: projects.length };
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
// 收窄/展开导航:与设计稿 Shell.toggleSidebarCollapse 一致 —— 切 body.sidebar-collapsed
// 类(CSS 在 design-restraint.css),并持久化到 localStorage。
const [collapsed, setCollapsed] = useState(() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "1");
useEffect(() => {
document.body.classList.toggle("sidebar-collapsed", collapsed);
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? "1" : "0");
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setPaletteOpen((value) => !value);
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
return (
<>
<aside className="sidebar">
<div className="sidebar-head">
<a className="brand" href="/dashboard" aria-label="Airshelf 工作台" onClick={(event) => { event.preventDefault(); navigate("dashboard"); }}>
<span className="brand-clip"><img className="brand-logo" src="/assets/logo.png" alt="Airshelf" /></span>
</a>
</div>
<button className="sidebar-toggle" type="button" aria-label="收窄导航" title="收窄导航">
<button
className="sidebar-toggle"
type="button"
aria-pressed={collapsed}
aria-label={collapsed ? "展开导航" : "收窄导航"}
title={collapsed ? "展开导航" : "收窄导航"}
onClick={() => setCollapsed((value) => !value)}
>
<span className="sidebar-toggle-icon sidebar-toggle-icon--collapse"><IconKitSvg name="chevronLeft" size={18} strokeWidth={1.8} /></span>
<span className="sidebar-toggle-icon sidebar-toggle-icon--expand"><IconKitSvg name="chevronRight" size={18} strokeWidth={1.8} /></span>
</button>
<div className="search-box" title="搜索">
<div className="search-box" title="搜索 (Ctrl K)" role="button" tabIndex={0} onClick={openCommandPalette} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openCommandPalette(); } }}>
<IconKitSvg name="search" />
<input id="global-search" placeholder="搜索" readOnly aria-label="打开全局搜索" />
<span className="kbd">Ctrl K</span>
@ -90,6 +211,8 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
</div>
</div>
</aside>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
</>
);
}

View File

@ -13,7 +13,8 @@
src: local('Alibaba PuHuiTi 3.0'),
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular');
local('AlibabaPuHuiTi-2-55-Regular'),
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +23,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium');
local('AlibabaPuHuiTi-2-65-Medium'),
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -30,7 +32,8 @@
font-style: normal;
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold');
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -39,7 +42,8 @@
font-display: swap;
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold');
local('AlibabaPuHuiTi-2-85-Bold'),
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -12,4 +12,8 @@
.asset-name { font-size: 13px; font-weight: 600; color: var(--accent-black); }
.asset-meta { font-size: 11px; color: var(--black-alpha-48); margin-top: 3px; font-family: var(--font-mono); letter-spacing: .02em; }
.asset-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 10px; letter-spacing: .04em; padding: 2px 6px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-56); }
.asset-card { position: relative; }
}
/* 编辑模式:开启「管理资产」后,资产卡删除按钮常显(否则全局只 hover 显) */
body.edit-mode .asset-card .card-del-btn { opacity: 1 !important; pointer-events: auto !important; }

View File

@ -11,5 +11,8 @@ import "./products-page.css";
import "./library-page.css";
import "./messages-page.css";
import "./settings-page.css";
import "./ai-tools-page.css";
import "./product-create-page.css";
import "./project-wizard-page.css";
createRoot(document.getElementById("root")!).render(<App />);

View File

@ -166,6 +166,14 @@
.shots-empty .empty-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.shots-empty .empty-hint { font-size: 12px; color: var(--black-alpha-56); line-height: 1.55; max-width: 280px; font-family: var(--font-mono); letter-spacing: .02em; }
/* 镜头脚本卡(真实脚本镜头列表) */
.shot-card { display: flex; gap: 12px; padding: 12px 4px; border-bottom: 1px solid var(--border-faint); }
.shot-card:last-child { border-bottom: 0; }
.shot-card .shot-n { flex: 0 0 auto; width: 26px; height: 26px; border-radius: 6px; background: var(--heat-12); color: var(--heat); font-family: var(--font-mono); font-size: 12px; font-weight: 600; display: grid; place-items: center; }
.shot-card .shot-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.shot-card .shot-meta { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; text-transform: uppercase; }
.shot-card .shot-narration { font-size: 13px; color: var(--accent-black); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
.chat-empty .ce-title { font-size: 13.5px; color: var(--accent-black); font-weight: 500; }
@ -232,6 +240,19 @@
.asset-card-2.prod-lib-card .prod-action .btn-aigen:hover { background: #FB6E2E; box-shadow: inset 0 -2px 4px rgba(250,93,25,.24), 0 2px 4px rgba(250,93,25,.20), 0 4px 12px rgba(250,93,25,.18); transform: translateY(-1px); }
.asset-card-2.prod-lib-card .prod-action .btn-aigen .ai-spark { width: 14px; height: 14px; flex-shrink: 0; }
/* ── 生成中统一置灰:凡 set disabled 的按钮都灰显 + 禁手势 + 去 hover/动效(设计师要求:已点击/生成中要置灰不可点)── */
.btn-aigen:disabled,
.asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; transform: none; }
.btn-aigen:disabled:hover,
.asset-card-2.prod-lib-card .prod-action .btn-aigen:disabled:hover { background: var(--heat); box-shadow: none; transform: none; }
.chat-mode:disabled { opacity: .45; cursor: not-allowed; }
.chat-mode:disabled:hover { background: var(--surface); border-color: var(--border-faint); color: var(--accent-black); }
.pill-cta:disabled { opacity: .5; cursor: not-allowed; box-shadow: none; }
.pill-cta:disabled:hover { box-shadow: none; }
.tag-add:disabled { opacity: .45; cursor: not-allowed; }
.tl-toolbar .tl-action:disabled { opacity: .38; cursor: not-allowed; }
.tl-toolbar .tl-action:disabled:hover { background: transparent; border-color: transparent; color: var(--black-alpha-72); }
.prompt-box { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; font-size: 12px; color: var(--black-alpha-56); margin-top: 8px; line-height: 1.55; font-family: var(--font-mono); letter-spacing: .01em; transition: border-color var(--t-base), background var(--t-base); }
.prompt-box[contenteditable="true"] { cursor: text; outline: none; }
.prompt-box[contenteditable="true"]:hover { border-color: var(--heat-20); }
@ -322,7 +343,10 @@
.editor { display: grid; grid-template-columns: 1fr 280px; grid-template-rows: 1fr auto; gap: 0; height: 580px; background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); }
.editor-preview { padding: 16px; border-right: 1px solid var(--border-faint); border-bottom: 1px solid var(--border-faint); display: flex; flex-direction: column; gap: 12px; }
.editor-preview .canvas { flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background: repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px), var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; }
.editor-preview .canvas { position: relative; overflow: hidden; flex: 1 1 0; min-height: 0; aspect-ratio: 9/16; margin: 0 auto; background: repeating-linear-gradient(135deg, rgba(0,0,0,0.03) 0 1px, transparent 1px 12px), var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); display: grid; place-items: center; color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; }
/* 转场实时预览:切片段时画面淡场(导出才是真 xfade) */
.editor-preview .canvas .ed-xfade-flash { position: absolute; inset: 0; z-index: 3; background: #000; pointer-events: none; animation: edXfadeFlash 0.45s ease forwards; }
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
.editor-preview .controls { display: flex; align-items: center; gap: 8px; justify-content: center; }
.ctl-btn { width: 36px; height: 36px; border: 1px solid var(--border-faint); background: var(--surface); color: var(--black-alpha-56); border-radius: var(--r-md); display: grid; place-items: center; cursor: pointer; transition: background var(--t-base), border-color var(--t-base), color var(--t-base); }
.ctl-btn:hover { color: var(--heat); border-color: var(--heat-40); background: var(--heat-12); }

View File

@ -265,3 +265,172 @@
@media (max-width: 1100px) {
.product-create-page .form-grid { grid-template-columns: 1fr; }
}
/* ============================================================
新建商品 · 右侧 Drawer(在商品库页面原地打开)
像素基线: public/exact/products.html #pc-drawer + 其内联 <style>
全部 scope .pc-drawer ,与上方整页 .product-create-page 互不影响
============================================================ */
/* .drawer.pc-drawer(0,2,0)·提高特异性压过共享 .drawer{width:540px}
dev App.tsx 链路的 product-create-page.css design-restraint.css 先注入,
等特异性时基类反而后加载会赢,故必须双类提权 */
.drawer.pc-drawer { width: 820px; max-width: 100vw; }
.pc-drawer .drawer-h h3 { font-size: 16px; font-weight: 600; }
.pc-drawer .drawer-b { padding: 24px 28px; }
.pc-drawer .drawer-b .form-card { background: transparent; border: 0; padding: 0; border-radius: 0; }
.pc-drawer .drawer-f { padding: 14px 24px; background: var(--surface); align-items: center; }
.pc-drawer .drawer-f .btn-guide {
margin-right: auto;
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--black-alpha-56);
background: transparent; border: 0; cursor: pointer;
padding: 8px 10px; border-radius: var(--r-md);
font-family: inherit;
transition: background var(--t-base), color var(--t-base);
}
.pc-drawer .drawer-f .btn-guide:hover { color: var(--accent-black); background: var(--black-alpha-4); }
.pc-drawer .drawer-f .btn-guide svg { width: 14px; height: 14px; }
/* form-card · 表单容器(drawer 内字段) */
.pc-drawer .form-card .field { margin-bottom: 16px; }
.pc-drawer .form-card .field:last-child { margin-bottom: 0; }
.pc-drawer .form-card .field-row {
display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 16px;
}
.pc-drawer .form-card .field-label {
display: block; font-size: 13px; font-weight: 500;
color: var(--accent-black); margin-bottom: 6px;
}
.pc-drawer .form-card .field-label .req { color: var(--heat); margin-left: 2px; }
.pc-drawer .form-card .field-label .opt {
color: var(--black-alpha-48); font-weight: 400; font-size: 12px; margin-left: 6px;
}
.pc-drawer .form-card .input,
.pc-drawer .form-card .select {
width: 100%; height: 38px;
background: var(--background-lighter);
border: 1px solid var(--black-alpha-12);
border-radius: var(--r-md);
padding: 0 14px;
font-size: 13.5px; color: var(--accent-black);
outline: none; font-family: inherit;
transition: border-color var(--t-base);
}
.pc-drawer .form-card .input:focus,
.pc-drawer .form-card .select:focus {
border-color: var(--heat-40);
box-shadow: inset 0 0 0 1px var(--heat-40);
}
/* 商品主图 · 上传(左) + 示例(右) */
.pc-drawer .form-card .pf-upload-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: 16px; align-items: stretch;
}
.pc-drawer .form-card .pf-upload-zone {
border: 1.5px dashed var(--black-alpha-24);
border-radius: var(--r-md);
padding: 28px 20px;
background: var(--background-lighter);
cursor: pointer; text-align: center;
transition: border-color var(--t-base), background var(--t-base);
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px;
}
.pc-drawer .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
.pc-drawer .form-card .pf-upload-zone .uz-ic {
width: 44px; height: 44px;
margin: 0 auto 10px;
background: var(--surface);
border: 1px solid var(--heat-20);
border-radius: var(--r-md);
color: var(--heat);
display: grid; place-items: center;
}
.pc-drawer .form-card .pf-upload-zone .uz-ic svg { width: 20px; height: 20px; }
.pc-drawer .form-card .pf-upload-zone .uz-t { font-size: 14px; color: var(--accent-black); font-weight: 500; }
.pc-drawer .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
.pc-drawer .form-card .pf-upload-zone .uz-d {
margin-top: 8px;
font-family: var(--font-mono); font-size: 11.5px;
color: var(--black-alpha-48); letter-spacing: .02em;
}
/* 示例图 · 纵向卡片 */
.pc-drawer .form-card .pf-example {
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
padding: 16px;
display: flex; flex-direction: column; gap: 10px;
}
.pc-drawer .form-card .pf-example .ex-h {
font-size: 13px; font-weight: 600; color: var(--accent-black);
}
.pc-drawer .form-card .pf-example .ex-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb {
aspect-ratio: 1;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
overflow: hidden; position: relative;
display: grid; place-items: center;
color: var(--black-alpha-32);
}
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb svg { width: 22px; height: 22px; }
.pc-drawer .form-card .pf-example .ex-grid .ex-thumb::after {
content: ''; position: absolute; inset: 0;
background: repeating-linear-gradient(135deg, transparent 0 6px, rgba(0,0,0,.03) 6px 7px);
pointer-events: none;
}
.pc-drawer .form-card .pf-example .ex-d {
font-size: 12px; color: var(--black-alpha-56); line-height: 1.5;
}
.pc-drawer .form-card .pf-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px; margin-top: 12px;
}
.pc-drawer .form-card .pf-grid:empty { display: none; }
/* 核心卖点 · bullet-list(drawer 变体) */
.pc-drawer .form-card .bullet-list { list-style: none; padding: 0; margin: 0; }
.pc-drawer .form-card .bullet-list .bl-item,
.pc-drawer .form-card .bullet-list .bl-add {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--background-lighter);
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
margin-bottom: 6px;
font-size: 13.5px;
}
.pc-drawer .form-card .bullet-list .bl-add { background: transparent; border-style: dashed; }
.pc-drawer .form-card .bullet-list .num {
width: 22px; height: 22px;
background: var(--surface);
border: 1px solid var(--border-faint);
border-radius: var(--r-sm);
font-family: var(--font-mono);
font-size: 11px; color: var(--heat); font-weight: 700;
display: grid; place-items: center; flex-shrink: 0;
}
.pc-drawer .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
.pc-drawer .form-card .bullet-list .bl-input {
flex: 1; background: transparent; border: 0; outline: none;
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
}
.pc-drawer .form-card .bullet-list .bl-x {
width: 22px; height: 22px;
color: var(--black-alpha-48);
cursor: pointer; display: grid; place-items: center;
border-radius: var(--r-sm);
transition: color var(--t-base), background var(--t-base);
}
.pc-drawer .form-card .bullet-list .bl-x:hover { color: var(--accent-crimson); background: var(--crimson-bg); }
.pc-drawer .form-card .bullet-list .bl-x svg { width: 11px; height: 11px; }
@media (max-width: 900px) {
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
}

View File

@ -30,4 +30,35 @@
/* 编辑模式 checkbox(默认隐藏) */
.product-card .card-check { position: absolute; top: 10px; left: 10px; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); border: 2px solid var(--black-alpha-32); display: none; place-items: center; color: var(--accent-white); z-index: 5; pointer-events: none; }
.product-card .card-check svg { width: 11px; height: 11px; opacity: 0; }
}
/* 批量编辑模式(忠实移植自 products.html · body.edit-mode 全局态,控件仅商品库出现) */
body.edit-mode .product-card { cursor: pointer; }
body.edit-mode .product-card .card-check { display: grid; }
body.edit-mode .product-card.selected .card-check { background: var(--heat); border-color: var(--heat); }
body.edit-mode .product-card.selected .card-check svg { opacity: 1; }
body.edit-mode .product-card.selected { border-color: var(--heat); box-shadow: 0 0 0 1px var(--heat) inset; }
body.edit-mode .product-footer .stat,
body.edit-mode .product-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
.bulk-bar {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background: var(--accent-black); color: var(--accent-white); border-radius: var(--r-md);
padding: 10px 14px 10px 18px; display: none; align-items: center; gap: 16px;
box-shadow: 0 8px 24px rgba(0,0,0,.18); z-index: 100; font-size: 13px;
}
body.edit-mode .bulk-bar { display: inline-flex; }
.bulk-bar .ct { font-family: var(--font-mono); letter-spacing: .02em; }
.bulk-bar .ct b { color: var(--heat); font-weight: 700; padding: 0 3px; }
.bulk-bar .sep { width: 1px; height: 18px; background: rgba(255,255,255,.16); }
.bulk-bar button { height: 30px; padding: 0 12px; background: transparent; border: 1px solid rgba(255,255,255,.24); border-radius: var(--r-sm); color: var(--accent-white); font-size: 12.5px; font-family: inherit; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; transition: background .15s, border-color .15s; }
.bulk-bar button:hover { background: rgba(255,255,255,.08); }
.bulk-bar button.danger { background: var(--accent-crimson, #c43d3d); border-color: var(--accent-crimson, #c43d3d); }
.bulk-bar button.danger:hover { filter: brightness(1.06); }
.bulk-bar button:disabled { opacity: .4; cursor: not-allowed; }
.bulk-bar button svg { width: 12px; height: 12px; }
.bulk-bar .clear-sel { color: rgba(255,255,255,.6); font-size: 12px; cursor: pointer; background: none; border: 0; padding: 4px 6px; }
.bulk-bar .clear-sel:hover { color: var(--accent-white); }
.btn-edit-toggle.active { background: var(--accent-black); color: var(--accent-white); border-color: var(--accent-black); }

View File

@ -1,7 +1,15 @@
import { useState } from "react";
import type { BillingSummary, Ledger, Project, TeamMember } from "../types";
import { useEffect, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
type TrendRange = "day" | "week" | "month";
const RANGE_META: Record<TrendRange, { chip: string; sub: string; totalLabel: string; avgLabel: string }> = {
day: { chip: "日", sub: "// 近 14 天 · 单位 ¥", totalLabel: "14 天合计", avgLabel: "日均" },
week: { chip: "周", sub: "// 近 8 周 · 单位 ¥", totalLabel: "8 周合计", avgLabel: "周均" },
month: { chip: "月", sub: "// 近 6 个月 · 单位 ¥", totalLabel: "6 月合计", avgLabel: "月均" }
};
type Tab = "overview" | "by-project" | "by-member" | "bills";
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
@ -11,16 +19,17 @@ const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: num
{ amt: 3000, gift: "+ ¥300 赠送", bonus: true, bonusAmt: 300 }
];
const STAGES: Array<{ k: string; color: string }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)" },
{ k: "故事板(image-2)", color: "var(--accent-forest)" },
{ k: "基础资产", color: "var(--black-alpha-56)" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)" }
const STAGES: Array<{ k: string; color: string; bucket: keyof BillingTrend["by_stage"] }> = [
{ k: "视频片段(Seedance)", color: "var(--heat)", bucket: "video" },
{ k: "故事板(image-2)", color: "var(--accent-forest)", bucket: "storyboard" },
{ k: "基础资产", color: "var(--black-alpha-56)", bucket: "base" },
{ k: "脚本 LLM", color: "var(--black-alpha-32)", bucket: "script" }
];
export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharge }: {
export function AccountPage({ billing, ledgers, trend, projects, teamMembers, onRecharge }: {
billing: BillingSummary | null;
ledgers: Ledger[];
trend: BillingTrend | null;
projects: Project[];
teamMembers: TeamMember[];
onRecharge: (amount: number, bonus: number) => void | Promise<unknown>;
@ -46,6 +55,26 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 消费趋势(日/周/月可切)+ 按阶段 / 按项目分布 —— 全部来自真实 CHARGE 流水
// day 用首屏 prop;week/month 切换时按真接口拉对应区间(缺接口已补 ?range=)
const [range, setRange] = useState<TrendRange>("day");
const [rangeTrend, setRangeTrend] = useState<BillingTrend | null>(null);
useEffect(() => {
if (range === "day") { setRangeTrend(null); return; }
let alive = true;
api.billingTrend(range).then((data) => { if (alive) setRangeTrend(data); }).catch(() => {});
return () => { alive = false; };
}, [range]);
const activeTrend = range === "day" ? trend : rangeTrend;
const meta = RANGE_META[range];
const daily = activeTrend?.daily ?? [];
const peak = Number(activeTrend?.peak || 0);
const total14 = Number(activeTrend?.total_14d ?? used);
const avgValue = daily.length ? total14 / daily.length : 0;
const stageTotal = STAGES.reduce((sum, s) => sum + Number(trend?.by_stage[s.bucket] || 0), 0) || used;
const projectSpend = (id: string) => Number(trend?.by_project[id] || 0);
return (
<section className="account-page">
<div className="page-head">
@ -137,32 +166,51 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<div className="pane trend-pane">
<div className="trend-head">
<h3></h3>
<span className="sub">// 近 14 天 · 单位 ¥</span>
<span className="sub">{meta.sub}</span>
<span className="spacer"></span>
<button className="chip active" type="button"></button>
<button className="chip" type="button"></button>
<button className="chip" type="button"></button>
{(["day", "week", "month"] as TrendRange[]).map((r) => (
<button key={r} className={`chip${range === r ? " active" : ""}`} type="button" onClick={() => setRange(r)}>{RANGE_META[r].chip}</button>
))}
</div>
<div className="trend-chart">
<div className="bars"></div>
<div className="x-axis"></div>
<div className="bars">
{daily.map((d) => {
const amt = Number(d.amount);
const h = peak > 0 ? Math.max(amt > 0 ? 4 : 0, (amt / peak) * 100) : 0;
const isPeak = peak > 0 && amt === peak;
return (
<div className={`bar${isPeak ? " peak" : ""}`} key={d.date} title={`${d.label} · ${money(amt)}`}>
<span style={{ height: `${h}%` }} />
</div>
);
})}
</div>
<div className="x-axis">
{daily.map((d, i) => (
<span key={d.date}>{i % 2 === 0 ? d.label : ""}</span>
))}
</div>
</div>
<div className="trend-foot">
<div className="item"><span className="k">14 </span><span className="v">{money(used)}</span></div>
<div className="item"><span className="k"></span><span className="v">{money(used / 14)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(used)}</span></div>
<div className="item"><span className="k">{meta.totalLabel}</span><span className="v">{money(total14)}</span></div>
<div className="item"><span className="k">{meta.avgLabel}</span><span className="v">{money(avgValue)}</span></div>
<div className="item"><span className="k"></span><span className="v warn">{money(peak)}</span></div>
</div>
</div>
<div className="pane stage-pane">
<h3></h3>
<div className="desc">// PRD §5.3.5 扣费规则 · 仅确认后扣</div>
{STAGES.map((s) => (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(0)}</span></div>
<div className="usage-bar"><span style={{ width: "0%", background: s.color }} /></div>
</div>
))}
{STAGES.map((s) => {
const amt = Number(trend?.by_stage[s.bucket] || 0);
const w = stageTotal > 0 ? Math.min(100, (amt / stageTotal) * 100) : 0;
return (
<div key={s.k}>
<div className="usage-line"><span className="k">{s.k}</span><span className="v">{money(amt)}</span></div>
<div className="usage-bar"><span style={{ width: `${w}%`, background: s.color }} /></div>
</div>
);
})}
<div className="total"><span></span><span className="v">{money(used)}</span></div>
</div>
</div>
@ -208,9 +256,12 @@ export function AccountPage({ billing, ledgers, projects, teamMembers, onRecharg
<table className="billing-table">
<thead><tr><th></th><th></th><th></th><th style={{ textAlign: "right" }}></th></tr></thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className="zero">{money(0)}</td></tr>
))}
{projects.map((p) => {
const spend = projectSpend(p.id);
return (
<tr key={p.id}><td>{p.name}</td><td>{p.current_stage}</td><td>{p.status}</td><td className={spend > 0 ? "neg" : "zero"}>{money(spend)}</td></tr>
);
})}
</tbody>
</table>
</div>

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent } from "react";
import {
ArrowLeft,
ArrowRight,
@ -52,6 +53,24 @@ function statusText(status: string) {
return STATUS_LABEL[status] || status;
}
// 下载图片:优先 fetch→blob 触发真实下载;跨域失败则回退到新标签打开(用户仍拿到图)
async function downloadImage(url: string, filename: string) {
try {
const res = await fetch(url, { mode: "cors" });
const blob = await res.blob();
const href = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
} catch {
window.open(url, "_blank", "noopener");
}
}
export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page) => void; aiTasks: AITask[] }) {
const cards = [
{
@ -88,7 +107,40 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
return acc;
}, [aiTasks]);
const visible = aiTasks.slice(0, 8);
// 任务中心筛选:状态 tab / 搜索 / 时间 / 任务类型 / 网格·列表视图 —— 全部对真实 aiTasks 生效
const [filter, setFilter] = useState<"all" | "gen" | "ok" | "err">("all");
const [query, setQuery] = useState("");
const [timeFilter, setTimeFilter] = useState<"all" | "1" | "7" | "30">("all");
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("list");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const typeOptions = Array.from(new Set(aiTasks.map((t) => t.task_type).filter(Boolean)));
const TIME_OPTS: Array<{ value: typeof timeFilter; label: string }> = [
{ value: "all", label: "全部时间" }, { value: "1", label: "今天" }, { value: "7", label: "近 7 天" }, { value: "30", label: "近 30 天" }
];
const visible = aiTasks.filter((task) => {
const pill = statusPill(task.status);
if (filter === "gen" && pill !== "info") return false;
if (filter === "ok" && pill !== "ok") return false;
if (filter === "err" && pill !== "err") return false;
if (typeFilter && task.task_type !== typeFilter) return false;
if (timeFilter !== "all" && task.created_at) {
const days = (Date.now() - new Date(task.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
if (query) {
const hay = `${TASK_TYPE_LABEL[task.task_type] || task.task_type} ${task.task_type} ${task.id}`.toLowerCase();
if (!hay.includes(query.toLowerCase())) return false;
}
return true;
});
return (
<div className="asset-factory">
@ -133,21 +185,63 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
</span>
</div>
{/* 状态 tabs(转写自 asset-factory.html #tc-tabs) */}
<div className="tabs" id="tc-tabs">
<div className={`tab${filter === "all" ? " active" : ""}`} data-filter="all" role="button" tabIndex={0} onClick={() => setFilter("all")}> <span className="count">{aiTasks.length}</span></div>
<div className={`tab${filter === "gen" ? " active" : ""}`} data-filter="gen" role="button" tabIndex={0} onClick={() => setFilter("gen")}> <span className="count">{counts.gen}</span></div>
<div className={`tab${filter === "ok" ? " active" : ""}`} data-filter="ok" role="button" tabIndex={0} onClick={() => setFilter("ok")}> <span className="count">{counts.ok}</span></div>
<div className={`tab${filter === "err" ? " active" : ""}`} data-filter="err" role="button" tabIndex={0} onClick={() => setFilter("err")}> <span className="count">{counts.err}</span></div>
</div>
<div className="toolbar">
<div className="search-inline">
<Search size={14} />
<input className="input" placeholder="搜索任务名" />
<input className="input" placeholder="搜索任务名" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{TIME_OPTS.find((o) => o.value === timeFilter)?.label !== "全部时间" ? TIME_OPTS.find((o) => o.value === timeFilter)?.label : "时间"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "type" ? " open" : ""}`} data-key="type">
<button className={`chip${typeFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "type" ? "" : "type"))}>
<span className="chip-label">{typeFilter ? TASK_TYPE_LABEL[typeFilter] || typeFilter : "任务类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((t) => (
<div className={`mi${typeFilter === t ? " selected" : ""}`} key={t} role="button" tabIndex={0} onClick={() => { setTypeFilter(t); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{TASK_TYPE_LABEL[t] || t}
</div>
))}
</div>
</div>
{(filter !== "all" || query || timeFilter !== "all" || typeFilter) && (
<button className="clear-filters" type="button" onClick={() => { setFilter("all"); setQuery(""); setTimeFilter("all"); setTypeFilter(""); }}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 4l8 8M12 4l-8 8" /></svg>
</button>
)}
<span className="spacer" />
<div className="view-toggle">
<button type="button" className="active">
<List size={13} />
</button>
<button type="button">
<button type="button" className={view === "grid" ? "active" : ""} data-view="grid" onClick={() => setView("grid")}>
<Grid2X2 size={13} />
</button>
<button type="button" className={view === "list" ? "active" : ""} data-view="list" onClick={() => setView("list")}>
<List size={13} />
</button>
</div>
</div>
@ -160,6 +254,31 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<div className="mono">// NO TASKS YET</div>
<div></div>
</div>
) : visible.length === 0 ? (
<div className="task-empty">
<div className="mono">// NO MATCH</div>
<div></div>
</div>
) : view === "grid" ? (
<div className="history-grid">
{visible.map((task) => {
const pill = statusPill(task.status);
const typeLabel = TASK_TYPE_LABEL[task.task_type] || task.task_type;
return (
<article className="task-card history-card" key={task.id}>
<div className="placeholder"><span className="ph-frame">{task.id.slice(0, 4)}</span></div>
<div className="history-body">
<div className="history-name">{typeLabel}</div>
<div className="history-type">// {task.task_type}</div>
<div className="history-foot">
<span className="mono">{(task.created_at || "").slice(0, 10)}</span>
<span className={`pill ${pill}`}><span className="dot" />{statusText(task.status)}</span>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="task-list-view">
<table className="t">
@ -168,7 +287,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
<th style={{ width: "42%" }}></th>
<th style={{ width: 160 }}></th>
<th></th>
<th style={{ width: 140 }}> ID</th>
<th style={{ width: 120 }}></th>
<th style={{ width: 48 }} />
</tr>
</thead>
<tbody>
@ -208,7 +328,8 @@ export function AssetFactoryPage({ navigate, aiTasks }: { navigate: (page: Page)
{statusText(task.status)}
</span>
</td>
<td className="muted-2">{task.id.slice(0, 8)}</td>
<td className="muted-2 mono" style={{ fontSize: 11 }}>{(task.created_at || "").slice(0, 10)}</td>
<td />
</tr>
);
})}
@ -243,7 +364,7 @@ const MODE_META: Record<
title: "模特上身图",
tag: "[ MODEL · TRY-ON ]",
desc: "选择模特和商品,生成电商模特上身图。",
ratio: "3:4",
ratio: "1:1",
promptTemplate: (title) => `${title},模特上身展示,自然光,真实质感,电商主图`
},
cover: {
@ -328,6 +449,19 @@ export function ImageWorkbenchPage({
const [pickedIds, setPickedIds] = useState<string[]>([]);
const [generating, setGenerating] = useState(false);
const [results, setResults] = useState<Asset[] | null>(null);
const [refImage, setRefImage] = useState<{ name: string; url: string } | null>(null);
const refInputRef = useRef<HTMLInputElement | null>(null);
// 模特/平台 工作台头部:搜索 + 时间排序 + 模特筛选(对左侧网格真实生效)
const [gridQuery, setGridQuery] = useState("");
const [gridSort, setGridSort] = useState<"recent" | "name">("recent");
const [tbOpen, setTbOpen] = useState<"" | "time" | "model">("");
const [searchOpen, setSearchOpen] = useState(false);
function pickReference(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setRefImage({ name: file.name, url: URL.createObjectURL(file) });
event.target.value = "";
}
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
@ -387,15 +521,19 @@ export function ImageWorkbenchPage({
</div>
)}
<div className="gen-image-actions">
<button className="gen-img-btn" type="button" title="重跑">
<button className="gen-img-btn" type="button" title="重跑" disabled={generating} onClick={() => runGenerate()}>
<RefreshCw size={14} />
</button>
<button className="gen-img-btn" type="button" title="下载">
<Download size={14} />
</button>
<button className="gen-img-btn" type="button" title="更多">
<MoreHorizontal size={14} />
</button>
{url && (
<button className="gen-img-btn" type="button" title="下载" onClick={() => downloadImage(url, `${meta.title}-${index + 1}.png`)}>
<Download size={14} />
</button>
)}
{url && (
<button className="gen-img-btn" type="button" title="查看原图" onClick={() => window.open(url, "_blank", "noopener")}>
<MoreHorizontal size={14} />
</button>
)}
</div>
</div>
))}
@ -418,7 +556,7 @@ export function ImageWorkbenchPage({
</button>
</div>
<button className="ic-new-conv" type="button">
<button className="ic-new-conv" type="button" onClick={() => { setResults(null); setPrompt(meta.promptTemplate(product?.title || "商品")); setPickedIds([]); }}>
<Plus size={13} />
</button>
@ -464,15 +602,15 @@ export function ImageWorkbenchPage({
</div>
<div className="gen-card">{renderResultGrid()}</div>
<div className="gen-card-actions">
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => setResults(null)}>
<RefreshCw size={13} />
</button>
<button className="btn btn-sm" type="button">
<button className="btn btn-sm" type="button" onClick={() => navigate?.("library")}>
<Check size={13} />
</button>
<button className="btn btn-sm btn-ghost" type="button" title="更多">
<button className="btn btn-sm btn-ghost" type="button" title="下载全部" onClick={() => results?.forEach((asset, i) => { const u = asset.files?.[0]?.preview_url; if (u) downloadImage(u, `${meta.title}-${i + 1}.png`); })}>
<MoreHorizontal size={13} />
</button>
</div>
@ -501,9 +639,17 @@ export function ImageWorkbenchPage({
<div className="ic-input-wrap">
<div className="ic-input">
<div className="ic-input-top">
<button className="add-btn" type="button" title="上传参考图">
<button className="add-btn" type="button" title="上传参考图" onClick={() => refInputRef.current?.click()}>
<Plus size={22} />
</button>
<input ref={refInputRef} type="file" accept="image/*" hidden onChange={pickReference} />
{refImage && (
<span className="meta-chip" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<img src={refImage.url} alt="参考图" style={{ width: 18, height: 18, borderRadius: 3, objectFit: "cover" }} />
{refImage.name.slice(0, 16)}
<button type="button" aria-label="移除参考图" style={{ border: 0, background: "none", cursor: "pointer", padding: 0, lineHeight: 1 }} onClick={() => setRefImage(null)}>×</button>
</span>
)}
</div>
<textarea
className="ic-input-text"
@ -568,6 +714,12 @@ export function ImageWorkbenchPage({
</div>
<div className="iw-list-h">
<span className="mono">// 商品空间</span>
{navigate && (
<button className="new-prod" type="button" title="新建商品" onClick={() => navigate("productCreateUpload")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<span></span>
</button>
)}
</div>
<div className="iw-ps-list">
{products.length === 0 ? (
@ -607,13 +759,33 @@ export function ImageWorkbenchPage({
</span>
</div>
<span className="spacer" />
<button className="search-btn" type="button" title="搜索">
<Search size={14} />
</button>
{mode === "model" && navigate && (
<button className="tb-chip" type="button" onClick={() => navigate("modelPhotoDemoA")}>
A
<div className="tb-search-wrap">
{searchOpen && (
<input className="input" autoFocus placeholder={mode === "model" ? "搜索模特" : "搜索平台"} value={gridQuery} onChange={(event) => setGridQuery(event.target.value)} style={{ height: 30, marginRight: 6, width: 140 }} />
)}
<button className={`search-btn${searchOpen ? " active" : ""}`} type="button" title={mode === "model" ? "搜索批次/模特" : "搜索"} onClick={() => { setSearchOpen((v) => !v); if (searchOpen) setGridQuery(""); }}>
<Search size={14} />
</button>
</div>
{mode === "model" && (
<>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "time" ? " open" : ""}`} data-filter="time">
<button className={`tb-chip${gridSort === "name" ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "time" ? "" : "time"))}><span className="lbl">{gridSort === "name" ? "按名称" : "时间"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${gridSort === "recent" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("recent"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg></div>
<div className={`mi${gridSort === "name" ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridSort("name"); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg></div>
</div>
</div>
<div className={`tb-menu-wrap chip-wrap${tbOpen === "model" ? " open" : ""}`} data-filter="model">
<button className={`tb-chip${gridQuery ? " active" : ""}`} type="button" onClick={() => setTbOpen((c) => (c === "model" ? "" : "model"))}><span className="lbl">{gridQuery || "模特"}</span> <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className="chip-menu align-right">
<div className={`mi${!gridQuery ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setGridQuery(""); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg></div>
{(personAssets.length > 0 ? personAssets.map((p) => p.name) : FALLBACK_MODELS.map((m) => m.name)).map((nm) => (
<div className={`mi${gridQuery === nm ? " selected" : ""}`} key={nm} role="button" tabIndex={0} onClick={() => { setGridQuery(nm); setTbOpen(""); }}><svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{nm}</div>
))}
</div>
</div>
</>
)}
</div>
@ -629,7 +801,11 @@ export function ImageWorkbenchPage({
</div>
<div className="model-grid">
{personAssets.length > 0
? personAssets.slice(0, 6).map((item) => {
? personAssets
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : (b.created_at || "").localeCompare(a.created_at || "")))
.slice(0, 6)
.map((item) => {
const url = item.files?.[0]?.preview_url;
return (
<button
@ -655,7 +831,10 @@ export function ImageWorkbenchPage({
</button>
);
})
: FALLBACK_MODELS.map((item) => (
: FALLBACK_MODELS
.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase()))
.sort((a, b) => (gridSort === "name" ? a.name.localeCompare(b.name) : 0))
.map((item) => (
<button
type="button"
key={item.id}
@ -681,7 +860,7 @@ export function ImageWorkbenchPage({
<span className="title"></span>
</div>
<div className="platform-grid">
{PLATFORM_OPTIONS.map((item) => (
{PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => (
<button
type="button"
key={item.id}
@ -867,7 +1046,11 @@ const DEMO_SIDE_PRODUCTS = [
{ id: "d6", title: "小熊 4L 可视空气炸锅", category: "家居家电", batches: 0 }
];
export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A" | "B"; products: Product[]; onBack: () => void }) {
export function ModelPhotoDemoPage({ variant, products, onBack, navigate }: { variant: "A" | "B"; products: Product[]; onBack: () => void; navigate?: (page: Page) => void }) {
// 方案 A/B 是设计展示页(mock 数据,无生成后端);所有动作按钮导向真实工具 / 改本地选中态,杜绝死按钮
const [demoCount, setDemoCount] = useState("4 张");
const [demoRatio, setDemoRatio] = useState("3:4");
const toRealTool = () => onBack();
// 左栏商品空间:优先真 products(最近 6 条),空则回退基线占位
const sideProducts =
products.length > 0
@ -889,7 +1072,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-side-h">
<div className="ti-row">
<span className="ti"></span>
<button className="add" type="button" title="新建商品">
<button className="add" type="button" title="新建商品" onClick={() => navigate?.("productCreateUpload")}>
<Plus size={11} />
</button>
</div>
@ -916,7 +1099,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
))}
</div>
<button className="dm-all" type="button">
<button className="dm-all" type="button" onClick={() => navigate?.("products")}>
<LayoutGrid size={12} />
<span className="ct">{totalCount} </span>
@ -987,7 +1170,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h"></div>
<div className="dm-chip-row">
{["1 张", "2 张", "4 张", "8 张"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "4 张" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoCount ? "active" : ""}`} onClick={() => setDemoCount(label)}>
{label}
</button>
))}
@ -998,7 +1181,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<div className="dm-field-h"></div>
<div className="dm-chip-row">
{["1:1", "3:4", "9:16", "16:9"].map((label) => (
<button type="button" key={label} className={`dm-chip ${label === "3:4" ? "active" : ""}`}>
<button type="button" key={label} className={`dm-chip ${label === demoRatio ? "active" : ""}`} onClick={() => setDemoRatio(label)}>
{label}
</button>
))}
@ -1016,7 +1199,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
<span> <span className="v"> ¥1.20</span></span>
<span> ¥327.40</span>
</div>
<button className="dm-gen" type="button">
<button className="dm-gen" type="button" onClick={toRealTool}>
<Sparkles size={15} />
· {active.title} × Ava
</button>
@ -1042,9 +1225,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1069,9 +1252,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1096,7 +1279,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1140,10 +1323,10 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
<span className="spacer" />
<div className="dm-tb">
<button className="icbtn" type="button" title="搜索批次"><Search size={13} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="chip" type="button"> <ChevronDown size={10} /></button>
<button className="icbtn" type="button" title="搜索批次" onClick={toRealTool}><Search size={13} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
<button className="chip" type="button" onClick={toRealTool}> <ChevronDown size={10} /></button>
</div>
</div>
</div>
@ -1168,9 +1351,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1193,9 +1376,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1218,7 +1401,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="取消"><X size={13} /></button>
<button type="button" title="取消" onClick={toRealTool}><X size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1245,9 +1428,9 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部下载"><Download size={13} /></button>
<button type="button" title="加入资产库"><Bookmark size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="全部下载" onClick={toRealTool}><Download size={13} /></button>
<button type="button" title="加入资产库" onClick={toRealTool}><Bookmark size={13} /></button>
</div>
</div>
<div className="dm-batch-grid">
@ -1274,7 +1457,7 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
</div>
</div>
<div className="ops">
<button type="button" title="全部重跑"><RefreshCw size={13} /></button>
<button type="button" title="全部重跑" onClick={toRealTool}><RefreshCw size={13} /></button>
<button type="button" title="删除"><Trash2 size={13} /></button>
</div>
</div>
@ -1288,28 +1471,28 @@ export function ModelPhotoDemoPage({ variant, products, onBack }: { variant: "A"
{/* 底部 fixed 参数面板 */}
<div className="dm-param-wrap">
<div className="dm-param">
<button className="pchip active" type="button">
<button className="pchip active" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>Ava</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span>3:4</span>
<ChevronDown size={10} />
</button>
<button className="pchip" type="button">
<button className="pchip" type="button" onClick={toRealTool}>
<span className="lbl-mono"></span>
<span className="muted">+ </span>
</button>
<span className="spacer" />
<span className="meta-right"> <span className="v">¥1.20</span> · <span className="v">¥327.40</span></span>
<button className="gen-btn" type="button">
<button className="gen-btn" type="button" onClick={toRealTool}>
<Sparkles size={14} />
· {active.title} × Ava
</button>

View File

@ -1,7 +1,11 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Asset } from "../types";
import { Drawer } from "../components/overlays";
import { ConfirmModal, Drawer } from "../components/overlays";
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
@ -34,16 +38,52 @@ function assetTab(asset: Asset): LibTab {
}
}
export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void }) {
export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; onUpload: (formData: FormData) => Promise<unknown> | void; onDelete?: (id: string) => Promise<unknown> | void }) {
const [tab, setTab] = useState<LibTab>("people");
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
const [sortDesc, setSortDesc] = useState(true);
// 编辑模式 + 元数据筛选(性别/年龄/角色/场景类型/关联/时长 走 asset.metadata,真实存在才有可选项)
const [editMode, setEditMode] = useState(false);
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
// 切 tab 时清空与该 tab 无关的筛选
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = LIB_TABS.reduce((acc, t) => { acc[t.key] = assets.filter((a) => assetTab(a) === t.key).length; return acc; }, {} as Record<LibTab, number>);
const inTab = assets.filter((a) => assetTab(a) === tab);
const filtered = inTab.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()));
// 当前 tab 下真实存在的来源 / 类型(下拉只列真有的)
const srcOptions = Array.from(new Set(inTab.map((a) => a.source).filter(Boolean)));
const kindOptions = Array.from(new Set(inTab.map((a) => a.asset_type).filter(Boolean)));
// 元数据筛选项:从当前 tab 真实资产的 metadata 派生(无则下拉只有「全部」)
const metaOptions = (key: string) => Array.from(new Set(inTab.map((a) => String((a.metadata as Record<string, unknown> | undefined)?.[key] ?? "")).filter(Boolean)));
const filtered = inTab
.filter((a) => `${a.name} ${a.category}`.toLowerCase().includes(query.toLowerCase()))
.filter((a) => !srcFilter || a.source === srcFilter)
.filter((a) => !kindFilter || a.asset_type === kindFilter)
.filter((a) => Object.entries(metaFilter).every(([k, v]) => !v || String((a.metadata as Record<string, unknown> | undefined)?.[k] ?? "") === v))
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return sortDesc ? cmp : -cmp;
});
async function submit(event: FormEvent) {
event.preventDefault();
@ -65,9 +105,9 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<div className="sub"><span className="mono">// 跨项目复用 · <span id="sub-people">{counts.people}</span> 人 · <span id="sub-scenes">{counts.scenes}</span> 景 · <span id="sub-products">{counts.products}</span> 商 · <span id="sub-finals">{counts.finals}</span> 片</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="lib-manage-btn">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => setEditMode((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="lib-manage-label"></span>
<span className="lib-manage-label">{editMode ? "完成" : "管理资产"}</span>
</button>
<button className="btn btn-primary" type="button" id="open-upload-btn" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
@ -87,14 +127,83 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索资产名称、标签" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => (
<div className="chip-wrap" data-key={chip.key} key={chip.key}>
<button className="chip" type="button"><span className="chip-label">{chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
</div>
))}
{LIB_CHIPS.filter((chip) => chip.tabs.includes(tab)).map((chip) => {
// 仅「来源 / 资产类型」有真实字段可筛;其余(性别/年龄/角色/场景类型/关联商品/关联项目/时长)Asset 无对应字段,保持静态
if (chip.key === "source") {
return (
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source" key="source">
<button className={`chip${srcFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{srcFilter ? SOURCE_LABELS[srcFilter] || srcFilter : "来源"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!srcFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSrcFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{srcOptions.length > 0 && <div className="mi-sep" />}
{srcOptions.map((src) => (
<div className={`mi${srcFilter === src ? " selected" : ""}`} key={src} role="button" tabIndex={0} onClick={() => { setSrcFilter(src); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{SOURCE_LABELS[src] || src}
</div>
))}
</div>
</div>
);
}
if (chip.key === "kind") {
return (
<div className={`chip-wrap${openChip === "kind" ? " open" : ""}`} data-key="kind" key="kind">
<button className={`chip${kindFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "kind" ? "" : "kind"))}>
<span className="chip-label">{kindFilter ? KIND_LABELS[kindFilter] || kindFilter : "资产类型"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!kindFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setKindFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{kindOptions.length > 0 && <div className="mi-sep" />}
{kindOptions.map((k) => (
<div className={`mi${kindFilter === k ? " selected" : ""}`} key={k} role="button" tabIndex={0} onClick={() => { setKindFilter(k); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{KIND_LABELS[k] || k}
</div>
))}
</div>
</div>
);
}
// 其余维度走 asset.metadata 真实派生:有标记数据才出可选项,否则只「全部」
const opts = metaOptions(chip.key);
const cur = metaFilter[chip.key] || "";
return (
<div className={`chip-wrap${openChip === chip.key ? " open" : ""}`} data-key={chip.key} key={chip.key}>
<button className={`chip${cur ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === chip.key ? "" : chip.key))}>
<span className="chip-label">{cur || chip.label}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!cur ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: "" })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{chip.label}
</div>
{opts.length > 0 && <div className="mi-sep" />}
{opts.map((v) => (
<div className={`mi${cur === v ? " selected" : ""}`} key={v} role="button" tabIndex={0} onClick={() => { setMetaFilter((m) => ({ ...m, [chip.key]: v })); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{v}
</div>
))}
</div>
</div>
);
})}
<span className="spacer"></span>
<div className="chip-wrap" data-key="sort">
<button className="chip" type="button"><span className="chip-label">使</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button>
<div className={`chip-wrap${openChip === "sort" ? " open" : ""}`} data-key="sort">
<button className="chip" type="button" onClick={() => setOpenChip((c) => (c === "sort" ? "" : "sort"))}>
<span className="chip-label">{sortDesc ? "最近添加" : "最早添加"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(true); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!sortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setSortDesc(false); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
@ -106,6 +215,11 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除资产" onClick={(event) => { event.stopPropagation(); setConfirmId(asset.id); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb">
{cover ? <img src={cover} alt={asset.name} loading="lazy" /> : <span className="ph-frame">{asset.asset_type}</span>}
</div>
@ -118,6 +232,15 @@ export function LibraryPage({ assets, onUpload }: { assets: Asset[]; onUpload: (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<ConfirmModal
open={Boolean(confirmId)}
title="删除资产"
detail="确定删除该资产?该操作不可撤销。"
confirmText="删除"
onCancel={() => setConfirmId(null)}
onConfirm={async () => { const id = confirmId; setConfirmId(null); if (id) await onDelete?.(id); }}
/>
<Drawer title="上传资产" open={drawer} close={() => setDrawer(false)}><form onSubmit={submit}><div className="field"><label className="field-label"></label><input className="input file-input" type="file" onChange={(event) => setFile(event.target.files?.[0] || null)} /></div><div className="field"><label className="field-label"></label><input className="input" value={name} onChange={(event) => setName(event.target.value)} /></div><div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit" disabled={!file}></button></div></form></Drawer>
</section>
);

View File

@ -1,7 +1,7 @@
import { Fragment, useState } from "react";
import type { CSSProperties } from "react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import { Play } from "lucide-react";
import type { Asset, BillingSummary, Product, Project, Team, User } from "../types";
import type { Asset, BillingSummary, ExportPoll, Product, Project, Team, TimelineSavePayload, User } from "../types";
import type { Notice, Page } from "./route-config";
import { money, stageOrder, statusPill } from "./stage-config";
import { CornerMarks, Decorations, Sidebar, ToastLike } from "../components/app-shell";
@ -67,20 +67,27 @@ export function PipelinePage(props: {
logout: () => void;
onRefresh: () => void;
onGenerateScript: (prompt: string) => void;
onAdoptScript: (scriptId: string) => void;
onAdoptScript: (scriptId: string) => void | Promise<unknown>;
onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void;
onGenerateStoryboard: (prompt: string) => void;
onSkipStoryboard: () => void;
onSubmitVideo: (segmentId: string, prompt: string) => void;
onPollVideo: (segmentId: string) => void;
onSubmitAllVideos: (prompt: string) => void;
onPollVideosQuiet: () => void | Promise<void>;
onPollAllVideos: () => void;
onSubmitExport: () => void;
exportResult: ExportPoll | null;
onRefreshExport: () => void;
onUploadVideoSegment: (segmentId: string, file: File) => void;
onUploadBgm: (file: File, volume: number) => void;
onSaveTimeline: (payload: TimelineSavePayload) => void;
onSubmitExport: (payload?: TimelineSavePayload) => void;
}) {
const {
project, loading, navigate, user, team, products, projects, assets, billing, notice, unreadCount, avatarChar, logout,
onGenerateScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onSubmitExport
onGenerateScript, onAdoptScript, onGenerateBaseAsset, onGenerateStoryboard, onSkipStoryboard,
onSubmitVideo, onSubmitAllVideos, onPollVideosQuiet, exportResult, onRefreshExport,
onUploadVideoSegment, onUploadBgm, onSaveTimeline, onSubmitExport
} = props;
// ── 资产解析:把各阶段引用的 asset id → 真实缩略图 preview_url(主图优先,其次首张)──
@ -91,11 +98,34 @@ export function PipelinePage(props: {
return a?.files?.find((f) => f.is_primary)?.preview_url || a?.files?.[0]?.preview_url || "";
};
const assetName = (id: string | null | undefined): string => (id ? byId.get(id)?.name || "" : "");
// 缩略图解析:优先用后端内嵌的 preview_url(不受团队 assets 分页 20 条影响),回退到 assets 列表解析
type GroupLike = { adopted_asset: string | null; adopted_asset_url?: string; candidate_assets?: string[]; candidate_asset_urls?: Record<string, string> };
const groupMainUrl = (g?: GroupLike | null): string =>
g?.adopted_asset_url || assetUrl(g?.adopted_asset) || (g?.candidate_assets?.[0] ? (g?.candidate_asset_urls?.[g.candidate_assets[0]] || assetUrl(g.candidate_assets[0])) : "");
const candUrl = (g: GroupLike | null | undefined, id: string): string => g?.candidate_asset_urls?.[id] || assetUrl(id);
const frameUrl = (f?: { asset: string; asset_url?: string } | null): string => f?.asset_url || assetUrl(f?.asset);
const segUrl = (s?: { adopted_asset?: string | null; adopted_asset_url?: string } | null): string => s?.adopted_asset_url || assetUrl(s?.adopted_asset);
// ── Stage 1:脚本(采用版优先,否则最近一版)+ 镜头列表 ──
const scripts = [...(project.script_versions ?? [])];
const currentScript =
scripts.find((s) => s.is_adopted) ||
[...scripts].sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""))[0] ||
null;
const scriptAdopted = Boolean(currentScript?.is_adopted);
const shots = [...(currentScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order);
// ── Stage 2:基础资产按 kind 分组(product/person/scene),保持设计稿三区顺序 ──
const groups = project.base_asset_groups ?? [];
const groupsByKind = (kind: string) => groups.filter((g) => g.kind === kind);
const KIND_ORDER: Array<"product" | "person" | "scene"> = ["product", "person", "scene"];
const [assetTab, setAssetTab] = useState<"product" | "person" | "scene">("product");
function jumpAssetSection(kind: "product" | "person" | "scene") {
setAssetTab(kind);
if (typeof document !== "undefined") {
document.getElementById(`asset-sec-${kind}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
}
}
// ── Stage 3:取已采用(is_adopted)的故事板版本,无则取第一版 ──
const storyboards = project.storyboard_versions ?? [];
@ -128,9 +158,319 @@ export function PipelinePage(props: {
const activeDot = navigated ? viewStage : projectStage;
const completed = Math.max(projectStage - 1, activeDot - 1);
const [chatText, setChatText] = useState("");
const [chatMode, setChatMode] = useState<"ai" | "theme" | "manual">("ai");
const [chatAttachments, setChatAttachments] = useState<Array<{ name: string; chars: number }>>([]);
const chatTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const chatFileRef = useRef<HTMLInputElement | null>(null);
function clearChat() {
setChatText("");
setChatAttachments([]);
setChatMode("ai");
}
function pickScriptMode() {
setChatMode("manual");
chatFileRef.current?.click();
}
function onPickScriptFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
const text = String(reader.result || "").trim();
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
setChatMode("manual");
chatTextareaRef.current?.focus();
};
reader.readAsText(file);
}
function focusThemeMode() {
setChatMode("theme");
chatTextareaRef.current?.focus();
}
const [storyboardPrompt, setStoryboardPrompt] = useState("统一商品、人物、场景风格,生成可直接指导视频的分镜图");
const [videoPrompt, setVideoPrompt] = useState("竖屏电商短视频,镜头稳定,商品露出清晰,节奏有转化感");
const canExport = project.video_segments.length > 0 && project.video_segments.every((segment) => Boolean(segment.adopted_version));
// ── Stage 5 · 真实视频播放器:时间轴 clips 当作播放列表,逐段播真实视频文件 ──
const isVideoAsset = (id: string | null | undefined): boolean => {
const a = id ? byId.get(id) : null;
if (!a) return false;
if (a.asset_type === "video") return true;
const f = a.files?.find((x) => x.is_primary) || a.files?.[0];
return !!f && /video\//.test(f.content_type || "");
};
// ── Stage 5 · 编辑器状态(可改片段/字幕文本/转场/BGM音量,本地编辑,保存草稿/导出时落盘)──
type EdClipState = { key: string; asset: string; url: string; isVideo: boolean; durMs: number; trimStartMs: number; trimEndMs: number | null; subtitle: string };
type EditorState = { clips: EdClipState[]; subtitleEnabled: boolean; subtitleStyle: string; transition: string; bgmVolume: number };
const buildInitialEditor = useCallback((): EditorState => {
const tl = project.timeline;
const sub = (tl?.subtitle_tracks ?? [])[0];
const savedTexts: string[] = (sub?.content ?? []).map((c) => String(c?.text || ""));
const scriptScript = (project.script_versions ?? []).find((s) => s.is_adopted) || (project.script_versions ?? [])[0];
const scriptTexts = [...(scriptScript?.segments ?? [])].sort((a, b) => a.sort_order - b.sort_order).map((s) => (s.narration || "").trim());
const subFor = (i: number) => savedTexts[i] || scriptTexts[i] || "";
const baseClips: EdClipState[] = tl?.clips?.length
? [...tl.clips].sort((a, b) => a.sort_order - b.sort_order).map((c, i) => ({
key: `c${i}-${c.id}`, asset: c.asset, url: c.asset_url || assetUrl(c.asset),
isVideo: c.asset_is_video ?? isVideoAsset(c.asset), durMs: c.duration_ms || 0,
trimStartMs: c.trim_start_ms || 0, trimEndMs: c.trim_end_ms ?? null, subtitle: subFor(i)
}))
: segments.filter((s) => s.adopted_asset).map((s, i) => ({
key: `s${i}-${s.id}`, asset: s.adopted_asset as string, url: segUrl(s), isVideo: true,
durMs: (s.target_duration_seconds || 0) * 1000, trimStartMs: 0, trimEndMs: null, subtitle: subFor(i)
}));
const bgm = (tl?.bgm_tracks ?? [])[0];
return {
clips: baseClips,
subtitleEnabled: sub ? sub.enabled : true,
subtitleStyle: (sub?.style?.key as string) || "plain",
transition: tl?.metadata?.transition?.type || "none",
bgmVolume: bgm?.volume ?? 60
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project.timeline, project.script_versions, segments]);
const [edState, setEdState] = useState<EditorState>(buildInitialEditor);
const [edHistory, setEdHistory] = useState<EditorState[]>([]);
const [edFuture, setEdFuture] = useState<EditorState[]>([]);
const [selectedClip, setSelectedClip] = useState(0);
const [propsTab, setPropsTab] = useState<"subtitle" | "transition" | "bgm">("subtitle");
const edHydratedRef = useRef(false);
const bgmFileRef = useRef<HTMLInputElement | null>(null);
const videoUploadRef = useRef<HTMLInputElement | null>(null);
const [uploadTargetSeg, setUploadTargetSeg] = useState<string | null>(null);
// 提交一个新编辑态(进 undo 栈)
const commitEdit = useCallback((next: EditorState) => {
setEdHistory((h) => [...h.slice(-49), edState]);
setEdFuture([]);
setEdState(next);
}, [edState]);
const edClips = edState.clips.map((c) => ({ id: c.key, assetId: c.asset, url: c.url, isVideo: c.isVideo, durMs: c.durMs }));
const edTotalMs = edClips.reduce((sum, c) => sum + c.durMs, 0) || tlRulerMs;
const edOffsetMs = (idx: number) => edClips.slice(0, idx).reduce((sum, c) => sum + c.durMs, 0);
const videoRef = useRef<HTMLVideoElement | null>(null);
const [edIdx, setEdIdx] = useState(0);
const [edPlaying, setEdPlaying] = useState(false);
const [edClipMs, setEdClipMs] = useState(0);
const edCur = edClips[Math.min(edIdx, Math.max(0, edClips.length - 1))] || null;
const edGlobalMs = edOffsetMs(edIdx) + edClipMs;
const gotoClip = useCallback((idx: number, atEnd = false) => {
if (idx < 0 || idx >= edClips.length) return;
setEdIdx(idx);
setEdClipMs(atEnd ? Math.max(0, (edClips[idx]?.durMs || 0) - 200) : 0);
}, [edClips]);
// 在时间轴上跳转到全局毫秒位置(点击标尺 seek)
const seekToMs = useCallback((globalMs: number) => {
let acc = 0;
for (let i = 0; i < edClips.length; i += 1) {
const dur = edClips[i].durMs || 0;
if (globalMs <= acc + dur || i === edClips.length - 1) {
const within = Math.max(0, Math.min(dur, globalMs - acc));
setEdIdx(i);
setEdClipMs(within);
if (edClips[i].isVideo && videoRef.current) videoRef.current.currentTime = within / 1000;
return;
}
acc += dur;
}
}, [edClips]);
const togglePlay = useCallback(() => {
setEdPlaying((p) => {
const next = !p;
const v = videoRef.current;
if (edCur?.isVideo && v) {
if (next) v.play().catch(() => undefined);
else v.pause();
}
return next;
});
}, [edCur]);
// 上一帧 / 下一帧:视频按 ±1/25s 逐帧 seek,越界切相邻段;静态图切相邻片段
const stepFrame = useCallback((dir: 1 | -1) => {
const v = videoRef.current;
if (edCur?.isVideo && v && v.duration) {
const t = v.currentTime + dir * (1 / 25);
if (t < 0) { gotoClip(edIdx - 1, true); return; }
if (t > v.duration) { gotoClip(edIdx + 1, false); return; }
v.currentTime = t;
setEdClipMs(t * 1000);
} else {
gotoClip(edIdx + dir, false);
}
}, [edCur, edIdx, gotoClip]);
// 静态图(无视频文件)播放:定时推进虚拟播放头,到段尾自动进下一段
useEffect(() => {
if (!edPlaying || edCur?.isVideo || edClips.length === 0) return;
const tick = window.setInterval(() => {
setEdClipMs((ms) => {
const dur = edCur?.durMs || 2000;
if (ms + 120 >= dur) {
if (edIdx + 1 < edClips.length) { setEdIdx(edIdx + 1); return 0; }
setEdPlaying(false);
return dur;
}
return ms + 120;
});
}, 120);
return () => window.clearInterval(tick);
}, [edPlaying, edCur, edIdx, edClips.length]);
// 切换片段时同步 video 进度,正在播放则续播
useEffect(() => {
const v = videoRef.current;
if (!v || !edCur?.isVideo) return;
v.currentTime = edClipMs / 1000;
if (edPlaying) v.play().catch(() => undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [edIdx]);
// 键盘:仅 stage5 生效(空格播放/暂停,←/→ 逐帧)
useEffect(() => {
if (viewStage !== 5) return;
function onKey(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
if (e.code === "Space") { e.preventDefault(); togglePlay(); }
else if (e.code === "ArrowLeft") { e.preventDefault(); stepFrame(-1); }
else if (e.code === "ArrowRight") { e.preventDefault(); stepFrame(1); }
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [viewStage, togglePlay, stepFrame]);
// Stage 4:有运行中的视频段时静默轮询推进(本机无 Celery worker,前端驱动 poll-video-segment),进度条/缩略图实时刷新
const activeVideoCount = segments.filter((s) => ["running", "queued"].includes(s.status)).length;
useEffect(() => {
if (viewStage !== 4 || activeVideoCount === 0) return;
const timer = window.setInterval(() => { void onPollVideosQuiet(); }, 5000);
return () => window.clearInterval(timer);
}, [viewStage, activeVideoCount, onPollVideosQuiet]);
// Stage 5:进入拼接页时回填已有导出成片(若此前导过)
useEffect(() => {
if (viewStage !== 5) return;
onRefreshExport();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewStage]);
// 确认脚本:采用当前脚本(后端推进 SCRIPT→BASE_ASSETS),再进入资产阶段。无脚本时仅切视图。
async function confirmScript() {
if (currentScript && !scriptAdopted) {
await onAdoptScript(currentScript.id);
}
goStage(2);
}
// ── Stage 5 编辑器:水合 + 片段操作 + 撤销/重做 + 保存负载 ──
const [edZoom, setEdZoom] = useState(100);
useEffect(() => {
if (viewStage !== 5) { edHydratedRef.current = false; return; }
if (edHydratedRef.current) return;
const hasData = Boolean(project.timeline?.clips?.length) || segments.some((s) => s.adopted_asset);
if (!hasData) return;
edHydratedRef.current = true;
setEdState(buildInitialEditor());
setEdHistory([]); setEdFuture([]); setSelectedClip(0);
}, [viewStage, project, segments, buildInitialEditor]);
const edUndo = useCallback(() => {
setEdHistory((h) => {
if (!h.length) return h;
setEdFuture((f) => [edState, ...f].slice(0, 50));
setEdState(h[h.length - 1]);
return h.slice(0, -1);
});
}, [edState]);
const edRedo = useCallback(() => {
setEdFuture((f) => {
if (!f.length) return f;
setEdHistory((h) => [...h, edState].slice(-50));
setEdState(f[0]);
return f.slice(1);
});
}, [edState]);
function edDeleteClip(idx: number) {
if (idx < 0 || idx >= edState.clips.length || edState.clips.length <= 1) return;
commitEdit({ ...edState, clips: edState.clips.filter((_, i) => i !== idx) });
setSelectedClip((s) => Math.max(0, Math.min(s, edState.clips.length - 2)));
}
function edCopyClip(idx: number) {
const c = edState.clips[idx];
if (!c) return;
const dup = { ...c, key: `${c.key}-copy-${Date.now()}` };
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx + 1), dup, ...edState.clips.slice(idx + 1)] });
}
function edSplitAtPlayhead() {
const idx = edIdx;
const c = edState.clips[idx];
if (!c || c.durMs <= 400) return;
const offset = Math.max(200, Math.min(c.durMs - 200, edClipMs));
const a: EdClipState = { ...c, key: `${c.key}-a-${Date.now()}`, durMs: offset, trimEndMs: c.trimStartMs + offset };
const b: EdClipState = { ...c, key: `${c.key}-b-${Date.now()}`, durMs: c.durMs - offset, trimStartMs: c.trimStartMs + offset, trimEndMs: c.trimEndMs };
commitEdit({ ...edState, clips: [...edState.clips.slice(0, idx), a, b, ...edState.clips.slice(idx + 1)] });
}
const buildSavePayload = useCallback((): TimelineSavePayload => {
let acc = 0;
const content = edState.clips.map((c) => { const start = acc; acc += c.durMs; return { start_ms: Math.round(start), text: c.subtitle || "" }; });
return {
clips: edState.clips.map((c) => ({ asset: c.asset, duration_ms: Math.round(c.durMs), trim_start_ms: Math.round(c.trimStartMs), trim_end_ms: c.trimEndMs == null ? null : Math.round(c.trimEndMs) })),
subtitle: { enabled: edState.subtitleEnabled, style_key: edState.subtitleStyle, content },
bgm: { volume: edState.bgmVolume },
transition: { type: edState.transition }
};
}, [edState]);
// 字幕文本编辑(打字不进 undo 栈,避免逐字刷历史)
function setClipSubtitle(idx: number, text: string) {
setEdState((s) => ({ ...s, clips: s.clips.map((c, i) => (i === idx ? { ...c, subtitle: text } : c)) }));
}
// 片段拖拽重排
const [dragIdx, setDragIdx] = useState<number | null>(null);
function reorderClip(from: number, to: number) {
if (from === to || from < 0 || to < 0 || from >= edState.clips.length || to >= edState.clips.length) return;
const clips = [...edState.clips];
const [moved] = clips.splice(from, 1);
clips.splice(to, 0, moved);
commitEdit({ ...edState, clips });
setSelectedClip(to);
}
// 转场实时预览:切片段时若有转场,画面做一次淡场(导出才是真 xfade,这里给即时视觉提示)
const [fadeKey, setFadeKey] = useState(0);
const prevEdIdxRef = useRef(edIdx);
useEffect(() => {
if (prevEdIdxRef.current !== edIdx) {
prevEdIdxRef.current = edIdx;
if (edState.transition !== "none") setFadeKey((k) => k + 1);
}
}, [edIdx, edState.transition]);
// Stage 4 / 5 文件上传
function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file && uploadTargetSeg) onUploadVideoSegment(uploadTargetSeg, file);
setUploadTargetSeg(null);
}
function triggerVideoUpload(segmentId: string) {
setUploadTargetSeg(segmentId);
videoUploadRef.current?.click();
}
function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (file) onUploadBgm(file, edState.bgmVolume);
}
// 真实商品名 + 封面资产 id(商品组无 adopted_asset 时,商品缩图回退到商品库封面)
const productRecord = products.find((item) => item.id === project.product);
const productName = productRecord?.title || "透真补水面膜";
@ -198,7 +538,9 @@ export function PipelinePage(props: {
<div className="pane-h">
<div className="shot-headline">
<strong></strong>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>· · </span>
<span className="muted-2 mono" id="shots-meta" style={{ fontSize: "11px" }}>
{shots.length ? `· ${shots.length} 镜 · ${scriptAdopted ? "已采用" : "待采用"}` : "· 空 · 待生成"}
</span>
</div>
<div className="script-brief-summary" aria-label="当前创作方向">
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-source"></span></span>
@ -208,22 +550,32 @@ export function PipelinePage(props: {
<div className="script-tags" id="script-tags">
<div className="tag-group" data-kind="char">
<span className="tg-lbl">// 人物</span>
<button className="tag-add" type="button" aria-label="添加人物">+</button>
<button className="tag-add" type="button" aria-label="添加人物" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个人物角色:"); }}>+</button>
</div>
<div className="tag-group" data-kind="scene">
<span className="tg-lbl">// 场景</span>
<button className="tag-add" type="button" aria-label="添加场景">+</button>
<button className="tag-add" type="button" aria-label="添加场景" onClick={() => { focusThemeMode(); setChatText((prev) => prev || "增加一个场景:"); }}>+</button>
</div>
</div>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn"> </button>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放")}> </button>
</div>
<div className="shots-body" id="shots-body">
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title"></div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
{shots.length ? shots.map((shot) => (
<div className="shot-card" key={shot.id}>
<div className="shot-n">{shot.sort_order + 1}</div>
<div className="shot-main">
<div className="shot-meta">// 场 {shot.sort_order + 1} · {shot.duration_seconds || 15}s</div>
<div className="shot-narration">{shot.narration?.trim() || "(本镜暂无文案,可在右侧重写脚本)"}</div>
</div>
</div>
)) : (
<div className="shots-empty">
<div className="empty-ico"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="5" width="18" height="14" rx="2" /><path d="M3 10h18M9 5v14" /></svg></div>
<div className="empty-title"></div>
<div className="empty-hint">// 跟右侧脚本助手对话<br />选择一种方式生成你的第一稿</div>
</div>
)}
</div>
</div>
@ -235,29 +587,38 @@ export function PipelinePage(props: {
<strong></strong>
<span className="muted-2 mono" style={{ fontSize: "11px" }}>· GPT-4o</span>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn"></button>
<button className="btn btn-ghost btn-sm" type="button" id="chat-clear-btn" disabled={!chatText && chatAttachments.length === 0} onClick={clearChat}></button>
</div>
<div className="chat-body" id="chat-body">
<div className="chat-empty">
<div className="ce-title"></div>
<div className="ce-hint">// 三种,由「最省事」到「最保真原意」</div>
<div className="chat-modes">
<button className="chat-mode primary" type="button" data-mode="ai" disabled={loading} onClick={() => onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放")}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI </button>
<button className="chat-mode" type="button" data-mode="theme"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg></button>
<button className="chat-mode" type="button" data-mode="manual"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg></button>
<button className={`chat-mode${chatMode === "ai" ? " primary" : ""}`} type="button" data-mode="ai" disabled={loading} onClick={() => { setChatMode("ai"); onGenerateScript("AI 全生 · 突出商品卖点,节奏紧凑,适合短视频投放"); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3z" /></svg>AI </button>
<button className={`chat-mode${chatMode === "theme" ? " primary" : ""}`} type="button" data-mode="theme" onClick={focusThemeMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18h6" /><path d="M10 22h4" /><path d="M15 14a4.65 4.65 0 0 0 1.4-2.5A6 6 0 1 0 6 8c0 1 .23 2.23 1.5 3.5" /></svg></button>
<button className={`chat-mode${chatMode === "manual" ? " primary" : ""}`} type="button" data-mode="manual" onClick={pickScriptMode}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg></button>
</div>
</div>
</div>
<div className="chat-input">
<div className="chat-input-card">
<div className="chat-attach-row" id="chat-attach-row" hidden></div>
<textarea className="chat-input-area" id="chat-textarea" placeholder="直接说怎么改,如:更像小红书种草 / 换成熬夜党" rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
{chatAttachments.map((att, index) => (
<span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><path d="M14 2v6h6" /></svg>
{att.name} · {att.chars}
<button type="button" aria-label="移除附件" style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => setChatAttachments((list) => list.filter((_, i) => i !== index))}>×</button>
</span>
))}
</div>
<textarea ref={chatTextareaRef} className="chat-input-area" id="chat-textarea" placeholder={chatMode === "theme" ? "用一句话描述主题,如:熬夜党的早八续命面膜" : chatMode === "manual" ? "粘贴或上传你的脚本,AI 将据此生成镜头脚本" : "直接说怎么改,如:更像小红书种草 / 换成熬夜党"} rows={2} value={chatText} onChange={(event) => setChatText(event.target.value)}></textarea>
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
<div className="chat-input-foot">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件">
<button className="chat-icon-btn" id="chat-upload-btn" type="button" title="上传脚本附件" aria-label="上传脚本附件" onClick={() => chatFileRef.current?.click()}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); setChatText(""); }}>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || !chatText.trim()} onClick={() => { onGenerateScript(chatText.trim()); clearChat(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
@ -270,7 +631,7 @@ export function PipelinePage(props: {
<div className="info"><span className="mono">[ LLM ~2.4k tokens · ¥0.04 · · ]</span></div>
<div className="hstack">
<button className="btn" type="button" disabled={loading} onClick={() => onGenerateScript("整体重新生成 · 突出商品卖点,节奏紧凑")}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg> </button>
<button className="btn btn-primary btn-lg" type="button" onClick={() => goStage(2)}>, <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
<button className="btn btn-primary btn-lg" type="button" disabled={loading || !currentScript} onClick={confirmScript}>{scriptAdopted ? "进入下一步" : "确认脚本,进入下一步"} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M12 5l7 7-7 7" /></svg></button>
</div>
</div>
</section>
@ -278,7 +639,7 @@ export function PipelinePage(props: {
{/* ============= STAGE 2 · 基础资产(真实 base_asset_groups,按 kind 分组)============= */}
{viewStage === 2 && (() => {
const productGroup = groupsByKind("product")[0] || null;
const productAssetUrl = assetUrl(productGroup?.adopted_asset) || assetUrl(productGroup?.candidate_assets?.[0]) || assetUrl(productCover);
const productAssetUrl = groupMainUrl(productGroup) || assetUrl(productCover);
const productCandidates = (productGroup?.candidate_assets ?? []).filter((id) => id !== productGroup?.adopted_asset);
return (
<section className="stage active" data-stage-pane="2">
@ -288,7 +649,7 @@ export function PipelinePage(props: {
const list = groupsByKind(kind);
const adopted = list.filter((g) => g.adopted_asset).length;
return (
<div className={`ttab${kind === "product" ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`}>
<div className={`ttab${kind === assetTab ? " active" : ""}`} key={kind} data-jump={`asset-sec-${kind}`} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => jumpAssetSection(kind)}>
<span>{KIND_LABEL[kind]}</span><span className="num">{list.length ? `${adopted}/${list.length}` : "0"}</span>
</div>
);
@ -338,10 +699,10 @@ export function PipelinePage(props: {
</div>
<div className={`prod-preview${productCandidates.length ? " show" : ""}`} id="asset-prod-preview">
<div className="prod-preview-h">// 候选三视图 · <span id="prod-preview-status">{productCandidates.length} 张</span></div>
<div className={`placeholder prod-preview-img${assetUrl(productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={assetUrl(productCandidates[0]) ? mediaStyle(assetUrl(productCandidates[0])) : undefined}><span className="ph-frame"> #1</span></div>
<div className={`placeholder prod-preview-img${candUrl(productGroup, productCandidates[0]) ? " has-mock-media" : ""}`} id="prod-preview-img" style={candUrl(productGroup, productCandidates[0]) ? mediaStyle(candUrl(productGroup, productCandidates[0])) : undefined}><span className="ph-frame"> #1</span></div>
<div className="prod-preview-foot" id="prod-preview-foot">
{productCandidates.slice(0, 4).map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
<div className={`placeholder${candUrl(productGroup, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(productGroup, id) ? mediaStyle(candUrl(productGroup, id)) : {}), width: "44px", height: "44px", flex: "0 0 44px" }}><span className="ph-frame"></span></div>
))}
</div>
</div>
@ -350,13 +711,23 @@ export function PipelinePage(props: {
{(["person", "scene"] as const).map((kind) => {
const list = groupsByKind(kind);
const genPrompt = kind === "person"
? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏`
: `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`;
return (
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
<div className="sec-h"><h3>{KIND_LABEL[kind]} · {list.length} </h3><span className="spacer"></span></div>
<div className="sec-h">
<h3>{KIND_LABEL[kind]} · {list.length} </h3>
<span className="spacer"></span>
<button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
AI {KIND_LABEL[kind]}
</button>
</div>
{list.length ? (
<div className="asset-grid-2">
{list.map((group, gi) => {
const mainUrl = assetUrl(group.adopted_asset) || assetUrl(group.candidate_assets?.[0]);
const mainUrl = groupMainUrl(group);
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
@ -375,7 +746,7 @@ export function PipelinePage(props: {
{cands.length > 0 && (
<div className="hstack" style={{ marginTop: "10px", gap: "6px", flexWrap: "wrap" }}>
{cands.map((id) => (
<div className={`placeholder${assetUrl(id) ? " has-mock-media" : ""}`} key={id} style={{ ...(assetUrl(id) ? mediaStyle(assetUrl(id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
<div className={`placeholder${candUrl(group, id) ? " has-mock-media" : ""}`} key={id} style={{ ...(candUrl(group, id) ? mediaStyle(candUrl(group, id)) : {}), width: "40px", height: "40px", flex: "0 0 40px" }}><span className="ph-frame"></span></div>
))}
</div>
)}
@ -385,7 +756,9 @@ export function PipelinePage(props: {
})}
</div>
) : (
<div className="placeholder" style={{ minHeight: "120px" }}><span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 待生成</span></div>
<div className="placeholder" style={{ minHeight: "120px", flexDirection: "column", gap: "10px" }}>
<span className="ph-frame">// 暂无{KIND_LABEL[kind]}资产 · 点上方「AI 生成{KIND_LABEL[kind]}」生成</span>
</div>
)}
</section>
);
@ -410,7 +783,7 @@ export function PipelinePage(props: {
<div className="sb-canvas">
<div className="sb-scenes-col" id="sb-scenes-row">
{sbFrames.length ? sbFrames.map((frame, idx) => {
const url = assetUrl(frame.asset);
const url = frameUrl(frame);
return (
<div className={`sb-scene-thumb${idx === sbSelected ? " selected" : ""}`} key={frame.id} data-sid={frame.id} onClick={() => setSbSelected(idx)}>
<div className={`placeholder${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}><span className="ph-frame"> {idx + 1}</span></div>
@ -421,7 +794,7 @@ export function PipelinePage(props: {
}) : <div className="placeholder" style={{ aspectRatio: "1" }}><span className="ph-frame">// 暂无</span></div>}
</div>
{(() => {
const url = assetUrl(sbActiveFrame?.asset);
const url = frameUrl(sbActiveFrame);
return (
<div className={`placeholder sb-main-img${url ? " has-mock-media" : ""}`} id="sb-main-img" style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame">{sbActiveFrame ? `${sbSelected + 1}` : "// 故事板未生成"}</span>
@ -451,7 +824,7 @@ export function PipelinePage(props: {
<div className="sb-stage-actions">
<button className="pill-cta heat" type="button" id="sb-rerun-btn" disabled={loading} onClick={() => onGenerateStoryboard(storyboardPrompt)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
{adoptedStoryboard ? "整张重跑" : "生成故事板"}
</button>
<span className="spacer"></span>
<span className="muted-2 mono" style={{ fontSize: "11px", alignSelf: "center" }}>~¥0.45/</span>
@ -460,7 +833,7 @@ export function PipelinePage(props: {
<div className="sb-history-h">// 历史版本(<span id="sb-history-ct">{storyboards.length}</span>)</div>
<div className="sb-history-row" id="sb-history-row">
{storyboards.length ? storyboards.map((ver) => {
const cover = assetUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]?.asset);
const cover = frameUrl([...(ver.frames ?? [])].sort((a, b) => a.sort_order - b.sort_order)[0]);
return (
<div className={`sb-history-thumb${ver.is_adopted ? " current" : ""}`} key={ver.id} data-vi={ver.id}>
<div className={`placeholder${cover ? " has-mock-media" : ""}`} style={cover ? mediaStyle(cover) : undefined}><span className="ph-frame">{ver.is_adopted ? "采用" : "历史"}</span></div>
@ -493,31 +866,43 @@ export function PipelinePage(props: {
{/* ============= STAGE 4 · 视频(video_segments,adopted_asset 缩略 + 状态 + 时长)============= */}
{viewStage === 4 && (() => {
const pct = segments.length ? Math.round((segDone / segments.length) * 100) : 0;
const anyStarted = segments.some((s) => ["running", "succeeded", "queued"].includes(s.status));
const statusText = !segments.length
? "暂无片段"
: segDone === segments.length
? "已完成所有场次"
: activeVideoCount > 0
? `生成中 · ${activeVideoCount} 段进行中(自动刷新)`
: "待生成";
return (
<section className="stage active" data-stage-pane="4">
<div className="queue-bar">
<div>
<div style={{ fontSize: "14px", fontWeight: 600 }}> · {segDone} / {segments.length} </div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {segments.length ? (segDone === segments.length ? "已完成所有场次" : "生成中") : "暂无片段"}</div>
<div className="muted-2 mono" style={{ fontSize: "11px", marginTop: "3px", letterSpacing: ".02em" }}>// 每场 Seedance 生成 · {statusText}</div>
</div>
<div className="bar-wrap"><span style={{ width: `${pct}%` }}></span></div>
<span className="muted mono" style={{ fontSize: "12px" }}>{pct}%</span>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => onSubmitAllVideos(videoPrompt)}> </button>
<button className="btn btn-sm" type="button">
<button className="btn btn-sm btn-primary" type="button" disabled={loading || !segments.length || activeVideoCount > 0} onClick={() => onSubmitAllVideos(videoPrompt)}>{anyStarted ? "↻ 全部重跑" : "▶ 开始生成视频"}</button>
<button className="btn btn-sm" type="button" disabled={loading || !segments.length} onClick={() => triggerVideoUpload((segments.find((s) => s.status !== "succeeded") || segments[0]).id)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button>
</div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
{segments.length ? (
<div className="video-grid" id="video-grid">
{segments.map((seg) => {
const url = assetUrl(seg.adopted_asset);
const url = segUrl(seg);
const tone = statusPill(seg.status);
const busy = ["running", "queued"].includes(seg.status);
return (
<div className="video-card" key={seg.id} data-video-id={seg.id}>
<div className={`placeholder video-thumb${url ? " has-mock-media" : ""}`} style={url ? mediaStyle(url) : undefined}>
<span className="ph-frame"> {seg.sort_order + 1}</span>
<div className="placeholder video-thumb" style={{ position: "relative", overflow: "hidden" }}>
{url
? <video src={url} muted playsInline preload="metadata" style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
: <span className="ph-frame"> {seg.sort_order + 1}</span>}
{url && <div className="play"><div className="btn-play"><Play size={14} fill="currentColor" /></div></div>}
</div>
<div className="body">
@ -527,9 +912,12 @@ export function PipelinePage(props: {
</div>
<div className="video-meta">{seg.target_duration_seconds}s{seg.error_message ? ` · ${seg.error_message}` : ""}</div>
<div className="video-actions">
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}></button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading || busy} onClick={() => onSubmitVideo(seg.id, `${videoPrompt}${seg.sort_order + 1} 段,时长 ${seg.target_duration_seconds}`)}>{busy ? "生成中…" : "重跑"}</button>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={loading} onClick={() => triggerVideoUpload(seg.id)}></button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" data-vstop disabled={!url}></button>
{url
? <a className="btn btn-ghost btn-sm" href={url} target="_blank" rel="noreferrer" data-vstop></a>
: <button className="btn btn-ghost btn-sm" type="button" data-vstop disabled></button>}
</div>
</div>
</div>
@ -552,78 +940,201 @@ export function PipelinePage(props: {
})()}
{/* ============= STAGE 5 · 拼接导出(timeline.clips / subtitle_tracks / bgm_tracks 真实定位)============= */}
{viewStage === 5 && (() => {
const previewUrl = assetUrl(tlClips[0]?.asset) || assetUrl(segments.find((s) => s.adopted_asset)?.adopted_asset);
const previewUrl = edCur?.url || assetUrl(tlClips[0]?.asset) || segUrl(segments.find((s) => s.adopted_asset));
const aspect = timeline?.aspect_ratio || "9:16";
const resolution = timeline?.resolution || "1080×1920";
const bgm = bgmTracks[0] || null;
const bgmName = assetName(bgm?.asset) || (bgm ? "背景音乐" : "");
const showVideo = !!(edCur?.isVideo && edCur.url);
// 拼接成片:导出成功后用整片预览/下载;导出中显示进度
const finalUrl = exportResult?.status === "succeeded" ? (exportResult.output_url || "") : "";
const exporting = exportResult?.status === "queued" || exportResult?.status === "running";
const exportFailed = exportResult?.status === "failed";
// ── 编辑器派生(随本地编辑实时变化的时间轴)──
const STYLE_SWATCHES = [
{ key: "plain", demo: "", nm: "朴素白底" }, { key: "cinema", demo: "b", nm: "影视黑底" },
{ key: "handwrite", demo: "c", nm: "手写描边" }, { key: "variety", demo: "d", nm: "综艺暖黄" }
];
const TRANSITIONS = [
{ key: "none", nm: "无转场" }, { key: "fade", nm: "淡入淡出" }, { key: "dissolve", nm: "溶解" },
{ key: "slideleft", nm: "左滑" }, { key: "wiperight", nm: "擦除" }
];
const edRulerMs = edTotalMs;
const edRuler = buildRuler(edRulerMs / 1000);
const edOffsets = edClips.map((_, i) => edClips.slice(0, i).reduce((sum, c) => sum + c.durMs, 0));
const serverBgm = (project.timeline?.bgm_tracks ?? [])[0] || null;
const serverBgmUrl = serverBgm?.asset_url || "";
const serverBgmName = serverBgm?.asset_name || (serverBgm ? "背景音乐" : "");
const subVisible = edState.subtitleEnabled;
return (
<section className="stage active" data-stage-pane="5">
<div className="editor">
<div className="editor-preview">
<div className={`canvas${previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={previewUrl ? mediaStyle(previewUrl) : undefined}><span id="ed-canvas-label">{aspect} · {resolution}</span></div>
<div className={`canvas${!showVideo && !finalUrl && previewUrl ? " has-mock-media" : ""}`} id="ed-canvas" style={!showVideo && !finalUrl && previewUrl ? mediaStyle(previewUrl) : undefined}>
{finalUrl ? (
<>
<video
src={finalUrl}
controls
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "contain", background: "#000", borderRadius: "inherit" }}
/>
<span className="pill ok" style={{ position: "absolute", top: 10, left: 10, zIndex: 4 }}><span className="dot"></span></span>
</>
) : showVideo ? (
<video
ref={videoRef}
src={edCur!.url}
playsInline
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }}
onTimeUpdate={(e) => setEdClipMs(e.currentTarget.currentTime * 1000)}
onEnded={() => { if (edIdx + 1 < edClips.length) gotoClip(edIdx + 1, false); else setEdPlaying(false); }}
onPlay={() => setEdPlaying(true)}
onPause={() => setEdPlaying(false)}
/>
) : (
<span id="ed-canvas-label">{exporting ? `拼接中… ${exportResult?.progress ?? 0}%` : `${aspect} 预览 · ${resolution}${edClips.length ? ` · 片段 ${Math.min(edIdx + 1, edClips.length)}/${edClips.length}` : ""}`}</span>
)}
{showVideo && !finalUrl && edState.transition !== "none" && <div key={fadeKey} className="ed-xfade-flash" aria-hidden="true" />}
</div>
<div className="controls">
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)"><svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">0:00</span> / <span id="ed-total-time">{fmtMs(tlRulerMs)}</span></span>
<button className="ctl-btn" type="button" id="ed-prev-btn" title="上一帧 (←)" onClick={() => stepFrame(-1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M3 3v10l4-5zM9 3v10l4-5z" fill="currentColor" /></svg></button>
<button className="ctl-btn" type="button" id="ed-play-btn" title="播放 / 暂停 (空格)" onClick={togglePlay} disabled={edClips.length === 0}>
{edPlaying
? <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M4 3h3v10H4zM9 3h3v10H9z" fill="currentColor" /></svg>
: <svg id="ed-play-icon" width="16" height="16" viewBox="0 0 16 16"><path d="M5 4l7 4-7 4z" fill="currentColor" /></svg>}
</button>
<button className="ctl-btn" type="button" id="ed-next-btn" title="下一帧 (→)" onClick={() => stepFrame(1)} disabled={edClips.length === 0}><svg width="14" height="14" viewBox="0 0 16 16"><path d="M13 3v10l-4-5zM7 3v10l-4-5z" fill="currentColor" /></svg></button>
<span className="muted mono" style={{ fontSize: "12px", marginLeft: "8px" }}><span id="ed-cur-time">{fmtMs(edGlobalMs)}</span> / <span id="ed-total-time">{fmtMs(edTotalMs)}</span></span>
</div>
</div>
<div className="editor-props">
<div className="props-tabs"><div className="active"></div><div></div><div>BGM</div></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式</div>
<div className="style-swatch">
<div className="swatch-card selected"><div className="demo"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo b"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo c"></div><div className="nm"></div></div>
<div className="swatch-card"><div className="demo d"></div><div className="nm"></div></div>
<div className="props-tabs">
<div className={propsTab === "subtitle" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("subtitle")}></div>
<div className={propsTab === "transition" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("transition")}></div>
<div className={propsTab === "bgm" ? "active" : ""} role="button" tabIndex={0} style={{ cursor: "pointer" }} onClick={() => setPropsTab("bgm")}>BGM</div>
</div>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-start" defaultValue={fmtMs(tlRulerMs)} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" id="ed-inspect-dur" defaultValue={`${tlClips.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" defaultValue={`${subtitleCues.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
{bgm && (
{propsTab === "subtitle" && (
<>
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// BGM</div>
<div className="props-row" style={{ borderBottom: 0 }}><span style={{ fontSize: "12px", flex: 1 }}>{bgmName} · {bgm.volume}</span><button className="btn btn-ghost btn-sm" type="button"></button></div>
<div className="props-row" style={{ marginBottom: 8 }}>
<span className="k"></span>
<button className={`btn btn-sm ${edState.subtitleEnabled ? "btn-primary" : "btn-ghost"}`} type="button" onClick={() => commitEdit({ ...edState, subtitleEnabled: !edState.subtitleEnabled })}>{edState.subtitleEnabled ? "已开启" : "已关闭"}</button>
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 字幕样式(导出烧入)</div>
<div className="style-swatch">
{STYLE_SWATCHES.map((sw) => (
<div className={`swatch-card${edState.subtitleStyle === sw.key ? " selected" : ""}`} key={sw.key} role="button" tabIndex={0} style={{ cursor: "pointer", opacity: edState.subtitleEnabled ? 1 : 0.5 }} onClick={() => commitEdit({ ...edState, subtitleStyle: sw.key, subtitleEnabled: true })}>
<div className={`demo${sw.demo ? ` ${sw.demo}` : ""}`}></div><div className="nm">{sw.nm}</div>
</div>
))}
</div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, margin: "12px 0 6px", letterSpacing: ".04em" }}>// 字幕文本(默认取脚本旁白,可逐段改)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", maxHeight: "186px", overflowY: "auto" }}>
{edState.clips.map((c, idx) => (
<div key={c.key} style={{ display: "flex", gap: "6px", alignItems: "flex-start" }}>
<span className="mono" style={{ fontSize: "10px", color: "var(--black-alpha-48)", marginTop: "7px", flex: "0 0 auto" }}>{idx + 1}</span>
<textarea value={c.subtitle} onChange={(e) => setClipSubtitle(idx, e.target.value)} rows={1} disabled={!edState.subtitleEnabled} placeholder={`${idx + 1} 段字幕`} style={{ flex: 1, minWidth: 0, resize: "vertical", fontSize: "12px", lineHeight: 1.4, padding: "4px 6px", border: "1px solid var(--border-faint)", borderRadius: "6px", background: "var(--surface)", color: "var(--accent-black)", fontFamily: "inherit" }} />
</div>
))}
</div>
</>
)}
{propsTab === "transition" && (
<>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 片段间转场(导出 xfade 烧入)</div>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{TRANSITIONS.map((tr) => (
<button className={`btn btn-sm ${edState.transition === tr.key ? "btn-primary" : "btn-ghost"}`} key={tr.key} type="button" style={{ justifyContent: "flex-start" }} onClick={() => commitEdit({ ...edState, transition: tr.key })}>{tr.nm}</button>
))}
</div>
</>
)}
{propsTab === "bgm" && (
<>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 背景音乐(导出混音)</div>
<div className="props-row"><span style={{ fontSize: "12px", flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{serverBgm ? serverBgmName : "未设置 BGM"}</span></div>
{serverBgmUrl && <audio src={serverBgmUrl} controls style={{ width: "100%", height: 30, marginBottom: 8 }} />}
<div className="props-row"><span className="k"> {edState.bgmVolume}</span>
<input type="range" min={0} max={100} value={edState.bgmVolume} onChange={(e) => setEdState((s) => ({ ...s, bgmVolume: Number(e.target.value) }))} style={{ flex: 1 }} />
</div>
<button className="btn btn-sm" type="button" disabled={loading} onClick={() => bgmFileRef.current?.click()} style={{ marginTop: 6 }}>{serverBgm ? "替换 BGM" : "上传 BGM"}</button>
<input ref={bgmFileRef} type="file" accept="audio/*" style={{ display: "none" }} onChange={onPickBgmFile} />
</>
)}
<div className="divider"></div>
<div className="muted mono" style={{ fontSize: "11px", fontWeight: 500, marginBottom: "8px", letterSpacing: ".04em" }}>// 时间轴(<span id="ed-inspect-name">{timeline?.name || "未命名"}</span>)</div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={fmtMs(edRulerMs)} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={`${edClips.length}`} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={subVisible ? `${edClips.length}` : "关"} readOnly /></div>
<div className="props-row"><span className="k"></span><input className="input-mini" value={(TRANSITIONS.find((t) => t.key === edState.transition) || TRANSITIONS[0]).nm} readOnly /></div>
<div className="props-row"><span className="k"></span><span className="mono" style={{ fontSize: "11.5px" }}>{resolution}</span></div>
</div>
<div className="timeline" id="ed-timeline">
<div className="timeline" id="ed-timeline" style={{ overflowX: edZoom > 100 ? "auto" : "hidden" }}>
<div className="tl-toolbar">
<button className="tl-action" type="button" title="撤销"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<button className="tl-action" type="button" title="撤销" disabled={!edHistory.length} onClick={edUndo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-15-6.7L3 13" /></svg></button>
<button className="tl-action" type="button" title="重做" disabled={!edFuture.length} onClick={edRedo}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 7v6h-6" /><path d="M3 17a9 9 0 0 1 15-6.7L21 13" /></svg></button>
<span className="tl-sep"></span>
<button className="tl-action" type="button" title="在播放头处分割选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg></button>
<button className="tl-action" type="button" title="复制选中片段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg></button>
<button className="tl-action danger" type="button" title="删除选中片段 (Delete)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg></button>
<button className="tl-action" type="button" title="在播放头处分割所在片段" disabled={!edClips.length} onClick={edSplitAtPlayhead}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M20 4L8.12 15.88" /><path d="M14.47 14.48L20 20" /><path d="M8.12 8.12L12 12" /></svg></button>
<button className="tl-action" type="button" title="复制选中片段" disabled={!edClips.length} onClick={() => edCopyClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg></button>
<button className="tl-action danger" type="button" title="删除选中片段" disabled={edClips.length <= 1} onClick={() => edDeleteClip(selectedClip)}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1.5 14a2 2 0 0 1-2 1.8H8.5a2 2 0 0 1-2-1.8L5 6" /></svg></button>
<span className="spacer"></span>
<div className="tl-zoom"><span className="lbl">// zoom</span><input type="range" min={50} max={200} defaultValue={100} /></div>
<div className="tl-zoom"><span className="lbl">// zoom {edZoom}%</span><input type="range" min={100} max={300} value={edZoom} onChange={(e) => setEdZoom(Number(e.target.value))} /></div>
</div>
<div style={{ width: `${edZoom}%`, minWidth: "100%" }}>
<div className="tl-ruler">
<div className="l">// time</div>
<div className="rule-track" id="ed-ruler">
{ruler.map((tick, i) => (
<div
className="rule-track"
id="ed-ruler"
style={{ cursor: edClips.length ? "pointer" : "default" }}
onClick={(event) => {
if (!edClips.length) return;
const rect = event.currentTarget.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
seekToMs(frac * edRulerMs);
}}
>
{edRuler.map((tick, i) => (
<span className={`tick ${tick.major ? "major" : "minor"}`} key={i} style={{ left: `${tick.leftPct}%` }}>{tick.t && <span className="t">{tick.t}</span>}</span>
))}
{edClips.length > 0 && (
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 5, pointerEvents: "none" }} />
)}
</div>
</div>
<div className="tl-track video-track">
<div className="label video"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" /><path d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5" /></svg></span></div>
<div className="lane" id="ed-lane-video" data-track="video">
{tlClips.length ? tlClips.map((clip, idx) => {
const { leftPct, widthPct } = clipLayout(clip.start_ms, clip.duration_ms, tlRulerMs);
const lbl = assetName(clip.asset) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(clip.duration_ms / 1000));
{edClips.length > 0 && (
<span style={{ position: "absolute", top: 0, bottom: 0, left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%`, width: "2px", background: "var(--heat)", zIndex: 6, pointerEvents: "none" }} />
)}
{edClips.length ? edClips.map((c, idx) => {
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
const lbl = assetName(c.assetId) || `片段 ${idx + 1}`;
const frameCount = Math.max(1, Math.round(c.durMs / 1000));
return (
<div className="clip video" key={clip.id} data-track="video" data-label={lbl} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<div
className="clip video"
key={c.id}
data-track="video"
data-label={lbl}
draggable
onDragStart={() => setDragIdx(idx)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); if (dragIdx != null) reorderClip(dragIdx, idx); setDragIdx(null); }}
onDragEnd={() => setDragIdx(null)}
title="拖拽可重排片段"
style={{ left: `${leftPct}%`, width: `${widthPct}%`, cursor: "grab", opacity: dragIdx === idx ? 0.4 : 1, outline: idx === selectedClip ? "2px solid var(--heat)" : undefined, outlineOffset: "-2px" }}
onClick={() => { setSelectedClip(idx); gotoClip(idx, false); }}
>
<span className="frames">{Array.from({ length: frameCount + 1 }).map((_, i) => <span className="fr" key={i}></span>)}</span>
<span className="num">{idx + 1}</span><span className="lbl">{lbl}</span>
</div>
@ -635,44 +1146,52 @@ export function PipelinePage(props: {
<div className="tl-track subtitle-track">
<div className="label subtitle"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 7V4h16v3" /><path d="M9 20h6" /><path d="M12 4v16" /></svg></span></div>
<div className="lane" id="ed-lane-subtitle" data-track="subtitle">
{subtitleCues.map((cue, i) => {
const next = subtitleCues[i + 1];
const endMs = next ? next.start_ms : tlRulerMs;
const { leftPct, widthPct } = clipLayout(cue.start_ms, Math.max(0, endMs - cue.start_ms), tlRulerMs);
{subVisible && edState.clips.map((c, idx) => {
const { leftPct, widthPct } = clipLayout(edOffsets[idx], c.durMs, edRulerMs);
const text = c.subtitle || `字幕 ${idx + 1}`;
return (
<div className="clip subtitle" key={i} data-track="subtitle" data-label={cue.text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{cue.text}</span></div>
<div className="clip subtitle" key={c.key} data-track="subtitle" data-label={text} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}><span className="lbl">{text}</span></div>
);
})}
<div className="playhead" id="ed-playhead" style={{ left: "0%" }}><span className="ph-grab"></span></div>
<div className="playhead" id="ed-playhead" style={{ left: `${Math.min(100, (edGlobalMs / (edRulerMs || 1)) * 100)}%` }}><span className="ph-grab"></span></div>
</div>
</div>
{bgmTracks.length > 0 && (
{serverBgm && (
<div className="tl-track bgm-track">
<div className="label bgm"><span className="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle cx="18" cy="16" r="3" /></svg></span>BGM</div>
<div className="lane">
{bgmTracks.map((track) => {
const { leftPct, widthPct } = clipLayout(track.start_ms, Math.max(0, tlRulerMs - track.start_ms), tlRulerMs);
const name = assetName(track.asset) || "背景音乐";
return (
<div className="clip bgm" key={track.id} data-track="bgm" data-label={name} style={{ left: `${leftPct}%`, width: `${widthPct}%` }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl">{name} · {track.volume}</span>
</div>
);
})}
<div className="clip bgm" data-track="bgm" data-label={serverBgmName} style={{ left: "0%", width: "100%" }}>
<span className="wave"><svg viewBox="0 0 600 20" preserveAspectRatio="none" fill="currentColor">{ED_WAVE.map(([y, h], i) => <rect key={i} x={i * 4} y={y} width="2" height={h} />)}</svg></span>
<span className="lbl">{serverBgmName} · {edState.bgmVolume}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="stage-foot">
<div className="info"><span className="mono">[ {fmtMs(tlRulerMs)} · {tlClips.length} · / 0 token ]</span></div>
<div className="info">
<span className="mono">[ {fmtMs(edRulerMs)} · {edClips.length} · / 0 token ]</span>
{exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 拼接中 {exportResult?.progress ?? 0}%</span>}
{finalUrl && <span className="mono" style={{ marginLeft: 10, color: "var(--heat)" }}>// 成片已就绪</span>}
{exportFailed && <span className="mono" style={{ marginLeft: 10, color: "var(--err, #d33)" }}>// 导出失败:{exportResult?.error_message || "请重试"}</span>}
{!canExport && !finalUrl && !exporting && <span className="mono" style={{ marginLeft: 10, color: "var(--black-alpha-48)" }}>// 待全部视频片段生成完成后可导出</span>}
</div>
<div className="hstack">
<button className="btn" type="button" onClick={() => goStage(4)}><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M19 12H5M12 19l-7-7 7-7" /></svg> </button>
<button className="btn" type="button">稿</button>
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport} onClick={onSubmitExport}> MP4 · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect} <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg></button>
<button className="btn" type="button" disabled={loading} onClick={() => onSaveTimeline(buildSavePayload())}>稿</button>
{finalUrl && (
<a className="btn" href={finalUrl} target="_blank" rel="noreferrer" download>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</a>
)}
<button className="btn btn-primary btn-lg" type="button" disabled={!canExport || loading || exporting} onClick={() => onSubmitExport(buildSavePayload())}>
{exporting ? "拼接中…" : finalUrl ? "重新导出" : "导出 MP4"} · {resolution.includes("1080") || resolution.includes("1920") ? "1080P" : resolution} {aspect}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 4v12m0 0l-5-5m5 5l5-5M4 20h16" /></svg>
</button>
</div>
</div>
</section>

View File

@ -1,11 +1,14 @@
import { useState } from "react";
import type { CSSProperties, FormEvent, KeyboardEvent } from "react";
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { ArrowLeft } from "lucide-react";
import { ConfirmModal } from "../components/overlays";
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import { Drawer } from "../components/overlays";
import "../product-create-page.css";
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
type ProductPayload = {
title?: string;
brand?: string;
@ -31,34 +34,117 @@ function productCover(name: string): string {
}
const prodMock = (file: string): CSSProperties => ({ ["--mock-media-url"]: `url(/exact/assets/mock/${file})` } as CSSProperties);
export function ProductsPage({ products, navigate, openProduct, onCreate }: {
export function ProductsPage({ products, projects = [], navigate, openProduct, onCreate, onDelete, autoOpenCreate = false }: {
products: Product[];
projects?: Project[];
navigate: (page: Page) => void;
openProduct: (productId: string) => void;
onCreate: (payload: ProductPayload) => Promise<unknown> | void;
onDelete?: (id: string) => Promise<unknown> | void;
autoOpenCreate?: boolean;
}) {
const [query, setQuery] = useState("");
const [drawer, setDrawer] = useState(false);
// 管理(编辑)模式:开关切 body.edit-mode → 卡片可多选 + 批量删除;单卡删除走 card-del-btn
const [editMode, setEditMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirmIds, setConfirmIds] = useState<string[] | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
const doDelete = async () => {
const ids = confirmIds || [];
setConfirmIds(null);
for (const id of ids) await onDelete?.(id);
setSelected(new Set());
};
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
const [title, setTitle] = useState("");
const [brand, setBrand] = useState("");
const [point, setPoint] = useState("");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
const [bullets, setBullets] = useState<string[]>([]);
const [bulletDraft, setBulletDraft] = useState("");
// 新建抽屉:主图选择(选图即预览,创建商品后到详情页上传)+ 使用指南面板
const [imagePreview, setImagePreview] = useState<string>("");
const imgInputRef = useRef<HTMLInputElement | null>(null);
const [showGuide, setShowGuide] = useState(false);
function pickImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) setImagePreview(URL.createObjectURL(file));
event.target.value = "";
}
const [openChip, setOpenChip] = useState<"" | "cat" | "date">("");
const [catFilter, setCatFilter] = useState("");
const [dateFilter, setDateFilter] = useState<"all" | "7" | "30" | "90">("all");
const filtered = products.filter((product) => `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase()));
// 筛选选项:商品分类来自真实 products,创建时间为固定区间
const categories = Array.from(new Set(products.map((p) => p.category).filter(Boolean))) as string[];
const DATE_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" },
{ value: "30", label: "近 30 天" },
{ value: "90", label: "近 90 天" }
];
const dateLabel = DATE_OPTS.find((o) => o.value === dateFilter)?.label || "创建时间";
function submit(event: FormEvent) {
// /products/new 进入时自动打开新建商品 drawer
useEffect(() => {
if (autoOpenCreate) setDrawer(true);
}, [autoOpenCreate]);
// 点击 chip 外部关闭下拉
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const filtered = products.filter((product) => {
const matchQuery = `${product.title} ${product.brand}`.toLowerCase().includes(query.toLowerCase());
const matchCat = !catFilter || product.category === catFilter;
let matchDate = true;
if (dateFilter !== "all" && product.created_at) {
const days = (Date.now() - new Date(product.created_at).getTime()) / 86400000;
matchDate = days <= Number(dateFilter);
}
return matchQuery && matchCat && matchDate;
});
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return;
event.preventDefault();
const value = bulletDraft.trim();
if (!value) return;
setBullets((list) => [...list, value]);
setBulletDraft("");
}
function removeBullet(index: number) {
setBullets((list) => list.filter((_, position) => position !== index));
}
function submit() {
if (!title.trim()) return;
onCreate({
title,
brand,
category: "电商商品",
target_audience: "泛人群",
description: point,
selling_points: [{ title: point || "核心卖点", detail: point || "待补充", sort_order: 0 }]
title: title.trim(),
category: category || PC_CAT_OPTIONS[0],
target_audience: target,
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
});
setDrawer(false);
setTitle("");
setBrand("");
setPoint("");
setCategory("");
setTarget("");
setBullets([]);
setBulletDraft("");
}
return (
@ -69,9 +155,9 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<div className="sub"><span className="mono">// <span id="sku-count">{products.length}</span> SKU</span> · 商品信息会作为脚本和资产生成的素材</div>
</div>
<div className="actions">
<button className="btn btn-edit-toggle" type="button" id="edit-toggle-btn">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="edit-toggle-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="btn-edit-label"></span>
<span className="btn-edit-label">{editMode ? "完成" : "管理商品"}</span>
</button>
<button className="btn btn-primary btn-create" type="button" id="open-new-product" onClick={() => setDrawer(true)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 22V12" /><path d="M16 17h6" /><path d="M19 14v6" /><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="m7.5 4.3 9 5.1" /></svg>
@ -86,8 +172,34 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索商品名称、品牌" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="cat"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="date"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className={`chip-wrap${openChip === "cat" ? " open" : ""}`} data-key="cat">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "cat" ? "" : "cat"))}>
<span className="chip-label">{catFilter || "商品分类"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{categories.length > 0 && <div className="mi-sep" />}
{categories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "date" ? " open" : ""}`} data-key="date">
<button className={`chip${dateFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "date" ? "" : "date"))}>
<span className="chip-label">{dateFilter === "all" ? "创建时间" : dateLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{DATE_OPTS.map((opt) => (
<div className={`mi${dateFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setDateFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
</div>
<div className="result-meta" id="result-meta">
@ -96,30 +208,159 @@ export function ProductsPage({ products, navigate, openProduct, onCreate }: {
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{filtered.map((product) => <ProductCard key={product.id} product={product} onOpen={() => openProduct(product.id)} />)}
{filtered.map((product) => (
<ProductCard
key={product.id}
product={product}
videoCount={projects.filter((p) => p.product === product.id).length}
editMode={editMode}
selected={selected.has(product.id)}
onOpen={() => (editMode ? toggleSelect(product.id) : openProduct(product.id))}
onDelete={onDelete ? () => setConfirmIds([product.id]) : undefined}
/>
))}
</div>
</div>
</div>
<Drawer title="新建商品" open={drawer} close={() => setDrawer(false)}>
<form onSubmit={submit}>
<div className="field"><label className="field-label"><span className="req">*</span></label><input className="input" value={title} onChange={(event) => setTitle(event.target.value)} required /></div>
<div className="field"><label className="field-label"></label><input className="input" value={brand} onChange={(event) => setBrand(event.target.value)} /></div>
<div className="field"><label className="field-label"><span className="req">*</span></label><textarea className="textarea" value={point} onChange={(event) => setPoint(event.target.value)} /></div>
<div className="drawer-actions"><button className="btn btn-ghost" type="button" onClick={() => setDrawer(false)}></button><button className="btn btn-primary" type="submit"></button></div>
</form>
</Drawer>
{/* 编辑模式浮动操作条 */}
<div className="bulk-bar" role="toolbar" aria-label="批量操作">
<span className="ct"> <b>{selected.size}</b> </span>
<button className="clear-sel" type="button" onClick={() => setSelected(new Set())}></button>
<span className="sep" />
<button
className="danger"
type="button"
disabled={selected.size === 0}
onClick={() => selected.size && setConfirmIds(Array.from(selected))}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
<button type="button" onClick={exitEdit}>退</button>
</div>
<ConfirmModal
open={Boolean(confirmIds && confirmIds.length)}
title="删除商品"
detail={`确定删除选中的 ${confirmIds?.length || 0} 个商品?该操作不可撤销,商品的图册与关联记录也会一并移除。`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}
/>
{/* 新建商品 · 右侧 Drawer · 在商品库页面原地打开(转写自 products.html #pc-drawer) */}
<div className={`drawer-bg${drawer ? " show" : ""}`} onClick={() => setDrawer(false)} />
<aside className={`drawer pc-drawer${drawer ? " show" : ""}`} role="dialog" aria-label="新建商品" aria-hidden={!drawer}>
<div className="drawer-h">
<h3></h3>
<button className="x" type="button" onClick={() => setDrawer(false)} aria-label="关闭">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</div>
<div className="drawer-b">
<div className="form-card">
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<input className="input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="请输入商品名称(必填)" maxLength={100} />
</div>
<div className="field-row">
<div>
<label className="field-label"><span className="req">*</span></label>
<select className="select" value={category} onChange={(event) => setCategory(event.target.value)}>
{PC_CAT_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</div>
<div>
<label className="field-label"><span className="opt">()</span></label>
<input className="input" value={target} onChange={(event) => setTarget(event.target.value)} placeholder="例: 22-32 岁女性、敏感肌、办公室通勤" />
</div>
</div>
<div className="field">
<label className="field-label"><span className="req">*</span></label>
<div className="pf-upload-row">
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
{imagePreview ? (
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
) : (
<>
<div className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
</div>
<div className="uz-t"><strong></strong></div>
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
</>
)}
</div>
<div className="pf-example">
<div className="ex-h"></div>
<div className="ex-grid">
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10l1 4v12H6V8l1-4z" /><path d="M9 4v3M15 4v3M9 11h6M9 14h6" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="5" width="12" height="15" rx="2" /><path d="M9 9h6M9 12h6M9 15h4" /></svg></div>
<div className="ex-thumb"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3h8l1 5v12H7V8l1-5z" /><circle cx="12" cy="13" r="2.5" /></svg></div>
</div>
<div className="ex-d"></div>
</div>
</div>
<div className="pf-grid" />
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label"><span className="req">*</span></label>
<ul className="bullet-list">
{bullets.map((bullet, index) => (
<li className="bl-item" key={`${bullet}-${index}`}>
<span className="num">{index + 1}</span>
<span className="bl-text">{bullet}</span>
<button className="bl-x" type="button" onClick={() => removeBullet(index)} aria-label="删除卖点">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
</button>
</li>
))}
<li className="bl-add">
<span className="num">+</span>
<input className="bl-input" value={bulletDraft} onChange={(event) => setBulletDraft(event.target.value)} onKeyDown={addBullet} placeholder="添加新卖点 · 回车确认" />
</li>
</ul>
</div>
</div>
</div>
{showGuide && (
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 12.5, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
<strong>// 建好商品的 3 步</strong><br />
+ (,/)<br />
(800×800 ),便 AI <br />
24 ,
</div>
)}
<div className="drawer-f">
<button className="btn-guide" type="button" aria-expanded={showGuide} onClick={() => setShowGuide((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 015 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></svg>
使
</button>
<button className="btn" type="button" onClick={() => setDrawer(false)}></button>
<button className="btn btn-primary" type="button" onClick={submit}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
</button>
</div>
</aside>
</section>
);
}
export function ProductCard({ product, onOpen }: { product: Product; onOpen: () => void }) {
export function ProductCard({ product, videoCount = 0, onOpen, editMode = false, selected = false, onDelete }: { product: Product; videoCount?: number; onOpen: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
const cover = productCover(product.title);
const assetCount = product.images?.length || 0;
return (
<div className="product-card" data-cat={product.category} data-name={product.title} role="button" tabIndex={0} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<div className={`product-card${selected ? " selected" : ""}`} data-cat={product.category} data-name={product.title} role="button" tabIndex={0} aria-pressed={editMode ? selected : undefined} onClick={onOpen} onKeyDown={(event) => event.key === "Enter" && onOpen()}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="3 8 7 12 13 4" /></svg></span>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => event.stopPropagation()}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
<button className="card-del-btn" type="button" title="删除商品" onClick={(event) => { event.stopPropagation(); onDelete?.(); }}><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></button>
<div className={`placeholder product-thumb${cover ? " has-mock-media" : ""}`} style={cover ? prodMock(cover) : undefined}><span className="ph-frame">{product.title} · 1200×800</span></div>
<div className="product-body">
<div className="product-name">{product.title}</div>
@ -134,16 +375,13 @@ export function ProductCard({ product, onOpen }: { product: Product; onOpen: ()
<span className="sep">·</span>
<span className="stat">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="6" width="14" height="12" rx="2" /><path d="M16 10l6-3v10l-6-3z" /></svg>
<b>0</b>
<b>{videoCount}</b>
</span>
</div>
</div>
);
}
const PC_PHOTO_SLOTS = ["主图", "细节 02", "细节 03", "细节 04", "细节 05"];
const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
export function ProductCreateUploadPage({ onCreate, onBack }: { onCreate: (payload: ProductPayload) => Promise<unknown> | void; onBack: () => void }) {
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
@ -328,16 +566,70 @@ function pdProjStatusLabel(project: Project) {
}
function pdProjPillClass(project: Project) { return project.status === "completed" ? "ok" : project.status === "failed" ? "err" : "info"; }
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate }: {
export function ProductDetailPage({ product, projects, assets, navigate, onUpdate, onUploadImage, onDeleteImage, onGenerateImages }: {
product: Product;
projects: Project[];
assets: Asset[];
navigate: (page: Page) => void;
onUpdate: (payload: Partial<Product>) => Promise<unknown> | void;
onUploadImage?: (formData: FormData) => Promise<unknown> | void;
onDeleteImage?: (imageId: string) => Promise<unknown> | void;
onGenerateImages?: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number }) => Promise<{ assets: Asset[] } | null>;
}) {
const [tab, setTab] = useState<"assets" | "videos">("assets");
const [editing, setEditing] = useState(false);
const [triOpen, setTriOpen] = useState(false);
const imgInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
// 三视图生成
const [triGenerating, setTriGenerating] = useState(false);
const [triUrl, setTriUrl] = useState<string>("");
// 素材 tab 筛选 / 排序 / 分页
const [openFilter, setOpenFilter] = useState<"" | "type" | "sort">("");
const [typeFilter, setTypeFilter] = useState("");
const [assetSortDesc, setAssetSortDesc] = useState(true);
const [assetLimit, setAssetLimit] = useState(12);
const [videoSortDesc, setVideoSortDesc] = useState(true);
useEffect(() => {
if (!openFilter) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenFilter("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openFilter]);
async function onPickProductImage(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !onUploadImage) return;
setUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
fd.append("name", `${product.title || "商品"}-图${(product.images?.length || 0) + 1}`);
await onUploadImage(fd);
} finally {
setUploading(false);
}
}
async function generateTriView() {
if (!onGenerateImages || triGenerating) return;
setTriGenerating(true);
try {
const res = await onGenerateImages({
prompt: `${product.title || "商品"} 白底商品三视图(正面/侧面/背面),电商主图,干净白底,高清`,
mode: "image",
count: 1
});
const url = res?.assets?.[0]?.files?.[0]?.preview_url;
if (url) setTriUrl(url);
} finally {
setTriGenerating(false);
}
}
// 商品图网格 · 用 product.images 的 asset id 在团队 assets 里查到真图;再叠加 cover_asset(去重)
const assetById = new Map(assets.map((asset) => [asset.id, asset]));
@ -350,13 +642,24 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
// AI 生成素材 · 团队资产中筛与该商品相关的类别(模特/场景/三视图/商品图/背景),取真图;无则回退到全部图片资产
const AI_CATS = new Set(["product_image", "person", "scene", "tri_view", "background"]);
const aiSource = assets.filter((asset) => AI_CATS.has(asset.category) || AI_CATS.has(asset.asset_type));
const imageAssets = (aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image"))
const allImageAssets = aiSource.length ? aiSource : assets.filter((asset) => asset.asset_type === "image");
// 类型筛选选项(当前素材里真实存在的 category)
const typeOptions = Array.from(new Set(allImageAssets.map((a) => a.category).filter(Boolean)));
const filteredAssets = allImageAssets
.filter((asset) => !typeFilter || asset.category === typeFilter)
.slice()
.sort((a, b) => (b.created_at || "").localeCompare(a.created_at || ""));
const assetCount = imageAssets.length;
.sort((a, b) => {
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return assetSortDesc ? cmp : -cmp;
});
const assetCount = filteredAssets.length;
const imageAssets = filteredAssets.slice(0, assetLimit);
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段
const videoProjects = projects;
// 视频项目 · 用传入的该商品 projects 渲染真实项目名 / 状态 / 阶段(按更新时间排序)
const videoProjects = [...projects].sort((a, b) => {
const cmp = (b.updated_at || "").localeCompare(a.updated_at || "");
return videoSortDesc ? cmp : -cmp;
});
// 真实字段 · 缺省时回退到设计稿镜像默认值(对齐 api-bridge setField 行为)
const realName = product.title || "补水保湿精华液";
@ -405,12 +708,14 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<button className="ov-tri-close" type="button" id="ov-tri-close" aria-label="关闭" onClick={() => setTriOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">待生成</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img"><span className="ph-frame">// 尚未生成 · 点击下方按钮开始</span></div>
<div className="prod-preview-h">// 三视图预览 · <span id="ov-tri-status">{triGenerating ? "生成中…" : triUrl ? "已生成" : "待生成"}</span></div>
<div className="placeholder prod-preview-img" id="ov-tri-img">
{triUrl ? <img src={triUrl} alt="三视图" loading="lazy" /> : <span className="ph-frame">{triGenerating ? "// 生成中,请稍候…" : "// 尚未生成 · 点击下方按钮开始"}</span>}
</div>
<div className="prod-preview-foot" id="ov-tri-foot">
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }}>
<button className="ov-edit primary" type="button" id="ov-tri-start" style={{ height: "28px" }} onClick={generateTriView} disabled={triGenerating || !onGenerateImages}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l1.8 4.2L18 9l-4.2 1.8L12 15l-1.8-4.2L6 9l4.2-1.8L12 3z" /></svg>
{triGenerating ? "生成中…" : "生成"}
</button>
<span style={{ flex: 1 }}></span>
<span className="mono" style={{ fontSize: "11px", color: "var(--black-alpha-56)" }}>~¥0.30 / </span>
@ -492,9 +797,14 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
{image.url ? <img src={image.url} alt={realName} loading="lazy" /> : <span className="ph-frame">1:1</span>}
</div>
))}
<div className="img-upload" id="ov-img-add" title="上传图片">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
<div className="img-upload" id="ov-img-add" title="上传图片" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
{uploading ? (
<span className="ph-frame" style={{ fontSize: 10 }}></span>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
)}
</div>
<input ref={imgInputRef} type="file" accept="image/*" style={{ display: "none" }} onChange={onPickProductImage} />
</div>
</div>
@ -545,27 +855,38 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<div className="pd-toolbar">
<div className="total"> AI <span className="ct">({assetCount})</span></div>
<button className="filter" type="button" data-key="type">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<button className="filter" type="button" data-key="status">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="right">
<div className="view-tog">
<button type="button" className="active" title="网格视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" /><rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" /></svg>
</button>
<button type="button" title="列表视图">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 6h18M3 12h18M3 18h18" /></svg>
</button>
</div>
<button className="filter" type="button" data-key="sort">
<div className={`chip-wrap${openFilter === "type" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="type">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "type" ? "" : "type"))}>
{typeFilter ? (pdAssetTypeLabel({ category: typeFilter, asset_type: "" } as Asset) || typeFilter) : "全部类型"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!typeFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setTypeFilter(""); setAssetLimit(12); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{typeOptions.length > 0 && <div className="mi-sep" />}
{typeOptions.map((cat) => (
<div className={`mi${typeFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setTypeFilter(cat); setAssetLimit(12); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{pdAssetTypeLabel({ category: cat, asset_type: "" } as Asset) || cat}
</div>
))}
</div>
</div>
<div className="right">
<div className={`chip-wrap${openFilter === "sort" ? " open" : ""}`} style={{ display: "inline-flex" }} data-key="sort">
<button className="filter" type="button" onClick={() => setOpenFilter((f) => (f === "sort" ? "" : "sort"))}>
{assetSortDesc ? "最新生成" : "最早生成"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu align-right">
<div className={`mi${assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(true); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
<div className={`mi${!assetSortDesc ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setAssetSortDesc(false); setOpenFilter(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
</div>
</div>
</div>
</div>
@ -586,7 +907,9 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
})}
</div>
<div className="pd-more"><button type="button"></button></div>
{imageAssets.length < filteredAssets.length && (
<div className="pd-more"><button type="button" onClick={() => setAssetLimit((n) => n + 12)}>({filteredAssets.length - imageAssets.length})</button></div>
)}
</div>
{/* ===== 视频项目 ===== */}
@ -594,8 +917,8 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
<div className="pd-toolbar">
<div className="total"> <span className="ct">({videoProjects.length})</span></div>
<div className="right">
<button className="filter" type="button" data-key="sort">
<button className="filter" type="button" data-key="sort" onClick={() => setVideoSortDesc((v) => !v)}>
{videoSortDesc ? "最新更新" : "最早更新"}
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
</div>
@ -609,8 +932,6 @@ export function ProductDetailPage({ product, projects, assets, navigate, onUpdat
</div>
))}
</div>
<div className="pd-more"><button type="button"></button></div>
</div>
</section>

View File

@ -32,7 +32,7 @@ const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品
export function ProjectWizardPage({ products, onBack, onCreate }: {
products: Product[];
onBack: () => void;
onCreate: (payload: { name: string; product: string }) => Promise<unknown> | void;
onCreate: (payload: { name: string; product: string; metadata?: Record<string, unknown> }) => Promise<unknown> | void;
}) {
const [productId, setProductId] = useState(products[0]?.id || "");
const product = products.find((item) => item.id === productId) || products[0];
@ -122,7 +122,20 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product) return;
void onCreate({ name: name.trim() || `${product.title} · 短视频`, product: product.id });
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
void onCreate({
name: name.trim() || `${product.title} · 短视频`,
product: product.id,
metadata: {
wizard: {
duration,
script_style: scriptStyle,
persona,
selling_point_ids: selectedPoints
}
}
});
}
const productCover = (p: Product): CSSProperties | undefined => {
@ -383,8 +396,43 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
const [view, setView] = useState<"list" | "grid">("list");
const [tab, setTab] = useState<"all" | "wip" | "done" | "fail">("all");
const [query, setQuery] = useState("");
const [editMode, setEditMode] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
const [openChip, setOpenChip] = useState<"" | "product" | "source" | "time">("");
const [catFilter, setCatFilter] = useState("");
const [sourceFilter, setSourceFilter] = useState<"all" | "has" | "none">("all");
const [timeFilter, setTimeFilter] = useState<"all" | "7" | "30" | "90">("all");
const productTitle = (id: string) => products.find((product) => product.id === id)?.title || "商品";
const productCat = (id: string) => products.find((product) => product.id === id)?.category || "";
// 筛选选项(全部来自真实数据)
const projectCategories = Array.from(new Set(projects.map((p) => productCat(p.product)).filter(Boolean))) as string[];
const SRC_OPTS: Array<{ value: "all" | "has" | "none"; label: string }> = [
{ value: "all", label: "全部来源" },
{ value: "has", label: "AI 已生成脚本" },
{ value: "none", label: "暂无脚本" }
];
const TIME_OPTS: Array<{ value: "all" | "7" | "30" | "90"; label: string }> = [
{ value: "all", label: "全部时间" },
{ value: "7", label: "近 7 天" },
{ value: "30", label: "近 30 天" },
{ value: "90", label: "近 90 天" }
];
const srcLabel = SRC_OPTS.find((o) => o.value === sourceFilter)?.label || "脚本来源";
const timeLabel = TIME_OPTS.find((o) => o.value === timeFilter)?.label || "创建时间";
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip("");
};
document.addEventListener("click", close);
return () => document.removeEventListener("click", close);
}, [openChip]);
const counts = {
all: projects.length,
@ -394,7 +442,18 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
};
const filtered = projects.filter((project) => {
if (tab !== "all" && projBucket(project) !== tab) return false;
return `${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase());
if (!`${project.name} ${productTitle(project.product)}`.toLowerCase().includes(query.toLowerCase())) return false;
if (catFilter && productCat(project.product) !== catFilter) return false;
if (sourceFilter !== "all") {
const hasScript = (project.script_versions?.length || 0) > 0;
if (sourceFilter === "has" && !hasScript) return false;
if (sourceFilter === "none" && hasScript) return false;
}
if (timeFilter !== "all" && project.created_at) {
const days = (Date.now() - new Date(project.created_at).getTime()) / 86400000;
if (days > Number(timeFilter)) return false;
}
return true;
});
async function confirmDelete() {
@ -411,9 +470,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<div className="sub"><span className="mono">// {counts.all} 个 · {counts.wip} 进行中 · {counts.done} 完成 · {counts.fail} 失败</span></div>
</div>
<div className="actions">
<button className="btn" type="button" id="proj-manage-btn">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="proj-manage-btn" onClick={() => setEditMode((v) => !v)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m3 7 2 2 4-4" /><path d="m3 17 2 2 4-4" /><path d="M13 6h8" /><path d="M13 12h8" /><path d="M13 18h8" /></svg>
<span className="proj-manage-label"></span>
<span className="proj-manage-label">{editMode ? "完成" : "管理项目"}</span>
</button>
<button className="btn btn-primary btn-lg btn-create" type="button" onClick={() => navigate("projectWizard")}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="m12.3 3.5 3 4" /><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z" /><path d="m6.2 5.3 3.1 3.9" /><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></svg>
@ -433,9 +492,46 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
<input className="input" id="search-input" placeholder="搜索项目名称、商品" value={query} onChange={(event) => setQuery(event.target.value)} />
</div>
<div className="chip-wrap" data-key="product"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="source"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className="chip-wrap" data-key="time"><button className="chip" type="button"><span className="chip-label"></span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg></button></div>
<div className={`chip-wrap${openChip === "product" ? " open" : ""}`} data-key="product">
<button className={`chip${catFilter ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "product" ? "" : "product"))}>
<span className="chip-label">{catFilter || "商品品类"}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
<div className={`mi${!catFilter ? " selected" : ""}`} role="button" tabIndex={0} onClick={() => { setCatFilter(""); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>
</div>
{projectCategories.length > 0 && <div className="mi-sep" />}
{projectCategories.map((cat) => (
<div className={`mi${catFilter === cat ? " selected" : ""}`} key={cat} role="button" tabIndex={0} onClick={() => { setCatFilter(cat); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{cat}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "source" ? " open" : ""}`} data-key="source">
<button className={`chip${sourceFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "source" ? "" : "source"))}>
<span className="chip-label">{sourceFilter === "all" ? "脚本来源" : srcLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{SRC_OPTS.map((opt) => (
<div className={`mi${sourceFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setSourceFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<div className={`chip-wrap${openChip === "time" ? " open" : ""}`} data-key="time">
<button className={`chip${timeFilter !== "all" ? " active" : ""}`} type="button" onClick={() => setOpenChip((c) => (c === "time" ? "" : "time"))}>
<span className="chip-label">{timeFilter === "all" ? "创建时间" : timeLabel}</span> <svg className="caret" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M4 6l4 4 4-4" /></svg>
</button>
<div className="chip-menu">
{TIME_OPTS.map((opt) => (
<div className={`mi${timeFilter === opt.value ? " selected" : ""}`} key={opt.value} role="button" tabIndex={0} onClick={() => { setTimeFilter(opt.value); setOpenChip(""); }}>
<svg className="mi-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 8l3.5 3.5L13 5" /></svg>{opt.label}
</div>
))}
</div>
</div>
<span className="spacer"></span>
<div className="view-toggle">
<button className={view === "grid" ? "active" : ""} type="button" data-view="grid" onClick={() => setView("grid")}>
@ -490,6 +586,9 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
<td className="muted-2">{projDate(project)}</td>
<td>
<div className="row-action">
{editMode && (
<a href="#" className="row-del" onClick={(event) => { event.preventDefault(); event.stopPropagation(); setDeleteTarget(project); }} title="删除项目"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" width="14" height="14"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg></a>
)}
<a href="#" onClick={(event) => { event.preventDefault(); event.stopPropagation(); openPipeline(project.id); }} title="继续"><svg width="14" height="14" viewBox="0 0 16 16"><path d="M5 4l6 4-6 4z" fill="currentColor" /></svg></a>
<span className="row-more" onClick={(event) => event.stopPropagation()}>
<svg width="14" height="14" viewBox="0 0 16 16"><circle cx="3" cy="8" r="1.2" fill="currentColor" /><circle cx="8" cy="8" r="1.2" fill="currentColor" /><circle cx="13" cy="8" r="1.2" fill="currentColor" /></svg>

View File

@ -11,7 +11,7 @@ import {
Upload,
User as UserIcon,
} from "lucide-react";
import type { Team, User } from "../types";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@ -52,11 +52,13 @@ const SUBTITLE_CHOICES = [
const DURATIONS = ["30", "45", "60"];
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
];
// 从 User-Agent 提取「系统 · 浏览器」可读名
function deviceName(ua: string): string {
if (!ua) return "未知设备";
const os = /Windows/i.test(ua) ? "Windows" : /Mac OS X|Macintosh/i.test(ua) ? "macOS" : /iPhone|iPad/i.test(ua) ? "iOS" : /Android/i.test(ua) ? "Android" : /Linux/i.test(ua) ? "Linux" : "设备";
const browser = /Edg/i.test(ua) ? "Edge" : /Chrome/i.test(ua) ? "Chrome" : /Safari/i.test(ua) ? "Safari" : /Firefox/i.test(ua) ? "Firefox" : ua.slice(0, 24);
return `${os} · ${browser}`;
}
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
@ -65,46 +67,20 @@ const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: s
{ key: "n-login", title: "异地登录告警", channels: "短信" },
];
// ─── 偏好持久化 · 后端无字段,纯本地 localStorage ───
const PREFS_KEY = "airshelf_settings_prefs";
type Prefs = {
template: string;
duration: string;
subtitle: string;
twoFactor: boolean;
notify: Record<string, boolean>;
appearance: string;
language: string;
density: string;
};
const DEFAULT_PREFS: Prefs = {
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
const DEFAULT_PREFS = {
template: "pain",
duration: "60",
subtitle: "big-variety",
bgm: "kapian",
transition: "fade",
twoFactor: false,
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true },
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record<string, boolean>,
appearance: "system",
language: "zh",
density: "standard",
};
function loadPrefs(): Prefs {
try {
const raw = localStorage.getItem(PREFS_KEY);
if (!raw) return DEFAULT_PREFS;
const parsed = JSON.parse(raw) as Partial<Prefs>;
return {
...DEFAULT_PREFS,
...parsed,
notify: { ...DEFAULT_PREFS.notify, ...(parsed.notify ?? {}) },
};
} catch {
return DEFAULT_PREFS;
}
}
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
return (
<label className="switch">
@ -118,16 +94,30 @@ export function SettingsPage({
user,
team,
initialSection = "profile",
preferences,
sessions = [],
onSavePreferences,
onRevokeSession,
onRevokeOthers,
onSaveProfile,
onChangePassword,
onUploadAvatar,
onResetAvatar,
onNotify,
}: {
user: User;
team: Team;
initialSection?: string;
preferences?: UserPreference | null;
sessions?: LoginSession[];
onSavePreferences?: (payload: Partial<UserPreference>) => void | Promise<unknown>;
onRevokeSession?: (id: string) => void | Promise<unknown>;
onRevokeOthers?: () => void | Promise<unknown>;
onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise<unknown>;
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
onResetAvatar?: () => void | Promise<unknown>;
onNotify?: (text: string) => void;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
@ -141,26 +131,42 @@ export function SettingsPage({
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
// 偏好 · localStorage 持久化(读 localStorage 初始化)
const initialPrefs = useMemo(() => loadPrefs(), []);
const [template, setTemplate] = useState(initialPrefs.template);
const [duration, setDuration] = useState(initialPrefs.duration);
const [subtitle, setSubtitle] = useState(initialPrefs.subtitle);
const [twoFactor, setTwoFactor] = useState(initialPrefs.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(initialPrefs.notify);
const [appearance, setAppearance] = useState(initialPrefs.appearance);
const [language, setLanguage] = useState(initialPrefs.language);
const [density, setDensity] = useState(initialPrefs.density);
// 偏好 · 服务端持久化(从后端 preferences 注入初值,改动即 PUT 回后端)
const [template, setTemplate] = useState(DEFAULT_PREFS.template);
const [duration, setDuration] = useState(DEFAULT_PREFS.duration);
const [subtitle, setSubtitle] = useState(DEFAULT_PREFS.subtitle);
const [bgm, setBgm] = useState(DEFAULT_PREFS.bgm);
const [transition, setTransition] = useState(DEFAULT_PREFS.transition);
const [twoFactor, setTwoFactor] = useState(DEFAULT_PREFS.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(DEFAULT_PREFS.notify);
const [appearance, setAppearance] = useState(DEFAULT_PREFS.appearance);
const [language, setLanguage] = useState(DEFAULT_PREFS.language);
const [density, setDensity] = useState(DEFAULT_PREFS.density);
// 偏好改动即写回 localStorage(不调后端)
// 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认)
useEffect(() => {
const prefs: Prefs = { template, duration, subtitle, twoFactor, notify, appearance, language, density };
try {
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
} catch {
/* localStorage 不可用时静默降级 */
}
}, [template, duration, subtitle, twoFactor, notify, appearance, language, density]);
if (!preferences) return;
const cd = preferences.creation_defaults || {};
setTemplate(cd.template ?? DEFAULT_PREFS.template);
setDuration(cd.duration ?? DEFAULT_PREFS.duration);
setSubtitle(cd.subtitle ?? DEFAULT_PREFS.subtitle);
setBgm(cd.bgm ?? DEFAULT_PREFS.bgm);
setTransition(cd.transition ?? DEFAULT_PREFS.transition);
setTwoFactor(!!preferences.two_factor_enabled);
setNotify({ ...DEFAULT_PREFS.notify, ...(preferences.notify || {}) });
const dp = preferences.display || {};
setAppearance(dp.appearance ?? DEFAULT_PREFS.appearance);
setLanguage(dp.language ?? DEFAULT_PREFS.language);
setDensity(dp.density ?? DEFAULT_PREFS.density);
}, [preferences]);
// 当前 creation_defaults / display 快照(配合 [key]:value 即时持久化单字段)
function saveCreation(patch: Partial<UserPreference["creation_defaults"]>) {
onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } });
}
function saveDisplay(patch: Partial<UserPreference["display"]>) {
onSavePreferences?.({ display: { appearance, language, density, ...patch } });
}
// 改密 · 受控输入
const [oldPassword, setOldPassword] = useState("");
@ -313,7 +319,7 @@ export function SettingsPage({
<div className="av-big">{avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={openAvatarModal}></button>
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}></button>
</div>
</div>
</div>
@ -326,14 +332,14 @@ export function SettingsPage({
<div className="lbl"></div>
<div className="val">
<input className="input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}></button>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
<button className="btn btn-ghost btn-sm" type="button"></button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => { if (phone.trim()) { onSaveProfile({ phone: phone.trim() }); onNotify?.("手机号已更新"); } else { onNotify?.("请先填写新手机号"); } }}></button>
</div>
</div>
<div className="form-row">
@ -367,36 +373,43 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={twoFactor} onChange={setTwoFactor} />
<Switch checked={twoFactor} onChange={(v) => { setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: v }); }} />
<span className="switch-note"> + Authenticator</span>
</div>
</div>
<h3 className="sub-head"></h3>
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
<div className="device-list">
{DEVICES.map((device) => (
<div className="device-row" key={device.name}>
<div className="ic">
{device.phone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">{device.meta}</div>
</div>
<div className="spacer" />
{device.current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button">线</button>}
</div>
))}
{sessions.length === 0 ? (
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>// 暂无其他登录会话记录</div></div>
) : (
sessions.map((s) => {
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
return (
<div className="device-row" key={s.id}>
<div className="ic">
{isPhone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
</div>
<div className="spacer" />
{s.is_current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>线</button>}
</div>
);
})
)}
</div>
<div style={{ marginTop: 14 }}>
<button className="btn" type="button">线</button>
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>线</button>
</div>
</section>
)}
@ -409,7 +422,7 @@ export function SettingsPage({
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
<div className="val">
<Switch checked={!!notify[row.key]} onChange={(next) => setNotify((prev) => ({ ...prev, [row.key]: next }))} />
<Switch checked={!!notify[row.key]} onChange={(next) => { const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
@ -432,7 +445,7 @@ export function SettingsPage({
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setTemplate(choice.v)}
onClick={() => { setTemplate(choice.v); saveCreation({ template: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@ -451,7 +464,7 @@ export function SettingsPage({
className={`dur-chip ${duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setDuration(d)}
onClick={() => { setDuration(d); saveCreation({ duration: d }); }}
>
{d}s
</span>
@ -470,7 +483,7 @@ export function SettingsPage({
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setSubtitle(choice.v)}
onClick={() => { setSubtitle(choice.v); saveCreation({ subtitle: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@ -482,7 +495,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"> BGM </div>
<div className="val">
<select className="select" defaultValue="kapian">
<select className="select" value={bgm} onChange={(event) => { setBgm(event.target.value); saveCreation({ bgm: event.target.value }); }}>
<option value="kapian"> Top10 </option>
<option value="emotion"> · /</option>
<option value="urban"> · </option>
@ -493,7 +506,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="fade">
<select className="select" value={transition} onChange={(event) => { setTransition(event.target.value); saveCreation({ transition: event.target.value }); }}>
<option value="none"></option>
<option value="fade"> · 0.3s</option>
<option value="slide"> · 0.3s</option>
@ -520,7 +533,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={appearance} onChange={(event) => setAppearance(event.target.value)}>
<select className="select" value={appearance} onChange={(event) => { setAppearance(event.target.value); saveDisplay({ appearance: event.target.value }); }}>
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
@ -530,7 +543,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
<select className="select" value={language} onChange={(event) => { setLanguage(event.target.value); saveDisplay({ language: event.target.value }); }}>
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
@ -539,7 +552,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
<select className="select" value={density} onChange={(event) => { setDensity(event.target.value); saveDisplay({ density: event.target.value }); }}>
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
import type { BillingSummary, Team, TeamMember, User } from "../types";
import type { BillingSummary, Notification, Team, TeamMember, User } from "../types";
import type { Page } from "./route-config";
import { money } from "./stage-config";
import { ConfirmModal, TeamModal } from "../components/overlays";
@ -24,11 +24,12 @@ const PERM_ROWS: Array<{ cap: string; cells: [string, string, string]; last?: bo
{ cap: "创建项目 / 用 AI 流程", cells: ["✓", "✓", "✓"], last: true }
];
export function TeamPage({ team, user, members, billing, navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
export function TeamPage({ team, user, members, billing, notifications = [], navigate, onCreateMember, onUpdateMember, onRemoveMember, onResetPassword, onRecharge }: {
team: Team;
user: User;
members: TeamMember[];
billing: BillingSummary | null;
notifications?: Notification[];
navigate: (page: Page) => void;
onCreateMember: (payload: { username: string; password: string; name?: string; role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
onUpdateMember: (id: string, payload: { role?: string; monthly_credit_limit?: number }) => void | Promise<unknown>;
@ -72,6 +73,11 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
const left = Math.max(0, limit - used);
const pct = limit > 0 ? Math.min(100, (used / limit) * 100) : 0;
// 团队动态:取最近 6 条真实通知
const feedItems = [...notifications]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 6);
const needle = search.trim().toLowerCase();
const list = rows.filter((member) => {
const name = member.user.username || "";
@ -190,21 +196,37 @@ export function TeamPage({ team, user, members, billing, navigate, onCreateMembe
</div>
</div>
{/* 团队动态(banner 右栏)· 对齐 api-bridge renderLiveTeamActivity 的真实状态占位 */}
{/* 团队动态(banner 右栏)· 接真实 ops/notifications 事件流 */}
<div className="team-feed">
<div className="h">
<h3></h3>
<span className="ct">// 真实动态接口待接入</span>
<span className="ct">// 最近 {Math.min(feedItems.length, 6)} 条 · 共 {notifications.length}</span>
<a className="more" id="open-feed-all" role="button" tabIndex={0} onClick={() => navigate("messages")}> </a>
</div>
<div className="feed-list">
<div className="feed-item">
<div className="av">Q</div>
<div>
<div className="txt"><span className="who"></span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">local cache</div>
{feedItems.length === 0 ? (
<div className="feed-item">
<div className="av">{(team.name || "T").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt"><span className="who">{team.name}</span><span className="act"></span><span className="obj">{rows.length} </span></div>
<div className="ts">// 暂无团队动态</div>
</div>
</div>
</div>
) : (
feedItems.map((n) => (
<div className="feed-item" key={n.id}>
<div className="av">{(n.owner_label || n.source || team.name || "·").slice(0, 1).toUpperCase()}</div>
<div>
<div className="txt">
<span className="who">{n.owner_label || n.source || "系统"}</span>
<span className="act">{n.title}</span>
{n.project_name && <span className="obj">{n.project_name}</span>}
</div>
<div className="ts">{n.created_at ? new Date(n.created_at).toLocaleString("zh-CN") : n.brief}</div>
</div>
</div>
))
)}
</div>
</div>

View File

@ -12,7 +12,7 @@
local('AlibabaPuHuiTi-3-55-Regular'),
local('Alibaba PuHuiTi 2.0'),
local('AlibabaPuHuiTi-2-55-Regular'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-55-Regular/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-55-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -22,7 +22,7 @@
src: local('Alibaba PuHuiTi 3.0 Medium'),
local('AlibabaPuHuiTi-3-65-Medium'),
local('AlibabaPuHuiTi-2-65-Medium'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-65-Medium/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-65-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -31,7 +31,7 @@
font-display: swap;
src: local('AlibabaPuHuiTi-3-75-SemiBold'),
local('AlibabaPuHuiTi-2-75-SemiBold'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-75-SemiBold/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Alibaba PuHuiTi';
@ -41,7 +41,7 @@
src: local('Alibaba PuHuiTi 3.0 Bold'),
local('AlibabaPuHuiTi-3-85-Bold'),
local('AlibabaPuHuiTi-2-85-Bold'),
url('https://chinese-fonts-cdn.deno.dev/packages/alibaba_puhuiti/dist/AlibabaPuHuiTi-3-85-Bold/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
url('/fonts/AlibabaPuHuiTi-3-85-Bold.woff2') format('woff2');
}
* { box-sizing: border-box; margin: 0; padding: 0; }

View File

@ -55,6 +55,7 @@ export type Asset = {
source: string;
category: string;
description: string;
metadata?: Record<string, unknown>;
files?: Array<{
id: string;
object_key: string;
@ -78,8 +79,11 @@ export type ScriptVersion = {
id: string;
title: string;
content: string;
source?: string;
is_adopted: boolean;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string }>;
segments: Array<{ id: string; sort_order: number; duration_seconds: number; narration: string; visual_prompt?: string }>;
created_at?: string;
updated_at?: string;
};
export type VideoSegment = {
@ -90,31 +94,41 @@ export type VideoSegment = {
error_message: string;
adopted_version: string | null;
adopted_asset?: string | null;
adopted_asset_url?: string;
};
export type StoryboardVersion = {
id: string;
prompt: string;
is_adopted: boolean;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string }>;
frames: Array<{ id: string; sort_order: number; prompt: string; asset: string; asset_url?: string }>;
created_at: string;
updated_at: string;
};
export type ExportPoll = {
status: string;
progress: number;
output_asset?: string | null;
output_url: string;
error_message?: string;
};
export type Timeline = {
id: string;
name: string;
aspect_ratio: string;
resolution: string;
duration_seconds: number;
clips: Array<{ id: string; asset: string; sort_order: number; start_ms: number; duration_ms: number }>;
metadata?: { transition?: { type: string }; draft?: Record<string, unknown> };
clips: Array<{ id: string; asset: string; asset_url?: string; asset_is_video?: boolean; sort_order: number; start_ms: number; duration_ms: number; trim_start_ms?: number; trim_end_ms?: number | null }>;
subtitle_tracks?: Array<{
id: string;
content: Array<{ start_ms: number; text: string }>;
style?: Record<string, unknown>;
enabled: boolean;
}>;
bgm_tracks?: Array<{ id: string; asset: string; volume: number; start_ms: number }>;
bgm_tracks?: Array<{ id: string; asset: string; asset_url?: string; asset_name?: string; volume: number; start_ms: number }>;
export_jobs?: Array<{
id: string;
status: string;
@ -126,6 +140,14 @@ export type Timeline = {
}>;
};
export type TimelineSavePayload = {
clips?: Array<{ asset: string; duration_ms: number; trim_start_ms?: number; trim_end_ms?: number | null }>;
subtitle?: { enabled?: boolean; style_key?: string; content?: Array<{ start_ms: number; text: string }> };
bgm?: { volume?: number; clear?: boolean };
transition?: { type: string };
draft?: Record<string, unknown>;
};
export type Project = {
id: string;
name: string;
@ -141,7 +163,9 @@ export type Project = {
kind: string;
prompt: string;
adopted_asset: string | null;
adopted_asset_url?: string;
candidate_assets: string[];
candidate_asset_urls?: Record<string, string>;
}>;
storyboard_versions: StoryboardVersion[];
timeline: Timeline | null;
@ -168,6 +192,32 @@ export type Ledger = {
created_at: string;
};
export type UserPreference = {
notify: Record<string, boolean>;
two_factor_enabled: boolean;
creation_defaults: { template: string; duration: string; subtitle: string; bgm: string; transition: string };
display: { appearance: string; language: string; density: string };
updated_at?: string;
};
export type LoginSession = {
id: string;
user_agent: string;
ip_address: string | null;
last_seen_at: string;
created_at: string;
is_current: boolean;
};
export type BillingTrend = {
daily: Array<{ date: string; label: string; amount: string }>;
total_14d: string;
avg: string;
peak: string;
by_stage: { script: string; base: string; storyboard: string; video: string };
by_project: Record<string, string>;
};
export type ModelConfig = {
id: string;
name: string;