后端生成闸+多项修复;前端全站更新;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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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) # 流水终点 == 账户余额,可对账
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+46
@@ -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),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")) # 余额未变,越权被拦
|
||||
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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("导出超时,后台可能仍在拼接,稍后回到本页查看");
|
||||
}, "成片已导出")
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ─── */
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; } }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+1229
-137
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
{/* 右:权限矩阵 */}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()
|
||||
@@ -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 流水(余额未变动)")
|
||||
@@ -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
|
||||
- 账户
|
||||
- 折叠侧栏
|
||||
- 时间
|
||||
|
||||
@@ -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
|
||||
- 账户
|
||||
- 管理资产
|
||||
- 性别
|
||||
- 年龄段
|
||||
- 角色标签
|
||||
- 来源
|
||||
- 最近使用
|
||||
- 缺三视图,查看说明
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 创
|
||||
|
||||
|
||||
@@ -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",
|
||||
"故事板生成失败"
|
||||
"视频片段生成中"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
- 账户
|
||||
- 上传新头像
|
||||
- 恢复默认
|
||||
- 验证
|
||||
- 更换
|
||||
- 管理团队 →
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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)全无反应,优先修。
|
||||
|
||||
@@ -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",
|
||||
"账户"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
- 账户
|
||||
- 编辑
|
||||
- 重置密码
|
||||
- 移出
|
||||
|
||||
|
||||
@@ -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)出图出片未联网触发。
|
||||
@@ -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 调用**。如果交接时开发需要做超出以上范围的改写,视为设计稿不合格,退回返工。
|
||||
Reference in New Issue
Block a user