后端生成闸+多项修复;前端全站更新;QA 审计与报告

后端:
- 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口
  一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失
- 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复
- 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令
- 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令)

前端:全站页面更新(pipeline/library/products/projects/team/account 等),
新增共享 pager 分页组件

QA:刷新 function-audit 全量输出,新增 full-qa 报告
文档:BP 产品介绍资料、design/CLAUDE.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-15 10:06:15 +08:00
co-authored by Claude Fable 5
parent 890cb9ab67
commit 216a711291
92 changed files with 5114 additions and 2027 deletions
+6
View File
@@ -22,3 +22,9 @@ TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ
VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DEFAULT_TRIAL_CREDITS=1000.0000
YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
# 豆包语音合成(旁白配音 TTS)· 火山控制台-语音技术-语音合成
VOLC_TTS_APPID=8945759494
VOLC_TTS_ACCESS_TOKEN=w7Ye8FdTHADU05PV5cVNjud8FseOnYzR
+2
View File
@@ -24,3 +24,5 @@ TOS_SECRET_ACCESS_KEY=change-me
VOLCANO_ARK_API_KEY=change-me
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
YUNQI_API_KEY=change-me
YUNQI_BASE_URL=https://www.yunqiai.chat/v1
+14
View File
@@ -172,4 +172,18 @@ VOLCANO = {
"ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
}
# 豆包语音合成(旁白配音 TTS)。与 ARK 不是同一套钥匙:
# 火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用,取 APPID 和 Access Token
VOLC_TTS = {
"appid": env("VOLC_TTS_APPID", ""),
"access_token": env("VOLC_TTS_ACCESS_TOKEN", ""),
"cluster": env("VOLC_TTS_CLUSTER", "volcano_tts"),
"base_url": env("VOLC_TTS_BASE_URL", "https://openspeech.bytedance.com/api/v1/tts"),
}
YUNQI = {
"api_key": env("YUNQI_API_KEY"),
"base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
+12 -3
View File
@@ -1,6 +1,6 @@
from rest_framework import serializers
from apps.billing.models import CreditAccount
from apps.billing.models import CreditAccount, CreditLedger
from .models import LoginSession, Team, TeamMember, User, UserPreference
@@ -82,9 +82,18 @@ class RegisterSerializer(serializers.Serializer):
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(
trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS))
CreditAccount.objects.create(team=team, balance=trial)
# 开户赠送额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(余额从第一条起就可追溯)
if trial > 0:
CreditLedger.objects.create(
team=team,
balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)),
user=user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=trial,
balance_after=trial,
reason="新用户试用额度赠送",
metadata={"kind": "trial_grant"},
)
return {"user": user, "team": team}
+21 -1
View File
@@ -1,8 +1,11 @@
from decimal import Decimal
from django.conf import settings
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.billing.models import CreditAccount
from apps.billing.models import CreditAccount, CreditLedger
class AuthApiTests(TestCase):
@@ -27,3 +30,20 @@ class AuthApiTests(TestCase):
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
self.assertTrue(CreditAccount.objects.filter(team=team).exists())
def test_register_trial_grant_is_recorded_in_ledger(self):
"""开户赠送额度必须进流水:余额=赠送额,且有一条 balance_after=赠送额的 genesis 流水,账目从第一条起可对账。"""
client = APIClient()
client.post(
"/api/auth/register/",
{"username": "ledger-owner", "password": "strong-password", "team_name": "Ledger Team"},
format="json",
)
team = Team.objects.get(name="Ledger Team")
trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS))
account = CreditAccount.objects.get(team=team)
self.assertEqual(account.balance, trial)
genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE)
self.assertEqual(genesis.count(), 1)
self.assertEqual(genesis.first().amount, trial)
self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账
+16
View File
@@ -4,6 +4,22 @@ VOLCANO_PROVIDER = {
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
}
YUNQI_PROVIDER = {
"name": "yunqi",
"display_name": "YunQi AI(New API 网关)",
"base_url": "https://www.yunqiai.chat/v1",
}
YUNQI_MODELS = [
{
"display_name": "GPT-Image-2",
"name": "gpt-image-2",
"capability": "image",
"endpoint": "images/generations",
"metadata": {"modes": ["text"], "response_format": "b64_json", "source": "https://www.yunqiai.chat"},
},
]
VOLCANO_MODELS = [
{
"display_name": "Doubao-Seed-2.0-Pro",
@@ -0,0 +1,46 @@
# Generated by Django 5.1.15 on 2026-06-11 07:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("ai", "0002_initial"),
]
operations = [
migrations.AlterField(
model_name="aitask",
name="task_type",
field=models.CharField(
choices=[
("script_generation", "Script Generation"),
("script_optimization", "Script Optimization"),
("product_image", "Product Image"),
("person_image", "Person Image"),
("scene_image", "Scene Image"),
("storyboard", "Storyboard"),
("video_segment", "Video Segment"),
("voiceover", "Voiceover"),
("export", "Export"),
],
max_length=48,
),
),
migrations.AlterField(
model_name="modelconfig",
name="capability",
field=models.CharField(
choices=[
("text", "Text"),
("image", "Image"),
("video", "Video"),
("vision", "Vision"),
("audio", "Audio"),
("export", "Export"),
],
max_length=32,
),
),
]
@@ -0,0 +1,36 @@
# 旁白配音(TTS):给 volcengine provider 补一条语音合成 ModelConfig。
# unit_price=0 时计费走 estimate_cost 默认 ¥1/次(一次=整批旁白合成),与其它能力同口径。
from django.db import migrations
def seed_tts_model(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
provider = ModelProvider.objects.filter(name="volcengine").first()
if provider is None:
return
ModelConfig.objects.get_or_create(
provider=provider,
name="doubao-voice-bigtts",
defaults={
"display_name": "豆包语音合成",
"capability": "audio",
"endpoint": "api/v1/tts",
"unit_price": "0.0000",
},
)
def unseed_tts_model(apps, schema_editor):
ModelConfig = apps.get_model("ai", "ModelConfig")
ModelConfig.objects.filter(name="doubao-voice-bigtts").delete()
class Migration(migrations.Migration):
dependencies = [
("ai", "0003_alter_aitask_task_type_alter_modelconfig_capability"),
]
operations = [
migrations.RunPython(seed_tts_model, unseed_tts_model),
]
+2
View File
@@ -24,6 +24,7 @@ class ModelConfig(TimeStampedModel):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
VISION = "vision", "Vision"
AUDIO = "audio", "Audio"
EXPORT = "export", "Export"
class Status(models.TextChoices):
@@ -56,6 +57,7 @@ class AITask(TeamOwnedModel):
SCENE_IMAGE = "scene_image", "Scene Image"
STORYBOARD = "storyboard", "Storyboard"
VIDEO_SEGMENT = "video_segment", "Video Segment"
VOICEOVER = "voiceover", "Voiceover"
EXPORT = "export", "Export"
class Status(models.TextChoices):
+3 -3
View File
@@ -1,6 +1,6 @@
from .base import AIProvider, AIProviderResult
from .volcano import VolcanoArkProvider
from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider
from .yunqi import YunqiProvider
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]
__all__ = ["AIProvider", "AIProviderResult", "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", "YunqiProvider"]
+58
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass
import base64
import uuid
from io import BytesIO
from typing import Any
@@ -171,3 +172,60 @@ class VolcanoArkProvider:
content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream"
return BytesIO(base64.b64decode(raw)), content_type
return BytesIO(base64.b64decode(media)), "image/png"
class TtsNotConfigured(ValueError):
"""语音合成凭证未配置(VOLC_TTS_APPID / VOLC_TTS_ACCESS_TOKEN)。"""
@dataclass
class VolcanoTtsProvider:
"""豆包语音合成大模型(openspeech V1 HTTP,非流式)。
注意:与 ARK 不是同一套钥匙,要在 火山引擎控制台→语音技术→语音合成大模型 创建应用拿 APPID/Access Token。"""
appid: str | None = None
access_token: str | None = None
cluster: str | None = None
base_url: str | None = None
def __post_init__(self) -> None:
cfg = getattr(settings, "VOLC_TTS", {}) or {}
self.appid = self.appid or cfg.get("appid")
self.access_token = self.access_token or cfg.get("access_token")
self.cluster = self.cluster or cfg.get("cluster") or "volcano_tts"
self.base_url = self.base_url or cfg.get("base_url") or "https://openspeech.bytedance.com/api/v1/tts"
@property
def configured(self) -> bool:
return bool(self.appid and self.access_token)
def synthesize(self, *, text: str, voice_type: str, speed_ratio: float = 1.0, uid: str = "airshelf") -> tuple[bytes, int]:
"""合成一段语音。返回 (mp3 字节, 时长毫秒;接口没回时长则为 0)。"""
if not self.configured:
raise TtsNotConfigured(
"语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN"
"(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)"
)
body = {
"app": {"appid": self.appid, "token": self.access_token, "cluster": self.cluster},
"user": {"uid": uid},
"audio": {"voice_type": voice_type, "encoding": "mp3", "speed_ratio": float(speed_ratio or 1.0)},
"request": {"reqid": str(uuid.uuid4()), "text": text, "operation": "query"},
}
response = requests.post(
self.base_url,
headers={"Authorization": f"Bearer;{self.access_token}"},
json=body,
timeout=60,
)
response.raise_for_status()
data = response.json()
if data.get("code") != 3000 or not data.get("data"):
raise ValueError(f"语音合成失败:{data.get('message') or data.get('code')}")
audio = base64.b64decode(data["data"])
duration_ms = 0
try:
duration_ms = int(float((data.get("addition") or {}).get("duration") or 0))
except (TypeError, ValueError):
duration_ms = 0
return audio, duration_ms
+43
View File
@@ -0,0 +1,43 @@
from typing import Any
import requests
from django.conf import settings
from .volcano import VolcanoArkProvider
class YunqiProvider(VolcanoArkProvider):
"""YunQi AI(New API 网关)适配器:OpenAI 兼容 images/generations。
与火山 ARK 的差异:不接受 watermark/sequential_image_generation/response_format 私有参数;
size 用 OpenAI 枚举(1024x1024/1024x1536/1536x1024);gpt-image-2 固定返回 b64_json,
下游 extract_first_media_url / media_to_bytes 已兼容 base64,无需改动。
"""
def __post_init__(self) -> None:
self.api_key = self.api_key or settings.YUNQI.get("api_key")
self.base_url = self.base_url or settings.YUNQI.get("base_url")
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "1024x1536",
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("YUNQI_API_KEY is not configured")
if image:
raise ValueError("gpt-image-2 via YunQi only supports text-to-image; reference image is not supported")
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
# 实测该网关生图平均延迟 75s+,超时须显著高于火山的 180s
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=300,
)
response.raise_for_status()
return response.json()
+224 -38
View File
@@ -10,7 +10,7 @@ from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import VolcanoArkProvider
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
@@ -22,6 +22,7 @@ from apps.projects.models import (
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
Timeline,
VideoSegment,
VideoSegmentVersion,
)
@@ -36,6 +37,13 @@ def get_default_model(capability: str) -> ModelConfig:
)
def get_image_provider(model_config: ModelConfig):
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
if model_config.provider.name == "yunqi":
return YunqiProvider(base_url=model_config.provider.base_url or None)
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
@@ -48,7 +56,9 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
"拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n"
"镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n"
"镜头2\n旁白:…\n画面:…(依此类推到镜头4)"
)
user = f"""
商品标题:{product.title}
@@ -65,6 +75,42 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def parse_segment_fields(block: str) -> tuple[str, str]:
"""从一镜文本里拆出(旁白, 画面)。
模型按 build_script_prompt 的格式输出「旁白:…/画面:…」标签行时精确拆分;
自带脚本/旧格式没有标签则两个字段都用整段(保持旧行为),字幕/故事板各自兜底。
"""
narration_lines: list[str] = []
visual_lines: list[str] = []
current: list[str] | None = None
for raw in (block or "").splitlines():
line = raw.strip()
if not line:
continue
matched = re.match(r"^(旁白|口播|台词|文案)\s*[::]\s*(.*)$", line)
if matched:
current = narration_lines
if matched.group(2):
current.append(matched.group(2))
continue
matched = re.match(r"^(画面|镜头描述|视觉|画面描述)\s*[::]\s*(.*)$", line)
if matched:
current = visual_lines
if matched.group(2):
current.append(matched.group(2))
continue
if re.match(r"^(镜头|分镜|场)\s*\d+", line):
continue # 「镜头N」标题行不计入任何字段
if current is not None:
current.append(line)
narration = " ".join(narration_lines).strip()
visual = " ".join(visual_lines).strip()
if not narration and not visual:
return block.strip(), block.strip()
return narration or visual, visual or narration
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
@@ -124,7 +170,7 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None, source: str = "ai") -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
@@ -162,16 +208,17 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
task=task,
title="AI 脚本",
content=content,
source="ai",
source=source if source in ("ai", "theme", "manual") else "ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
narration, visual = parse_segment_fields(segment_text)
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=segment_text,
visual_prompt=segment_text,
narration=narration,
visual_prompt=visual,
)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
@@ -283,7 +330,7 @@ def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGr
)
reservation = task.credit_reservation
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
@@ -408,7 +455,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
response = provider.image_generation(
model=model_config.name,
@@ -416,12 +463,9 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
prompt=frame_prompt,
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
asset = _store_generated_media(
team=project.team,
user=user,
@@ -432,6 +476,15 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
category=Asset.Category.SCENE,
asset_type=Asset.Type.IMAGE,
)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
# 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧
if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists():
StoryboardFrame.objects.create(
storyboard=version,
script_segment=segment,
@@ -470,22 +523,30 @@ def generate_storyboard_frame(*, project, user) -> dict:
_finalize_storyboard(project, version)
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
# 该版本内是否已有帧在后台生成中(RESERVED/SUBMITTED 的故事板任务即为「占位锁」)。
# 仅算「近 3 分钟内」的任务:若进程/线程意外中断留下僵尸任务,超时后不再视为在生成,允许重新发起。
# 每镜独立「占位锁」(该镜有 CREATED/RESERVED/SUBMITTED 的任务=在生成中)。
# 旧实现是版本级单锁、一次只生成一帧;改为缺哪几镜就同时起哪几镜的线程。
# ★ 实测注记(2026-06-10):4 线程并发时当前 Seedream 端点在服务侧排队,单帧 25s→83-105s,
# 整版总时长 ≈ 串行(117s)。瓶颈是 ARK 端点并发配额而非本机;并行无额外成本,
# 配额提升后自动受益。可用 settings.STORYBOARD_MAX_PARALLEL 调并发(1=回到串行)。
# 仅算「近 3 分钟内」的任务:线程意外中断留下的僵尸任务超时后不再占锁,允许重新发起。
from django.conf import settings as dj_settings
STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
stale_cutoff = timezone.now() - timedelta(minutes=3)
inflight = AITask.objects.filter(
inflight_segment_ids = {
str(v)
for v in 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)}
).values_list("request_payload__storyboard_segment", flat=True)
if v
}
pending = [s for s in segments if s.id not in done_segment_ids]
segment = pending[0]
# 单帧失败次数上限,避免持续失败时无限重试
# 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败
for segment in pending:
failed_for_segment = AITask.objects.filter(
project=project,
task_type=AITask.Type.STORYBOARD,
@@ -498,7 +559,10 @@ def generate_storyboard_frame(*, project, user) -> dict:
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
"error": last.error_message if last else "storyboard frame failed"}
spawnable = [s for s in pending if str(s.id) not in inflight_segment_ids]
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_segment_ids))
model_config = get_default_model(ModelConfig.Capability.IMAGE)
for segment in spawnable[:slots]:
task = create_ai_task(
project=project,
user=user,
@@ -656,16 +720,17 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
if video_segment.status == VideoSegment.Status.FAILED:
return None
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
ai_task = task.task
if ai_task is None:
# ★ 先找「在途任务」再回退旧版本的任务。旧实现反过来:重跑时段上已有(旧)版本,
# 取到旧版本挂的已成功任务 → 短路返回旧版,在途的新任务永远没人轮询,
# 段永远卡「生成中」、新视频取不回来(实测重跑卡 40 分钟,ARK 侧其实早已生成完)。
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
if ai_task is None:
latest_version = video_segment.versions.order_by("-created_at").first()
ai_task = latest_version.task if latest_version else None
if ai_task is None:
raise ValueError("no active video generation task")
@@ -679,9 +744,11 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
# 仍在生成:只在状态首次进入 POLLING 时落一次库。旧实现每次 poll(5s 一次)都把完整
# response JSON 回写远程 MySQL——纯浪费写带宽,终态时反正会存完整 payload。
if ai_task.status != AITask.Status.POLLING:
ai_task.status = AITask.Status.POLLING
ai_task.response_payload = response
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
ai_task.save(update_fields=["status", "updated_at"])
return None
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
@@ -706,19 +773,29 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
category=Asset.Category.VIDEO_CLIP,
asset_type=Asset.Type.VIDEO,
)
ai_task.status = AITask.Status.SUCCEEDED
ai_task.response_payload = response
ai_task.actual_cost = ai_task.estimated_cost
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
# 终态化必须持锁原子做:两个并发 poll(前端 5s 静默轮询 × 提交后轮询/worker)同时走到这里时,
# 旧实现会同 task 建两个版本 + charge_reserved_credit 双扣费(实测 03:08:25 同秒双版本)。
# select_for_update 锁 task 行,后到者看到 SUCCEEDED 直接回已有版,不再建版/扣费。
with transaction.atomic():
locked_task = AITask.objects.select_for_update().get(id=ai_task.id)
if locked_task.status == AITask.Status.SUCCEEDED:
existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first()
if existing is not None:
return existing
locked_task.status = AITask.Status.SUCCEEDED
locked_task.response_payload = response
locked_task.actual_cost = locked_task.estimated_cost
locked_task.completed_at = timezone.now()
locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=ai_task,
task=locked_task,
asset=asset,
prompt=ai_task.request_payload.get("prompt", ""),
prompt=locked_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.versions.exclude(id=version.id).update(is_adopted=False)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
@@ -750,7 +827,7 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
count = max(1, min(int(count or 1), 4))
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
provider = get_image_provider(model_config)
assets: list[Asset] = []
for index in range(count):
cost = estimate_cost(model_config)
@@ -798,3 +875,112 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
release_credit(reservation=reservation, reason=str(exc))
raise
return assets
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
# 音色按「语音合成(经典版)」试用包实测可用清单配置;大模型音色(*_bigtts)需另开通「语音合成大模型」服务,当前账号 403
VOICEOVER_VOICES = [
{"key": "BV700_streaming", "label": "灿灿 · 活力女声"},
{"key": "BV034_streaming", "label": "知性姐姐 · 沉稳女声"},
{"key": "BV001_streaming", "label": "通用女声"},
{"key": "BV056_streaming", "label": "阳光男声"},
{"key": "BV102_streaming", "label": "儒雅青年 · 解说男声"},
{"key": "BV002_streaming", "label": "通用男声"},
]
DEFAULT_VOICEOVER_VOICE = VOICEOVER_VOICES[0]["key"]
def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type: str, speed_ratio: float = 1.0) -> dict:
"""每镜旁白 → **逐句** TTS 配音资产(一句一段音频,带句内起点 offset_ms),映射写入
timeline.metadata["voiceover"]。逐句才能支持「拖动字幕块 = 字幕和它的语音一起移动」;
一次调用 = 一个 AITask = 计一次费;任何一句失败则整体失败并释放预留(不留半套配音)。"""
from apps.projects.services.export import _split_subtitle_text
texts = [] # (片段 index, 句序 cue, 句文本)
for n, item in enumerate(items or []):
text = str(item.get("text") or "").strip()
if not text:
continue
idx = int(item.get("index", n))
pieces = _split_subtitle_text(text) or [text]
for j, piece in enumerate(pieces):
texts.append((idx, j, piece))
if not texts:
raise ValueError("没有可配音的旁白文本")
provider = VolcanoTtsProvider()
if not provider.configured:
raise TtsNotConfigured(
"语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN"
"(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)"
)
voice_type = voice_type or DEFAULT_VOICEOVER_VOICE
model_config = get_default_model(ModelConfig.Capability.AUDIO)
if model_config is None:
raise ValueError("no active audio model configured")
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VOICEOVER,
model_config=model_config,
request_payload={
"voice_type": voice_type,
"speed_ratio": float(speed_ratio or 1.0),
"items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts],
},
)
reservation = task.credit_reservation
try:
synthesized = []
for idx, j, text in texts:
audio, duration_ms = provider.synthesize(text=text, voice_type=voice_type, speed_ratio=speed_ratio, uid=str(user.id))
synthesized.append((idx, j, text, audio, duration_ms))
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.response_payload = {"segments": len(synthesized)}
task.save(update_fields=["status", "actual_cost", "completed_at", "response_payload", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
vo_items = []
offset_acc: dict[int, int] = {} # 同一片段内逐句顺排:句 j 的默认起点 = 前面句时长之和
for idx, j, text, audio, duration_ms in synthesized:
asset_id = uuid.uuid4()
object_key = f"teams/{project.team_id}/projects/{project.id}/voiceover/{asset_id}.mp3"
stored = TosStorage().upload_fileobj(fileobj=BytesIO(audio), object_key=object_key, content_type="audio/mpeg")
asset = Asset.objects.create(
id=asset_id, team=project.team, created_by=user,
name=f"配音 · 场 {idx + 1} · 句 {j + 1}", asset_type=Asset.Type.AUDIO,
source=Asset.Source.AI_GENERATED, category=Asset.Category.UNCATEGORIZED,
origin_task=task, description=text,
)
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,
)
offset_ms = offset_acc.get(idx, 0)
offset_acc[idx] = offset_ms + (duration_ms or 0)
vo_items.append({
"index": idx, "cue": j, "text": text, "asset": str(asset.id),
"duration_ms": duration_ms, "offset_ms": offset_ms,
})
timeline, _ = Timeline.objects.get_or_create(
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
)
metadata = dict(timeline.metadata or {})
metadata["voiceover"] = {
"enabled": True,
"voice_type": voice_type,
"speed_ratio": float(speed_ratio or 1.0),
"items": vo_items,
}
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "updated_at"])
return metadata["voiceover"]
except Exception as exc:
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
+2
View File
@@ -5,6 +5,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet
from apps.assets.serializers import AssetSerializer
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from apps.common.celery_health import require_worker
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
@@ -15,6 +16,7 @@ class GenerateImageView(APIView):
"""POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。"""
def post(self, request):
require_worker()
prompt = str(request.data.get("prompt") or "").strip()
if not prompt:
return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST)
+26
View File
@@ -1,8 +1,11 @@
from pathlib import Path
import uuid
import requests
from django.db import transaction
from django.http import StreamingHttpResponse
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.views import APIView
@@ -21,6 +24,29 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
@action(detail=True, methods=["get"], url_path="raw")
def raw(self, request, pk=None):
"""同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据——
编辑器抽视频缩略图(canvas 会被 taint)/解码音频波形(fetch 被拦)都需要走这里。"""
asset = self.get_object()
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
return Response({"detail": "asset has no file"}, status=status.HTTP_404_NOT_FOUND)
url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=600)
upstream_headers = {}
if request.META.get("HTTP_RANGE"):
upstream_headers["Range"] = request.META["HTTP_RANGE"]
upstream = requests.get(url, headers=upstream_headers, stream=True, timeout=120)
response = StreamingHttpResponse(
upstream.iter_content(chunk_size=256 * 1024),
status=upstream.status_code,
content_type=primary.content_type or "application/octet-stream",
)
for header in ("Content-Length", "Content-Range", "Accept-Ranges"):
if header in upstream.headers:
response[header] = upstream.headers[header]
return response
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@@ -0,0 +1,63 @@
"""释放僵尸预留额度。
生成流程中断(进程被杀/浏览器关闭/轮询停止)会留下 status=ACTIVE 的 CreditReservation,
对应 AITask 永远停在 reserved/submitted/polling——这些预留会一直占用 reserved_balance,
吃掉团队可用额度。本命令把「超过 --hours 小时仍未终态」的预留按正规 release 流程释放
(走 release_credit,有 RELEASE 流水可审计),并把对应任务标记为 FAILED。
用法:
python manage.py sweep_stale_reservations # 默认释放超过 12 小时的
python manage.py sweep_stale_reservations --hours 1 # 收紧窗口
python manage.py sweep_stale_reservations --dry-run # 只看不动
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils import timezone
from apps.ai.models import AITask
from apps.billing.models import CreditReservation
from apps.billing.services.ledger import release_credit
# 这些任务状态意味着流程没走到终态(成功会 charge、失败会 release),超时即视为僵尸
STUCK_TASK_STATUSES = (
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
)
class Command(BaseCommand):
help = "释放超时未终态的 ACTIVE 预留额度(僵尸预留),并把对应任务标记为失败"
def add_arguments(self, parser):
parser.add_argument("--hours", type=int, default=12, help="超过多少小时视为僵尸(默认 12)")
parser.add_argument("--dry-run", action="store_true", help="只列出将要释放的,不实际执行")
def handle(self, *args, **options):
cutoff = timezone.now() - timedelta(hours=options["hours"])
stale = (
CreditReservation.objects.filter(status=CreditReservation.Status.ACTIVE, created_at__lt=cutoff)
.select_related("task")
.order_by("created_at")
)
released = 0
for reservation in stale:
task = reservation.task
if task is not None and task.status not in STUCK_TASK_STATUSES:
# 任务已终态却仍 ACTIVE:同样是泄漏,照常释放
pass
label = f"resv {str(reservation.id)[:8]} ¥{reservation.amount} task={str(task.id)[:8] if task else '-'}({task.status if task else '-'}) created={reservation.created_at:%m-%d %H:%M}"
if options["dry_run"]:
self.stdout.write(f"[dry-run] would release {label}")
continue
release_credit(reservation=reservation, reason=f"僵尸预留清扫(>{options['hours']}h 未终态)")
if task is not None and task.status in STUCK_TASK_STATUSES:
task.status = AITask.Status.FAILED
task.error_message = "stale task swept: 流程中断超时,预留已释放"
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
released += 1
self.stdout.write(f"released {label}")
self.stdout.write(self.style.SUCCESS(f"done: released {released} stale reservation(s)"))
@@ -1,16 +1,46 @@
from decimal import Decimal
from django.db import transaction
from django.db.models import Sum
from django.utils import timezone
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None:
"""成员月度限额管控。团队页可设 monthly_credit_limit(0=不限),此前只存不管——纯装饰。
口径与成员列表的 month_charged 一致:自然月内该成员的 CHARGE 流水合计;
再加上其当前 ACTIVE 预留(在途任务),防止并发把限额冲穿。
调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,此检查无竞态。"""
if user is None:
return
member = team.members.filter(user=user).first()
if member is None:
return
limit = member.monthly_credit_limit or Decimal("0")
if limit <= 0:
return # 0 = 不限额
now = timezone.now()
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
charged = (
CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start)
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
)
reserved = (
CreditReservation.objects.filter(team=team, user=user, status=CreditReservation.Status.ACTIVE)
.aggregate(s=Sum("amount"))["s"] or Decimal("0")
)
if charged + reserved + amount > limit:
raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})")
@transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
available = account.balance - account.reserved_balance
if available < amount:
raise ValueError("insufficient credit")
_enforce_member_monthly_limit(team=team, user=user, amount=amount)
account.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"])
@@ -37,6 +67,9 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
@transaction.atomic
def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
# 锁内重读:与 charge 同款竞态——并发双 release(或 charge+release 交错)用陈旧快照都看到 ACTIVE,
# reserved_balance 会被减两次直接记坏账。已终态(已扣/已释放)→ 幂等返回。
reservation = CreditReservation.objects.select_for_update().get(id=reservation.id)
if reservation.status != CreditReservation.Status.ACTIVE:
return
@@ -59,6 +92,11 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
@transaction.atomic
def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
# 锁内重读 reservation:调用方传入的可能是并发竞态下的陈旧快照(双方都看到 ACTIVE),
# 旧实现会对同一笔预留扣两次费(实测同秒两条 charge 流水)。已扣费 → 幂等直接返回。
reservation = CreditReservation.objects.select_for_update().get(id=reservation.id)
if reservation.status == CreditReservation.Status.CHARGED:
return
if reservation.status != CreditReservation.Status.ACTIVE:
raise ValueError("reservation is not active")
if actual_amount > reservation.amount:
+29
View File
@@ -1,6 +1,7 @@
from decimal import Decimal
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
@@ -58,3 +59,31 @@ class CreditLedgerTests(TestCase):
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
class RechargePermissionTests(TestCase):
"""充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。"""
def setUp(self):
self.owner = User.objects.create_user(username="r-owner", password="ownerpass123")
self.member = User.objects.create_user(username="r-member", password="memberpass123")
self.team = Team.objects.create(name="Recharge Team", owner=self.owner)
TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER, status=TeamMember.Status.ACTIVE)
TeamMember.objects.create(team=self.team, user=self.member, role=TeamMember.Role.MEMBER, status=TeamMember.Status.ACTIVE)
CreditAccount.objects.create(team=self.team, balance=Decimal("0.0000"))
def test_owner_can_recharge(self):
client = APIClient()
client.force_authenticate(self.owner)
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
self.assertEqual(response.status_code, 201)
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(account.balance, Decimal("100.0000"))
def test_member_cannot_recharge(self):
client = APIClient()
client.force_authenticate(self.member)
response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json")
self.assertEqual(response.status_code, 403)
account = CreditAccount.objects.get(team=self.team)
self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦
+4 -1
View File
@@ -11,7 +11,7 @@ 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 apps.common.api import can_manage_team, get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
@@ -83,6 +83,9 @@ def ledgers(request):
@permission_classes([IsAuthenticated])
def recharge(request):
team = get_current_team(request.user)
# 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
try:
amount = Decimal(str(request.data.get("amount", "0")))
bonus = Decimal(str(request.data.get("bonus", "0")))
+6
View File
@@ -8,6 +8,12 @@ def get_current_team(user):
return membership.team
def can_manage_team(user, team):
"""团队管理权限:仅 owner / admin 可做充值、改限额、增删成员等管理动作。"""
membership = user.team_memberships.filter(team=team, status="active").first()
return bool(membership and membership.role in {"owner", "admin"})
class TeamScopedViewSetMixin:
team_field = "team"
+57
View File
@@ -0,0 +1,57 @@
"""Celery worker 在线探测 —— 生成类任务的提交前置闸。
设计决策(2026-06-10):无 worker 时**禁止提交**图片/视频生成,而非静默降级到前端轮询。
旧行为的风险:提交到 ARK 后浏览器一关就无人轮询,生成结果悬在云端无人取回入库、
额度预扣长期冻结——等于数据丢失。额度预检挡"钱不够",这道闸挡"环境不齐"。
探测用 control.ping 广播,结果带缓存:在线缓存 30s(别让每次提交都付 ping 开销),
离线只缓存 5s(worker 一启动尽快解封)。CELERY_TASK_ALWAYS_EAGER(测试/同步模式)
下任务就地执行不需要 worker,直接放行。
"""
import time
from django.conf import settings
from rest_framework.exceptions import APIException
class WorkerUnavailable(APIException):
status_code = 503
default_detail = (
"后台任务服务(Celery worker)未运行:生成结果将无人取回入库,可能造成数据丢失。"
"已暂停生成功能,请启动 worker 后重试(celery -A airshelf worker)。"
)
default_code = "worker_unavailable"
_OK_TTL = 30.0
_FAIL_TTL = 5.0
_cache = {"ok": False, "expires": 0.0}
def _ping_workers() -> bool:
from airshelf.celery import app as celery_app
# limit=1:收到第一个 worker 回包立即返回,不傻等满 timeout
return bool(celery_app.control.ping(timeout=1.0, limit=1))
def celery_worker_available() -> bool:
if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False):
return True
now = time.monotonic()
if now < _cache["expires"]:
return _cache["ok"]
try:
ok = _ping_workers()
except Exception: # noqa: BLE001 — broker 不可达等同于无 worker,一样要拦
ok = False
_cache["ok"] = ok
_cache["expires"] = now + (_OK_TTL if ok else _FAIL_TTL)
return ok
def require_worker() -> None:
"""生成类提交入口的前置检查:无 worker 直接 503,不让任务出门。"""
if not celery_worker_available():
raise WorkerUnavailable()
@@ -0,0 +1,45 @@
from django.core.management.base import BaseCommand
from apps.ai.catalog import VOLCANO_PROVIDER, YUNQI_MODELS, YUNQI_PROVIDER
from apps.ai.models import ModelConfig, ModelProvider
class Command(BaseCommand):
help = "Create or update YunQi (New API) provider with gpt-image-2, and disable Volcano image models."
def handle(self, *args, **options):
provider, _ = ModelProvider.objects.update_or_create(
name=YUNQI_PROVIDER["name"],
defaults={
"display_name": YUNQI_PROVIDER["display_name"],
"base_url": YUNQI_PROVIDER["base_url"],
"status": ModelProvider.Status.ACTIVE,
},
)
count = 0
for item in YUNQI_MODELS:
ModelConfig.objects.update_or_create(
provider=provider,
name=item["name"],
capability=item["capability"],
defaults={
"display_name": item["display_name"],
"endpoint": item["endpoint"],
"status": ModelConfig.Status.ACTIVE,
"metadata": item["metadata"],
},
)
count += 1
# 全站生图切到 YunQi:火山 image 模型停用,文本/视频模型保持不动
disabled = ModelConfig.objects.filter(
provider__name=VOLCANO_PROVIDER["name"],
capability=ModelConfig.Capability.IMAGE,
).update(status=ModelConfig.Status.DISABLED)
self.stdout.write(
self.style.SUCCESS(
f"Bootstrapped {count} YunQi model config(s); disabled {disabled} Volcano image model(s)."
)
)
+47 -3
View File
@@ -1,3 +1,5 @@
import uuid
from rest_framework import serializers
from apps.assets.serializers import AssetFileSerializer
@@ -40,10 +42,11 @@ class ProjectStageSerializer(serializers.ModelSerializer):
class VideoSegmentSerializer(serializers.ModelSerializer):
adopted_asset = serializers.SerializerMethodField()
adopted_asset_url = serializers.SerializerMethodField()
versions = serializers.SerializerMethodField()
class Meta:
model = VideoSegment
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url"]
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url", "versions"]
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
def get_adopted_asset(self, obj):
@@ -55,6 +58,21 @@ class VideoSegmentSerializer(serializers.ModelSerializer):
version = obj.adopted_version
return _asset_preview_url(version.asset) if version is not None else ""
def get_versions(self, obj):
# 视频详情弹窗:历史版本(倒序,带可播放 URL)。模型无 Meta.ordering,这里按 created_at 排
versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True)
return [
{
"id": str(v.id),
"asset": str(v.asset_id) if v.asset_id else None,
"asset_url": _asset_preview_url(v.asset) if v.asset_id else "",
"prompt": v.prompt,
"is_adopted": v.is_adopted,
"created_at": v.created_at.isoformat() if v.created_at else "",
}
for v in versions
]
class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
@@ -167,11 +185,37 @@ class TimelineSerializer(serializers.ModelSerializer):
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
subtitle_tracks = SubtitleTrackSerializer(many=True, read_only=True)
bgm_tracks = BgmTrackSerializer(many=True, read_only=True)
voiceover = serializers.SerializerMethodField()
class Meta:
model = Timeline
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
read_only_fields = ["id", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
read_only_fields = ["id", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
def get_voiceover(self, obj):
# 旁白配音映射(metadata.voiceover)+ 每段新鲜的可播放 URL(TOS 签名 URL 会过期,不能存死)
vo = (obj.metadata or {}).get("voiceover")
if not isinstance(vo, dict) or not vo.get("items"):
return None
from apps.assets.models import Asset
ids = []
for item in vo["items"]:
try:
ids.append(uuid.UUID(str(item.get("asset"))))
except (TypeError, ValueError):
continue
assets = {str(a.id): a for a in Asset.objects.filter(id__in=ids).prefetch_related("files")}
items = []
for item in vo["items"]:
asset = assets.get(str(item.get("asset")))
items.append({**item, "asset_url": _asset_preview_url(asset)})
return {
"enabled": bool(vo.get("enabled")),
"voice_type": str(vo.get("voice_type") or ""),
"speed_ratio": vo.get("speed_ratio", 1.0),
"items": items,
}
class ExportJobSerializer(serializers.ModelSerializer):
+180 -17
View File
@@ -139,17 +139,49 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
def _probe_fps(path: Path) -> float:
"""探测视频帧率(avg_frame_rate)。失败返回 0 由调用方兜底。"""
try:
proc = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
"stream=avg_frame_rate", "-of", "csv=p=0", str(path)],
capture_output=True, timeout=60,
)
raw = proc.stdout.decode("utf-8", "ignore").strip().splitlines()[0] if proc.stdout else ""
num, _, den = raw.partition("/")
fps = float(num) / float(den or 1)
return fps if 1.0 <= fps <= 120.0 else 0.0
except Exception: # noqa: BLE001
return 0.0
def _pick_output_fps(fps_list: list[float]) -> float:
"""输出帧率取各片段帧率的众数(并列取更高)。
源 24fps 被硬转 30fps 会按 4:5 不均匀复帧——成片肉眼可见一卡一顿;帧率跟随源才是帧帧对应。"""
valid = [round(f, 3) for f in fps_list if f > 0]
if not valid:
return 30.0
counts: dict[float, int] = {}
for f in valid:
counts[f] = counts.get(f, 0) + 1
best = max(counts.items(), key=lambda kv: (kv[1], kv[0]))
return best[0]
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,
has_audio: list[bool] | None = None) -> list[str]:
has_audio: list[bool] | None = None, fps: float = 30.0,
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
has_audio = has_audio or [False] * n
voice_overlays = voice_overlays or []
fps_expr = f"{fps:.6g}"
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) + "]"
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
@@ -175,7 +207,7 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
want_audio = any(has_audio) or bool(bgm_name)
want_audio = any(has_audio) or bool(bgm_name) or bool(voice_overlays)
audio_label: str | None = None
if want_audio:
for i, s in enumerate(specs):
@@ -191,9 +223,23 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
audio_label = "avoice"
# 旁白配音(TTS):每段延迟到所属片段在输出时间轴的起点,裁到片段时长,叠在基础音轨之上
if voice_overlays:
vo_base = n + (1 if bgm_name else 0) + len(sub_overlays)
for j, (_name, vstart, vdur) in enumerate(voice_overlays):
delay_ms = max(0, int(round(vstart * 1000)))
parts.append(
f"[{vo_base + j}:a]atrim=0:{vdur:.3f},asetpts=PTS-STARTPTS,{_AFMT},"
f"adelay={delay_ms}|{delay_ms}[vo{j}]"
)
vo_inputs = "".join(f"[vo{j}]" for j in range(len(voice_overlays)))
parts.append(
f"[avoice]{vo_inputs}amix=inputs={len(voice_overlays) + 1}:duration=first:dropout_transition=0:normalize=0[anarr]"
)
audio_label = "anarr"
if bgm_name:
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
parts.append(f"[{audio_label}][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
audio_label = "aout"
cmd = ["ffmpeg", "-y"]
@@ -203,10 +249,12 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
cmd += ["-stream_loop", "-1", "-i", bgm_name]
for png, _s, _e in sub_overlays:
cmd += ["-loop", "1", "-i", png]
for name, _vs, _vd in voice_overlays:
cmd += ["-i", name]
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
if audio_label:
cmd += ["-map", f"[{audio_label}]"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps_expr, "-preset", "veryfast"]
if audio_label:
cmd += ["-c:a", "aac", "-b:a", "192k"]
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
@@ -232,23 +280,113 @@ def run_export_job_in_thread(export_job_id: str) -> None:
threading.Thread(target=_worker, daemon=True).start()
def _split_subtitle_text(text: str) -> list[str]:
"""整段旁白 → 短句列表(与前端 splitSubtitleCues 同规则):
硬标点(。!?;…)必切;长句(≥12 字)在逗号处再切;过短碎句(<5 字)并入前句;去尾部逗号句号。"""
clean = " ".join(str(text or "").split())
if not clean:
return []
hard = "\u3002\uff01\uff1f!?\uff1b;\u2026" # 。!?!?;;… 全角+半角
soft = "\uff0c,\u3001" # ,,、
parts: list[str] = []
cur = ""
for ch in clean:
cur += ch
if ch in hard or (ch in soft and len(cur) >= 12):
parts.append(cur)
cur = ""
if cur.strip():
parts.append(cur)
merged: list[str] = []
for raw in parts:
p = raw.strip()
if not p:
continue
core = sum(1 for c in p if c not in hard and c not in soft)
if merged and core < 5:
merged[-1] += p
else:
merged.append(p)
out: list[str] = []
for p in merged:
p = p.rstrip("\uff0c\u3002\uff1b,;\u3001")
if p:
out.append(p)
return out
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目:文本取 SubtitleTrack.content,空则回退脚本旁白;时间按输出布局(对 xfade 也对齐)。"""
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
无保存字幕时回退脚本旁白,逐段按句切分铺满片段时长。旧行为是整段旁白糊满 15s(切割错误)。"""
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")]
in_starts: list[float] = []
acc = 0.0
for s in specs:
in_starts.append(acc)
acc += s["dur"]
def clip_index(t_in: float) -> int:
for k in range(len(specs)):
if t_in < in_starts[k] + specs[k]["dur"]:
return k
return len(specs) - 1
# 旁白配音时长(秒)按片段索引取:有配音时字幕窗口必须跟语音走,而不是铺满片段
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
vo_durs: dict[int, float] = {}
if vo_meta.get("enabled"):
for item in vo_meta.get("items") or []:
try:
vo_idx = int(item.get("index", -1))
vo_dur = float(item.get("duration_ms") or 0) / 1000.0
except (TypeError, ValueError):
continue
if vo_idx >= 0 and vo_dur > 0:
vo_durs[vo_idx] = vo_dur
cues: list[tuple[float, float, str]] = []
content = [c for c in (track.content or []) if str((c or {}).get("text", "")).strip()]
if content:
entries = sorted(content, key=lambda c: int((c or {}).get("start_ms", 0) or 0))
outs: list[tuple[float, int, str, float | None]] = []
for c in entries:
t_in = int(c.get("start_ms", 0) or 0) / 1000.0
i = clip_index(t_in)
offset = max(0.0, min(specs[i]["dur"], t_in - in_starts[i]))
# 新格式 cue 自带 end_ms(已对齐配音语速),同样重映射到输出时间轴;旧草稿无 end_ms 走相邻推断
out_end: float | None = None
if c.get("end_ms"):
e_off = max(0.0, min(specs[i]["dur"], int(c["end_ms"]) / 1000.0 - in_starts[i]))
out_end = starts[i] + e_off
outs.append((starts[i] + offset, i, str(c.get("text", "")).strip(), out_end))
for j, (start, i, text, out_end) in enumerate(outs):
if out_end is None:
if j + 1 < len(outs) and outs[j + 1][1] == i:
out_end = outs[j + 1][0]
else:
out_end = min(total, starts[i] + specs[i]["dur"])
cues.append((start, max(start + 0.5, min(total, out_end)), text))
return cues
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")] if script else []
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))
pieces = _split_subtitle_text(texts[i] if i < len(texts) else "")
if not pieces:
continue
span = (starts[i + 1] if i + 1 < len(starts) else total) - starts[i]
if i in vo_durs:
span = min(span, vo_durs[i])
total_chars = sum(len(p) for p in pieces) or 1
acc_chars = 0
for p in pieces:
start = starts[i] + (acc_chars / total_chars) * span
acc_chars += len(p)
end = starts[i] + (acc_chars / total_chars) * span
cues.append((start, max(start + 0.5, end), p))
return cues
@@ -279,6 +417,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
# 输出帧率跟随源帧率众数(Seedance 出 24fps,硬转 30fps 会不均匀复帧=成片一卡一顿)
output_fps = _pick_output_fps([_probe_fps(tmp / f"clip{index}.mp4") for index in range(len(clips))])
bgm_name = None
if bgm_track is not None and bgm_track.asset_id:
@@ -294,13 +434,36 @@ def run_export_job(export_job_id: str) -> ExportJob:
_render_subtitle_png(text, style_key, tmp / png)
sub_overlays.append((png, start, end))
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
# 逐句条目带 offset_ms(句内起点,可被拖动调整),输出位置 = 片段输出起点 + 句内偏移
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
voice_overlays: list[tuple[str, float, float]] = []
if vo_meta.get("enabled") and isinstance(vo_meta.get("items"), list):
for j, item in enumerate(vo_meta["items"]):
try:
seg_index = int(item.get("index", -1))
offset_s = max(0.0, float(item.get("offset_ms") or 0) / 1000.0)
except (TypeError, ValueError):
continue
if seg_index < 0 or seg_index >= len(clips) or not item.get("asset"):
continue
remain = specs[seg_index]["dur"] - offset_s
if remain <= 0.05:
continue # 句子被拖到片段尾外,导出时丢弃(预览同样不响)
vo_asset = Asset.objects.filter(team=project.team, id=item["asset"]).first()
if vo_asset is None:
continue
vo_name = f"vo{j}.mp3"
_download_asset_primary_file(vo_asset, tmp / vo_name)
voice_overlays.append((vo_name, starts[seg_index] + offset_s, remain))
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,
has_audio=has_audio,
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
)
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
if proc.returncode != 0:
+215 -1
View File
@@ -5,9 +5,19 @@ from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.assets.models import Asset
from apps.billing.models import CreditAccount, CreditLedger
from apps.products.models import Product
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
from apps.projects.models import (
Project,
ProjectStage,
ScriptVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectApiTests(TestCase):
@@ -100,3 +110,207 @@ class ProjectApiTests(TestCase):
self.assertEqual(script.segments.count(), 4)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
def test_adopt_video_version_remaps_timeline_draft(self):
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
make_asset = lambda name: Asset.objects.create(
team=self.team, created_by=self.user, name=name,
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
)
old_asset, new_asset = make_asset("v1"), make_asset("v2")
segment = VideoSegment.objects.create(project=project, sort_order=0)
old_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=old_asset, is_adopted=True)
new_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=new_asset)
segment.adopted_version = old_ver
segment.save(update_fields=["adopted_version"])
timeline = Timeline.objects.create(project=project)
clip = TimelineClip.objects.create(
timeline=timeline, asset=old_asset, sort_order=0,
duration_ms=15000, trim_start_ms=1000, trim_end_ms=9000,
)
response = self.client.post(
f"/api/projects/{project.id}/adopt-video-version/",
{"video_segment_id": str(segment.id), "version_id": str(new_ver.id)},
format="json",
)
self.assertEqual(response.status_code, 200)
clip.refresh_from_db()
self.assertEqual(clip.asset_id, new_asset.id)
# 旧素材上的裁剪点对新素材无意义,必须复位
self.assertEqual(clip.trim_start_ms, 0)
self.assertIsNone(clip.trim_end_ms)
def _make_audio_model(self):
return ModelConfig.objects.create(
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
capability="audio", endpoint="api/v1/tts", unit_price="2.0000",
)
@patch("apps.ai.services.TosStorage")
@patch("apps.ai.services.VolcanoTtsProvider")
def test_generate_voiceover_creates_assets_and_charges_once(self, provider_cls, storage_cls):
"""每镜旁白 TTS:产出音频资产、timeline.metadata 落映射、一次调用只计一次费。"""
self._make_audio_model()
provider = provider_cls.return_value
provider.configured = True
provider.synthesize.return_value = (b"fake-mp3-bytes", 4200)
stored = storage_cls.return_value.upload_fileobj.return_value
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "k.mp3", "b", "audio/mpeg", 14
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO")
response = self.client.post(
f"/api/projects/{project.id}/generate-voiceover/",
{"items": [{"index": 0, "text": "第一镜旁白"}, {"index": 1, "text": "第二镜旁白"}]},
format="json",
)
self.assertEqual(response.status_code, 201)
voiceover = response.json()["voiceover"]
self.assertTrue(voiceover["enabled"])
self.assertEqual(len(voiceover["items"]), 2)
self.assertEqual(provider.synthesize.call_count, 2)
from apps.assets.models import Asset as AssetModel
self.assertEqual(AssetModel.objects.filter(team=self.team, asset_type="audio").count(), 2)
self.assertEqual(
CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1
)
project.refresh_from_db()
self.assertEqual(len(project.timeline.metadata["voiceover"]["items"]), 2)
@patch("apps.ai.services.VolcanoTtsProvider")
def test_generate_voiceover_unconfigured_returns_400_and_no_charge(self, provider_cls):
"""语音凭证未配置:400 + 人话提示,不建任务不扣费。"""
self._make_audio_model()
provider_cls.return_value.configured = False
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO2")
response = self.client.post(
f"/api/projects/{project.id}/generate-voiceover/",
{"items": [{"index": 0, "text": "旁白"}]},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertIn("语音合成未配置", response.json()["detail"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 0)
def test_export_command_mixes_voiceover_above_bgm(self):
"""导出命令:人声按片段起点 adelay 延迟、先与原声 amix、再与 BGM amix。"""
from apps.projects.services.export import _build_export_command
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}, {"ts": 0.0, "te": 15.0, "dur": 15.0}]
cmd = _build_export_command(
n=2, specs=specs, starts=[0.0, 15.0], total=30.0, transition="none",
sub_overlays=[], bgm_name="bgm.mp3", bgm_volume=0.6, has_audio=[False, False],
voice_overlays=[("vo0.mp3", 0.0, 15.0), ("vo1.mp3", 15.0, 15.0)],
)
self.assertIn("vo0.mp3", cmd)
self.assertIn("vo1.mp3", cmd)
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("adelay=15000|15000", graph)
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
def test_save_timeline_updates_voiceover_offsets(self):
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
Timeline.objects.create(project=project, metadata={"voiceover": {"enabled": True, "items": [
{"index": 0, "cue": 0, "asset": "a1", "text": "x", "duration_ms": 2000, "offset_ms": 0},
{"index": 0, "cue": 1, "asset": "a2", "text": "y", "duration_ms": 2000, "offset_ms": 2000},
]}})
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/",
{"voiceover": {"items": [{"asset": "a2", "offset_ms": 9000}]}},
format="json",
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
items = project.timeline.metadata["voiceover"]["items"]
self.assertEqual(items[0]["offset_ms"], 0)
self.assertEqual(items[1]["offset_ms"], 9000)
def test_subtitle_cues_use_end_ms_and_follow_voiceover(self):
"""字幕烧入时序:cue 自带 end_ms 时按它收尾(不再拖到片段尾)——这是配音对齐的关键;
无保存字幕的回退路径,有配音时逐句窗口也要压进语音真实时长。"""
from apps.projects.services.export import _subtitle_cues
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="SUB")
timeline = Timeline.objects.create(
project=project,
metadata={"voiceover": {"enabled": True, "items": [{"index": 0, "asset": "x", "text": "t", "duration_ms": 4000}]}},
)
SubtitleTrack.objects.create(timeline=timeline, enabled=True, content=[
{"start_ms": 0, "end_ms": 2000, "text": "第一句"},
{"start_ms": 2000, "end_ms": 4000, "text": "第二句"},
])
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}]
cues = _subtitle_cues(timeline, project, specs, [0.0], 15.0)
self.assertEqual(len(cues), 2)
self.assertEqual((cues[0][0], cues[0][1]), (0.0, 2.0))
self.assertEqual(cues[1][1], 4.0) # 最后一句在语音念完处收尾,而非拖到 15s
def test_save_timeline_voiceover_toggle_and_clear(self):
"""保存草稿可关/开配音、可移除映射(不动已生成的音频资产)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO3")
Timeline.objects.create(
project=project,
metadata={"voiceover": {"enabled": True, "voice_type": "v", "items": [{"index": 0, "asset": "a", "text": "t"}]}},
)
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"enabled": False}}, format="json"
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
self.assertFalse(project.timeline.metadata["voiceover"]["enabled"])
response = self.client.post(
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"clear": True}}, format="json"
)
self.assertEqual(response.status_code, 200)
project.timeline.refresh_from_db()
self.assertNotIn("voiceover", project.timeline.metadata)
class WorkerGateTests(TestCase):
"""无 Celery worker 时,图片/视频生成入口必须 503 拒绝(防结果无人取回=数据丢失)。"""
def setUp(self):
self.user = User.objects.create_user(username="gate", password="pass")
self.team = Team.objects.create(name="Gate Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance="100.0000")
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Gate Product")
self.project = Project.objects.create(team=self.team, created_by=self.user, name="Gate", product=self.product)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _blocked(self):
return patch("apps.common.celery_health.celery_worker_available", return_value=False)
def test_generation_endpoints_blocked_without_worker(self):
cases = [
(f"/api/projects/{self.project.id}/generate-base-asset/", {"kind": "person"}),
(f"/api/projects/{self.project.id}/generate-storyboard/", {}),
(f"/api/projects/{self.project.id}/submit-video-segment/", {"video_segment_id": "x"}),
("/api/ai/generate-image/", {"prompt": "测试"}),
]
with self._blocked():
for url, payload in cases:
response = self.client.post(url, payload, format="json")
self.assertEqual(response.status_code, 503, msg=f"{url} 应被 worker 闸拦截")
self.assertIn("worker", response.json()["detail"].lower() if isinstance(response.json().get("detail"), str) else "")
def test_eager_mode_bypasses_gate(self):
from apps.common.celery_health import celery_worker_available
# 测试配置 CELERY_TASK_ALWAYS_EAGER=True:任务就地执行,无需 worker,闸放行
self.assertTrue(celery_worker_available())
def test_ping_failure_means_unavailable(self):
from apps.common import celery_health
celery_health._cache["expires"] = 0.0
with self.settings(CELERY_TASK_ALWAYS_EAGER=False):
with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")):
self.assertFalse(celery_health.celery_worker_available())
celery_health._cache["expires"] = 0.0
+161 -3
View File
@@ -9,7 +9,10 @@ from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.ai.providers import TtsNotConfigured
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
create_export_job,
generate_base_asset,
generate_project_script,
@@ -17,11 +20,13 @@ from apps.ai.services import (
poll_video_segment,
submit_storyboard,
submit_video_segment,
synthesize_project_voiceover,
)
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 apps.common.celery_health import require_worker
from .models import (
BaseAssetGroup,
@@ -29,6 +34,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
SubtitleTrack,
Timeline,
@@ -92,6 +98,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"stages",
"video_segments",
"video_segments__adopted_version__asset__files",
"video_segments__versions__asset__files",
"script_versions",
"script_versions__segments",
"base_asset_groups",
@@ -121,6 +128,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
user=request.user,
user_prompt=request.data.get("prompt", ""),
selling_point_ids=request.data.get("selling_point_ids") or [],
source=request.data.get("source") or "ai",
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@@ -143,6 +151,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="generate-base-asset")
def generate_base_asset_action(self, request, pk=None):
require_worker()
project = self.get_object()
kind = request.data.get("kind")
if kind not in BaseAssetGroup.Kind.values:
@@ -167,9 +176,117 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
promote_base_asset_stage_if_ready(project)
return Response(BaseAssetGroupSerializer(group).data)
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。"""
if not script.is_adopted:
return
target = script.segments.count()
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
next_order += 1
@action(detail=True, methods=["post"], url_path="update-script-segment")
def update_script_segment(self, request, pk=None):
project = self.get_object()
segment = ScriptSegment.objects.get(id=request.data.get("segment_id"), script_version__project=project)
changed = []
for field in ("narration", "visual_prompt"):
if field in request.data:
setattr(segment, field, str(request.data.get(field) or "").strip())
changed.append(field)
if "duration_seconds" in request.data:
try:
segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"])))
changed.append("duration_seconds")
except (TypeError, ValueError):
pass
if changed:
segment.save(update_fields=[*changed, "updated_at"])
return Response(ScriptVersionSerializer(segment.script_version).data)
@action(detail=True, methods=["post"], url_path="add-script-segment")
@transaction.atomic
def add_script_segment(self, request, pk=None):
project = self.get_object()
after = ScriptSegment.objects.select_related("script_version").get(
id=request.data.get("after_segment_id"), script_version__project=project
)
script = after.script_version
segments = list(script.segments.order_by("sort_order"))
insert_at = next(i for i, s in enumerate(segments) if s.id == after.id) + 1
created = ScriptSegment.objects.create(
script_version=script,
sort_order=insert_at,
duration_seconds=int(request.data.get("duration_seconds") or 15),
narration=str(request.data.get("narration") or "").strip(),
visual_prompt=str(request.data.get("visual_prompt") or "").strip(),
)
segments.insert(insert_at, created)
for index, seg in enumerate(segments):
if seg.sort_order != index:
seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="delete-script-segment")
@transaction.atomic
def delete_script_segment(self, request, pk=None):
project = self.get_object()
segment = ScriptSegment.objects.select_related("script_version").get(
id=request.data.get("segment_id"), script_version__project=project
)
script = segment.script_version
if script.segments.count() <= 1:
return Response({"detail": "至少保留一个分镜"}, status=status.HTTP_400_BAD_REQUEST)
segment.delete()
for index, seg in enumerate(script.segments.order_by("sort_order")):
if seg.sort_order != index:
seg.sort_order = index
seg.save(update_fields=["sort_order", "updated_at"])
self._sync_video_segments_to_script(project, script)
return Response(ScriptVersionSerializer(script).data)
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
@action(detail=True, methods=["post"], url_path="adopt-video-version")
@transaction.atomic
def adopt_video_version(self, request, pk=None):
project = self.get_object()
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
version = VideoSegmentVersion.objects.get(video_segment=segment, id=request.data.get("version_id"))
segment.versions.update(is_adopted=False)
version.is_adopted = True
version.save(update_fields=["is_adopted", "updated_at"])
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.error_message = ""
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
# 时间线草稿(剪辑台/拼接导出都读它)若还引用本场旧版本的资产,必须跟随切换,
# 否则采用新版本后预览和导出仍是旧片段;裁剪点是对旧素材设的,一并复位
timeline = Timeline.objects.filter(project=project).first()
if timeline is not None and version.asset_id:
segment_asset_ids = list(segment.versions.values_list("asset_id", flat=True))
timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update(
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
)
return Response(ProjectSerializer(project).data)
@action(detail=True, methods=["post"], url_path="generate-storyboard")
def generate_storyboard_action(self, request, pk=None):
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
require_worker()
project = self.get_object()
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)
@@ -206,15 +323,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="submit-video-segment")
def submit_video_segment_action(self, request, pk=None):
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
require_worker()
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", ""))
# 有 Celery worker 时由它自动轮询;无 worker(本机 dev)则前端驱动 poll-video-segment。
# 队列不可用不应让提交 500——已提交到 ARK,轮询是次要路径。
# worker 在线由上面的闸保证;此处入队失败只剩极小窗口(刚提交完 broker 闪断),
# 任务已在 ARK,前端轮询仍可兜底取回,故不让提交 500。
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)
logger.error("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")
@@ -345,6 +464,24 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
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)
# ── Stage 5 · 旁白配音(TTS):每镜旁白合成语音资产,导出时作为人声轨混在 BGM 之上 ──
@action(detail=True, methods=["get", "post"], url_path="generate-voiceover")
def generate_voiceover_action(self, request, pk=None):
project = self.get_object()
if request.method == "GET":
return Response({"voices": VOICEOVER_VOICES, "default_voice": DEFAULT_VOICEOVER_VOICE})
try:
voiceover = synthesize_project_voiceover(
project=project,
user=request.user,
items=request.data.get("items") or [],
voice_type=request.data.get("voice_type") or DEFAULT_VOICEOVER_VOICE,
speed_ratio=float(request.data.get("speed_ratio") or 1.0),
)
except (TtsNotConfigured, ValueError) as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"voiceover": voiceover}, 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):
@@ -402,6 +539,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
metadata["transition"] = {"type": str(data["transition"].get("type", "none"))}
if isinstance(data.get("draft"), dict):
metadata["draft"] = data["draft"]
if isinstance(data.get("voiceover"), dict):
vo_patch = data["voiceover"]
existing = metadata.get("voiceover")
if vo_patch.get("clear"):
metadata.pop("voiceover", None)
elif isinstance(existing, dict):
if vo_patch.get("enabled") is not None:
existing["enabled"] = bool(vo_patch["enabled"])
# 拖动字幕块后回写每句语音的句内起点(按 asset id 对位,只动 offset_ms)
if isinstance(vo_patch.get("items"), list):
offsets = {}
for it in vo_patch["items"]:
if isinstance(it, dict) and it.get("asset") is not None and it.get("offset_ms") is not None:
try:
offsets[str(it["asset"])] = max(0, int(it["offset_ms"]))
except (TypeError, ValueError):
continue
for it in existing.get("items") or []:
if str(it.get("asset")) in offsets:
it["offset_ms"] = offsets[str(it.get("asset"))]
metadata["voiceover"] = existing
timeline.metadata = metadata
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
return Response(ProjectSerializer(self.get_object()).data)
+185
View File
@@ -0,0 +1,185 @@
# AirShelf 产品介绍资料(BP「产品与业务」章节)
> 版本:2026-06-10 | 面向投资人 | 数据口径与出处见各节标注
> 产品名:AirShelf(中文暂定名「带货流水线」,对外名称待定,见文末清单)
---
## 一句话介绍
**AirShelf 是一条给电商商家用的"AI 带货视频生产线":商家选好商品,AI 自动写脚本、生成主播和场景画面、画出分镜、生成视频,最后拼成一条可以直接发抖音的 60 秒带货成片——全程 1-2 小时,单条成本只要二三十元。**
---
## 行业痛点:内容电商把"做内容"变成了商家最贵的固定成本
抖音电商 2024 年成交额约 3.5 万亿元,同比增长 30%(来源:36氪独家报道,2025 年 2 月)。在这个盘子里,每一单成交背后都是一条条短视频在带货——视频就是货架,不发视频等于关店。
但对占商家主体的中小白牌商家来说,"持续产出带货视频"是一件又贵、又慢、又碎的事:
- **贵**:传统方式做一条带货视频,主播、场地、拍摄、剪辑加起来单条几百元起步;一张商品精修图,摄影棚报价几十到几百元,还要按天排期。
- **慢**:找代运营外包,一条视频沟通加交付要 3-7 天。抖音的玩法是日更甚至一天多条,这个速度根本跟不上。
- **碎**:市面上的 AI 工具是"零件"不是"生产线"——写文案一个工具、生图一个工具、做视频又一个工具,商家要自己当导演把零件攒起来,最后还经常拼不出能直接发抖音的竖版成片。
- **怕**:AI 生成有不确定性,商家最怕"点一下扣一笔钱,出来的东西不能用"。这层心理障碍挡住了大量本来愿意尝试的商家。
一句话:**市场不缺 AI 工具,缺的是一条让普通商家敢用、用得起、出来就能发的完整生产线。**
---
## 我们的答案:把"拍视频"变成"下订单"
商家在 AirShelf 上做一条带货视频,体验就像点一份外卖:
1. **选商品**:把自家商品放进商品库(传几张图、写几条卖点),全团队共用。
2. **AI 写脚本**:不会写脚本的商家选"AI 全生成",AI 直接产出 60 秒的分镜脚本;自己有文案的直接粘贴进来;只有一个想法的,说一句话主题就行。商家可以像聊天一样让 AI 改:"第二段语气活泼一点"。
3. **AI 生成画面素材**:AI 根据脚本自动生成主播形象、拍摄场景,并把商品图一键变成专业级效果(去掉杂乱背景、重新打光、变清晰)。
4. **AI 画分镜、出视频**:先画出整条片子的分镜画面给商家过目,确认后再生成视频片段。
5. **一键成片**:自动拼接、加抖音风格字幕、配热门卡点音乐,导出高清竖版成片,直接可发。
三条贯穿全程的产品规则,专门拆掉商家的心理防线:
- **每一步商家点头才往下走**:脚本、画面、分镜、视频,每个环节商家确认满意才进入下一步,不满意免费重做。
- **失败不扣费、采用才扣费**:生成失败、效果不满意重跑,一律不收钱;只有商家点了"采用"才计费。商家试错零成本。
- **素材越用越省**:生成过的主播形象、场景画面沉淀进团队资产库,下一条视频直接复用——主播形象稳定(观众认得出"这是这家店的人"),成本还更低。
服务三类客户:抖音白牌商家(自己做内容的小店主)、代运营机构/MCN(多人团队批量生产,平台内置成员管理和额度分配)、带货达人(要稳定人设、多变剧情)。
---
## 市场与竞争
### 这个赛道已经被全球资本验证过了
AI 视频生成是当前 AI 应用层融资最热的赛道之一,两家头部公司的估值轨迹可以直接说明问题:
**Synthesia(英国,全球最大的 AI 视频生成公司)**
一句话定位:用 AI 数字人帮企业把文档变成培训、营销视频。
| 轮次 | 时间 | 金额 | 估值 | 主要投资方 |
|---|---|---|---|---|
| 种子轮 | 2019 | 310 万美元 | — | LDV Capital、MMC 等 |
| A 轮 | 2021.4 | 1,250 万美元 | — | FirstMark |
| B 轮 | 2021.12 | 5,000 万美元 | — | Kleiner Perkins、GV(谷歌风投) |
| C 轮 | 2023.6 | 9,000 万美元 | 10 亿美元 | Accel 领投,英伟达 NVentures 参投 |
| D 轮 | 2025.1 | 1.8 亿美元 | 21 亿美元 | NEA 领投 |
| E 轮 | 2026.1 | 2 亿美元 | **40 亿美元** | GV 领投,英伟达等跟投 |
累计融资超 5.3 亿美元;2025 年 4 月年度经常性收入(ARR)突破 1 亿美元,客户覆盖超过 60% 的财富 100 强。(来源:CNBC 2025-01-15、2026-01-26 报道;TechCrunch 2026-01-26 报道;Synthesia 官方公告)
**HeyGen(美国,华人创办,赛道内增速最快)**
一句话定位:让企业用 AI 数字人快速生成营销和销售视频。
- 2024 年 6 月:A 轮融资 6,000 万美元,估值 **5 亿美元**,Benchmark 领投,Thrive Capital、BOND 等参投(来源:彭博社 2024-06-20 报道);早期投资方包括红杉中国(HongShan)、真格基金,累计融资约 7,460 万美元(来源:Crunchbase / Tracxn,截至 2026 年 6 月)。
- 2025 年 10 月:年度经常性收入(ARR)突破 **1 亿美元**,两年增长超过 10 倍,付费企业客户超 8.5 万家(来源:Sacra / 公司公开披露)。
**对投资人的含义**:两家公司证明了"AI 生成商业视频"是一个能在 2-3 年内做到 1 亿美元收入、支撑 40 亿美元估值的赛道,且资本(Benchmark、Accel、NEA、GV、英伟达)持续加注。
### 我们和它们的差异:它们做"企业宣传",我们做"电商卖货"
| 维度 | Synthesia / HeyGen | AirShelf |
|---|---|---|
| 核心场景 | 企业培训、品牌宣传、横屏口播视频 | 中国电商带货,60 秒竖版短剧化成片 |
| 交付物 | 数字人讲解视频(半成品居多) | 直接能发抖音的完整成片(脚本+画面+字幕+音乐) |
| 客户形态 | 欧美大企业市场部 | 中国白牌商家、MCN、代运营团队 |
| 付费方式 | 订阅制(按席位/年) | 预充值按量计费(用多少花多少,门槛极低) |
| 团队协作 | 通用协作 | 为 MCN 定制:成员额度分配、按项目/按人对账 |
它们验证了赛道,但没有覆盖"中国内容电商"这个最高频、最刚需的子市场——商家不需要一条精美的企业宣传片,需要的是每天都能发、发了能出单的带货视频。这是一个用量逻辑完全不同的市场:企业一年拍几十条宣传片,电商商家一个月就要几十条带货视频、上千张商品图。**高频消耗 + 按量计费,是我们和订阅制玩家在商业模式上的根本分野。**
---
## 核心竞争力
1. **端到端生产线,不是工具箱。** 从商品到可发布成片一条线打通,商家不需要在五个工具之间搬运素材。市面上单点工具越多,"最后一公里拼装"的价值反而越稀缺——我们交付的是结果(成片),不是原料。
2. **"失败不扣费、采用才扣费"的信任机制。** 每个环节有人工确认关口,不满意免费重做。这直接解决了商家"怕 AI 乱花钱"的最大使用障碍,是把尝鲜用户转成常用用户的关键设计,也是同类产品普遍没有做的。
3. **资产沉淀形成切换成本。** 商家的主播形象、场景画面、优化后的商品图都沉淀在团队资产库里,跨视频复用——用得越久,素材库越厚、出片越快越便宜、主播人设越稳定。商家换平台等于放弃整个素材资产,这是天然的留存护城河。
4. **火山引擎深度绑定,供给侧有先发权。** 已与火山引擎(字节跳动旗下云服务)签署模型服务白名单协议与年度框架合同,享有新模型能力优先接入权。这意味着两件事:上游算力成本与供给稳定可控;字节系新一代生成能力上线时,我们能先于市场拿到——在能力快速迭代的赛道里,"早一个季度接入"就是产品代差。
5. **为 MCN/代运营长出来的团队体系。** 成员邀请、角色权限、四层额度管控(人/天、人/月、团队/月、团队总额)、按项目按成员对账——机构客户最看重的"管人管钱"能力是产品原生的,不是后补的。机构客户客单价高、用量稳定,是天然的收入压舱石。
---
## 商业模式
**谁付费**:商家/机构充值开通团队账户(超级管理员充值,给成员分配额度),三类客户画像中,白牌商家约占一半,MCN/代运营机构约占三分之一强,带货达人为补充。
**怎么收**:预充值 + 按量计费。每生成一张图、一段文案、一条视频按单价从余额扣费;失败不扣、采用才扣。对客户来说没有订阅门槛、用多少花多少;对平台来说,客户用量随其生意增长自然爬坡,收入跟着客户的内容产量走。
**未来增收方向**(不计入当前模型):去水印/高级模板等增值订阅、面向机构的批量生产能力、爆款复刻等高阶功能。
---
## 利润估算
### 1. 单位经济模型
| 业务 | 单位成本 | 对客定价 | 名义毛利率 |
|---|---|---|---|
| AI 出图(商品图/主播/场景/分镜) | 约 0.03 元/张 | 约 0.2 元/张 | 约 85% |
| AI 文案/脚本(语言模型调用) | 同等加价倍率 | 按次计费 | 70%-80% |
**为什么这个定价站得住——客户的替代成本**:传统电商摄影一张精修图几十到几百元、按天排期交付;传统带货视频单条制作几百元起、外包周期 3-7 天。对比之下,0.2 元/张约等于免费——哪怕我们把价格翻倍,对客户依然是百分之一的成本。**客户对这个价位不敏感,定价有充足的安全垫,毛利率的下行风险很小。**
### 2. 典型客户月度用量测算
以一家 20-30 个商品、按抖音日更节奏运营的白牌店铺为例:
| 用量项 | 测算 | 月出图量 |
|---|---|---|
| 带货视频生产 | 月产约 20 条成片,每条计费出图约 8 张(分镜 4 张 + 主播/场景/商品图约 4 张,资产复用后的稳态值) | 约 160 张 |
| 日常图片素材 | 在售商品的主图优化、详情更新、短视频封面、活动海报、测款素材,日均 40-50 张 | 约 1,300 张 |
| **合计** | | **约 1,500 张/月** |
- 出图收入:1,500 张 × 0.2 元 ≈ **300 元/月**
- 文案/脚本收入:20 条视频的脚本生成与改稿,加商品卖点文案,约 **65 元/月**
- **单店客户:月收入约 365 元,年收入约 4,400 元**
- **机构客户(MCN/代运营,5-20 人团队)**:用量约为单店的 8-10 倍,月消耗约 3,000 元(与产品内典型团队月额度设置一致),**年收入约 3.6 万元**
### 3. 综合毛利率(按收入结构加权)
- 出图收入占比约 82%。计入"失败不扣费"承担的免费重跑成本(按每张计费图平均消耗 1.4 次实际生成估算),出图的**有效毛利率约 79%**(0.2 − 0.03×1.4 = 0.158 元/张)。
- 文案收入占比约 18%,毛利率按区间稳健取 72%。
- **加权综合毛利率 ≈ 82% × 79% + 18% × 72% ≈ 78%**,落在 70%-80% 区间。
- **对外口径建议表述为"综合毛利率约 75%"**——已经把免费重跑、文案低毛利部分都算进去,留有余量,经得起验算。
### 4. 这个模型的含义
一个客户一年 4,400 元,对应的替代预算(传统摄影 + 视频外包)是数万元——我们只取了客户原预算的零头,客户省下 90% 以上,平台仍有约 75% 毛利。**便宜不是靠补贴,是靠生成成本的结构性优势;客户越省,我们越赚,这个模型两头都成立。**
---
## 当前进展
- **产品已上线内测**:从商品入库、AI 脚本、画面素材生成、分镜、视频生成到成片导出的完整生产线已经打通,内测环境可稳定产出 60 秒竖版成片。
- **团队与计费体系完整可用**:团队账户、成员角色与额度管控、预充值与按量扣费、"失败不扣费"规则均已实现。
- **供给侧合作落地**:已与火山引擎签署模型服务白名单协议与年度框架合同,锁定算力供给与新模型优先接入权。
- 当前阶段以内测打磨为主,暂无对外运营数据;下一阶段目标为扩大内测商家规模、校准用量模型后正式开放。
---
## 待 PM 确认清单
1. **产品对外名称**:资料中暂用工程名"AirShelf"(PRD 中文暂名"带货流水线"),对外品牌名需最终确认。
2. **视频生成环节的成本与定价底数未提供**:本文利润估算按既定口径只计入"出图 + 文案"两项;若计入视频生成环节收入(PRD 场景示例中单条成片整体消耗约 22 元),单客户月收入会显著高于本文测算,综合毛利率也需按其实际毛利重新加权。
3. **典型客户月出图量假设(约 1,500 张/月,其中日常图片素材约 1,300 张)**:为合理假设,需用内测真实用量数据校准后再对外引用。
4. **免费重跑系数假设(每张计费图平均 1.4 次实际生成)**:影响有效毛利率约 6 个百分点,需用内测数据验证。
5. **文案/脚本月消耗约 65 元的假设**:单次调用定价与典型调用次数需财务定价细则落定后复核。
6. **机构客户"用量为单店 8-10 倍、月消耗约 3,000 元"**:参考产品内典型团队额度设置推估,需销售侧确认目标客单价。
7. **火山引擎协议的可披露范围**:白名单协议与年度框架合同的具体条款(金额、期限、独占性)哪些可写入 BP 正文、哪些只能口头沟通,需确认。
8. **外部数据引用复核**:抖音电商 2024 年 GMV 约 3.5 万亿元为 36氪报道口径(非官方发布);Synthesia、HeyGen 融资与估值数据截至 2026 年 6 月检索(CNBC、TechCrunch、彭博社、Crunchbase 等公开报道),BP 定稿前建议复核一次是否有新轮次。
---
### 主要信息来源
- [CNBC:Synthesia 估值翻倍至 21 亿美元(2025-01-15)](https://www.cnbc.com/2025/01/15/ai-video-platform-synthesia-doubles-valuation-to-2point1-billion.html)
- [CNBC:英伟达、谷歌风投加注 Synthesia,估值 40 亿美元(2026-01-26)](https://www.cnbc.com/2026/01/26/nvidia-alphabet-vc-arms-back-synthesia.html)
- [TechCrunch:Synthesia E 轮 2 亿美元、估值 40 亿(2026-01-26)](https://techcrunch.com/2026/01/26/synthesia-hits-4b-valuation-lets-employees-cash-in/)
- [Synthesia 官方:E 轮融资公告](https://www.synthesia.io/post/series-e-200-million-4-billion-valuation-future-work)
- [彭博社:HeyGen 融资估值 5 亿美元(2024-06-20)](https://www.bloomberg.com/news/articles/2024-06-20/ai-video-startup-heygen-valued-at-500-million-in-funding-round)
- [HeyGen 官方:A 轮 6,000 万美元公告](https://www.heygen.com/blog/announcing-our-series-a)
- [Sacra:HeyGen 收入与估值档案](https://sacra.com/c/heygen/)
- [36氪:抖音电商 2024 年 GMV 约 3.5 万亿(2025-02)](https://eu.36kr.com/zh/p/3166066575158018)
+47 -29
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, getToken, setToken } from "./api";
import { api, ApiError, getToken, setToken } from "./api";
import { IconKitSvg } from "./components/IconKitSvg";
import type {
AITask,
@@ -177,12 +177,24 @@ export function App() {
let cancelled = false;
(async () => {
try {
const identity = await api.me();
if (cancelled) return;
// 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token
let identity: Awaited<ReturnType<typeof api.me>> | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
identity = await api.me();
break;
} catch (error) {
const status = error instanceof ApiError ? error.status : 0;
if (status === 401 || status === 403 || attempt === 2) throw error;
await new Promise((resolve) => setTimeout(resolve, 1200));
}
}
if (cancelled || !identity) return;
setUser(identity.user);
setTeam(identity.team);
await loadData();
} catch {
} catch (bootError) {
console.error("[boot] failed:", bootError);
setToken(null);
if (!cancelled) setAuthed(false);
} finally {
@@ -233,18 +245,23 @@ export function App() {
}, [authed, page, activeProjectId]);
// 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。
// 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行
// ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。
const projectDetailRef = useRef<Project | null>(null);
useEffect(() => {
projectDetailRef.current = projectDetail;
}, [projectDetail]);
const pollVideosQuiet = useCallback(async () => {
if (!activeProjectId) return;
const detail = await api.project(activeProjectId).catch(() => null);
let detail = projectDetailRef.current;
if (!detail || detail.id !== activeProjectId) {
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 active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
if (active.length === 0) return;
await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined)));
const next = await api.project(activeProjectId).catch(() => null);
if (next) setProjectDetail(next);
}, [activeProjectId]);
@@ -457,6 +474,7 @@ export function App() {
return (
<ProjectWizardPage
products={products}
projects={projects}
onBack={() => navigate("projects")}
onCreate={async (payload) => {
const created = await action(() => api.createProject(payload), "项目已创建");
@@ -532,9 +550,9 @@ export function App() {
case "modelPhotoDemoB":
return <ModelPhotoDemoPage variant="B" products={products} onBack={() => navigate("modelPhoto")} navigate={navigate} />;
case "settings":
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 })} />;
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 })} onLogout={logout} />;
case "settingsNotify":
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 })} />;
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 })} onLogout={logout} />;
default:
return <Dashboard products={products} projects={projects} assets={assets} billing={billing} navigate={navigate} />;
}
@@ -546,9 +564,11 @@ export function App() {
// 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell
const pipelineProject = projectDetail || activeProject;
if (page === "pipeline" && pipelineProject) {
const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text");
return (
<PipelinePage
project={pipelineProject}
scriptModelName={textModel?.display_name || textModel?.name || "AI"}
loading={loading}
navigate={navigate}
user={currentUser}
@@ -561,9 +581,13 @@ export function App() {
unreadCount={unreadCount}
avatarChar={avatarChar}
logout={logout}
onRefresh={refreshProjectDetail}
onGenerateScript={(prompt) => action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")}
onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")}
onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")}
onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")}
onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")}
onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")}
onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")}
onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")}
onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")}
onGenerateStoryboard={(prompt) =>
action(async () => {
@@ -571,16 +595,17 @@ export function App() {
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("故事板生成失败,请重试");
if (res.status === "succeeded") return true;
if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 4000));
}
return true;
// 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast
throw new Error("故事板生成超时,请稍后刷新查看或重试");
}, "故事板已生成")
}
onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")}
onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")}
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 () => {
const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status));
@@ -594,17 +619,9 @@ export function App() {
}, "多段视频已提交,生成中…")
}
onPollVideosQuiet={pollVideosQuiet}
onPollAllVideos={() =>
action(async () => {
const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status));
for (const segment of targets) {
await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined);
}
return targets.length;
}, "视频片段状态已刷新")
}
exportResult={exportResult}
onRefreshExport={refreshExport}
onRefreshProject={refreshProjectDetail}
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), "草稿已保存")}
@@ -621,7 +638,8 @@ export function App() {
if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试");
await new Promise((resolve) => setTimeout(resolve, 2500));
}
return null;
// 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填)
throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看");
}, "成片已导出")
}
/>
+38 -3
View File
@@ -17,7 +17,8 @@ import type {
Team,
TeamMember,
User,
UserPreference
UserPreference,
VoiceoverInfo
} from "./types";
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
@@ -53,7 +54,17 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
const text = await response.text();
throw new ApiError(response.status, text || `${response.status} ${response.statusText}`);
// DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上
let message = text || `${response.status} ${response.statusText}`;
try {
const data = JSON.parse(text) as Record<string, unknown>;
const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0];
if (typeof first === "string") message = first;
else if (Array.isArray(first) && typeof first[0] === "string") message = first[0];
} catch {
/* 非 JSON(如网关 HTML)保持原文 */
}
throw new ApiError(response.status, message);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
@@ -168,7 +179,7 @@ export const api = {
deleteProject(id: string) {
return request<void>(`/api/projects/${id}/`, { method: "DELETE" });
},
generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) {
generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) {
return request<ScriptVersion>(`/api/projects/${projectId}/generate-script/`, {
method: "POST",
body: JSON.stringify(payload)
@@ -180,6 +191,21 @@ export const api = {
body: JSON.stringify({ script_version_id })
});
},
updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) {
return request<ScriptVersion>(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
deleteScriptSegment(projectId: string, payload: { segment_id: string }) {
return request<ScriptVersion>(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) });
},
adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) {
return request<Project>(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) });
},
generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) {
return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) });
},
generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) {
return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) });
},
@@ -231,6 +257,15 @@ export const api = {
assets() {
return request<Paginated<Asset>>("/api/assets/");
},
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
async fetchAssetBlob(id: string): Promise<Blob> {
const token = getToken();
const response = await fetch(`${API_BASE}/api/assets/${id}/raw/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!response.ok) throw new ApiError(response.status, `fetch asset raw failed: ${response.status}`);
return response.blob();
},
// 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url,
// 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。
async allAssets(): Promise<Asset[]> {
+19 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Check } from "lucide-react";
import { IconKitSvg } from "./IconKitSvg";
import { useBodyScrollLock } from "./overlays";
import type { Product, Project, Team, User } from "../types";
import type { Notice, Page } from "../routes/route-config";
@@ -28,6 +29,7 @@ const SHELL_COMMANDS: Command[] = [
function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) {
const [query, setQuery] = useState("");
useBodyScrollLock(open);
useEffect(() => { if (open) setQuery(""); }, [open]);
const items = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -149,6 +151,16 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return () => document.body.classList.remove("sidebar-collapsed");
}, [collapsed]);
// 移动端导航抽屉:窄屏侧栏默认收起(display:none),靠左上汉堡键拉出为浮层 + 背景遮罩。
// 点导航项 / 遮罩 / Esc 关闭。桌面端(>1100px)汉堡键 CSS 隐藏,不影响原有收窄逻辑。
const [mobileNavOpen, setMobileNavOpen] = useState(false);
useEffect(() => {
if (!mobileNavOpen) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMobileNavOpen(false); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [mobileNavOpen]);
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
const [paletteOpen, setPaletteOpen] = useState(false);
const openCommandPalette = () => setPaletteOpen(true);
@@ -165,7 +177,12 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
return (
<>
<aside className="sidebar">
{/* 移动端汉堡键(桌面 CSS 隐藏)+ 抽屉遮罩 */}
<button className="mobile-nav-btn" type="button" aria-label="打开导航" aria-expanded={mobileNavOpen} onClick={() => setMobileNavOpen(true)}>
<span /><span /><span />
</button>
{mobileNavOpen && <div className="mobile-nav-backdrop" onClick={() => setMobileNavOpen(false)} />}
<aside className={`sidebar${mobileNavOpen ? " mobile-open" : ""}`}>
<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>
@@ -196,7 +213,7 @@ export function Sidebar({ page, navigate, user, team, products, projects }: {
className={activeNav === item.page ? "active" : ""}
title={item.label}
aria-label={item.label}
onClick={(event) => { event.preventDefault(); navigate(item.page); }}
onClick={(event) => { event.preventDefault(); setMobileNavOpen(false); navigate(item.page); }}
>
<IconKitSvg name={item.icon} />
<span>{item.label}</span>
+27 -3
View File
@@ -1,16 +1,32 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Shield, X } from "lucide-react";
import { Music, Shield, X } from "lucide-react";
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放(复用 .np-lightbox 样式)
// 浮层打开时锁住 body 滚动(否则滚轮会滚动遮罩后面的页面,体感"遮罩没盖住")。
// 多浮层叠开用计数,最后一个关闭才解锁。
let scrollLockCount = 0;
export function useBodyScrollLock(active: boolean) {
useEffect(() => {
if (!active) return;
scrollLockCount += 1;
document.body.style.overflow = "hidden";
return () => {
scrollLockCount -= 1;
if (scrollLockCount <= 0) { scrollLockCount = 0; document.body.style.overflow = ""; }
};
}, [active]);
}
// 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式)
// 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。
export function MediaLightbox({ open, src, kind, name, close }: {
open: boolean;
src: string;
kind?: "image" | "video";
kind?: "image" | "video" | "audio";
name?: string;
close: () => void;
}) {
useBodyScrollLock(open && !!src);
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") close(); };
@@ -31,6 +47,11 @@ export function MediaLightbox({ open, src, kind, name, close }: {
onClick={(event) => event.stopPropagation()}
style={{ maxWidth: "90vw", maxHeight: "88vh", borderRadius: "var(--r-md)", boxShadow: "0 20px 60px rgba(0,0,0,.5)", background: "#000", cursor: "default" }}
/>
) : kind === "audio" ? (
<div className="lb-audio" onClick={(event) => event.stopPropagation()}>
<Music aria-hidden="true" />
<audio src={src} controls autoPlay />
</div>
) : (
<img src={src} alt={name || "预览"} onClick={(event) => event.stopPropagation()} style={{ cursor: "default" }} />
)}
@@ -58,6 +79,7 @@ export function TeamModal({ open, title, subtitle, icon, close, children, footer
children: ReactNode;
footer?: ReactNode;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -81,6 +103,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
@@ -97,6 +120,7 @@ export function ConfirmModal({ open, title, detail, confirmText, onCancel, onCon
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
useBodyScrollLock(open);
if (!open) return null;
// 挂到 body:脱离 .content(z-index:1)层叠上下文,遮罩才能盖住头部/侧栏
return createPortal(
+42
View File
@@ -0,0 +1,42 @@
// 通用列表分页器:沿用项目向导 pp-pager / 消费页 bill-pager 的视觉(mono 总数 + 页码窗口 + 每页条数)。
// 纯前端切片分页:父组件持有 page state,自己 slice;本组件只画控件。
// total <= pageSize 时不渲染(单页无需分页器)。
// 页码窗口:超过 7 页折叠成 1 … cur-1 cur cur+1 … last
export function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const items: Array<number | "ellipsis"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("ellipsis");
for (let p = start; p <= end; p++) items.push(p);
if (end < total - 1) items.push("ellipsis");
items.push(total);
return items;
}
export function Pager({ page, total, pageSize, onChange }: {
page: number;
total: number;
pageSize: number;
onChange: (page: number) => void;
}) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const cur = Math.min(Math.max(1, page), totalPages);
if (total <= pageSize) return null;
return (
<div className="list-pager">
<span className="total">// 共 {total} 条 · 第 {cur} / {totalPages} 页</span>
<div className="pages">
<button type="button" disabled={cur === 1} onClick={() => onChange(cur - 1)} aria-label="上一页">‹</button>
{pageWindow(cur, totalPages).map((p, i) => (
p === "ellipsis"
? <span key={`e${i}`} className="ellipsis">…</span>
: <button type="button" key={p} className={p === cur ? "active" : ""} onClick={() => onChange(p)}>{p}</button>
))}
<button type="button" disabled={cur === totalPages} onClick={() => onChange(cur + 1)} aria-label="下一页">›</button>
</div>
<span className="page-size">每页 {pageSize} 条</span>
</div>
);
}
+64 -1
View File
@@ -1483,6 +1483,38 @@ select.duration-select:focus,
color: rgba(255, 255, 255, .7);
letter-spacing: .04em;
}
/* ─── 通用列表分页器(components/pager.tsx,视觉同向导 pp-pager)─── */
.list-pager {
display: flex; align-items: center; gap: 16px;
margin-top: 18px; padding-top: 14px;
border-top: 1px solid var(--border-faint);
font-size: 12.5px; color: var(--black-alpha-56);
}
.list-pager .total { font-family: var(--font-mono); letter-spacing: .02em; }
.list-pager .pages { display: inline-flex; gap: 4px; margin-left: auto; }
.list-pager .pages button {
min-width: 28px; height: 28px; padding: 0 8px;
border: 1px solid var(--border-faint); background: var(--surface);
border-radius: var(--r-sm);
cursor: pointer; font-size: 12.5px; color: var(--black-alpha-72); font-family: inherit;
transition: border-color var(--t-base), background var(--t-base), color var(--t-base);
}
.list-pager .pages button:hover:not(.active):not(:disabled) { border-color: var(--black-alpha-32); color: var(--accent-black); }
.list-pager .pages button.active { background: var(--heat); color: var(--accent-white); border-color: var(--heat); font-weight: 600; }
.list-pager .pages button:disabled { opacity: .4; cursor: not-allowed; }
.list-pager .pages .ellipsis { min-width: 22px; height: 28px; display: inline-flex; align-items: center; justify-content: center; color: var(--black-alpha-48); }
.list-pager .page-size { font-family: var(--font-mono); letter-spacing: .02em; }
/* 音频试听面板:灯箱内居中,音符 + 原生播放器 */
.np-lightbox .lb-audio {
display: flex; flex-direction: column; align-items: center; gap: 20px;
background: rgba(255, 255, 255, .08);
border-radius: var(--r-md);
padding: 36px 44px;
cursor: default;
}
.np-lightbox .lb-audio svg { width: 34px; height: 34px; color: rgba(255, 255, 255, .8); }
.np-lightbox .lb-audio audio { width: min(420px, 76vw); }
/* Bullet list · 弹窗内 (复用样式) */
.np-body .bullet-list { list-style: none; padding: 0; }
@@ -2121,14 +2153,45 @@ table.t tbody tr:hover { background: var(--black-alpha-4); }
}
/* ─── Responsive ─── */
/* 桌面端汉堡键不出现(基础态,放在 @media 前,避免被后置规则覆盖) */
.mobile-nav-btn { display: none; }
@media (max-width: 1100px) {
.app { grid-template-columns: 1fr; }
aside.sidebar { display: none; }
/* 窄屏:侧栏改为左侧抽屉(默认移出视口),汉堡键拉出;桌面收窄键隐藏 */
aside.sidebar {
display: block;
position: fixed; top: 0; left: 0; bottom: 0;
width: 248px;
z-index: 1200;
transform: translateX(-100%);
transition: transform .28s cubic-bezier(.32, .72, 0, 1);
box-shadow: 0 0 40px rgba(0, 0, 0, .18);
}
/* 抽屉里收窄态不适用,强制展开宽度,避免 body.sidebar-collapsed 把抽屉压成 96px */
body.sidebar-collapsed aside.sidebar { width: 248px; }
aside.sidebar.mobile-open { transform: translateX(0); }
.sidebar-toggle { display: none; }
/* 抽屉内汉堡键无意义,藏掉,留出顶部空间给页面自己的汉堡 */
.mobile-nav-btn {
display: flex; flex-direction: column; gap: 4px; justify-content: center; align-items: center;
position: fixed; top: 14px; left: 14px; z-index: 60;
width: 38px; height: 38px;
background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm);
cursor: pointer;
}
.mobile-nav-btn span { display: block; width: 16px; height: 1.6px; background: var(--accent-black); border-radius: 2px; }
.mobile-nav-backdrop { position: fixed; inset: 0; z-index: 1150; background: rgba(21, 20, 15, .42); }
/* 顶栏给汉堡键让出左侧空间 */
.topbar { padding-left: 64px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.stat:nth-child(2) { border-right: 0; }
.stat:nth-child(1), .stat:nth-child(2) { border-bottom: 1px solid var(--border-faint); }
/* 统计卡标签不要被挤到逐字竖排 */
.stat .lbl { white-space: nowrap; }
.content { padding: 28px 24px 48px; }
/* 宽数据表(项目列表/成员表/账单表)在窄屏改横向滚动,否则右侧列被裁切、点不到 */
#list-view, .members-table-wrap, .billing-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.members-table, .billing-table, #list-view table.t { min-width: 640px; }
}
/* ─── Spinner ─── */
+6 -1
View File
@@ -5,7 +5,12 @@
.asset-card { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: background .15s; position: relative; }
.asset-card:hover { background: var(--background-lighter); border-color: var(--black-alpha-48); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
/* width:100% 必须显式给:否则 max-height 会经 aspect-ratio 把宽度也钳到 157.5px,卡片右侧露白 */
.asset-card.video .asset-thumb { width: 100%; aspect-ratio: 9/16; max-height: 280px; }
/* 音频资产无封面:默认音符占位(居中、mono 标签),点击灯箱播放 */
.lib-audio-ph { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--black-alpha-48); }
.lib-audio-ph svg { width: 26px; height: 26px; }
.lib-audio-ph .mono { font-size: 10px; letter-spacing: .08em; }
/* 有 preview_url 显真图:铺满缩略容器、cover 裁切、继承 8px 圆角(由 .placeholder overflow:hidden 裁切) */
.asset-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: inherit; }
.asset-body { padding: 12px 14px; }
+75 -3
View File
@@ -173,6 +173,60 @@
.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; }
/* 画面描述行(narration/visual_prompt 结构化分离后,旁白下方的次要画面说明) */
.shot-card .shot-visual { font-size: 12px; color: var(--black-alpha-56); line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
/* 镜头卡编辑态(对齐设计稿:旁白/画面逐字段 contenteditable + 卡间插入分镜) */
.shot-card .shot-actions { display: inline-flex; gap: 6px; margin-left: auto; }
.icon-mini-btn { width: 24px; height: 24px; display: grid; place-items: center; color: var(--black-alpha-48); background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-sm); cursor: pointer; font-size: 14px; line-height: 1; padding: 0; }
.icon-mini-btn:hover { color: var(--heat); border-color: var(--heat); }
.icon-mini-btn.armed { width: auto; padding: 0 8px; font-size: 11px; color: var(--accent-crimson); border-color: var(--accent-crimson); background: var(--surface); }
.shot-card .shot-meta-row { display: flex; align-items: center; gap: 8px; }
.shot-row { display: grid; grid-template-columns: 36px 1fr; gap: 8px; padding: 4px 0; }
.shot-k { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); padding-top: 2px; letter-spacing: .04em; }
.shot-v { font-size: 12.5px; color: var(--accent-black); line-height: 1.55; outline: none; border-radius: var(--r-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-base); white-space: pre-wrap; word-break: break-word; }
.shot-v[contenteditable="true"]:hover { background: var(--heat-12); cursor: text; }
.shot-v[contenteditable="true"]:focus { background: var(--surface); box-shadow: inset 0 0 0 1px var(--heat); }
.shot-v[data-empty="true"]::before { content: attr(data-placeholder); color: var(--black-alpha-32); font-style: italic; }
.shot-insert-gap { height: 10px; position: relative; display: flex; align-items: center; justify-content: center; padding: 0; transition: height .24s cubic-bezier(.18,.72,.28,1), padding .24s cubic-bezier(.18,.72,.28,1); }
.shot-insert-gap:hover { height: 72px; padding: 14px 0; }
.shot-insert-gap .add-shot-btn { opacity: 0; transform: translateY(4px) scale(.96); height: 28px; padding: 0 14px; background: var(--surface); color: var(--heat); border: 1px dashed var(--heat-40); border-radius: var(--r-md); font-size: 12.5px; font-family: inherit; font-weight: 500; cursor: pointer; transition: opacity .2s ease .04s, transform .24s cubic-bezier(.18,.72,.28,1) .04s, background var(--t-base), border-color var(--t-base), color var(--t-base); display: inline-flex; align-items: center; gap: 6px; pointer-events: none; white-space: nowrap; }
.shot-insert-gap .add-shot-btn svg { width: 12px; height: 12px; }
.shot-insert-gap:hover .add-shot-btn { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.shot-insert-gap .add-shot-btn:hover { background: var(--heat-12); border-style: solid; border-color: var(--heat); }
/* ── Stage 4 · 视频详情弹窗(对齐设计稿 video-detail-modal) ── */
.asset-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1010; display: flex; align-items: center; justify-content: center; padding: 40px; }
.asset-modal { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); width: min(880px, 100%); max-height: calc(100vh - 80px); overflow: hidden; display: flex; flex-direction: column; box-shadow: 0 16px 48px rgba(0,0,0,.18); }
.asset-modal-h { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-bottom: 1px solid var(--border-faint); }
.asset-modal-h h2 { font-size: 15px; font-weight: 600; }
.asset-modal-h .x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; }
.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; }
.asset-modal-f { padding: 14px 20px; border-top: 1px solid var(--border-faint); display: flex; align-items: center; gap: 8px; }
.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; }
.vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; background: #000; border-radius: var(--r-md); overflow: hidden; position: relative; }
.vd-main video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.vd-info { flex: 1; min-width: 0; }
.vd-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; }
.vd-kv { display: grid; grid-template-columns: 72px 1fr; gap: 6px 10px; font-size: 12.5px; }
.vd-kv .k { font-family: var(--font-mono); font-size: 11px; color: var(--black-alpha-48); letter-spacing: .03em; padding-top: 1px; }
.vd-kv .v { color: var(--accent-black); min-width: 0; overflow-wrap: anywhere; }
.vd-history-h { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; }
.vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; }
.vd-history-thumb:hover { border-color: var(--heat); }
.vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); }
.vd-history-thumb.adopted::after { content: ''; position: absolute; top: 2px; right: 2px; width: 14px; height: 14px; background: var(--heat); border-radius: 50%; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center; background-size: 9px 9px; }
.vd-history-thumb .placeholder { aspect-ratio: 9/16; }
.vd-history-thumb .ts { font-family: var(--font-mono); font-size: 9.5px; color: var(--black-alpha-48); text-align: center; }
.vd-prompt-field { margin-top: 16px; }
.vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
.vd-prompt-head .label { font-family: var(--font-mono); font-size: 11px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; }
.vd-prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 96px; outline: none; letter-spacing: .01em; cursor: text; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
.vd-prompt-edit:hover { border-color: var(--heat-20); }
.vd-prompt-edit:focus { border-color: var(--heat); background: var(--surface); box-shadow: 0 0 0 3px var(--heat-12); }
.vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; }
/* 对话空态三胶囊 */
.chat-empty { padding: 28px 18px 14px; margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; }
@@ -240,6 +294,17 @@
.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; }
/* 区头版生成按钮(人物/场景 sec-h 复用 btn-aigen,商品卡版被 .prod-action 作用域锁死,这里给区头补壳) */
.asset-sec .sec-h .btn-aigen {
display: inline-flex; align-items: center; gap: 6px;
height: 30px; padding: 0 12px; background: var(--heat); color: var(--accent-white);
border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12.5px; font-weight: 500; cursor: pointer; font-family: inherit;
box-shadow: inset 0 -2px 4px rgba(250,93,25,.20), 0 1px 1px rgba(250,93,25,.12);
transition: background var(--t-base), box-shadow var(--t-base), transform var(--t-base);
}
.asset-sec .sec-h .btn-aigen:hover { background: #FB6E2E; transform: translateY(-1px); }
.asset-sec .sec-h .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; }
@@ -264,6 +329,8 @@
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard { gap: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas { border: 0; border-radius: 0; background: var(--surface); padding: 18px 14px 18px 28px; align-items: center; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img { width: 100%; }
/* 有真图时大图占满画布整个高度(不再 16/9 居中留上下空白) */
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-canvas > .sb-main-img.has-mock-media { align-self: stretch; aspect-ratio: auto; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side { display: flex; flex-direction: column; min-height: 0; }
.content--fh-flat .stage[data-stage-pane="3"].active > .stage-storyboard > .sb-side > .pane { flex: 1 1 0; min-height: 0; overflow-y: auto; border: 0; border-radius: 0; background: var(--surface); padding: 18px 28px; }
.content--fh-flat .stage[data-stage-pane="3"].active .sb-scenes-col { max-height: none; }
@@ -280,6 +347,8 @@
.sb-scene-thumb .nm { font-size: 11.5px; font-weight: 500; color: var(--accent-black); }
.sb-scene-thumb .sub { font-family: var(--font-mono); font-size: 10.5px; color: var(--black-alpha-48); }
.sb-main-img { aspect-ratio: 16/9; min-height: 0; }
/* 整张故事板是竖长拼图,cover 会裁掉上下 —— 用 contain 完整显示 */
.sb-main-img.has-mock-media { background-size: contain; }
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
@@ -344,9 +413,9 @@
.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 { 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; } }
/* 转场实时预览:切片段时画面淡场(导出才是真 xfade)。
opacity:0 兜底——@keyframes 必须在顶层(嵌套块内不生效),若动画没跑也不能变成永久黑盖板 */
.editor-preview .canvas .ed-xfade-flash { position: absolute; inset: 0; z-index: 3; background: #000; opacity: 0; pointer-events: none; animation: edXfadeFlash 0.45s ease forwards; }
.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); }
@@ -420,3 +489,6 @@
.playhead::before { content: ''; position: absolute; top: -4px; left: 50%; transform: translateX(-50%) rotate(45deg); width: 10px; height: 10px; background: var(--heat); box-shadow: 0 0 0 1.5px var(--surface); border-radius: 1px; pointer-events: none; }
.playhead .ph-grab { position: absolute; top: -10px; left: 50%; transform: translateX(-50%); width: 24px; height: 24px; cursor: ew-resize; pointer-events: auto; border-radius: 50%; }
}
/* 顶层 @keyframes:嵌套进 .pipeline-page 块内不会注册,动画不执行(product-detail 同坑) */
@keyframes edXfadeFlash { from { opacity: 0.72; } to { opacity: 0; } }
+7 -12
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { api } from "../api";
import type { BillingSummary, BillingTrend, Ledger, Project, TeamMember } from "../types";
import { money } from "./stage-config";
import { pageWindow } from "../components/pager";
const ROLE_LABEL: Record<string, string> = { owner: "超管", admin: "团管", member: "成员", viewer: "访客" };
const STATUS_LABEL: Record<string, string> = { active: "活跃", invited: "待激活", disabled: "已停用" };
@@ -30,18 +31,6 @@ const ledgerTypeLabel = (t: string) => LEDGER_TYPE_LABEL[t] ?? t;
const ledgerReasonLabel = (r: string) => LEDGER_REASON_LABEL[r] ?? r;
// 分页页码窗口:页数多时折叠成 1 … 当前±1 … 末页(≤7 页则全展开)
function pageWindow(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1);
const items: Array<number | "ellipsis"> = [1];
const start = Math.max(2, current - 1);
const end = Math.min(total - 1, current + 1);
if (start > 2) items.push("ellipsis");
for (let p = start; p <= end; p++) items.push(p);
if (end < total - 1) items.push("ellipsis");
items.push(total);
return items;
}
const RECHARGE: Array<{ amt: number; gift: string; bonus: boolean; bonusAmt: number; ribbon?: string }> = [
{ amt: 100, gift: "无赠送", bonus: false, bonusAmt: 0 },
{ amt: 500, gift: "+ ¥30 赠送", bonus: true, bonusAmt: 30, ribbon: "推荐" },
@@ -282,6 +271,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</div>
<div className={`tab-panel ${tab === "bills" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>时间</th><th>项目 / 类型</th><th>详情</th><th>成员</th><th>状态</th><th style={{ textAlign: "right" }}>金额</th></tr></thead>
<tbody>
@@ -299,6 +289,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
))}
</tbody>
</table>
</div>
{ledgerCount > BILLS_PER_PAGE && (
<div className="bill-pager">
<span className="total">// 共 {ledgerCount} 条 · 第 {safeBillPage} / {billTotalPages} 页</span>
@@ -316,6 +307,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</div>
<div className={`tab-panel ${tab === "by-project" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>项目</th><th>当前阶段</th><th>状态</th><th style={{ textAlign: "right" }}>消耗</th></tr></thead>
<tbody>
@@ -328,8 +320,10 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</tbody>
</table>
</div>
</div>
<div className={`tab-panel ${tab === "by-member" ? "active" : ""}`}>
<div className="billing-table-wrap">
<table className="billing-table">
<thead><tr><th>成员</th><th>角色</th><th>已用 / 月度额度</th><th>状态</th></tr></thead>
<tbody>
@@ -342,6 +336,7 @@ export function AccountPage({ billing, ledgers, trend, projects, teamMembers, on
</tbody>
</table>
</div>
</div>
</section>
);
}
+10 -14
View File
@@ -23,6 +23,7 @@ import {
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import { MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
import type { Page } from "./route-config";
import { statusPill } from "./stage-config";
import "../ai-tools-page.css";
@@ -133,9 +134,9 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
const [typeFilter, setTypeFilter] = useState("");
const [view, setView] = useState<"grid" | "list">("grid");
const [openChip, setOpenChip] = useState<"" | "time" | "type">("");
// 任务中心分页:每次加载 12 条,「加载更多」递增;筛选/搜索变化时重置
const TASKS_PER_LOAD = 12;
const [shown, setShown] = useState(TASKS_PER_LOAD);
// 任务中心分页:每页 10 条,筛选/搜索变化时回第 1 页
const TASKS_PER_PAGE = 10;
const [taskPage, setTaskPage] = useState(1);
useEffect(() => {
if (!openChip) return;
const close = (event: MouseEvent) => { if (!(event.target as HTMLElement).closest(".chip-wrap")) setOpenChip(""); };
@@ -163,10 +164,11 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
}
return true;
});
// 筛选条件变化时回到第一屏(12 条)
useEffect(() => { setShown(TASKS_PER_LOAD); }, [filter, query, timeFilter, typeFilter]);
const paged = visible.slice(0, shown);
const hasMore = visible.length > paged.length;
// 筛选条件变化时回到第 1 页
useEffect(() => { setTaskPage(1); }, [filter, query, timeFilter, typeFilter]);
const taskTotalPages = Math.max(1, Math.ceil(visible.length / TASKS_PER_PAGE));
const taskCurPage = Math.min(taskPage, taskTotalPages);
const paged = visible.slice((taskCurPage - 1) * TASKS_PER_PAGE, taskCurPage * TASKS_PER_PAGE);
return (
<div className="asset-factory">
@@ -366,13 +368,7 @@ export function AssetFactoryPage({ navigate, aiTasks, assets = [] }: { navigate:
</div>
)}
{hasMore && (
<div className="task-load-more">
<button className="btn" type="button" onClick={() => setShown((n) => n + TASKS_PER_LOAD)}>
加载更多 <span className="lm-rest">还有 {visible.length - paged.length} 个</span>
</button>
</div>
)}
<Pager page={taskCurPage} total={visible.length} pageSize={TASKS_PER_PAGE} onChange={setTaskPage} />
</div>
);
}
+13 -4
View File
@@ -30,8 +30,9 @@ export function AuthScreen({
onAuthed: (payload: { token: string; user: User; team: Team }) => void;
}) {
const [mode, setMode] = useState<AuthMode>(initialMode);
const [username, setUsername] = useState("li@shop.com");
const [password, setPassword] = useState("demo-1234");
// 不预填设计稿假账号:真实登录页字段必须从空开始
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [teamName, setTeamName] = useState("");
const [email, setEmail] = useState("");
const [registerPassword, setRegisterPassword] = useState("");
@@ -66,6 +67,9 @@ export function AuthScreen({
setBusy(true);
setError("");
try {
if (mode === "login") {
if (!username.trim() || !password) throw new Error("请输入邮箱和密码");
}
if (mode === "register") {
if (registerPassword.length < 8) throw new Error("密码至少 8 位");
if (registerPassword !== registerPassword2) throw new Error("两次密码不一致");
@@ -82,7 +86,12 @@ export function AuthScreen({
});
onAuthed(payload);
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
const raw = err instanceof Error ? err.message : "";
// 后端错误是英文原文(invalid credentials 等),映射成用户可读的中文
const friendly = /invalid credentials|unable to log in|non_field_errors/i.test(raw)
? "邮箱或密码不正确"
: raw || (mode === "login" ? "登录失败,请稍后重试" : "注册失败,请稍后重试");
setError(friendly);
} finally {
setBusy(false);
}
@@ -141,7 +150,7 @@ export function AuthScreen({
</div>
<div className="row-between">
<label><input type="checkbox" defaultChecked /> 记住我 7 天</label>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", "请到 li@shop.com 收件箱查看 · 链接 30 分钟有效"); }}>忘记密码?</a>
<a href="#" onClick={(event) => { event.preventDefault(); showToast("已发送重置邮件", `请到 ${username.trim() || "登录邮箱"} 收件箱查看 · 链接 30 分钟有效`); }}>忘记密码?</a>
</div>
{error && <div className="form-error">{error}</div>}
<button className="btn-cta" type="submit" disabled={busy}>
+64 -16
View File
@@ -2,10 +2,25 @@ import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Asset } from "../types";
import { ConfirmModal, Drawer, MediaLightbox } from "../components/overlays";
import { Pager } from "../components/pager";
// asset.source / asset.asset_type → 中文标签(筛选下拉用)
const LIB_PAGE_SIZE = 10;
// asset.source / asset.asset_type / asset.category → 中文标签(筛选下拉 + 卡片 meta 用,不给用户看裸枚举)
const SOURCE_LABELS: Record<string, string> = { upload: "上传", ai_generated: "AI 生成", exported: "导出", system: "系统" };
const KIND_LABELS: Record<string, string> = { image: "图片", video: "视频", audio: "音频", subtitle: "字幕", document: "文档" };
const CATEGORY_LABELS: Record<string, string> = { person: "人物", scene: "场景", product_image: "商品图", video_clip: "视频片段", final_video: "成片", upload: "上传", uncategorized: "未分类" };
// 上传文件 → 资产类型:按 MIME 推断,字幕按后缀,兜底文档(后端直接存这个值,不能一律写死 image)
function inferAssetType(file: File): string {
const mime = file.type || "";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
const ext = (file.name.split(".").pop() || "").toLowerCase();
if (["srt", "vtt", "ass"].includes(ext)) return "subtitle";
return "document";
}
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
@@ -44,6 +59,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const [drawer, setDrawer] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState("");
const [uploading, setUploading] = useState(false);
const [openChip, setOpenChip] = useState("");
const [srcFilter, setSrcFilter] = useState("");
const [kindFilter, setKindFilter] = useState("");
@@ -52,13 +68,17 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const [editMode, setEditMode] = useState(false);
const [metaFilter, setMetaFilter] = useState<Record<string, string>>({});
const [confirmId, setConfirmId] = useState<string | null>(null);
// 资产预览灯箱(图片放大 / 视频播放)
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video"; name: string } | null>(null);
// 资产预览灯箱(图片放大 / 视频播放 / 音频试听)
const [preview, setPreview] = useState<{ src: string; kind: "image" | "video" | "audio"; name: string } | null>(null);
useEffect(() => {
document.body.classList.toggle("edit-mode", editMode);
return () => document.body.classList.remove("edit-mode");
}, [editMode]);
// 分页:每页 10 个,切 tab / 改筛选 / 改排序回到第 1 页(数据多时全量渲染卡顿,视频卡每张挂一个 <video>)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, srcFilter, kindFilter, metaFilter, sortDesc]);
// 切 tab 时清空与该 tab 无关的筛选
useEffect(() => { setOpenChip(""); setSrcFilter(""); setKindFilter(""); setMetaFilter({}); }, [tab]);
useEffect(() => {
@@ -86,17 +106,29 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
const cmp = (b.created_at || "").localeCompare(a.created_at || "");
return sortDesc ? cmp : -cmp;
});
// 当前页切片(删除资产后页数缩水时钳回最后一页)
const totalPages = Math.max(1, Math.ceil(filtered.length / LIB_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * LIB_PAGE_SIZE, curPage * LIB_PAGE_SIZE);
async function submit(event: FormEvent) {
event.preventDefault();
if (!file) return;
if (!file || uploading) return;
const formData = new FormData();
formData.append("file", file);
formData.append("name", name || file.name);
formData.append("asset_type", "image");
formData.append("asset_type", inferAssetType(file));
formData.append("category", "upload");
// 上传 + 列表刷新要数秒,按钮给「上传中…」反馈,防止用户重复点
setUploading(true);
try {
await onUpload(formData);
setDrawer(false);
setFile(null);
setName("");
} finally {
setUploading(false);
}
}
return (
@@ -209,14 +241,21 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {inTab.length} 个资产</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个资产{filtered.length !== inTab.length ? "(已筛选)" : ""}</div>
{filtered.length ? (
<div className="asset-grid" id="asset-grid">
{filtered.map((asset) => {
{pageItems.map((asset) => {
const cover = asset.files?.find((f) => f.is_primary)?.preview_url || asset.files?.[0]?.preview_url || "";
const isVideo = asset.asset_type === "video";
const openPreview = cover ? () => setPreview({ src: cover, kind: isVideo ? "video" : "image", name: asset.name }) : undefined;
const isAudio = asset.asset_type === "audio";
const previewKind = isVideo ? "video" : isAudio ? "audio" : "image";
const openPreview = cover ? () => setPreview({ src: cover, kind: previewKind, name: asset.name }) : undefined;
// 卡片 meta:upload 分类下「上传 · 上传」无信息量,改显资产类型;其余显分类中文
const catLabel = (asset.category === "upload" ? KIND_LABELS[asset.asset_type] : CATEGORY_LABELS[asset.category || ""]) || asset.category;
const srcLabel = SOURCE_LABELS[asset.source || ""] || asset.source;
// 成片卡补导出时间(created_at = 导出入库时刻),格式 2026-06-09 18:21
const exportedAt = asset.category === "final_video" ? (asset.created_at || "").slice(0, 16).replace("T", " ") : "";
return (
<article className={`asset-card ${asset.asset_type}`} key={asset.id}>
{editMode && onDelete && (
@@ -224,17 +263,24 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<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" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
{cover
? (isVideo
? <>
<div className="placeholder asset-thumb" role={openPreview ? "button" : undefined} tabIndex={openPreview ? 0 : undefined} title={openPreview ? (isVideo ? "点击播放" : isAudio ? "点击试听" : "点击放大") : undefined} style={openPreview ? { cursor: isVideo || isAudio ? "pointer" : "zoom-in", position: "relative" } : undefined} onClick={openPreview} onKeyDown={openPreview ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPreview(); } } : undefined}>
{cover && isVideo && (
<>
<video src={cover} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</>
: <img src={cover} alt={asset.name} loading="lazy" />)
: <span className="ph-frame">{asset.asset_type}</span>}
)}
{isAudio && (
// 音频没有可视封面,preview_url 是音频文件本身,塞 <img> 必裂图 → 用默认音符占位
<span className="lib-audio-ph" aria-hidden="true">
<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 className="mono">AUDIO</span>
</span>
)}
{cover && !isVideo && !isAudio && <img src={cover} alt={asset.name} loading="lazy" />}
{!cover && !isAudio && <span className="ph-frame">{KIND_LABELS[asset.asset_type] || asset.asset_type}</span>}
</div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{asset.category} · {asset.source}</div></div>
<div className="asset-body"><div className="asset-name">{asset.name}</div><div className="asset-meta">{catLabel} · {srcLabel}{exportedAt && <span className="asset-meta-time"> · {exportedAt}</span>}</div></div>
</article>
);
})}
@@ -243,6 +289,8 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
<div className="empty-filter">// 当前分类暂无真实资产</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={LIB_PAGE_SIZE} onChange={setPage} />
<MediaLightbox open={!!preview} src={preview?.src || ""} kind={preview?.kind} name={preview?.name} close={() => setPreview(null)} />
<ConfirmModal
@@ -254,7 +302,7 @@ export function LibraryPage({ assets, onUpload, onDelete }: { assets: Asset[]; o
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>
<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" accept="image/*,video/*,audio/*,.srt,.vtt,.ass" 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 || uploading}>{uploading ? "上传中…" : "上传资产"}</button></div></form></Drawer>
</section>
);
}
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties, FormEvent, KeyboardEvent } from "react";
import { createPortal } from "react-dom";
import { ArrowLeft } from "lucide-react";
import { ConfirmModal, MediaLightbox } from "../components/overlays";
import { ConfirmModal, MediaLightbox, useBodyScrollLock } from "../components/overlays";
import { Pager } from "../components/pager";
const PROD_PAGE_SIZE = 10;
import type { Asset, Product, Project } from "../types";
import type { Page } from "./route-config";
import "../product-create-page.css";
@@ -66,6 +69,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
setSelected(new Set());
};
const [drawer, setDrawer] = useState(Boolean(autoOpenCreate));
useBodyScrollLock(drawer);
const [title, setTitle] = useState("");
const [category, setCategory] = useState("");
const [target, setTarget] = useState("");
@@ -120,6 +124,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
}
return matchQuery && matchCat && matchDate;
});
// 分页:每页 10 个,搜索/筛选变化回第 1 页
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [query, catFilter, dateFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROD_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROD_PAGE_SIZE, curPage * PROD_PAGE_SIZE);
function addBullet(event: KeyboardEvent<HTMLInputElement>) {
if (event.key !== "Enter") return;
@@ -210,12 +220,12 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
</div>
<div className="result-meta" id="result-meta">
<span>// 显示 <span className="count">{filtered.length}</span> / {products.length} 个商品</span>
<span>// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个商品</span>
</div>
<div className="product-grid-wrap">
<div className="product-grid" id="product-grid">
{filtered.map((product) => (
{pageItems.map((product) => (
<ProductCard
key={product.id}
product={product}
@@ -227,6 +237,7 @@ export function ProductsPage({ products, projects = [], navigate, openProduct, o
/>
))}
</div>
<Pager page={curPage} total={filtered.length} pageSize={PROD_PAGE_SIZE} onChange={setPage} />
</div>
</div>
+41 -11
View File
@@ -3,8 +3,11 @@ import type { CSSProperties, FormEvent } from "react";
import type { Product, Project } from "../types";
import type { Page } from "./route-config";
import { ConfirmModal, EmptyPanel } from "../components/overlays";
import { Pager } from "../components/pager";
import "../project-wizard-page.css";
const PROJ_PAGE_SIZE = 10; // 项目列表/网格每页条数
// 时长 / 脚本风格 / 人设 — 与 projects-new.html 基线对齐(创建页仅作视觉选择,
// 脚本走向细节进入 Stage 1;onCreate 契约只携带 name + product)。
const WIZ_DURATIONS = [
@@ -29,14 +32,18 @@ const WIZ_PERSONAS = [
const WIZ_PAGE_SIZE = 7; // 4 列 × 2 行 = 8 格,首格为「创建新商品」→ 每页 7 商品
export function ProjectWizardPage({ products, onBack, onCreate }: {
export function ProjectWizardPage({ products, projects = [], onBack, onCreate }: {
products: Product[];
projects?: Project[];
onBack: () => 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];
const [name, setName] = useState(() => `${(products[0]?.title || "商品").split(" ")[0]} · 痛点种草 · v1`);
// 默认项目名跟随所选商品+脚本风格,版本号按已有同名项目自动递增(避免连建多个同名 v1);
// 用户手动改过名字后不再覆盖。
const [name, setName] = useState("");
const [nameTouched, setNameTouched] = useState(false);
// Step 1 · 商品选择器本地交互态
const [pickSearch, setPickSearch] = useState("");
@@ -56,6 +63,15 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
if (!productId && products[0]) setProductId(products[0].id);
}, [productId, products]);
useEffect(() => {
if (nameTouched) return;
const styleName = WIZ_STYLES.find((s) => s.id === scriptStyle)?.name || "痛点种草";
const base = `${(product?.title || "商品").split(" ")[0]} · ${styleName}`;
let version = 1;
while (projects.some((p) => p.name === `${base} · v${version}`)) version += 1;
setName(`${base} · v${version}`);
}, [nameTouched, product, scriptStyle, projects]);
// 分类清单
const cats = useMemo(
() => ["全部", ...Array.from(new Set(products.map((p) => p.category || "未分类")))],
@@ -119,12 +135,16 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
const config2Done = !!duration && !!styleObj && name.trim().length >= 2;
const canStart = product1Done && config2Done;
function submit(event: FormEvent) {
// 提交中状态:创建项目后 App 端全量刷新需数秒,按钮要给「创建中…」反馈,避免用户以为没点上
const [starting, setStarting] = useState(false);
async function submit(event: FormEvent) {
event.preventDefault();
if (!canStart || !product) return;
if (!canStart || !product || starting) return;
// 向导选项(时长档/脚本风格/人设/选中卖点)随项目一起持久化进 metadata,Stage 1 生成脚本时可用
const selectedPoints = Object.entries(points).filter(([, on]) => on).map(([id]) => id);
void onCreate({
setStarting(true);
try {
await onCreate({
name: name.trim() || `${product.title} · 短视频`,
product: product.id,
metadata: {
@@ -136,6 +156,9 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
}
}
});
} finally {
setStarting(false);
}
}
const productCover = (p: Product): CSSProperties | undefined => {
@@ -273,7 +296,7 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
<div className="config-row">
<div className="field">
<label className="field-label">项目名称<span className="req">*</span></label>
<input className="input" value={name} onChange={(event) => setName(event.target.value)} />
<input className="input" value={name} onChange={(event) => { setName(event.target.value); setNameTouched(true); }} />
</div>
<div className="field">
<label className="field-label">视频时长<span className="req">*</span></label>
@@ -346,9 +369,9 @@ export function ProjectWizardPage({ products, onBack, onCreate }: {
{/* ── 底部「开始」CTA ── */}
<div className="wiz-start-bar">
<button className={`btn-start${canStart ? "" : " disabled"}`} type="submit" disabled={!canStart}>
<button className={`btn-start${canStart && !starting ? "" : " disabled"}`} type="submit" disabled={!canStart || starting}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 3l14 9-14 9V3z" /></svg>
<span>开始</span>
<span>{starting ? "创建中…" : "开始"}</span>
</button>
</div>
</div>
@@ -455,6 +478,12 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
}
return true;
});
// 分页:每页 10 个,切 tab / 搜索 / 筛选回第 1 页(列表与网格共用)
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [tab, query, catFilter, sourceFilter, timeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PROJ_PAGE_SIZE));
const curPage = Math.min(page, totalPages);
const pageItems = filtered.slice((curPage - 1) * PROJ_PAGE_SIZE, curPage * PROJ_PAGE_SIZE);
async function confirmDelete() {
if (!deleteTarget) return;
@@ -545,7 +574,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{filtered.length}</span> / {projects.length} 个项目</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{pageItems.length}</span> / {filtered.length} 个项目</div>
{view === "list" ? (
<div id="list-view">
@@ -562,7 +591,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</tr>
</thead>
<tbody id="list-tbody">
{filtered.map((project) => {
{pageItems.map((project) => {
const no = projStageNo(project);
const shots = project.video_segments.length || 4;
const cover = projCover(project.name);
@@ -603,7 +632,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
</table>
</div>
) : (
<div className="proj-grid">{filtered.map((project) => {
<div className="proj-grid">{pageItems.map((project) => {
const cover = projCover(project.name);
return (
<article className="proj-card" key={project.id} onClick={() => openPipeline(project.id)}>
@@ -617,6 +646,7 @@ export function ProjectsPage({ products, projects, navigate, openPipeline, onDel
);
})}</div>
)}
<Pager page={curPage} total={filtered.length} pageSize={PROJ_PAGE_SIZE} onChange={setPage} />
{filtered.length === 0 && <EmptyPanel title="当前筛选下没有项目" action="新建视频项目" onAction={() => navigate("projectWizard")} />}
<ConfirmModal open={Boolean(deleteTarget)} title="确认删除项目" detail={`即将删除 ${deleteTarget?.name || ""}。`} confirmText="删除" onCancel={() => setDeleteTarget(null)} onConfirm={confirmDelete} />
</section>
+3 -1
View File
@@ -104,6 +104,7 @@ export function SettingsPage({
onUploadAvatar,
onResetAvatar,
onNotify,
onLogout,
}: {
user: User;
team: Team;
@@ -118,6 +119,7 @@ export function SettingsPage({
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
onResetAvatar?: () => void | Promise<unknown>;
onNotify?: (text: string) => void;
onLogout?: () => void | Promise<void>;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
@@ -676,7 +678,7 @@ export function SettingsPage({
icon={<LogOut size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<button className="btn btn-primary" type="button" onClick={() => { setModal(""); void onLogout?.(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
确认退出
</button>
+17 -3
View File
@@ -1,9 +1,12 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { CircleDollarSign, KeyRound, UserPlus } from "lucide-react";
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";
import { Pager } from "../components/pager";
const MEMBERS_PER_PAGE = 10;
// 角色 → pill key/label(对齐 api-bridge roleUi)
function roleUi(role: string): { key: "super" | "admin" | "member"; label: string } {
@@ -84,6 +87,12 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
const email = member.user.email || "";
return !needle || `${name} ${email}`.toLowerCase().includes(needle);
});
// 成员分页:每页 10 人,搜索变化回第 1 页
const [memberPage, setMemberPage] = useState(1);
useEffect(() => { setMemberPage(1); }, [search]);
const memberTotalPages = Math.max(1, Math.ceil(list.length / MEMBERS_PER_PAGE));
const memberCurPage = Math.min(memberPage, memberTotalPages);
const pagedMembers = list.slice((memberCurPage - 1) * MEMBERS_PER_PAGE, memberCurPage * MEMBERS_PER_PAGE);
function openEdit(member: TeamMember) {
setEditTarget(member);
@@ -93,13 +102,15 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
async function submitCreate() {
if (!cuUser.trim() || cuPass.length < 8) return;
await onCreateMember({
const created = await onCreateMember({
username: cuUser.trim(),
password: cuPass,
name: cuName.trim() || undefined,
role: cuRole,
monthly_credit_limit: Number(cuMonthly) || 0
});
// 创建失败(action 报错返回 null)时保持弹窗与已填内容,只成功才关闭并清空
if (!created) return;
setModal("");
setCuUser("");
setCuPass("");
@@ -241,6 +252,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
<span className="spacer"></span>
<input className="input" id="member-search" placeholder="搜索姓名 / 手机号" style={{ height: "32px", fontSize: "12px", width: "220px" }} value={search} onChange={(event) => setSearch(event.target.value)} />
</div>
<div className="members-table-wrap">
<table className="t members-table" style={{ border: 0, borderRadius: 0 }}>
<thead>
<tr>
@@ -253,7 +265,7 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
</tr>
</thead>
<tbody id="members-tbody">
{list.map((member) => {
{pagedMembers.map((member) => {
const rawName = (member.user.username || "").trim();
const email = (member.user.email || "").trim();
// 用户名是邮箱时取 @ 前作为显示名,完整邮箱作副行,避免名字与邮箱重复显示
@@ -288,6 +300,8 @@ export function TeamPage({ team, user, members, billing, notifications = [], nav
</tbody>
</table>
</div>
<Pager page={memberCurPage} total={list.length} pageSize={MEMBERS_PER_PAGE} onChange={setMemberPage} />
</div>
</div>
{/* 右:权限矩阵 */}
+4 -2
View File
@@ -233,7 +233,8 @@ main { position: relative; overflow: hidden; background: var(--bg); }
.icon-btn .dot-noti { position: absolute; top: 8px; right: 9px; width: 7px; height: 7px; border-radius: 50%; background: var(--orange); border: 1.5px solid var(--card); }
/* ─── Content ─── */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; max-width: 1480px; }
/* 内容区始终紧贴侧栏铺满整个剩余宽度(与生产管线全屏页一致),不限宽、不居中 —— 否则宽屏会右侧留白或内容居中变窄 */
.content { padding: 36px 48px 60px; position: relative; z-index: 1; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 28px; gap: 16px; flex-wrap: wrap; }
.page-head h1 { font-size: 26px; font-weight: 600; letter-spacing: -.018em; line-height: 1.2; }
.page-head .sub { font-size: 13.5px; color: var(--ink-2); margin-top: 6px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
@@ -1224,7 +1225,8 @@ nav button svg { width: 14px; height: 14px; opacity: .85; }
.asset-card { background: var(--card); border: 1px solid var(--border); cursor: pointer; transition: background .15s; }
.asset-card:hover { background: var(--bg-soft); border-color: var(--ink-3); }
.asset-thumb { aspect-ratio: 1; }
.asset-card.video .asset-thumb { aspect-ratio: 9/16; max-height: 280px; }
/* width:100% 必须显式给:否则 max-height 会经 aspect-ratio 把宽度也钳到 157.5px,卡片右侧露白 */
.asset-card.video .asset-thumb { width: 100%; aspect-ratio: 9/16; max-height: 280px; }
.asset-body { padding: 10px 12px; }
.asset-name { font-size: 13px; font-weight: 600; }
.asset-meta { font-size: 11px; color: var(--ink-3); margin-top: 3px; font-family: 'JetBrains Mono', monospace; letter-spacing: .02em; }
+19 -2
View File
@@ -88,6 +88,15 @@ export type ScriptVersion = {
updated_at?: string;
};
export type VideoSegmentVersion = {
id: string;
asset: string | null;
asset_url: string;
prompt: string;
is_adopted: boolean;
created_at: string;
};
export type VideoSegment = {
id: string;
sort_order: number;
@@ -97,6 +106,7 @@ export type VideoSegment = {
adopted_version: string | null;
adopted_asset?: string | null;
adopted_asset_url?: string;
versions?: VideoSegmentVersion[];
};
export type StoryboardVersion = {
@@ -116,6 +126,9 @@ export type ExportPoll = {
error_message?: string;
};
export type VoiceoverItem = { index: number; cue?: number; offset_ms?: number; text: string; asset: string; asset_url: string; duration_ms: number };
export type VoiceoverInfo = { enabled: boolean; voice_type: string; speed_ratio: number; items: VoiceoverItem[] };
export type Timeline = {
id: string;
name: string;
@@ -123,10 +136,11 @@ export type Timeline = {
resolution: string;
duration_seconds: number;
metadata?: { transition?: { type: string }; draft?: Record<string, unknown> };
voiceover?: VoiceoverInfo | null;
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 }>;
content: Array<{ start_ms: number; end_ms?: number; text: string }>;
style?: Record<string, unknown>;
enabled: boolean;
}>;
@@ -144,9 +158,10 @@ 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 }> };
subtitle?: { enabled?: boolean; style_key?: string; content?: Array<{ start_ms: number; end_ms?: number; text: string }> };
bgm?: { volume?: number; clear?: boolean };
transition?: { type: string };
voiceover?: { enabled?: boolean; clear?: boolean; items?: Array<{ asset: string; offset_ms: number }> };
draft?: Record<string, unknown>;
};
@@ -168,9 +183,11 @@ export type Project = {
adopted_asset_url?: string;
candidate_assets: string[];
candidate_asset_urls?: Record<string, string>;
created_at?: string;
}>;
storyboard_versions: StoryboardVersion[];
timeline: Timeline | null;
metadata?: { wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] } } & Record<string, unknown>;
created_at: string;
updated_at: string;
};
+159
View File
@@ -0,0 +1,159 @@
"""资金核算审计:针对真实 DB,逐条验证账目不变量,任何不平即报。
跑法:cd backend && DJANGO_SETTINGS_MODULE=airshelf.settings.development .venv/bin/python ../qa/audit_billing.py
不变量:
I1 余额自洽 balance == Σ(recharge+refund) - Σ(charge) + Σ(adjustment 带符号)
I2 冻结自洽 reserved_balance == Σ(amount of ACTIVE reservations)
I3 非负 & 可用 balance>=0, reserved>=0, balance>=reserved(可用余额>=0)
I4 预留闭环 每个 reservation 状态与其 ledger 一致;CHARGED 恰好 1 条 charge 且 amount<=预留
I5 失败不扣费 失败 task 的 reservation 不得为 CHARGED
I6 无重复扣费 同一 task 不得有 >1 条 charge ledger
I7 流水连续 按时间重放,recharge/charge 的 balance_after 与重放值一致
"""
import os, sys
from decimal import Decimal
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
import django # noqa: E402
django.setup()
from django.db.models import Sum # noqa: E402
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation # noqa: E402
from apps.ai.models import AITask # noqa: E402
Z = Decimal("0")
problems = []
def flag(account, code, msg):
problems.append((str(account.team), code, msg))
print(f" ✗ [{code}] {msg}")
def audit_account(acct):
team = acct.team
led = CreditLedger.objects.filter(team=team)
print(f"\n== team={team} | balance={acct.balance} reserved={acct.reserved_balance} ==")
def s(t):
return led.filter(ledger_type=t).aggregate(x=Sum("amount"))["x"] or Z
recharge, charge, refund, adjustment = s("recharge"), s("charge"), s("refund"), s("adjustment")
# I1 余额=流水终点:account.balance 应等于最后一条动-balance 流水的 balance_after(不依赖开户 genesis)
last_bal_led = led.filter(ledger_type__in=["recharge", "refund", "charge", "adjustment"]).order_by("created_at", "id").last()
if last_bal_led is not None:
if acct.balance != last_bal_led.balance_after:
flag(acct, "I1", f"账户余额与流水终点脱节:account.balance={acct.balance} 但末条动账流水 balance_after={last_bal_led.balance_after}")
else:
print(f" ✓ I1 余额=流水终点 {acct.balance}")
else:
print(f" · I1 无动账流水(纯开户额度 {acct.balance})")
# I2 冻结自洽
active_sum = CreditReservation.objects.filter(team=team, status="active").aggregate(x=Sum("amount"))["x"] or Z
if acct.reserved_balance != active_sum:
flag(acct, "I2", f"冻结不平:account.reserved_balance={acct.reserved_balance} 但 ACTIVE 预留之和={active_sum}(差 {acct.reserved_balance - active_sum})")
else:
print(f" ✓ I2 冻结自洽 ACTIVE 预留之和={active_sum}")
# I3 非负 & 可用
if acct.balance < Z:
flag(acct, "I3", f"余额为负 {acct.balance}")
if acct.reserved_balance < Z:
flag(acct, "I3", f"冻结为负 {acct.reserved_balance}")
if acct.balance < acct.reserved_balance:
flag(acct, "I3", f"可用余额为负:balance {acct.balance} < reserved {acct.reserved_balance}")
if acct.balance >= Z and acct.reserved_balance >= Z and acct.balance >= acct.reserved_balance:
print(f" ✓ I3 非负&可用余额>=0(可用 {acct.balance - acct.reserved_balance})")
# I4/I5/I6 预留闭环 + 失败不扣 + 无重复扣
i4 = i5 = i6 = True
for r in CreditReservation.objects.filter(team=team).select_related("task"):
charges = led.filter(task=r.task, ledger_type="charge")
charge_sum = charges.aggregate(x=Sum("amount"))["x"] or Z
adj = led.filter(task=r.task, ledger_type="adjustment").aggregate(x=Sum("amount"))["x"] or Z
net_charge = charge_sum - adj # 扣费扣除回冲后的净额
dup_reconciled = charges.count() > 1 and net_charge == r.amount
if charges.count() > 1:
if dup_reconciled:
print(f" · I6 task={str(r.task_id)[:8]} 历史双扣 {charges.count()} 笔,已被 adjustment 回冲(净扣 {net_charge} = 预留 {r.amount},账平)")
else:
i6 = False
flag(acct, "I6", f"task={str(r.task_id)[:8]} {charges.count()} 条扣费,净扣 {net_charge} != 预留 {r.amount}(未完全回冲,真多扣)")
if r.status == "charged":
if charges.count() != 1 and not dup_reconciled:
i4 = False
flag(acct, "I4", f"reservation(task={str(r.task_id)[:8]}) 状态 CHARGED 却有 {charges.count()} 条扣费")
elif net_charge > r.amount:
i4 = False
flag(acct, "I4", f"净扣费 {net_charge} > 预留 {r.amount}(task={str(r.task_id)[:8]})")
elif r.status == "active":
if charges.exists():
i4 = False
flag(acct, "I4", f"reservation ACTIVE 却已有扣费(task={str(r.task_id)[:8]})")
# I5 失败不扣费
task = r.task
if task and task.status in ("failed", "cancelled") and r.status == "charged":
i5 = False
flag(acct, "I5", f"失败/取消 task={str(r.task_id)[:8]}(status={task.status})却被扣费")
if i4:
print(" ✓ I4 预留状态与扣费/释放流水一致")
if i5:
print(" ✓ I5 失败/取消任务均未扣费")
if i6:
print(" ✓ I6 无重复扣费")
# I7 逐笔差分自洽(不依赖开户起点):每条动账流水的 balance_after 必须 = 前一条动账流水的 balance_after ± 本笔金额。
# reserve/release 不动 balance,其 balance_after 应 = 当时余额快照(上一条动账流水的 balance_after)。
i7 = True
prev_bal = None # 上一条动账后的余额
for L in led.order_by("created_at", "id"):
if L.ledger_type in ("recharge", "refund", "adjustment"):
expected = (prev_bal if prev_bal is not None else Z) + L.amount
if prev_bal is not None and L.balance_after != expected:
i7 = False
flag(acct, "I7", f"差分不符 {L.ledger_type}@{str(L.created_at)[:19]} amount={L.amount} 记录={L.balance_after} 应为前值{prev_bal}+{L.amount}={expected}")
prev_bal = L.balance_after
elif L.ledger_type == "charge":
if prev_bal is not None:
expected = prev_bal - L.amount
if L.balance_after != expected:
i7 = False
flag(acct, "I7", f"差分不符 charge@{str(L.created_at)[:19]} amount={L.amount} 记录={L.balance_after} 应为前值{prev_bal}-{L.amount}={expected}")
prev_bal = L.balance_after
else: # reserve / release:不动 balance,快照应等于上一条动账后的余额
if prev_bal is not None and L.balance_after != prev_bal:
i7 = False
flag(acct, "I7", f"reserve/release 余额快照漂移@{str(L.created_at)[:19]} 记录={L.balance_after} 应={prev_bal}")
if i7:
print(" ✓ I7 逐笔差分自洽(每条 balance_after = 前一条 ± 本笔金额)")
# I8 流水完整性(可审计):首条流水之前的隐含余额应为 0(开户额度也应有 genesis 流水)
first = led.order_by("created_at", "id").first()
if first is not None:
delta = first.amount if first.ledger_type in ("recharge", "refund", "adjustment") else (-first.amount if first.ledger_type == "charge" else Z)
opening = first.balance_after - delta
if opening != Z:
flag(acct, "I8", f"开户额度无流水凭证:首条流水前隐含余额={opening}(应为 0,缺 genesis 赠送流水)")
else:
print(" ✓ I8 流水完整(开户额度有凭证)")
def main():
accts = CreditAccount.objects.select_related("team").all()
print(f"审计 {accts.count()} 个团队账户…")
for a in accts:
audit_account(a)
print("\n" + "=" * 60)
if problems:
print(f"发现 {len(problems)} 处账目问题:")
for team, code, msg in problems:
print(f" [{code}] {team}: {msg}")
sys.exit(1)
print("✓ 全部账户账目自洽,无金额核算错误")
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
"""一次性回填:给历史账户补开户赠送的 genesis 流水(只补凭证,不改任何余额)。
对每个 account,若"首条流水之前的隐含余额">0 且尚无 trial_grant 流水,补一条 RECHARGE,
created_at 设为开户时间(早于所有流水),balance_after=该隐含开户额度。幂等可重跑。
跑法:cd backend && DJANGO_SETTINGS_MODULE=airshelf.settings.development .venv/bin/python ../qa/backfill_genesis_ledger.py
"""
import os, sys
from decimal import Decimal
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
import django # noqa: E402
django.setup()
from django.db import transaction # noqa: E402
from apps.billing.models import CreditAccount, CreditLedger # noqa: E402
Z = Decimal("0")
fixed = 0
for acct in CreditAccount.objects.select_related("team").all():
led = CreditLedger.objects.filter(team=acct.team)
if led.filter(metadata__kind="trial_grant").exists():
continue # 已有 genesis
first = led.order_by("created_at", "id").first()
if first is None:
# 纯开户无任何流水:开户额度即 balance
opening = acct.balance
genesis_time = acct.created_at
else:
delta = first.amount if first.ledger_type in ("recharge", "refund", "adjustment") else (-first.amount if first.ledger_type == "charge" else Z)
opening = first.balance_after - delta
genesis_time = acct.created_at if acct.created_at < first.created_at else first.created_at
if opening <= Z:
continue
with transaction.atomic():
g = CreditLedger.objects.create(
team=acct.team,
user=acct.team.owner,
ledger_type=CreditLedger.Type.RECHARGE,
amount=opening,
balance_after=opening,
reason="新用户试用额度赠送(历史回填)",
metadata={"kind": "trial_grant", "backfilled": True},
)
# created 字段由 auto_now_add 控制,需手动回拨到开户时刻,保证它排在所有流水之前
CreditLedger.objects.filter(id=g.id).update(created_at=genesis_time)
fixed += 1
print(f" + {acct.team} 补 genesis ¥{opening} @ {genesis_time:%Y-%m-%d %H:%M}")
print(f"\n回填完成:{fixed} 个账户补齐 genesis 流水(余额未变动)")
+3 -1
View File
@@ -46,7 +46,9 @@ fs.mkdirSync(outDir, { recursive: true });
// 只跳「单击即不可逆」的真实付费/真生成/真导出 + 弹窗里的最终确认按钮。
// 触发型(删除/移除/下线/充值/退出登录 = 点了只是开二次确认弹窗或跳转)不在此列,照常点验。
const DESTRUCTIVE = /(微信支付|支付宝|立即支付|去支付|确认支付|确认充值|立即生成|生成脚本|生成基础资产|生成故事板|生成分镜|提交片段|提交视频|提交生成|提交导出|重新导出|导出\s*MP4|确认删除|确认移除|确认退出|确认下线)/i;
// ★ 2026-06-10 教训:漏了「整体重写/AI 全生/重新生成/重跑/确认脚本」,一轮审计对演示项目真跑了
// 6 次 generate-script(真扣费 ¥6)。凡单击即触发真实 AI 生成/落库写的按钮文字必须进这张表。
const DESTRUCTIVE = /(微信支付|支付宝|立即支付|去支付|确认支付|确认充值|立即生成|生成脚本|生成基础资产|生成故事板|生成分镜|提交片段|提交视频|提交生成|提交导出|重新导出|导出\s*MP4|确认删除|确认移除|确认退出|确认下线|整体重写|AI\s*全生|重新生成全部|重跑|开始生成视频|确认脚本|跳过故事板|采用此版本|AI\s*生成(三视图|人物|场景)|保存草稿|删除本场|添加分镜|发送|^生成$)/i;
async function login() {
if (TOKEN) return TOKEN;
@@ -2,15 +2,15 @@
"name": "account",
"route": "/account",
"url": "http://127.0.0.1:5173/account",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 26,
"works": 21,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 2,
"noop": 3,
"skipped": 5,
"noop": 1,
"blocked": 0
},
"results": [
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -270,17 +270,17 @@
"idx": 14,
"tag": "div",
"role": "button",
"cls": "recharge-card.selected",
"cls": "recharge-card.",
"label": "推荐¥500+ ¥30 赠送",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"self": true,
"mutations": 9,
"api": 0
}
},
@@ -401,7 +401,7 @@
"tag": "button",
"role": "",
"cls": "tab.",
"label": "账单流水 100",
"label": "账单流水 254",
"href": "",
"disabled": false,
"active": false,
@@ -416,64 +416,27 @@
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "chip.active",
"label": "日",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "chip",
"label": "周",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 38,
"api": 1
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 25,
"tag": "button",
"role": "",
"cls": "chip",
"label": "月",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 38,
"api": 1
}
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"账户",
"账单流水 30天"
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户"
]
}
@@ -1,16 +1,15 @@
# 功能审计 · account
路由:`/account` · 模式:isolated
合计 26 · ✅ works 21 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)2 · ◷ noop-active 3 · ⚪ disabled 0
路由:`/account` · 模式:quick
合计 26 · ✅ works 20 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)5 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 账单流水 30天
## ⏭ 破坏性按钮(未自动点,需人工验)
- 微信支付 `button`
@@ -2,15 +2,15 @@
"name": "asset-factory",
"route": "/asset-factory",
"url": "http://127.0.0.1:5173/asset-factory",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 24,
"works": 22,
"works": 23,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 2,
"noop": 1,
"blocked": 0
},
"results": [
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "收窄导航",
"label": "展开导航",
"href": "",
"disabled": false,
"active": false,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -338,7 +338,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 10,
"mutations": 23,
"api": 0
}
},
@@ -356,7 +356,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 11,
"mutations": 30,
"api": 0
}
},
@@ -365,7 +365,7 @@
"tag": "div",
"role": "button",
"cls": "tab",
"label": "失败 0",
"label": "失败 5",
"href": "",
"disabled": false,
"active": false,
@@ -374,7 +374,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 10,
"mutations": 24,
"api": 0
}
},
@@ -398,13 +398,13 @@
},
{
"idx": 21,
"tag": "button",
"role": "",
"cls": "chip",
"label": "任务类型",
"tag": "div",
"role": "button",
"cls": "mi.selected",
"label": "全部时间",
"href": "",
"disabled": false,
"active": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
@@ -418,8 +418,8 @@
"idx": 22,
"tag": "button",
"role": "",
"cls": "",
"label": "网格",
"cls": "clear-filters",
"label": "清空筛选",
"href": "",
"disabled": false,
"active": false,
@@ -428,7 +428,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 26,
"mutations": 16,
"api": 0
}
},
@@ -437,24 +437,25 @@
"tag": "button",
"role": "",
"cls": "active",
"label": "列表",
"label": "网格",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"self": true,
"mutations": 15,
"api": 0
}
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"账户"
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"时间"
]
}
@@ -1,13 +1,14 @@
# 功能审计 · asset-factory
路由:`/asset-factory` · 模式:isolated
合计 24 · ✅ works 22 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 2 · ⚪ disabled 0
路由:`/asset-factory` · 模式:quick
合计 24 · ✅ works 23 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 时间
@@ -2,14 +2,14 @@
"name": "dashboard",
"route": "/dashboard",
"url": "http://127.0.0.1:5173/dashboard",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 25,
"works": 25,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"skipped": 5,
"noop": 0,
"blocked": 0
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 30,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 29,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 41,
"mutations": 42,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 27,
"api": 0
"api": 1
}
},
{
@@ -208,7 +208,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 54,
"api": 2
}
},
@@ -226,8 +226,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
"mutations": 28,
"api": 1
}
},
{
@@ -244,7 +244,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 25,
"mutations": 30,
"api": 0
}
},
@@ -271,7 +271,7 @@
"tag": "button",
"role": "",
"cls": "stat",
"label": "余额 ¥¥11116.00已冻结 ¥5.00",
"label": "余额 ¥¥14908.00已冻结 ¥0.00",
"href": "",
"disabled": false,
"active": false,
@@ -281,7 +281,7 @@
"overlay": false,
"self": false,
"mutations": 27,
"api": 0
"api": 1
}
},
{
@@ -311,14 +311,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 5,
"api": 1
}
"verdict": "skipped-destructive"
},
{
"idx": 17,
@@ -329,50 +322,29 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 5,
"api": 1
}
"verdict": "skipped-destructive"
},
{
"idx": 18,
"tag": "button",
"role": "",
"cls": "recent-row",
"label": "9:16桥接测试项目 20260529透真玻尿酸补水面膜 / AI 全生 / 4 镜脚本继续",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 5,
"api": 1
}
},
{
"idx": 19,
"tag": "button",
"role": "",
"cls": "recent-row",
"label": "9:16南卡 · 痛点种草 · v1南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 5,
"api": 1
}
"verdict": "skipped-destructive"
},
{
"idx": 19,
"tag": "button",
"role": "",
"cls": "recent-row",
"label": "9:16桥接测试项目 20260529透真玻尿酸补水面膜 / AI 全生 / 4 镜脚本继续",
"href": "",
"disabled": false,
"active": false,
"verdict": "skipped-destructive"
},
{
"idx": 20,
@@ -383,14 +355,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 5,
"api": 1
}
"verdict": "skipped-destructive"
},
{
"idx": 21,
@@ -406,7 +371,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 25,
"mutations": 30,
"api": 0
}
},
@@ -415,7 +380,7 @@
"tag": "a",
"role": "",
"cls": "shortcut",
"label": "资产库67 资产",
"label": "资产库101 资产",
"href": "",
"disabled": false,
"active": false,
@@ -424,7 +389,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 41,
"mutations": 42,
"api": 0
}
},
@@ -433,7 +398,7 @@
"tag": "a",
"role": "",
"cls": "shortcut",
"label": "充值¥11116.00",
"label": "充值¥14908.00",
"href": "",
"disabled": false,
"active": false,
@@ -443,7 +408,7 @@
"overlay": false,
"self": false,
"mutations": 27,
"api": 0
"api": 1
}
},
{
@@ -467,24 +432,22 @@
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,908.00",
"账户",
"总项目 ALL 8 本月 +3",
"进行中 WIP 3 2 个待审核",
"本月成片 DONE 3 较上月 +33%",
"余额 ¥ ¥327.40 已用 ¥162.60 / ¥500",
"[ ALL · 8 ]",
"9:16 补水面膜 · 痛点种草 · v3 透真补水面膜 / AI 全生 / 7 镜 故事板 待",
"9:16 透真防晒 · 通勤对比 透真防晒 / AI 全生 / 6 镜 已完成 打开",
"9:16 蓝牙耳机 · 开箱测评 Pro 4 蓝牙耳机 / 自带脚本 / 6 镜 视频生成 4/",
"9:16 春日新品 · 立体口红 凝彩立体口红 / 一句话 / 5 镜 资产生成中 继续",
"9:16 咖啡冻干 · 早八 冷萃咖啡冻干 / 一句话 / 5 镜 故事板失败 查看",
"资产库人 8 · 景 14 · 片 8",
"故事板 待确认",
"总项目 ALL 6 本月完成 2",
"进行中 WIP 4 全部正常推进",
"本月成片 DONE 2 累计完成 2",
"余额 ¥ ¥14,908.00 已用 ¥113.00 / ¥15,021.00",
"[ ALL · 6 ]",
"9:16真应用验证项目 · 补水面膜种草透真玻尿酸补水面膜 / AI 全生 / 4 镜基础资产生",
"9:16端到端播放器验证南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜已完成打开",
"9:16桥接测试项目 20260529透真玻尿酸补水面膜 / AI 全生 / 4 镜脚本待生成继",
"资产库资产 20 个",
"充值¥14,908.00",
"基础资产生成中",
"已完成",
"视频生成 4/6",
"资产生成中",
"故事板失败"
"脚本待生成",
"故事板生成中"
]
}
@@ -1,29 +1,34 @@
# 功能审计 · dashboard
路由:`/dashboard` · 模式:isolated
合计 25 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
路由:`/dashboard` · 模式:quick
合计 25 · ✅ works 20 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)5 · ◷ noop-active 0 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,908.00
- 账户
- 总项目 ALL 8 本月 +3
- 进行中 WIP 3 2 个待审核
- 本月成片 DONE 3 较上月 +33%
- 余额 ¥ ¥327.40 已用 ¥162.60 / ¥500
- [ ALL · 8 ]
- 9:16 补水面膜 · 痛点种草 · v3 透真补水面膜 / AI 全生 / 7 镜 故事板 待
- 9:16 透真防晒 · 通勤对比 透真防晒 / AI 全生 / 6 镜 已完成 打开
- 9:16 蓝牙耳机 · 开箱测评 Pro 4 蓝牙耳机 / 自带脚本 / 6 镜 视频生成 4/
- 9:16 春日新品 · 立体口红 凝彩立体口红 / 一句话 / 5 镜 资产生成中 继续
- 9:16 咖啡冻干 · 早八 冷萃咖啡冻干 / 一句话 / 5 镜 故事板失败 查看
- 资产库人 8 · 景 14 · 片 8
- 故事板 待确认
- 总项目 ALL 6 本月完成 2
- 进行中 WIP 4 全部正常推进
- 本月成片 DONE 2 累计完成 2
- 余额 ¥ ¥14,908.00 已用 ¥113.00 / ¥15,021.00
- [ ALL · 6 ]
- 9:16真应用验证项目 · 补水面膜种草透真玻尿酸补水面膜 / AI 全生 / 4 镜基础资产生
- 9:16端到端播放器验证南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜已完成打开
- 9:16桥接测试项目 20260529透真玻尿酸补水面膜 / AI 全生 / 4 镜脚本待生成继
- 资产库资产 20 个
- 充值¥14,908.00
- 基础资产生成中
- 已完成
- 视频生成 4/6
- 资产生成中
- 故事板失败
- 脚本待生成
- 故事板生成中
## ⏭ 破坏性按钮(未自动点,需人工验)
- 9:16真应用验证项目 · 补水面膜种草透真玻尿酸补水面膜 / AI 全生 / 4 镜基础资产继 `button`
- 9:16端到端播放器验证南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜拼接导出继续 `button`
- 9:16南卡 · 痛点种草 · v1南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜 `button`
- 9:16桥接测试项目 20260529透真玻尿酸补水面膜 / AI 全生 / 4 镜脚本继续 `button`
- 9:16南卡 · 痛点种草 · v1南卡 Lite Pro 蓝牙耳机 / AI 全生 / 4 镜 `button`
@@ -2,15 +2,15 @@
"name": "image-optimize",
"route": "/image-optimize",
"url": "http://127.0.0.1:5173/image-optimize",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 24,
"works": 24,
"dead": 0,
"works": 21,
"dead": 2,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 0,
"noop": 1,
"blocked": 0
},
"results": [
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "展开导航",
"label": "收窄导航",
"href": "",
"disabled": false,
"active": false,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -396,17 +396,17 @@
"idx": 21,
"tag": "button",
"role": "",
"cls": "ic-param-btn",
"label": "风格默认",
"cls": "mi.selected",
"label": "1:1",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"active": true,
"verdict": "noop-active",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 1,
"mutations": 0,
"api": 0
}
},
@@ -414,17 +414,17 @@
"idx": 22,
"tag": "button",
"role": "",
"cls": "ic-param-btn",
"label": "张数4",
"cls": "mi.",
"label": "3:4",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"verdict": "dead",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 1,
"mutations": 0,
"api": 0
}
},
@@ -432,25 +432,25 @@
"idx": 23,
"tag": "button",
"role": "",
"cls": "send-btn",
"label": "生成",
"cls": "mi.",
"label": "4:5",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"verdict": "dead",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 21,
"api": 1
"mutations": 0,
"api": 0
}
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"折叠侧栏",
"时间",
@@ -1,14 +1,20 @@
# 功能审计 · image-optimize
路由:`/image-optimize` · 模式:isolated
合计 24 · ✅ works 24 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
路由:`/image-optimize` · 模式:quick
合计 24 · ✅ works 21 · ❌ **dead 2** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
## ❌ 点了没反应(DEAD —— 重点修)
| # | 文字 | 标签 | 类名 |
|---|---|---|---|
| 22 | 3:4 | `button` | `mi.` |
| 23 | 4:5 | `button` | `mi.` |
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 折叠侧栏
- 时间
+113 -248
View File
@@ -2,14 +2,14 @@
"name": "library",
"route": "/library",
"url": "http://127.0.0.1:5173/library",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 38,
"works": 35,
"works": 23,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 2,
"disabled": 1,
"skipped": 13,
"noop": 1,
"blocked": 0
},
@@ -82,7 +82,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 26,
"api": 0
}
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 19,
"mutations": 20,
"api": 0
}
},
@@ -289,7 +289,7 @@
"tag": "div",
"role": "",
"cls": "tab.active",
"label": "人物 14",
"label": "人物 15",
"href": "",
"disabled": false,
"active": true,
@@ -307,7 +307,7 @@
"tag": "div",
"role": "",
"cls": "tab",
"label": "场景 11",
"label": "场景 22",
"href": "",
"disabled": false,
"active": false,
@@ -316,7 +316,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 48,
"mutations": 81,
"api": 0
}
},
@@ -325,7 +325,7 @@
"tag": "div",
"role": "",
"cls": "tab",
"label": "商品图 35",
"label": "商品图 39",
"href": "",
"disabled": false,
"active": false,
@@ -334,7 +334,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 97,
"mutations": 121,
"api": 0
}
},
@@ -343,7 +343,7 @@
"tag": "div",
"role": "",
"cls": "tab",
"label": "成片 10",
"label": "成片 23",
"href": "",
"disabled": false,
"active": false,
@@ -352,7 +352,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 38,
"mutations": 84,
"api": 0
}
},
@@ -361,7 +361,7 @@
"tag": "div",
"role": "",
"cls": "tab",
"label": "我的上传 1",
"label": "我的上传 3",
"href": "",
"disabled": false,
"active": false,
@@ -370,7 +370,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 30,
"mutations": 51,
"api": 0
}
},
@@ -388,7 +388,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 29,
"mutations": 26,
"api": 0
}
},
@@ -397,78 +397,6 @@
"tag": "button",
"role": "",
"cls": "chip",
"label": "性别",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
},
{
"idx": 22,
"tag": "button",
"role": "",
"cls": "chip",
"label": "年龄段",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "chip",
"label": "角色标签",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "chip",
"label": "来源",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
},
{
"idx": 25,
"tag": "button",
"role": "",
"cls": "chip",
"label": "最近添加",
"href": "",
"disabled": false,
@@ -478,189 +406,122 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"mutations": 8,
"api": 0
}
},
{
"idx": 22,
"tag": "div",
"role": "button",
"cls": "mi.selected",
"label": "最近添加",
"href": "",
"disabled": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 8,
"api": 0
}
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost",
"label": "取消",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 10,
"api": 0
}
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "btn.btn-primary",
"label": "上传资产",
"href": "",
"disabled": true,
"active": false,
"verdict": "disabled"
},
{
"idx": 25,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 26,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 27,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 28,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 29,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 30,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 31,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 32,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 33,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 34,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 35,
"tag": "div",
"role": "button",
"cls": "placeholder.asset-thumb",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 4,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 36,
@@ -677,10 +538,14 @@
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"最近使用",
"缺三视图,查看说明"
"管理资产",
"性别",
"年龄段",
"角色标签",
"来源",
"最近使用"
]
}
@@ -1,15 +1,19 @@
# 功能审计 · library
路由:`/library` · 模式:isolated
合计 38 · ✅ works 35 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)2 · ◷ noop-active 1 · ⚪ disabled 0
路由:`/library` · 模式:quick
合计 38 · ✅ works 23 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)13 · ◷ noop-active 1 · ⚪ disabled 1
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 管理资产
- 性别
- 年龄段
- 角色标签
- 来源
- 最近使用
- 缺三视图,查看说明
+55 -105
View File
@@ -2,14 +2,14 @@
"name": "messages",
"route": "/messages",
"url": "http://127.0.0.1:5173/messages",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 26,
"works": 25,
"total": 28,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"skipped": 7,
"noop": 1,
"blocked": 0
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 19,
"mutations": 24,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 19,
"mutations": 23,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 35,
"mutations": 36,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 21,
"api": 0
"api": 1
}
},
{
@@ -289,7 +289,7 @@
"tag": "button",
"role": "",
"cls": "msg-filter.active",
"label": "全部20",
"label": "全部111",
"href": "",
"disabled": false,
"active": true,
@@ -316,8 +316,8 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 26,
"api": 0
"mutations": 28,
"api": 1
}
},
{
@@ -325,7 +325,7 @@
"tag": "button",
"role": "",
"cls": "msg-filter.",
"label": "任务20",
"label": "任务110",
"href": "",
"disabled": false,
"active": false,
@@ -334,8 +334,8 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 4,
"api": 0
"mutations": 6,
"api": 1
}
},
{
@@ -352,8 +352,8 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 26,
"api": 0
"mutations": 6,
"api": 1
}
},
{
@@ -370,8 +370,8 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 26,
"api": 0
"mutations": 6,
"api": 1
}
},
{
@@ -379,7 +379,7 @@
"tag": "button",
"role": "",
"cls": "msg-filter.",
"label": "系统0",
"label": "系统1",
"href": "",
"disabled": false,
"active": false,
@@ -388,112 +388,62 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 26,
"api": 0
"mutations": 6,
"api": 1
}
},
{
"idx": 21,
"tag": "button",
"role": "",
"cls": "msg-item.active.read",
"label": "资产「AI 生成 · image · 4」已加入资产库7mProduct Image · Ima",
"href": "",
"disabled": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 2,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 22,
"tag": "button",
"role": "",
"cls": "msg-item.read",
"label": "资产「AI 生成 · image · 3」已加入资产库8mProduct Image · Ima",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 5,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "msg-item.read",
"label": "资产「AI 生成 · image · 2」已加入资产库8mProduct Image · Ima",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 5,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "msg-item.read",
"label": "资产「AI 生成 · image · 1」已加入资产库9mProduct Image · Ima",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 7,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 25,
"tag": "button",
"role": "",
"cls": "msg-item.read",
"label": "资产「AI 生成 · cover · 4」已加入资产库21mProduct Image · Im",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 7,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 26,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 27,
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"补水面膜 · 痛点种草 v3 成片已完成 12m 7 镜 · 40 秒 · ¥18.40 已结算",
"脚本生成失败 · 618 大促 1h Prompt 超过服务限制,本次失败未扣费,可一键重试。 ",
"4 张模特上身图已加入资产库 2h 祛痘精华 · 通勤白领 · 3:4,可直接进入视频项目。 已",
"@刘 正在编辑「补水面膜」项目 3h 你暂时以只读方式查看该项目,避免两人同时改动覆盖。 更新 ",
"@王芳 已加入团队 5h 角色为运营,月限额 ¥800,可读写团队资产。 更新 团队",
"团队余额低于预警线 7h 当前余额 ¥87.20,按近 7 天速度预计可支撑 4 个视频项目。 ",
"补水面膜 →"
"资产「AI 生成 · image · 1」已加入资产库11mProduct Image · Im",
"资产「南卡 · 痛点种草 · v1-final.mp4」已加入资产库26mFinal Video",
"资产「南卡 · 痛点种草 · v1-final.mp4」已加入资产库1hFinal Video ",
"资产「南卡 · 痛点种草 · v1-BGM」已加入资产库1hUpload · Audio已完成系",
"系统 →"
]
}
@@ -1,20 +1,18 @@
# 功能审计 · messages
路由:`/messages` · 模式:isolated
合计 26 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
路由:`/messages` · 模式:quick
合计 28 · ✅ works 20 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)7 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 补水面膜 · 痛点种草 v3 成片已完成 12m 7 镜 · 40 秒 · ¥18.40 已结算
- 脚本生成失败 · 618 大促 1h Prompt 超过服务限制,本次失败未扣费,可一键重试。
- 4 张模特上身图已加入资产库 2h 祛痘精华 · 通勤白领 · 3:4,可直接进入视频项目。 已
- @刘 正在编辑「补水面膜」项目 3h 你暂时以只读方式查看该项目,避免两人同时改动覆盖。 更新
- @王芳 已加入团队 5h 角色为运营,月限额 ¥800,可读写团队资产。 更新 团队
- 团队余额低于预警线 7h 当前余额 ¥87.20,按近 7 天速度预计可支撑 4 个视频项目。
- 补水面膜 →
- 资产「AI 生成 · image · 1」已加入资产库11mProduct Image · Im
- 资产「南卡 · 痛点种草 · v1-final.mp4」已加入资产库26mFinal Video
- 资产「南卡 · 痛点种草 · v1-final.mp4」已加入资产库1hFinal Video
- 资产「南卡 · 痛点种草 · v1-BGM」已加入资产库1hUpload · Audio已完成系
- 系统 →
@@ -2,15 +2,15 @@
"name": "model-photo-demo-a",
"route": "/model-photo/demo-a",
"url": "http://127.0.0.1:5173/model-photo/demo-a",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 37,
"total": 38,
"works": 33,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 1,
"noop": 3,
"skipped": 4,
"noop": 1,
"blocked": 0
},
"results": [
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "展开导航",
"label": "收窄导航",
"href": "",
"disabled": false,
"active": false,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -388,7 +388,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -454,17 +454,17 @@
"idx": 24,
"tag": "button",
"role": "",
"cls": "dm-chip.active",
"cls": "dm-chip.",
"label": "4 张",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"self": true,
"mutations": 5,
"api": 0
}
},
@@ -508,17 +508,17 @@
"idx": 27,
"tag": "button",
"role": "",
"cls": "dm-chip.active",
"cls": "dm-chip.",
"label": "3:4",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"self": true,
"mutations": 5,
"api": 0
}
},
@@ -578,18 +578,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
},
"consoleNote": [
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — ",
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — "
]
"verdict": "skipped-destructive"
},
{
"idx": 32,
@@ -644,18 +633,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
},
"consoleNote": [
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — ",
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — "
]
"verdict": "skipped-destructive"
},
{
"idx": 35,
@@ -700,12 +678,18 @@
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — ",
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — "
]
},
{
"idx": 37,
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"立即生成 · 透真补水面膜 × Ava"
]
@@ -1,17 +1,19 @@
# 功能审计 · model-photo-demo-a
路由:`/model-photo/demo-a` · 模式:isolated
合计 37 · ✅ works 33 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)1 · ◷ noop-active 3 · ⚪ disabled 0
路由:`/model-photo/demo-a` · 模式:quick
合计 38 · ✅ works 33 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)4 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 立即生成 · 透真补水面膜 × Ava
## ⏭ 破坏性按钮(未自动点,需人工验)
- 立即生成 · 南卡 Lite Pro 蓝牙耳机 × Ava `button`
- 全部重跑 `button`
- 全部重跑 `button`
@@ -2,14 +2,14 @@
"name": "model-photo-demo-b",
"route": "/model-photo/demo-b",
"url": "http://127.0.0.1:5173/model-photo/demo-b",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 37,
"works": 36,
"works": 34,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"skipped": 2,
"noop": 1,
"blocked": 0
},
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "收窄导航",
"label": "展开导航",
"href": "",
"disabled": false,
"active": false,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -208,7 +208,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 49,
"api": 2
}
},
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 36,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 20,
"mutations": 26,
"api": 0
}
},
@@ -370,7 +370,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 8,
"mutations": 7,
"api": 0
}
},
@@ -388,7 +388,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -511,18 +511,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
},
"consoleNote": [
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — ",
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — "
]
"verdict": "skipped-destructive"
},
{
"idx": 27,
@@ -577,18 +566,7 @@
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
},
"consoleNote": [
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — ",
"Encountered two children with the same key, `%s`. Keys should be unique so that components maintain their identity across updates. Non-unique keys may cause children to be duplicated and/or omitted — "
]
"verdict": "skipped-destructive"
},
{
"idx": 30,
@@ -747,8 +725,8 @@
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"生成 · 透真补水面膜 × Ava"
]
@@ -1,14 +1,18 @@
# 功能审计 · model-photo-demo-b
路由:`/model-photo/demo-b` · 模式:isolated
合计 37 · ✅ works 36 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
路由:`/model-photo/demo-b` · 模式:quick
合计 37 · ✅ works 34 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)2 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 生成 · 透真补水面膜 × Ava
## ⏭ 破坏性按钮(未自动点,需人工验)
- 全部重跑 `button`
- 全部重跑 `button`
@@ -2,7 +2,7 @@
"name": "model-photo",
"route": "/model-photo",
"url": "http://127.0.0.1:5173/model-photo",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 32,
"works": 31,
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "收窄导航",
"label": "展开导航",
"href": "",
"disabled": false,
"active": false,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -280,7 +280,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -492,7 +492,7 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -502,19 +502,19 @@
},
{
"idx": 25,
"tag": "button",
"role": "",
"cls": "tb-chip",
"label": "模特",
"tag": "div",
"role": "button",
"cls": "mi.selected",
"label": "最近添加",
"href": "",
"disabled": false,
"active": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -527,7 +527,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 4// 真人模特",
"label": "南卡 · 痛点种草 · v1-person// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -536,7 +536,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 22,
"api": 0
},
"consoleNote": [
@@ -549,7 +549,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 3// 真人模特",
"label": "AI 生成 · model · 1// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -558,7 +558,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -571,7 +571,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 2// 真人模特",
"label": "AI 生成 · model · 1// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -580,7 +580,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -593,7 +593,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 1// 真人模特",
"label": "AI 生成 · model · 2// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -602,7 +602,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -615,7 +615,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 4// 真人模特",
"label": "AI 生成 · model · 2// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -624,7 +624,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -637,7 +637,7 @@
"tag": "button",
"role": "",
"cls": "model-card.",
"label": "AI 生成 · model · 3// 真人模特",
"label": "AI 生成 · model · 2// 真人模特",
"href": "",
"disabled": false,
"active": false,
@@ -646,7 +646,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
},
"consoleNote": [
@@ -657,11 +657,12 @@
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"折叠侧栏",
"全部商品 7 个",
"时间",
"立即生成 (预估 ¥0.00)"
]
}
@@ -1,16 +1,17 @@
# 功能审计 · model-photo
路由:`/model-photo` · 模式:isolated
路由:`/model-photo` · 模式:quick
合计 32 · ✅ works 31 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 折叠侧栏
- 全部商品 7 个
- 时间
- 立即生成 (预估 ¥0.00)
+168 -110
View File
@@ -2,13 +2,13 @@
"name": "pipeline",
"route": "/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f",
"url": "http://127.0.0.1:5173/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 29,
"works": 27,
"total": 34,
"works": 26,
"dead": 0,
"error": 0,
"disabled": 2,
"disabled": 8,
"skipped": 0,
"noop": 0,
"blocked": 0
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 31,
"mutations": 36,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 22,
"mutations": 26,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": false,
"mutations": 25,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 22,
"api": 0
"mutations": 24,
"api": 1
}
},
{
@@ -270,17 +270,17 @@
"idx": 14,
"tag": "a",
"role": "",
"cls": "sp-dot.active",
"cls": "sp-dot",
"label": "基础资产",
"href": "#stage-2",
"disabled": false,
"active": true,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 20,
"self": true,
"mutations": 22,
"api": 0
}
},
@@ -316,7 +316,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 23,
"api": 0
}
},
@@ -334,7 +334,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 27,
"mutations": 23,
"api": 1
}
},
@@ -342,62 +342,41 @@
"idx": 18,
"tag": "button",
"role": "",
"cls": "tag-add",
"label": "添加人物",
"cls": "ctl-btn",
"label": "上一帧 (←)",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
}
"verdict": "disabled"
},
{
"idx": 19,
"tag": "button",
"role": "",
"cls": "tag-add",
"label": "添加场景",
"cls": "ctl-btn",
"label": "播放 / 暂停 (空格)",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 24,
"api": 0
}
"verdict": "disabled"
},
{
"idx": 20,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost.btn-sm",
"label": "↻ 整体重写",
"cls": "ctl-btn",
"label": "下一帧 (→)",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 22,
"api": 1
}
"verdict": "disabled"
},
{
"idx": 21,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost.btn-sm",
"label": "清空对话",
"cls": "ctl-btn",
"label": "静音",
"href": "",
"disabled": true,
"active": false,
@@ -405,10 +384,28 @@
},
{
"idx": 22,
"tag": "button",
"role": "",
"cls": "chat-mode.primary",
"label": "AI 全生",
"tag": "div",
"role": "button",
"cls": "active",
"label": "字幕",
"href": "",
"disabled": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 61,
"api": 0
}
},
{
"idx": 23,
"tag": "div",
"role": "button",
"cls": "",
"label": "转场",
"href": "",
"disabled": false,
"active": false,
@@ -417,34 +414,16 @@
"url": false,
"overlay": false,
"self": false,
"mutations": 22,
"api": 1
}
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "chat-mode",
"label": "一句话主题",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 21,
"mutations": 33,
"api": 0
}
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "chat-mode",
"label": "自带脚本",
"tag": "div",
"role": "button",
"cls": "",
"label": "BGM",
"href": "",
"disabled": false,
"active": false,
@@ -453,7 +432,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 21,
"mutations": 35,
"api": 0
}
},
@@ -461,8 +440,8 @@
"idx": 25,
"tag": "button",
"role": "",
"cls": "chat-icon-btn",
"label": "上传脚本附件",
"cls": "btn.btn-sm.btn-primary",
"label": "已开启",
"href": "",
"disabled": false,
"active": false,
@@ -471,63 +450,142 @@
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"mutations": 30,
"api": 0
}
},
{
"idx": 26,
"tag": "div",
"role": "button",
"cls": "swatch-card.selected",
"label": "真实分享朴素白底",
"href": "",
"disabled": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 35,
"api": 0
}
},
{
"idx": 27,
"tag": "div",
"role": "button",
"cls": "swatch-card",
"label": "真实分享影视黑底",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 28,
"tag": "div",
"role": "button",
"cls": "swatch-card",
"label": "真实分享手写描边",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 35,
"api": 0
}
},
{
"idx": 29,
"tag": "div",
"role": "button",
"cls": "swatch-card",
"label": "真实分享综艺暖黄",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 36,
"api": 0
}
},
{
"idx": 30,
"tag": "button",
"role": "",
"cls": "chat-send-btn",
"label": "发送",
"cls": "tl-action",
"label": "撤销",
"href": "",
"disabled": true,
"active": false,
"verdict": "disabled"
},
{
"idx": 27,
"idx": 31,
"tag": "button",
"role": "",
"cls": "btn",
"label": "重新生成全部",
"cls": "tl-action",
"label": "重做",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 22,
"api": 1
}
"verdict": "disabled"
},
{
"idx": 28,
"idx": 32,
"tag": "button",
"role": "",
"cls": "btn.btn-primary.btn-lg",
"label": "进入下一步",
"cls": "tl-action",
"label": "在播放头处分割所在片段",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 20,
"api": 0
}
"verdict": "disabled"
},
{
"idx": 33,
"tag": "button",
"role": "",
"cls": "tl-action",
"label": "复制选中片段",
"href": "",
"disabled": true,
"active": false,
"verdict": "disabled"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"添加人物",
"添加场景",
"↻ 整体重写",
"清空对话",
"AI 全生",
"一句话主题",
"自带脚本",
"上传脚本附件",
"发送",
"重新生成全部",
"确认脚本,进入下一步"
]
}
@@ -1,14 +1,24 @@
# 功能审计 · pipeline
路由:`/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f` · 模式:isolated
合计 29 · ✅ works 27 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 2
路由:`/pipeline/1048451e-3b4a-4b66-a8f4-cb865096de2f` · 模式:quick
合计 34 · ✅ works 26 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 8
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 添加人物
- 添加场景
- ↻ 整体重写
- 清空对话
- AI 全生
- 一句话主题
- 自带脚本
- 上传脚本附件
- 发送
- 重新生成全部
- 确认脚本,进入下一步
@@ -2,7 +2,7 @@
"name": "platform-cover",
"route": "/platform-cover",
"url": "http://127.0.0.1:5173/platform-cover",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 38,
"works": 35,
@@ -37,7 +37,7 @@
"tag": "button",
"role": "",
"cls": "sidebar-toggle",
"label": "展开导航",
"label": "收窄导航",
"href": "",
"disabled": false,
"active": false,
@@ -64,7 +64,7 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 14,
"mutations": 15,
"api": 0
}
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -262,7 +262,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 22,
"api": 0
}
},
@@ -280,7 +280,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 20,
"mutations": 26,
"api": 0
}
},
@@ -460,7 +460,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -478,7 +478,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -496,7 +496,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -514,7 +514,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -532,7 +532,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -550,7 +550,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -568,7 +568,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -586,7 +586,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -604,7 +604,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -622,7 +622,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 3,
"mutations": 5,
"api": 0
}
},
@@ -658,7 +658,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 5,
"mutations": 7,
"api": 0
}
},
@@ -676,7 +676,7 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 5,
"mutations": 7,
"api": 0
}
},
@@ -685,7 +685,7 @@
"tag": "button",
"role": "",
"cls": "btn.btn-primary",
"label": "立即生成 (预估 ¥2.00)",
"label": "立即生成 (预估 ¥6.00)",
"href": "",
"disabled": false,
"active": false,
@@ -693,8 +693,8 @@
}
],
"missing": [
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"折叠侧栏",
"全部商品 7 个",
@@ -1,13 +1,13 @@
# 功能审计 · platform-cover
路由:`/platform-cover` · 模式:isolated
路由:`/platform-cover` · 模式:quick
合计 38 · ✅ works 35 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)1 · ◷ noop-active 2 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 折叠侧栏
- 全部商品 7 个
@@ -16,5 +16,5 @@
- 平台
## ⏭ 破坏性按钮(未自动点,需人工验)
- 立即生成 (预估 ¥2.00) `button`
- 立即生成 (预估 ¥6.00) `button`
@@ -2,14 +2,14 @@
"name": "product-create",
"route": "/products/new",
"url": "http://127.0.0.1:5173/products/new",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 31,
"works": 31,
"works": 25,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"disabled": 1,
"skipped": 5,
"noop": 0,
"blocked": 0
},
@@ -28,7 +28,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 26,
"mutations": 28,
"api": 0
}
},
@@ -82,7 +82,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 25,
"mutations": 28,
"api": 0
}
},
@@ -118,7 +118,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 20,
"mutations": 22,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 20,
"mutations": 26,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 36,
"mutations": 39,
"api": 0
}
},
@@ -172,7 +172,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 20,
"mutations": 22,
"api": 0
}
},
@@ -190,8 +190,8 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 22,
"api": 0
"mutations": 24,
"api": 1
}
},
{
@@ -208,7 +208,7 @@
"url": true,
"overlay": true,
"self": true,
"mutations": 49,
"mutations": 51,
"api": 2
}
},
@@ -226,7 +226,7 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 25,
"mutations": 27,
"api": 0
}
},
@@ -244,8 +244,8 @@
"url": true,
"overlay": true,
"self": false,
"mutations": 19,
"api": 0
"mutations": 25,
"api": 1
}
},
{
@@ -304,13 +304,13 @@
},
{
"idx": 16,
"tag": "button",
"role": "",
"cls": "chip",
"label": "创建时间",
"tag": "div",
"role": "button",
"cls": "mi.selected",
"label": "全部分类",
"href": "",
"disabled": false,
"active": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
@@ -331,159 +331,80 @@
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 31,
"url": false,
"overlay": false,
"self": true,
"mutations": 23,
"api": 0
}
},
{
"idx": 18,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "露露同款裸感瑜伽裤 · 1200×800露露同款裸感瑜伽裤运动户外2026-06-08 创建素材",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 19,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "透真清透物理防晒霜 · 1200×800透真清透物理防晒霜美妆个护2026-06-08 创建素材",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 20,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "滋啦速食牛肉面 · 6 桶装 · 1200×800滋啦速食牛肉面 · 6 桶装食品饮料2026-",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 21,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "透真玻尿酸补水面膜 · 1200×800透真玻尿酸补水面膜美妆个护2026-05-29 创建素材",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 31,
"api": 0
}
},
{
"idx": 22,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "桥接测试补水面膜 20260529 · 1200×800桥接测试补水面膜 20260529美妆个",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 23,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "三顿半同款冻干咖啡粉 · 1200×800三顿半同款冻干咖啡粉食品饮料2026-06-08 创建",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 24,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "小熊 4L 可视空气炸锅 · 1200×800小熊 4L 可视空气炸锅家居家电2026-06-0",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": true,
"self": false,
"mutations": 30,
"api": 0
}
},
{
"idx": 25,
"tag": "button",
"role": "",
"cls": "x",
"label": "关闭",
"cls": "clear-sel",
"label": "清空",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 14,
"overlay": false,
"self": true,
"mutations": 16,
"api": 0
}
},
{
"idx": 26,
"idx": 19,
"tag": "button",
"role": "",
"cls": "danger",
"label": "删除选中",
"href": "",
"disabled": true,
"active": false,
"verdict": "disabled"
},
{
"idx": 20,
"tag": "button",
"role": "",
"cls": "",
"label": "退出",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 18,
"api": 0
}
},
{
"idx": 21,
"tag": "button",
"role": "",
"cls": "",
"label": "退出",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 18,
"api": 0
}
},
{
"idx": 22,
"tag": "select",
"role": "",
"cls": "select",
@@ -501,7 +422,7 @@
}
},
{
"idx": 27,
"idx": 23,
"tag": "div",
"role": "button",
"cls": "pf-upload-zone",
@@ -519,7 +440,7 @@
}
},
{
"idx": 28,
"idx": 24,
"tag": "button",
"role": "",
"cls": "btn-guide",
@@ -537,7 +458,7 @@
}
},
{
"idx": 29,
"idx": 25,
"tag": "button",
"role": "",
"cls": "btn",
@@ -554,36 +475,48 @@
"api": 0
}
},
{
"idx": 26,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 27,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 28,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 29,
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 30,
"tag": "button",
"role": "",
"cls": "btn.btn-primary",
"label": "创建商品",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 14,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创建 素材 4",
"空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-05-03 创建 ",
"咖啡冻干粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05 创建 素",
"防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创建 素材 7",
"速食牛肉面 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 2026-05-10 ",
"蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码 3C 2026-05-1",
"补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创建 素材 "
"管理商品",
"商品分类",
"关闭",
"美妆个护",
"使用指南",
"取消",
"创建商品"
]
}
@@ -1,20 +1,20 @@
# 功能审计 · product-create
路由:`/products/new` · 模式:isolated
合计 31 · ✅ works 31 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
路由:`/products/new` · 模式:quick
合计 31 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)5 · ◷ noop-active 0 · ⚪ disabled 1
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创建 素材 4
- 空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-05-03 创建
- 咖啡冻干粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05 创建 素
- 防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创建 素材 7
- 速食牛肉面 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 2026-05-10
- 蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码 3C 2026-05-1
- 补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创建 素材
- 管理商品
- 商品分类
- 关闭
- 美妆个护
- 使用指南
- 取消
- 创建商品
@@ -2,14 +2,14 @@
"name": "product-detail",
"route": "/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f",
"url": "http://127.0.0.1:5173/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 29,
"works": 28,
"works": 22,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"skipped": 6,
"noop": 1,
"blocked": 0
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 18,
"mutations": 23,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -270,17 +270,17 @@
"idx": 14,
"tag": "button",
"role": "",
"cls": "ov-edit.ov-edit-single",
"label": "编辑商品信息",
"cls": "ov-tri-close",
"label": "关闭",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"overlay": true,
"self": false,
"mutations": 10,
"mutations": 12,
"api": 0
}
},
@@ -298,8 +298,8 @@
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
"mutations": 28,
"api": 1
}
},
{
@@ -419,14 +419,14 @@
"tag": "button",
"role": "",
"cls": "filter",
"label": "全部类型",
"label": "最新更新",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"overlay": false,
"self": false,
"mutations": 10,
"api": 0
@@ -434,120 +434,50 @@
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "filter",
"label": "最新生成",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 10,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 24,
"tag": "div",
"role": "button",
"cls": "thumb.placeholder",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 25,
"tag": "div",
"role": "button",
"cls": "thumb.placeholder",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 26,
"tag": "div",
"role": "button",
"cls": "thumb.placeholder",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 27,
"tag": "div",
"role": "button",
"cls": "thumb.placeholder",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 28,
"tag": "div",
"role": "button",
"cls": "thumb.placeholder",
"label": "点击放大",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 11,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,908.00",
"账户",
"全部类型",
"通过",
"网格视图",
"列表视图"
"列表视图",
"最新生成"
]
}
@@ -1,16 +1,18 @@
# 功能审计 · product-detail
路由:`/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f` · 模式:isolated
合计 29 · ✅ works 28 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
路由:`/products/045ad8d6-8b15-486b-a4a9-f6968eb1555f` · 模式:quick
合计 29 · ✅ works 22 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)6 · ◷ noop-active 1 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,908.00
- 账户
- 全部类型
- 通过
- 网格视图
- 列表视图
- 最新生成
@@ -2,13 +2,13 @@
"name": "products",
"route": "/products",
"url": "http://127.0.0.1:5173/products",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 25,
"works": 25,
"works": 24,
"dead": 0,
"error": 0,
"disabled": 0,
"disabled": 1,
"skipped": 0,
"noop": 0,
"blocked": 0
@@ -28,7 +28,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 26,
"mutations": 28,
"api": 0
}
},
@@ -82,7 +82,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 25,
"mutations": 28,
"api": 0
}
},
@@ -118,7 +118,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 22,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 26,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 39,
"api": 0
}
},
@@ -172,7 +172,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 22,
"api": 0
}
},
@@ -190,8 +190,8 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"mutations": 24,
"api": 1
}
},
{
@@ -208,7 +208,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 49,
"mutations": 51,
"api": 2
}
},
@@ -226,7 +226,7 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 25,
"mutations": 27,
"api": 0
}
},
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 25,
"api": 1
}
},
{
@@ -304,13 +304,13 @@
},
{
"idx": 16,
"tag": "button",
"role": "",
"cls": "chip",
"label": "创建时间",
"tag": "div",
"role": "button",
"cls": "mi.selected",
"label": "全部分类",
"href": "",
"disabled": false,
"active": false,
"active": true,
"verdict": "works",
"signals": {
"url": false,
@@ -331,100 +331,93 @@
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 31,
"self": true,
"mutations": 23,
"api": 0
}
},
{
"idx": 18,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "露露同款裸感瑜伽裤 · 1200×800露露同款裸感瑜伽裤运动户外2026-06-08 创建素材",
"tag": "button",
"role": "",
"cls": "clear-sel",
"label": "清空",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"self": true,
"mutations": 16,
"api": 0
}
},
{
"idx": 19,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "透真清透物理防晒霜 · 1200×800透真清透物理防晒霜美妆个护2026-06-08 创建素材",
"tag": "button",
"role": "",
"cls": "danger",
"label": "删除选中",
"href": "",
"disabled": false,
"disabled": true,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 30,
"api": 0
}
"verdict": "disabled"
},
{
"idx": 20,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "滋啦速食牛肉面 · 6 桶装 · 1200×800滋啦速食牛肉面 · 6 桶装食品饮料2026-",
"tag": "button",
"role": "",
"cls": "",
"label": "退出",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"self": true,
"mutations": 18,
"api": 0
}
},
{
"idx": 21,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "透真玻尿酸补水面膜 · 1200×800透真玻尿酸补水面膜美妆个护2026-05-29 创建素材",
"tag": "button",
"role": "",
"cls": "",
"label": "退出",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 31,
"self": true,
"mutations": 18,
"api": 0
}
},
{
"idx": 22,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "桥接测试补水面膜 20260529 · 1200×800桥接测试补水面膜 20260529美妆个",
"tag": "select",
"role": "",
"cls": "select",
"label": "美妆个护",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"mutations": 0,
"api": 0
}
},
@@ -432,50 +425,45 @@
"idx": 23,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "三顿半同款冻干咖啡粉 · 1200×800三顿半同款冻干咖啡粉食品饮料2026-06-08 创建",
"cls": "pf-upload-zone",
"label": "点击上传或拖拽图片到此处// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"mutations": 0,
"api": 0
}
},
{
"idx": 24,
"tag": "div",
"role": "button",
"cls": "product-card",
"label": "小熊 4L 可视空气炸锅 · 1200×800小熊 4L 可视空气炸锅家居家电2026-06-0",
"tag": "button",
"role": "",
"cls": "btn-guide",
"label": "使用指南",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"url": false,
"overlay": false,
"self": false,
"mutations": 30,
"self": true,
"mutations": 13,
"api": 0
}
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,908.00",
"账户",
"瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创建 素材 4",
"空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-05-03 创建 ",
"咖啡冻干粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05 创建 素",
"防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创建 素材 7",
"速食牛肉面 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 2026-05-10 ",
"蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码 3C 2026-05-1",
"补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创建 素材 "
"管理商品",
"商品分类"
]
}
@@ -1,20 +1,15 @@
# 功能审计 · products
路由:`/products` · 模式:isolated
合计 25 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
路由:`/products` · 模式:quick
合计 25 · ✅ works 24 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 1
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,908.00
- 账户
- 瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创建 素材 4
- 空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-05-03 创建
- 咖啡冻干粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05 创建 素
- 防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创建 素材 7
- 速食牛肉面 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 2026-05-10
- 蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码 3C 2026-05-1
- 补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创建 素材
- 管理商品
- 商品分类
@@ -2,7 +2,7 @@
"name": "projects-new",
"route": "/projects/new",
"url": "http://127.0.0.1:5173/projects/new",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 15,
"works": 15,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -208,7 +208,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 49,
"mutations": 20,
"api": 2
}
},
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -287,18 +287,10 @@
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"1 选择商品 未选择",
"2 项目配置 时长 · 风格 · 人物",
"创建新商品 // 在此添加一个新商品",
"透真玻尿酸补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创",
"透真清透物理防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创",
"三顿半同款冻干咖啡粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05",
"南卡 Lite Pro 蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码 ",
"滋啦速食牛肉面 · 6 桶装 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 20",
"小熊 4L 可视空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-0",
"露露同款裸感瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创"
"2 项目配置 时长 · 风格 · 人物"
]
}
@@ -1,23 +1,15 @@
# 功能审计 · projects-new
路由:`/projects/new` · 模式:isolated
路由:`/projects/new` · 模式:quick
合计 15 · ✅ works 15 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 1 选择商品 未选择
- 2 项目配置 时长 · 风格 · 人物
- 创建新商品 // 在此添加一个新商品
- 透真玻尿酸补水面膜 · 1200×800 透真玻尿酸补水面膜 美妆个护 2026-05-15 创
- 透真清透物理防晒霜 · 1200×800 透真清透物理防晒霜 美妆个护 2026-05-08 创
- 三顿半同款冻干咖啡粉 · 1200×800 三顿半同款冻干咖啡粉 食品饮料 2026-05-05
- 南卡 Lite Pro 蓝牙耳机 · 1200×800 南卡 Lite Pro 蓝牙耳机 数码
- 滋啦速食牛肉面 · 6 桶装 · 1200×800 滋啦速食牛肉面 · 6 桶装 食品饮料 20
- 小熊 4L 可视空气炸锅 · 1200×800 小熊 4L 可视空气炸锅 家居家电 2026-0
- 露露同款裸感瑜伽裤 · 1200×800 露露同款裸感瑜伽裤 运动户外 2026-04-30 创
+53 -157
View File
@@ -2,15 +2,15 @@
"name": "projects",
"route": "/projects",
"url": "http://127.0.0.1:5173/projects",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 24,
"works": 22,
"works": 15,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 2,
"skipped": 9,
"noop": 0,
"blocked": 0
},
"results": [
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 21,
"mutations": 26,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 21,
"mutations": 25,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 37,
"mutations": 38,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 23,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 20,
"api": 0
"mutations": 24,
"api": 1
}
},
{
@@ -286,182 +286,78 @@
},
{
"idx": 15,
"tag": "div",
"role": "",
"cls": "tab.active",
"label": "全部 6",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 16,
"tag": "div",
"role": "",
"cls": "tab",
"label": "进行中 5",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 6,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 17,
"tag": "div",
"role": "",
"cls": "tab",
"label": "已完成 1",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 10,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 18,
"tag": "div",
"role": "",
"cls": "tab",
"label": "失败 0",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 12,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 19,
"tag": "button",
"role": "",
"cls": "chip",
"label": "商品品类",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 20,
"tag": "button",
"role": "",
"cls": "chip",
"label": "脚本来源",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 21,
"tag": "button",
"role": "",
"cls": "chip",
"label": "创建时间",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"self": false,
"mutations": 3,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 22,
"tag": "button",
"role": "",
"cls": "",
"label": "网格",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": true,
"mutations": 13,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "active",
"label": "列表",
"href": "",
"disabled": false,
"active": true,
"verdict": "noop-active",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"9:16 补水面膜 · 痛点种草 · v36 镜 · 0-15s 透真补水面膜 AI 全生 3/",
"9:16 速食牛肉面 · 加班治愈4 镜 · 0-12s 滋啦速食 · 6 桶装 一句话主题 2",
"删除项目",
"9:16 透真防晒 · 通勤对比6 镜 · 0-18s 透真清透防晒霜 AI 全生 4/5 视频",
"9:16 咖啡冻干 · 早八剧情5 镜 · 0-15s 三顿半同款冻干 一句话主题 3/5 故事",
"9:16 蓝牙耳机 · 开箱测评5 镜 · 0-15s 南卡 Lite Pro 自带脚本 5/5",
"9:16 瑜伽裤 · 通勤穿搭5 镜 · 0-15s 露露同款瑜伽裤 AI 全生 5/5 已完成",
"管理项目",
"新建项目",
"商品品类",
"脚本来源",
"创建时间",
"网格",
"列表",
"全部 6",
"进行中 4",
"已完成 2",
"失败 0",
"基础资产生成中",
"脚本待生成",
"故事板生成中",
"资产生成中",
"视频生成 4/6",
"故事板生成失败"
"视频片段生成中"
]
}
+18 -14
View File
@@ -1,24 +1,28 @@
# 功能审计 · projects
路由:`/projects` · 模式:isolated
合计 24 · ✅ works 22 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 2 · ⚪ disabled 0
路由:`/projects` · 模式:quick
合计 24 · ✅ works 15 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)9 · ◷ noop-active 0 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 9:16 补水面膜 · 痛点种草 · v36 镜 · 0-15s 透真补水面膜 AI 全生 3/
- 9:16 速食牛肉面 · 加班治愈4 镜 · 0-12s 滋啦速食 · 6 桶装 一句话主题 2
- 删除项目
- 9:16 透真防晒 · 通勤对比6 镜 · 0-18s 透真清透防晒霜 AI 全生 4/5 视频
- 9:16 咖啡冻干 · 早八剧情5 镜 · 0-15s 三顿半同款冻干 一句话主题 3/5 故事
- 9:16 蓝牙耳机 · 开箱测评5 镜 · 0-15s 南卡 Lite Pro 自带脚本 5/5
- 9:16 瑜伽裤 · 通勤穿搭5 镜 · 0-15s 露露同款瑜伽裤 AI 全生 5/5 已完成
- 管理项目
- 新建项目
- 商品品类
- 脚本来源
- 创建时间
- 网格
- 列表
- 全部 6
- 进行中 4
- 已完成 2
- 失败 0
- 基础资产生成中
- 脚本待生成
- 故事板生成中
- 资产生成中
- 视频生成 4/6
- 故事板生成失败
- 视频片段生成中
@@ -0,0 +1,45 @@
[function-audit] dashboard … dead 0 / 25, missing 18
[function-audit] products … dead 0 / 25, missing 6
[function-audit] product-detail … dead 0 / 29, missing 9
[function-audit] product-create … dead 0 / 31, missing 11
[function-audit] projects … dead 0 / 24, missing 19
[function-audit] projects-new … dead 0 / 15, missing 6
[function-audit] pipeline … dead 0 / 34, missing 15
[function-audit] library … dead 0 / 38, missing 10
[function-audit] account … dead 0 / 26, missing 4
[function-audit] team … dead 0 / 17, missing 4
[function-audit] messages … dead 0 / 28, missing 9
[function-audit] asset-factory … dead 0 / 24, missing 5
[function-audit] image-optimize … dead 2 / 24, missing 12
[function-audit] model-photo … dead 0 / 32, missing 8
[function-audit] model-photo-demo-a … dead 0 / 38, missing 5
[function-audit] model-photo-demo-b … dead 0 / 37, missing 5
[function-audit] platform-cover … dead 0 / 38, missing 8
[function-audit] settings … dead 0 / 26, missing 9
[function-audit] summary
┌─────────┬──────────────────────┬───────┬───────┬──────┬───────┬─────────┐
│ (index) │ page │ total │ works │ dead │ error │ missing │
├─────────┼──────────────────────┼───────┼───────┼──────┼───────┼─────────┤
│ 0 │ 'dashboard' │ 25 │ 20 │ 0 │ 0 │ 18 │
│ 1 │ 'products' │ 25 │ 24 │ 0 │ 0 │ 6 │
│ 2 │ 'product-detail' │ 29 │ 22 │ 0 │ 0 │ 9 │
│ 3 │ 'product-create' │ 31 │ 25 │ 0 │ 0 │ 11 │
│ 4 │ 'projects' │ 24 │ 15 │ 0 │ 0 │ 19 │
│ 5 │ 'projects-new' │ 15 │ 15 │ 0 │ 0 │ 6 │
│ 6 │ 'pipeline' │ 34 │ 26 │ 0 │ 0 │ 15 │
│ 7 │ 'library' │ 38 │ 23 │ 0 │ 0 │ 10 │
│ 8 │ 'account' │ 26 │ 20 │ 0 │ 0 │ 4 │
│ 9 │ 'team' │ 17 │ 17 │ 0 │ 0 │ 4 │
│ 10 │ 'messages' │ 28 │ 20 │ 0 │ 0 │ 9 │
│ 11 │ 'asset-factory' │ 24 │ 23 │ 0 │ 0 │ 5 │
│ 12 │ 'image-optimize' │ 24 │ 21 │ 2 │ 0 │ 12 │
│ 13 │ 'model-photo' │ 32 │ 31 │ 0 │ 0 │ 8 │
│ 14 │ 'model-photo-demo-a' │ 38 │ 33 │ 0 │ 0 │ 5 │
│ 15 │ 'model-photo-demo-b' │ 37 │ 34 │ 0 │ 0 │ 5 │
│ 16 │ 'platform-cover' │ 38 │ 35 │ 0 │ 0 │ 8 │
│ 17 │ 'settings' │ 26 │ 25 │ 0 │ 0 │ 9 │
└─────────┴──────────────────────┴───────┴───────┴──────┴───────┴─────────┘
报告:core/qa/function-audit/output/summary.md
@@ -2,15 +2,15 @@
"name": "settings",
"route": "/settings",
"url": "http://127.0.0.1:5173/settings",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 26,
"works": 25,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 1,
"skipped": 1,
"noop": 0,
"blocked": 0
},
"results": [
@@ -64,7 +64,7 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 14,
"mutations": 15,
"api": 0
}
},
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -280,7 +280,7 @@
"url": false,
"overlay": false,
"self": false,
"mutations": 47,
"mutations": 48,
"api": 11
}
},
@@ -293,13 +293,13 @@
"href": "#sec-profile",
"disabled": false,
"active": true,
"verdict": "noop-active",
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
"api": 1
}
},
{
@@ -317,7 +317,7 @@
"overlay": false,
"self": true,
"mutations": 6,
"api": 0
"api": 1
}
},
{
@@ -353,7 +353,7 @@
"overlay": false,
"self": true,
"mutations": 6,
"api": 0
"api": 3
}
},
{
@@ -370,8 +370,8 @@
"url": false,
"overlay": false,
"self": true,
"mutations": 6,
"api": 0
"mutations": 21,
"api": 2
}
},
{
@@ -388,16 +388,70 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 8,
"mutations": 2,
"api": 0
}
},
{
"idx": 21,
"tag": "select",
"role": "",
"cls": "select",
"label": "system",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 17,
"api": 0
}
},
{
"idx": 22,
"tag": "select",
"role": "",
"cls": "select",
"label": "zh",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
}
},
{
"idx": 23,
"tag": "select",
"role": "",
"cls": "select",
"label": "standard",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 0,
"api": 0
}
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "btn.btn-sm",
"label": "上传新头像",
"cls": "x.modal-x",
"label": "关闭",
"href": "",
"disabled": false,
"active": false,
@@ -406,87 +460,26 @@
"url": false,
"overlay": true,
"self": false,
"mutations": 8,
"api": 0
}
},
{
"idx": 22,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost.btn-sm",
"label": "恢复默认",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 45,
"api": 11
}
},
{
"idx": 23,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost.btn-sm",
"label": "验证",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 23,
"api": 0
}
},
{
"idx": 24,
"tag": "button",
"role": "",
"cls": "btn.btn-ghost.btn-sm",
"label": "更换",
"href": "",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": false,
"overlay": false,
"self": false,
"mutations": 23,
"mutations": 2,
"api": 0
}
},
{
"idx": 25,
"tag": "a",
"role": "",
"cls": "row-link",
"label": "管理团队 →",
"href": "#team",
"disabled": false,
"active": false,
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"self": false,
"mutations": 22,
"api": 0
}
"label": "",
"tag": "",
"verdict": "stale"
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"账户"
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户",
"上传新头像",
"恢复默认",
"验证",
"更换",
"管理团队 →"
]
}
@@ -1,13 +1,18 @@
# 功能审计 · settings
路由:`/settings` · 模式:isolated
合计 26 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 1 · ⚪ disabled 0
路由:`/settings` · 模式:quick
合计 26 · ✅ works 25 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)1 · ◷ noop-active 0 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 上传新头像
- 恢复默认
- 验证
- 更换
- 管理团队 →
+157 -13
View File
@@ -1,26 +1,158 @@
[
{
"page": "asset-factory",
"page": "dashboard",
"blocked": 0,
"total": 24,
"total": 25,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 5,
"noop": 0,
"missing": 18
},
{
"page": "products",
"blocked": 0,
"total": 25,
"works": 24,
"dead": 0,
"error": 0,
"disabled": 1,
"skipped": 0,
"noop": 0,
"missing": 6
},
{
"page": "product-detail",
"blocked": 0,
"total": 29,
"works": 22,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 2,
"missing": 4
"skipped": 6,
"noop": 1,
"missing": 9
},
{
"page": "image-optimize",
"page": "product-create",
"blocked": 0,
"total": 31,
"works": 25,
"dead": 0,
"error": 0,
"disabled": 1,
"skipped": 5,
"noop": 0,
"missing": 11
},
{
"page": "projects",
"blocked": 0,
"total": 24,
"works": 24,
"works": 15,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 9,
"noop": 0,
"missing": 19
},
{
"page": "projects-new",
"blocked": 0,
"total": 15,
"works": 15,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 0,
"missing": 6
},
{
"page": "pipeline",
"blocked": 0,
"total": 34,
"works": 26,
"dead": 0,
"error": 0,
"disabled": 8,
"skipped": 0,
"noop": 0,
"missing": 15
},
{
"page": "library",
"blocked": 0,
"total": 38,
"works": 23,
"dead": 0,
"error": 0,
"disabled": 1,
"skipped": 13,
"noop": 1,
"missing": 10
},
{
"page": "account",
"blocked": 0,
"total": 26,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 5,
"noop": 1,
"missing": 4
},
{
"page": "team",
"blocked": 0,
"total": 17,
"works": 17,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 0,
"missing": 4
},
{
"page": "messages",
"blocked": 0,
"total": 28,
"works": 20,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 7,
"noop": 1,
"missing": 9
},
{
"page": "asset-factory",
"blocked": 0,
"total": 24,
"works": 23,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 1,
"missing": 5
},
{
"page": "image-optimize",
"blocked": 0,
"total": 24,
"works": 21,
"dead": 2,
"error": 0,
"disabled": 0,
"skipped": 0,
"noop": 1,
"missing": 12
},
{
@@ -33,29 +165,29 @@
"disabled": 0,
"skipped": 0,
"noop": 1,
"missing": 7
"missing": 8
},
{
"page": "model-photo-demo-a",
"blocked": 0,
"total": 37,
"total": 38,
"works": 33,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 1,
"noop": 3,
"skipped": 4,
"noop": 1,
"missing": 5
},
{
"page": "model-photo-demo-b",
"blocked": 0,
"total": 37,
"works": 36,
"works": 34,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 0,
"skipped": 2,
"noop": 1,
"missing": 5
},
@@ -70,5 +202,17 @@
"skipped": 1,
"noop": 2,
"missing": 8
},
{
"page": "settings",
"blocked": 0,
"total": 26,
"works": 25,
"dead": 0,
"error": 0,
"disabled": 0,
"skipped": 1,
"noop": 0,
"missing": 9
}
]
+18 -6
View File
@@ -1,15 +1,27 @@
# 功能审计汇总
生成时间:2026-06-09T07:56:41.981Z · 模式:isolated
生成时间:2026-06-10T09:29:34.278Z · 模式:quick
| 页面 | 合计 | ✅works | ❌dead | 🛑error | ⏭skip | 🔍missing | 状态 |
|---|---|---|---|---|---|---|---|
| asset-factory | 24 | 22 | **0** | 0 | 0 | 4 | |
| image-optimize | 24 | 24 | **0** | 0 | 0 | 12 | |
| model-photo | 32 | 31 | **0** | 0 | 0 | 7 | |
| model-photo-demo-a | 37 | 33 | **0** | 0 | 1 | 5 | |
| model-photo-demo-b | 37 | 36 | **0** | 0 | 0 | 5 | |
| dashboard | 25 | 20 | **0** | 0 | 5 | 18 | |
| products | 25 | 24 | **0** | 0 | 0 | 6 | |
| product-detail | 29 | 22 | **0** | 0 | 6 | 9 | |
| product-create | 31 | 25 | **0** | 0 | 5 | 11 | |
| projects | 24 | 15 | **0** | 0 | 9 | 19 | |
| projects-new | 15 | 15 | **0** | 0 | 0 | 6 | |
| pipeline | 34 | 26 | **0** | 0 | 0 | 15 | |
| library | 38 | 23 | **0** | 0 | 13 | 10 | |
| account | 26 | 20 | **0** | 0 | 5 | 4 | |
| team | 17 | 17 | **0** | 0 | 0 | 4 | |
| messages | 28 | 20 | **0** | 0 | 7 | 9 | |
| asset-factory | 24 | 23 | **0** | 0 | 0 | 5 | |
| image-optimize | 24 | 21 | **2** | 0 | 0 | 12 | |
| model-photo | 32 | 31 | **0** | 0 | 0 | 8 | |
| model-photo-demo-a | 38 | 33 | **0** | 0 | 4 | 5 | |
| model-photo-demo-b | 37 | 34 | **0** | 0 | 2 | 5 | |
| platform-cover | 38 | 35 | **0** | 0 | 1 | 8 | |
| settings | 26 | 25 | **0** | 0 | 1 | 9 | |
- ⛔blocked = 页面始终落登录页/未就绪(后端或远程库不可用),**未审计**,非「0 缺陷」。修好后端再单独 `--only <page>` 复跑。
- ❌dead = 点了五路探针(URL/浮层/自身状态/网络/DOM)全无反应,优先修。
+17 -20
View File
@@ -2,7 +2,7 @@
"name": "team",
"route": "/team",
"url": "http://127.0.0.1:5173/team",
"mode": "isolated",
"mode": "quick",
"tally": {
"total": 17,
"works": 17,
@@ -100,7 +100,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 25,
"api": 0
}
},
@@ -136,7 +136,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 20,
"mutations": 24,
"api": 0
}
},
@@ -154,7 +154,7 @@
"url": true,
"overlay": false,
"self": true,
"mutations": 36,
"mutations": 37,
"api": 0
}
},
@@ -191,7 +191,7 @@
"overlay": false,
"self": true,
"mutations": 22,
"api": 0
"api": 1
}
},
{
@@ -244,8 +244,8 @@
"url": true,
"overlay": false,
"self": false,
"mutations": 19,
"api": 0
"mutations": 23,
"api": 1
}
},
{
@@ -278,9 +278,9 @@
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"overlay": false,
"self": false,
"mutations": 3,
"mutations": 4,
"api": 0
}
},
@@ -296,9 +296,9 @@
"verdict": "works",
"signals": {
"url": false,
"overlay": true,
"overlay": false,
"self": false,
"mutations": 3,
"mutations": 4,
"api": 0
}
},
@@ -314,20 +314,17 @@
"verdict": "works",
"signals": {
"url": true,
"overlay": false,
"overlay": true,
"self": false,
"mutations": 19,
"api": 0
"mutations": 24,
"api": 1
}
}
],
"missing": [
"搜索",
"李 小李的店",
"余额 ¥327.40",
"账户",
"编辑",
"重置密码",
"移出"
"E E2E 桥接测试团队 20260529",
"余额 ¥14,907.00",
"账户"
]
}
+3 -6
View File
@@ -1,16 +1,13 @@
# 功能审计 · team
路由:`/team` · 模式:isolated
路由:`/team` · 模式:quick
合计 17 · ✅ works 17 · ❌ **dead 0** · 🛑 error 0 · ⏭ skipped(破坏性)0 · ◷ noop-active 0 · ⚪ disabled 0
## 🔍 设计稿里有、React 没渲染出来的控件(MISSING · 启发式)
> 按控件文字与设计稿 `/exact` 对比,可能含装饰/动态文案误报,人工过一眼。
- 搜索
- 李 小李的店
- 余额 ¥327.40
- E E2E 桥接测试团队 20260529
- 余额 ¥14,907.00
- 账户
- 编辑
- 重置密码
- 移出
+107
View File
@@ -0,0 +1,107 @@
# AirShelf/core 全站测试报告 · 2026-06-10
> 依据 `test-agent.md` 四层测试体系(A 代码 → B/E 浏览器 → D 端到端 → C 报告)。
> 数据策略:**全真实数据**(真实后端 127.0.0.1:8010 + 真实 SQLite,禁 mock)。
> 不重新生成视频(用户指定);其余链路全测,含团队 / 成员 / 消费多用户场景。
## 测试概览
| 层 | 范围 | 结果 |
|---|---|---|
| A 代码 | 后端 Django test + 前端 tsc + vite build | ✅ 后端 10/10(原 8 + 新增 2 充值权限回归)· tsc 0 错 · build 成功 |
| B/E 浏览器 | 登录/工作台/商品/项目/资产/图片生成/团队/消费/消息/设置 + pipeline 五阶段 | ✅ 逐页控制台无 P0 报错,截图人审通过 |
| D 端到端 | 多用户(owner+成员)权限 / 数据隔离 / 限额 / 账单归属 | ✅ 发现 1 个 P0 越权 bug 已修 |
| E 移动端 | iPhone 13 视口逐页 | ✅ 发现 2 个移动端 bug 已修 |
**判定:首轮发现 9 个 bug(8 修复 + 1 确认非缺陷),全部复验通过。**
---
## Bug 清单(全部已修复并复验)
| # | 严重度 | 类型 | 描述 | 定位 | 复验 |
|---|---|---|---|---|---|
| 1 | **P0** | 权限越权 | **普通成员能给团队充值**:`POST /api/billing/recharge/` 只校验登录不校验角色,成员调用返回 201 并真实加钱(实测余额 ¥14906→¥15006) | `apps/billing/views.py:recharge` | 成员调用→403;owner→201;加回归测试 2 条 |
| 2 | P1 | 功能缺失 | **设置页「退出登录」点确认无反应**:`onLogout` 没从 App 传进 SettingsPage,确认按钮只关弹窗不退登 | `App.tsx` + `routes/settings.tsx` | 确认→清 token→跳 /login ✓ |
| 3 | P1 | 移动端可用性 | **窄屏无法导航**:`@media(max-width:1100px)` 把侧栏和收窄键都 `display:none`,移动端没有任何菜单入口 | `design-restraint.css` + `components/app-shell.tsx` | 新增汉堡键+抽屉+遮罩,点导航跳转并自动收起 ✓ |
| 4 | P1 | 移动端布局 | **宽数据表被裁切**:项目列表(715px)/成员表/账单表在窄屏溢出且无横向滚动,右侧列(状态/操作)点不到 | `design-restraint.css` + projects/team/account.tsx | 包 `overflow-x:auto` 容器,表内横向滚动,页面不再侧滑 ✓ |
| 5 | P2 | 文案 | **登录失败提示英文**:错误密码/空提交弹后端原文 `invalid credentials` | `routes/auth-screen.tsx` | 映射成「邮箱或密码不正确」✓ |
| 6 | P2 | 安全/体验 | **登录页预填设计稿假账号** `li@shop.com / demo-1234`,真实环境误导 | `routes/auth-screen.tsx` | 字段从空开始 + 前端必填校验 ✓ |
| 7 | P2 | 体验 | **新建项目默认名固定 v1**:连建多个项目重名,易混淆 | `routes/projects.tsx` | 按已有项目自动递增 v1→v2…实测「南卡·痛点种草·v2」✓ |
| 8 | P2 | 体验 | **慢操作无反馈**:新建项目「开始」、上传资产「上传资产」按钮点击后无 loading 态,用户疑似没点上 | projects.tsx / library.tsx | 加「创建中…」「上传中…」禁用态 ✓ |
| 9 | P2 | 体验 | **建成员失败丢表单**:用户名重复等失败时弹窗仍关闭并清空,要重填 | `routes/team.tsx` | 失败保留弹窗与已填内容,仅成功才清 ✓ |
> 移动端统计卡标签竖排("总/项/目")一并修复(加 `.stat .lbl { white-space: nowrap }`)。
---
## D 层 · 多用户 / 消费链路验证(真实数据)
| 验证项 | 方法 | 结果 |
|---|---|---|
| 建成员 | 团队页创建 `qa-member-0610`,月限额 ¥1 | ✅ 落库,成员表出现 |
| 成员登录 | 该成员凭证登录 | ✅ 进工作台,身份=成员、团队=同团队 |
| 数据共享(同团队) | 成员可见团队 8 商品 | ✅ TeamScopedViewSetMixin 按 team 过滤 |
| 数据隔离(跨团队) | 各团队 product/project queryset 按 team 隔离 | ✅ 结构性强隔离 |
| 权限·改他人限额 | 成员 PATCH owner 限额 | ✅ 403 |
| 权限·建成员 | 成员 POST members | ✅ 403 |
| 权限·充值 | 成员 POST recharge | ❌→✅ **修复前 201(bug#1),修复后 403** |
| 月限额拦截 | `_enforce_member_monthly_limit` 限额 ¥1 | ✅ ¥1 任务放行、¥2 拦截 |
| 账单归属 | ledger 带 `user`/`user_label`,成员列消费可追溯 | ✅ 每笔扣费挂触发人 |
测试数据已清理:QA 成员已删、误充 ¥100 已回退(余额复原 ¥14906)、QA 测试商品/项目/音频资产均删除。
---
## A 层 · 代码测试输出
```
后端:python manage.py test --settings=airshelf.settings.test
Ran 10 tests — OK(新增 RechargePermissionTests: owner 可充值 / 成员 403 余额不变)
前端:tsc --noEmit → 0 errors
vite build → 成功
```
## 环境
| 服务 | 状态 | 说明 |
|---|---|---|
| 后端 Django | ✅ 127.0.0.1:8010 | .venv/bin/python · SQLite · StatReloader 热载 |
| 前端 Vite | ✅ 127.0.0.1:5173 | 真实 /api 代理 |
| Playwright | ✅ headed chromium | MCP 驱动,iPhone 13 视口测移动端 |
---
## 资金核算深度审计(2026-06-11 补)
> 用户追问"数据流转、金额核算有没有出错"。写了核算审计脚本 `qa/audit_billing.py`,对真实 DB **17 个团队账户**逐条验证 8 项账目不变量。
| 不变量 | 含义 | 结果 |
|---|---|---|
| I1 余额=流水终点 | `account.balance` == 末条动账流水 `balance_after` | ✅ 全过 |
| I2 冻结自洽 | `reserved_balance` == Σ(ACTIVE 预留) | ✅ 全过 |
| I3 非负&可用 | balance/reserved ≥ 0 且 balance ≥ reserved | ✅ 全过 |
| I4 预留闭环 | reservation 状态与扣费/释放流水一致,扣费 ≤ 预留 | ✅ 全过 |
| I5 失败不扣费 | 失败/取消 task 的预留不得为 CHARGED | ✅ 全过 |
| I6 无重复扣费 | 同 task 不得多扣(净额=预留) | ✅ 全过 |
| **I7 逐笔差分自洽** | **每条 `balance_after` == 前一条 ± 本笔金额** | ✅ 全过 |
| I8 流水完整 | 开户额度有 genesis 凭证(可从首条对账) | ✅ 修复后全过 |
**结论:金额核算零错误。** 每个账户余额、冻结、逐笔差分账都精确自洽,无多扣/少扣/重复扣/冻结泄漏。
### 审计中查实的两件事
1. **历史双扣(已自愈,账平)**:`E2E 桥接测试团队` task `8b9745b8` 在 2026-06-10 03:08:25 同秒两条 ¥1 扣费(并发竞态历史脏数据),但 06:32 已有一条 `adjustment +¥1` 回冲,净扣 ¥1=预留 ¥1。当前 `charge_reserved_credit` 已加 `select_for_update` 锁内重读幂等,不会复发。
2. **Bug #10(P1 可审计性)· 开户赠送额度不进流水**:注册开户(`accounts/serializers.py`)给 `DEFAULT_TRIAL_CREDITS` 试用额度直接写进 `balance`,**不记任何 ledger**。后果:这笔钱在账单流水里查无凭证,无法对账、无法向用户解释余额来源(17 个账户全中)。
- **余额数字本身没错**(初始额度 + 流水净额 = 真实余额,逐团队核对分毫不差),纯属可审计性缺陷。
- **修复**:开户时补记一条 `recharge` 赠送流水(`balance_after`=赠送额),加回归测试 `test_register_trial_grant_is_recorded_in_ledger`;历史 17 个账户用 `qa/backfill_genesis_ledger.py` 回填 genesis 凭证(**只补凭证,不动余额**)。回填后审计 I8 全过。
> 复跑审计:`cd backend && DJANGO_SETTINGS_MODULE=airshelf.settings.development .venv/bin/python ../qa/audit_billing.py` → `✓ 全部账户账目自洽,无金额核算错误`。后端测试 11/11。
---
## 遗留 / 未测(用户指定跳过)
- **视频生成 / 拼接导出**:用户指定不重跑。pipeline 五阶段页面渲染、弹窗、控制台已查,生成动作本身未触发。
- 真实第三方(火山 ARK / TOS)出图出片未联网触发。
+88
View File
@@ -0,0 +1,88 @@
# UI 设计稿开发规范(通用 CLAUDE.md)
> 把本文件放在 UI 设计稿工作区根目录。适用于任何 Web 项目:你(UI/设计 agent)产出的**不是静态 HTML 画稿,而是可直接并入目标工程的 SPA 页面代码**。
>
> **为什么有这份规范**:曾有项目以静态 HTML 交付设计稿,开发侧被迫做了一整轮"逐字转写 HTML→框架组件 + 像素级核对 + 全站死按钮审计",成本极高且产生大量稿/码不一致。本规范的唯一目标:**设计稿即代码,交付物零转换并入主工程。**
---
## 0. 项目参数表(每个新项目开工前先填,未填不得动工)
向开发侧确认以下信息并填入。本文件其余部分所有规则引用这张表,不要凭喜好自选:
| 参数 | 本项目取值 | 说明 |
|---|---|---|
| 前端框架 | (如 React 19 / Vue 3 / Svelte) | 必须与目标工程一致;全新项目默认 React + TypeScript |
| 构建工具 | (如 Vite) | 默认 Vite |
| 路由方案 | (目标工程现有方案) | 复用目标工程的路由机制,不自带新路由库 |
| 样式方案 | (如纯 CSS / Tailwind / CSS Modules) | 与目标工程一致;目标工程没有的方案不准引入 |
| 组件/图标库 | (目标工程已有的) | 只用已有的,不新增 UI 框架和图标库 |
| 设计 token 文件 | (如 `src/styles.css` / `tokens.css`) | 颜色、字号、间距的唯一来源 |
| 基准视口 | (如 1440×900) | 像素核对和验收用的视口 |
| 页面文件落位 | (如 `src/routes/` + `src/<page>.css`) | 与目标工程目录同构 |
| Mock 数据落位 | (如 `src/mock/`) | 见 §4 |
| 类型定义文件 | (如 `src/types.ts`,可空) | mock 字段形状的对齐目标 |
> 目标工程尚不存在(全新项目)时:默认 React + TypeScript + Vite + 纯 CSS,并把你定下的取值回填此表,作为后续开发工程的初始约定。
## 1. 交付物形态(最重要的一条)
每个页面的交付物固定为:
| 文件 | 说明 |
|---|---|
| 页面组件 | 一个独立的页面级组件文件,按参数表落位 |
| 页面样式 | 该页专属样式文件,全部选择器收敛在页面根 class 下 |
| Mock 数据 | 该页全部演示数据,集中一个文件 |
| 路由注册 | 在目标工程的路由配置中加一项,页面可直达 |
**明确禁止**:
- ❌ 交付静态 `.html` 文件(任何形式,包括"先 HTML 后转组件"的中间产物)
- ❌ iframe 嵌套原型、截图/图片代替可交互区域
- ❌ jQuery、CDN `<script>`、内联 `<style>` 巨块
- ❌ 引入参数表之外的任何框架、UI 库、CSS 方案、图标库
## 2. 工作区与工程同构
- 设计稿工作区直接以目标工程的前端为模板搭骨架(入口、公共布局、token 文件、路由配置),在其上加页面;`npm run dev`(或等价命令)必须能跑
- 公共骨架(侧栏/导航/页脚/Toast 等)只实现一份并各页复用,不要每页重画
- 全新项目则先搭最小可运行骨架,再出页面
## 3. 视觉规范
- 颜色、间距、字号一律引用参数表指定的 token 文件;**新增 token 必须加进 token 文件并注释用途**,不许在页面样式里写裸色值复制粘贴
- 页面样式顶层必须有唯一根 class(如 `.products-page`),所有规则在其作用域内,防止跨页泄漏;不要重复写 reset
- 布局在基准视口下必须与设计意图逐像素一致,无横向滚动条;这是后续像素核对(pixelmatch 类工具)的验收视口
- 响应式要求由项目参数表注明;未注明时至少保证基准视口完整可用
## 4. 数据规范:mock 必须长得像真接口
这是历史返工的重灾区。规则:
1. 每页 mock 集中放参数表指定的 mock 目录,**禁止把数据硬编码散落在组件 JSX/模板里**
2. 目标工程已有类型定义的,mock 字段形状必须对齐;没有的,先写出 interface/类型再造数据,并在交接说明里标注"需后端提供的接口与字段"
3. 组件一律通过 props 或一层 `getXxx()` 取数函数拿数据——接真后端时只换数据源这一层
4. 列表类数据至少 8 条以上真实感数据(真实语言文案、不同状态混排),并且必须设计并实现 **loading / 空态 / 错误态** 三种 UI
## 5. 交互规范:不许有死按钮
交付页面默认会被行为审计工具**逐个控件真实点击**验收:
- 每个按钮、tab、toggle、下拉、可点卡片,点击后必须有**可观察的状态变化**(切换内容、开弹窗、改样式、出提示——本地 state 实现即可)
- 弹窗/抽屉可开可关(含遮罩点击、Esc);表单为受控输入并有提交反馈
- 暂时没想好行为的入口:**宁可不画,也不要画一个没有 handler 的假按钮**;确需占位的,统一 `disabled` + 注明"规划中"
- 控制台零 error/warning
## 6. 交付自查清单(每页提交前过一遍)
- [ ] dev server 启动后,该页可通过路由直达
- [ ] 类型检查/构建零错误
- [ ] 基准视口下与设计意图一致,无横向滚动条
- [ ] mock 数据集中存放、字段对齐类型定义、三态齐全
- [ ] 全部可点控件有真实 handler,控制台无报错
- [ ] 页面样式收敛在根 class 下,未污染其他页面
- [ ] 附《接口接入说明》:本页用到哪些数据、对应/期望哪个后端接口、哪些字段是新增
## 7. 与开发的交接标准
设计稿工作区与目标工程目录同构,开发合入一个页面的动作应该只有:**拷贝页面组件 + 样式 + mock 三个文件、路由配置加一行、把 mock 取数层换成真实 API 调用**。如果交接时开发需要做超出以上范围的改写,视为设计稿不合格,退回返工。