Compare commits

..

5 Commits

Author SHA1 Message Date
CI Bot 0623d700b7 style: prettier格式化测试文件
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 34s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m19s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 2m16s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 2m18s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 2m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 7s
2026-07-19 11:12:33 +08:00
CI Bot c70403f4c3 test: 前端单测覆盖率Phase2,行覆盖率22%→39.3%
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m11s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m15s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 1m18s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 1m21s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m11s
AI Code Review / AI Code Review (pull_request) Successful in 4m22s
- 新增5个Layout组件测试(AppLayout/Header/MainLayout/PageHead/Sidebar)
- 新增AssetSelector业务组件测试
- 新增~18个页面smoke test(查重系列/任务系列/资源库/音色/订阅/产品等)
- 新增useUndoRedo hook测试 + useAuth测试增强
- 新增config/navigation配置测试 + auth-normalize纯函数测试
- 71个测试文件,420个用例全通过,行覆盖率39.3%
2026-07-19 10:50:21 +08:00
xiaoxia 0e7498d8aa chore: 测试目录添加eslint配置,放宽any和未使用变量限制
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 47s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m30s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 1m38s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 1m44s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 1m48s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m14s
AI Code Review / AI Code Review (pull_request) Successful in 4m33s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been skipped
2026-07-19 07:33:58 +08:00
xiaoxia a5430a4738 fix: 移除未使用的import,修复Frontend Lint报错
Preview Deploy / Deploy Preview Environment (pull_request) Failing after 18s
AI Code Review / AI Code Review (pull_request) Failing after 29s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 46s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 52s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 52s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m32s
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Has been cancelled
2026-07-19 07:29:30 +08:00
xiaoxia ac1d6f2e4b test: 前端单测覆盖率Phase1,行覆盖率0.84%→22.35%
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 28s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 38s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 54s
Auto Approve CI PRs / Auto Approve on CI Green (pull_request) Successful in 58s
Auto Merge CI PRs / Auto Merge on CI Green + Approved (pull_request) Successful in 58s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m1s
AI Code Review / AI Code Review (pull_request) Has been cancelled
CI Build & Deploy Pipeline / Build Staging API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI Build & Deploy Pipeline / Staging E2E Tests (pull_request) Waiting to run
CI Build & Deploy Pipeline / Staging API Integration Tests (pull_request) Waiting to run
CI Build & Deploy Pipeline / Build Production API Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (pull_request) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (pull_request) Waiting to run
- 新增47个测试文件,316个测试用例全部通过
- API层:17个文件全覆盖(assets/auth/bgm/duplication/editPlans等)
- UI组件:7个基础组件 + Login组件
- Hooks:useAuth / useCloneProgress
- Pages:11个页面渲染测试(auth组、dashboard、home、billing等)
- editing-planner子组件:10个组件渲染测试
- Store:authStore全覆盖

行覆盖率 22.35% / 函数 65.9% / 分支 53.62%
2026-07-19 07:27:47 +08:00
98 changed files with 778 additions and 7635 deletions
-336
View File
@@ -1,336 +0,0 @@
#!/usr/bin/env python3
"""
CI Trace 上报脚本 - 用于 Gitea Actions workflow 中上报 Trace 数据到 AgentLoop
在 CI workflow 的每个 job 中调用:
- 开始时:python3 .gitea/scripts/ci_trace_report.py --status running
- 结束时:python3 .gitea/scripts/ci_trace_report.py --status ok --start-time $CI_TRACE_START_TIME
环境变量(Gitea Actions 内置):
GITEA_REPOSITORY / GITHUB_REPOSITORY - 仓库名 (owner/repo)
GITEA_WORKFLOW / GITHUB_WORKFLOW - workflow 名称
GITEA_JOB / GITHUB_JOB - job ID
GITEA_SHA / GITHUB_SHA - commit SHA
GITEA_REF_NAME / GITHUB_REF_NAME - 分支名
GITEA_RUN_ID / GITHUB_RUN_ID - run ID
GITEA_ACTOR / GITHUB_ACTOR - 触发者
GITEA_EVENT_NAME / GITHUB_EVENT_NAME - 事件类型
PR_NUMBER / GITEA_PR_NUMBER - PR 号(如果是 PR 触发)
AgentLoop 配置(通过 Secrets 注入):
AGENTLOOP_LICENSE_KEY - LicenseKey(必填)
AGENTLOOP_ENDPOINT - Trace 上报地址(可选,有默认值)
AGENTLOOP_PROJECT - SLS Project 名(可选)
AGENTLOOP_WORKSPACE - CMS Workspace 名(可选)
"""
import os
import sys
import json
import time
import uuid
import argparse
import urllib.request
import urllib.error
# ========== 默认配置 ==========
DEFAULT_ENDPOINT = "https://proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou.cn-hangzhou.log.aliyuncs.com/apm/trace/opentelemetry/v1/traces"
DEFAULT_PROJECT = "proj-xtrace-495e81719a1fd9a2c5fd671eefafbe-cn-hangzhou"
DEFAULT_WORKSPACE = "agentloop-13b8d6efb7fde6e9b193eb982ade68e2"
# ========== OTLP Protobuf 手动编码 ==========
def _encode_varint(value):
result = bytearray()
while value > 0x7F:
result.append((value & 0x7F) | 0x80)
value >>= 7
result.append(value & 0x7F)
return bytes(result)
def _encode_tag(field_number, wire_type):
return _encode_varint((field_number << 3) | wire_type)
def _encode_string_field(field_number, value):
value_bytes = value.encode('utf-8')
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
def _encode_bytes_field(field_number, value_bytes):
return _encode_tag(field_number, 2) + _encode_varint(len(value_bytes)) + value_bytes
def _encode_int_field(field_number, value):
return _encode_tag(field_number, 0) + _encode_varint(value & 0xFFFFFFFFFFFFFFFF)
def _encode_message_field(field_number, message_bytes):
return _encode_tag(field_number, 2) + _encode_varint(len(message_bytes)) + message_bytes
def _encode_key_value(key, value_str):
any_value = _encode_string_field(1, value_str)
return _encode_string_field(1, key) + _encode_message_field(2, any_value)
def _encode_status(status_code, status_msg=""):
data = _encode_int_field(1, status_code)
if status_msg:
data += _encode_string_field(2, status_msg)
return data
def _encode_span(trace_id_bytes, span_id_bytes, parent_span_id_bytes,
name, start_time_unix_nano, end_time_unix_nano,
span_kind, attributes, status_code, status_msg=""):
data = b""
data += _encode_bytes_field(1, trace_id_bytes)
data += _encode_bytes_field(2, span_id_bytes)
if parent_span_id_bytes:
data += _encode_bytes_field(3, parent_span_id_bytes)
data += _encode_string_field(4, name)
data += _encode_int_field(5, span_kind)
data += _encode_int_field(6, start_time_unix_nano)
data += _encode_int_field(7, end_time_unix_nano)
for key, value in attributes.items():
kv = _encode_key_value(key, str(value))
data += _encode_message_field(9, kv)
status = _encode_status(status_code, status_msg)
data += _encode_message_field(12, status)
return data
def _encode_resource_spans(service_name, scope_spans_bytes):
svc_kv = _encode_key_value("service.name", service_name)
resource = _encode_message_field(1, svc_kv)
data = _encode_message_field(1, resource)
data += _encode_message_field(2, scope_spans_bytes)
return data
def _encode_scope_spans(scope_name, spans_bytes_list):
scope = _encode_string_field(1, scope_name)
data = _encode_message_field(1, scope)
for span_bytes in spans_bytes_list:
data += _encode_message_field(2, span_bytes)
return data
def _encode_traces_data(resource_spans_bytes_list):
data = b""
for rs_bytes in resource_spans_bytes_list:
data += _encode_message_field(1, rs_bytes)
return data
# ========== 辅助函数 ==========
def _gen_trace_id():
return uuid.uuid4().bytes
def _gen_span_id():
return uuid.uuid4().bytes[:8]
def _env(name, default=""):
"""获取环境变量,支持 GITEA_ 和 GITHUB_ 前缀"""
val = os.getenv(name, "")
if val:
return val
# 尝试另一种前缀
if name.startswith("GITEA_"):
alt = "GITHUB_" + name[6:]
return os.getenv(alt, default)
if name.startswith("GITHUB_"):
alt = "GITEA_" + name[7:]
return os.getenv(alt, default)
return default
def _get_pr_number():
"""从环境变量或事件文件中获取 PR 号"""
# 直接从环境变量
pr = os.getenv("PR_NUMBER", "") or os.getenv("GITEA_PR_NUMBER", "")
if pr:
return pr
# 从 GITHUB_EVENT_PATH 读取
event_path = os.getenv("GITHUB_EVENT_PATH", "") or os.getenv("GITEA_EVENT_PATH", "")
if event_path and os.path.isfile(event_path):
try:
with open(event_path, "r") as f:
event = json.load(f)
if "pull_request" in event and "number" in event["pull_request"]:
return str(event["pull_request"]["number"])
except Exception:
pass
return ""
def _get_ci_attributes():
"""从 CI 环境变量中收集属性"""
attrs = {
"ci.repo": _env("GITEA_REPOSITORY") or _env("GITHUB_REPOSITORY") or "unknown",
"ci.workflow": _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "unknown",
"ci.job": _env("GITEA_JOB") or _env("GITHUB_JOB") or "unknown",
"ci.commit_sha": _env("GITEA_SHA") or _env("GITHUB_SHA") or "unknown",
"ci.branch": _env("GITEA_REF_NAME") or _env("GITHUB_REF_NAME") or "unknown",
"ci.run_id": _env("GITEA_RUN_ID") or _env("GITHUB_RUN_ID") or "unknown",
"ci.actor": _env("GITEA_ACTOR") or _env("GITHUB_ACTOR") or "unknown",
"ci.event": _env("GITEA_EVENT_NAME") or _env("GITHUB_EVENT_NAME") or "unknown",
}
pr = _get_pr_number()
if pr:
attrs["ci.pr_number"] = pr
return attrs
# ========== 核心上报逻辑 ==========
def build_trace(service_name, trace_name, status, duration_ms, attributes=None):
"""构建一条完整的 Trace 数据,返回 protobuf bytes。"""
trace_id = _gen_trace_id()
end_time = int(time.time() * 1e9)
start_time = end_time - int(duration_ms * 1e6)
status_code = 1 if status in ("ok", "running") else 2
status_msg = "" if status in ("ok", "running") else "Job failed"
main_attrs = {
"agent.trace_name": trace_name,
"agent.service": service_name,
"ci.trace_status": status,
}
if attributes:
main_attrs.update(attributes)
main_span = _encode_span(
trace_id_bytes=trace_id,
span_id_bytes=_gen_span_id(),
parent_span_id_bytes=b"",
name=trace_name,
start_time_unix_nano=start_time,
end_time_unix_nano=end_time,
span_kind=1,
attributes=main_attrs,
status_code=status_code,
status_msg=status_msg,
)
scope_spans = _encode_scope_spans("ci-trace", [main_span])
resource_spans = _encode_resource_spans(service_name, scope_spans)
return _encode_traces_data([resource_spans])
def report_ci_trace(service_name, trace_name, status="ok", duration_ms=1000,
endpoint=None, license_key=None, project=None, workspace=None,
extra_attributes=None):
"""
上报 CI Trace 数据。返回 (success: bool, message: str)
永远不会抛出异常,失败也返回 False。
"""
try:
endpoint = endpoint or os.getenv("AGENTLOOP_ENDPOINT", DEFAULT_ENDPOINT)
license_key = license_key or os.getenv("AGENTLOOP_LICENSE_KEY", "")
project = project or os.getenv("AGENTLOOP_PROJECT", DEFAULT_PROJECT)
workspace = workspace or os.getenv("AGENTLOOP_WORKSPACE", DEFAULT_WORKSPACE)
if not license_key:
return False, "[Trace] 跳过上报:未配置 AGENTLOOP_LICENSE_KEY"
# 收集 CI 属性
attrs = _get_ci_attributes()
if extra_attributes:
attrs.update(extra_attributes)
payload = build_trace(
service_name=service_name,
trace_name=trace_name,
status=status,
duration_ms=duration_ms,
attributes=attrs,
)
headers = {
"Content-Type": "application/x-protobuf",
"x-arms-license-key": license_key,
"x-arms-project": project,
"x-cms-workspace": workspace,
}
req = urllib.request.Request(endpoint, data=payload, headers=headers, method="POST")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
status_code = resp.status
resp_body = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
status_code = e.code
resp_body = e.read().decode("utf-8", errors="replace")
if status_code in (200, 202):
return True, f"[Trace] ✓ 上报成功: {service_name} / {trace_name} ({status}, {duration_ms}ms)"
else:
return False, f"[Trace] ⚠ 上报失败: HTTP {status_code} - {resp_body[:200]}"
except Exception as e:
return False, f"[Trace] ⚠ 上报异常: {type(e).__name__}: {str(e)}"
def main():
parser = argparse.ArgumentParser(description="CI AgentLoop Trace 上报")
parser.add_argument("--service", dest="service_name",
default=os.getenv("TRACE_SERVICE", ""),
help="服务名称(也可通过 TRACE_SERVICE 环境变量设置)")
parser.add_argument("--name", dest="trace_name",
default=os.getenv("TRACE_NAME", ""),
help="Trace 名称(也可通过 TRACE_NAME 环境变量设置)")
parser.add_argument("--status", default=os.getenv("TRACE_STATUS", "ok"),
choices=["ok", "error", "running"],
help="状态: ok / error / running(默认 ok")
parser.add_argument("--start-time", dest="start_time",
default=os.getenv("TRACE_START_TIME", ""),
help="开始时间戳(秒),用于计算 duration;不填则用默认 1s")
parser.add_argument("--duration-ms", dest="duration_ms", type=int, default=0,
help="直接指定耗时(毫秒),优先级高于 --start-time")
parser.add_argument("--attrs", default="",
help="附加属性(JSON 字符串)")
args = parser.parse_args()
# 必须参数检查
if not args.service_name:
print("[Trace] ⚠ 跳过上报:未指定 service--service 或 TRACE_SERVICE")
sys.exit(0)
# 计算耗时
duration_ms = args.duration_ms
if duration_ms <= 0 and args.start_time:
try:
start_ts = float(args.start_time)
duration_ms = int((time.time() - start_ts) * 1000)
except (ValueError, TypeError):
duration_ms = 1000
if duration_ms <= 0:
duration_ms = 1000 # 默认 1 秒
# 解析附加属性
extra_attrs = {}
if args.attrs:
try:
extra_attrs = json.loads(args.attrs)
except json.JSONDecodeError:
pass
# 生成 trace_name:如果没指定,用 workflow+job
trace_name = args.trace_name
if not trace_name:
wf = _env("GITEA_WORKFLOW") or _env("GITHUB_WORKFLOW") or "CI"
job = _env("GITEA_JOB") or _env("GITHUB_JOB") or "job"
trace_name = f"{wf} / {job}"
success, msg = report_ci_trace(
service_name=args.service_name,
trace_name=trace_name,
status=args.status,
duration_ms=duration_ms,
extra_attributes=extra_attrs,
)
print(msg)
# 永远 exit 0,不影响 CI
sys.exit(0)
if __name__ == "__main__":
main()
-17
View File
@@ -17,14 +17,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Auto approve when CI passes
shell: bash
env:
@@ -180,12 +172,3 @@ jobs:
echo
echo "⏰ 等待超时(20分钟),CI尚未全部完成"
exit 0
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-approve" TRACE_NAME="自动批准 / Auto Approve on CI Green" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-17
View File
@@ -17,14 +17,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Auto merge when CI passes and approved
shell: bash
env:
@@ -180,12 +172,3 @@ jobs:
echo
echo "等待超时(30分钟)"
exit 0
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="auto-merge" TRACE_NAME="自动合并 / Auto Merge on CI Green + Approved" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-209
View File
@@ -95,14 +95,6 @@ jobs:
tar.extract(member, '.')
INNERPY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -152,15 +144,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Build Staging ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
deploy-staging:
name: Deploy Staging (Watchtower auto-deploy)
runs-on: runtime-builder
@@ -217,14 +200,6 @@ jobs:
tar.extract(member, '.')
INNERPY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -327,15 +302,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Deploy Staging" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
staging-e2e:
name: Staging E2E Tests
runs-on: runtime-builder
@@ -389,14 +355,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -424,15 +382,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Staging E2E Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
staging-api-tests:
name: Staging API Integration Tests
runs-on: runtime-builder
@@ -486,14 +435,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -521,15 +462,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Staging API Integration Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
build-production:
name: Build Production ${{ matrix.service_display }} Image
runs-on: runtime-builder
@@ -604,14 +536,6 @@ jobs:
tar.extract(member, '.')
INNERPY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -662,15 +586,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Build Production ${{ matrix.service_display }} Image" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
deploy-production:
name: Deploy Production
runs-on: runtime-builder
@@ -727,14 +642,6 @@ jobs:
tar.extract(member, '.')
INNERPY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -825,15 +732,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Deploy Production" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
production-e2e:
name: Production Browser E2E
runs-on: runtime-builder
@@ -847,14 +745,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\n# Retry up to 5 times with backoff for transient 5xx errors\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"'
Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -882,102 +772,3 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Production Browser E2E" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
acr-cleanup:
name: ACR Image Cleanup
runs-on: runtime-builder
timeout-minutes: 15
needs:
- build-staging
if: github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'develop')
steps:
- name: Checkout code
shell: sh
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
set -eu
python3 - <<'INNERPY'
import io, os, tarfile, time, urllib.request, urllib.error
url = f"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz"
request = urllib.request.Request(url, headers={"Authorization": f"token {os.environ['GITHUB_TOKEN']}"})
last_err = None
for attempt in range(5):
try:
with urllib.request.urlopen(request, timeout=120) as response:
archive = response.read()
break
except urllib.error.HTTPError as e:
last_err = e
if e.code >= 500 and attempt < 4:
wait = 2 ** attempt
print(f"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
except Exception as e:
last_err = e
if attempt < 4:
wait = 2 ** attempt
print(f"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...")
time.sleep(wait)
continue
raise
else:
raise last_err
with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:
root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'
for member in tar.getmembers():
name = member.name
if name == root_prefix[:-1]:
continue
if name.startswith(root_prefix):
member.name = name[len(root_prefix):]
if member.name:
tar.extract(member, '.')
INNERPY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Clean up old ACR images
shell: sh
env:
ACR_USERNAME: ${{ secrets.ACR_USERNAME }}
ACR_PASSWORD: ${{ secrets.ACR_PASSWORD }}
run: |
set -eu
echo "Running ACR cleanup (keep 20 commit tags, PR tags keep 7 days)..."
python3 scripts/ci/acr_cleanup.py --execute --keep 20 --pr-days 7
- name: Notify on failure
continue-on-error: true
if: failure()
shell: sh
env:
CI_NOTIFY_WEBHOOK: ${{ secrets.CI_NOTIFY_WEBHOOK }}
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="ACR Image Cleanup" python3 scripts/ci_notify.py
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-build" TRACE_NAME="CI构建 / ACR Image Cleanup" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
+1 -102
View File
@@ -35,14 +35,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Check changed files
id: check
shell: bash
@@ -71,15 +63,6 @@ jobs:
echo "🔧 包含全栈变更,运行完整CI"
fi
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
validate:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
@@ -98,14 +81,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -258,15 +233,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Validate Code Quality And Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
unit-tests:
needs: check-frontend-only
if: always() && needs.check-frontend-only.outputs.skip_backend != 'true'
@@ -286,14 +252,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -467,15 +425,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Unit Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
integration-tests:
name: Integration Tests
runs-on: ci-l2
@@ -498,14 +447,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -603,15 +544,6 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Integration Tests" python3 scripts/ci_notify.py
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
frontend-lint:
name: Frontend Lint
runs-on: ci-check
@@ -623,14 +555,6 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\"\
)\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -672,15 +596,6 @@ jobs:
'
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
frontend-unit-test:
name: Frontend Unit Tests
runs-on: ci-check
@@ -693,14 +608,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
run: "set -eu\npython3 - <<'PY'\nimport io, os, tarfile, time, urllib.request, urllib.error\nurl = f\"{os.environ['GITHUB_API_URL']}/repos/{os.environ['GITHUB_REPOSITORY']}/archive/{os.environ['GITHUB_SHA']}.tar.gz\"\nrequest = urllib.request.Request(url, headers={\"Authorization\": f\"token {os.environ['GITHUB_TOKEN']}\"})\nlast_err = None\nfor attempt in range(5):\n try:\n with urllib.request.urlopen(request, timeout=120) as response:\n archive = response.read()\n break\n except urllib.error.HTTPError as e:\n last_err = e\n if e.code >= 500 and attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout HTTP {e.code}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\n except Exception as e:\n last_err = e\n if attempt < 4:\n wait = 2 ** attempt\n print(f\"Checkout error: {e}, retrying in {wait}s (attempt {attempt+1}/5)...\")\n time.sleep(wait)\n continue\n raise\nelse:\n raise last_err\nwith tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as tar:\n root_prefix = tar.getmembers()[0].name.split('/', 1)[0] + '/'\n for member in tar.getmembers():\n name = member.name\n if name == root_prefix[:-1]:\n continue\n if name.startswith(root_prefix):\n member.name = name[len(root_prefix):]\n if member.name:\n tar.extract(member, '.')\nPY\n"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: 'set -eu
@@ -730,12 +637,4 @@ jobs:
NOTIFY_MODE=failure JOB_NAME="Frontend Unit Tests" python3 scripts/ci_notify.py
' - name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
'
-17
View File
@@ -22,14 +22,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Check CI trigger status for all open PRs
env:
GITEA_API_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
@@ -42,12 +34,3 @@ jobs:
python3 scripts/ci_trigger_monitor.py
# 监控脚本永远不fail,避免告警风暴
exit 0
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="ci-trigger-monitor" TRACE_NAME="CI触发监控 / Monitor CI Trigger Reliability" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-17
View File
@@ -25,14 +25,6 @@ jobs:
with:
fetch-depth: 0
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Install dependencies
run: |
python3 -m pip install --upgrade pip
@@ -59,12 +51,3 @@ jobs:
python3 scripts/ci_code_review.py
# 审查脚本异常不影响 CI 通过
continue-on-error: true
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="code-review" TRACE_NAME="AI代码审查 / AI Code Review" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-85
View File
@@ -64,14 +64,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Production health check & smoke test
id: smoke
shell: sh
@@ -115,15 +107,6 @@ jobs:
exit $SMOKE_EXIT
# ── 2. Staging API 集成测试 ─────────────────────────────────────────
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Production Smoke Test" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
staging-api-tests:
name: Staging API Integration Tests
runs-on: saas
@@ -178,14 +161,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Run API smoke test on staging
id: smoke
shell: sh
@@ -269,15 +244,6 @@ jobs:
fi
# ── 3. Staging 浏览器 E2E ──────────────────────────────────────────
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging API Integration Tests" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
staging-e2e:
name: Staging Browser E2E
runs-on: saas
@@ -332,14 +298,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Run Playwright E2E on staging
id: e2e
shell: sh
@@ -375,15 +333,6 @@ jobs:
exit $EXIT_CODE
# ── 4. 性能基线巡检 ────────────────────────────────────────────────
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Staging Browser E2E" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
performance-check:
name: Performance Baseline Check
runs-on: saas
@@ -507,14 +456,6 @@ jobs:
echo "耗时: ${ELAPSED}s"
echo "======================================"
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Generate performance report
id: report
shell: sh
@@ -640,15 +581,6 @@ jobs:
fi
# ── 5. 每日巡检汇总报告 ────────────────────────────────────────────
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Performance Baseline Check" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
daily-report:
name: Daily Check Report
runs-on: saas
@@ -724,20 +656,3 @@ jobs:
# 不 exit 1,因为我们用了 always(),保持 report job 成功,
# 但其他失败的 job 已经让整体流水线标记为失败
fi
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="daily-check" TRACE_NAME="每日健康检查 / Daily Check Report" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-17
View File
@@ -61,14 +61,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Extract PR number
shell: sh
run: |
@@ -201,12 +193,3 @@ jobs:
> /dev/null
echo "Cleanup comment posted"
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
-17
View File
@@ -72,14 +72,6 @@ jobs:
tar.extract(member, '.')
PY
- name: Record start time & report trace start
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\necho "CI_TRACE_START_TIME=$(date +%s)" >> $GITHUB_ENV\nTRACE_SERVICE="" TRACE_NAME="" python3 .gitea/scripts/ci_trace_report.py --status running\n\nexit 0\n"
- name: Record job start time
shell: sh
run: |
@@ -280,12 +272,3 @@ jobs:
run: |
set +e
NOTIFY_MODE=failure JOB_NAME="Deploy Preview Environment" python3 scripts/ci_notify.py
- name: Report trace end (always)
if: always()
shell: sh
env:
AGENTLOOP_ENDPOINT: ${ secrets.AGENTLOOP_ENDPOINT }
AGENTLOOP_LICENSE_KEY: ${ secrets.AGENTLOOP_LICENSE_KEY }
AGENTLOOP_PROJECT: ${ secrets.AGENTLOOP_PROJECT }
AGENTLOOP_WORKSPACE: ${ secrets.AGENTLOOP_WORKSPACE }
run: "set +e\n\nif [ "${{ job.status }}" = "success" ]; then\n TRACE_STATUS="ok"\nelse\n TRACE_STATUS="error"\nfi\n\nTRACE_SERVICE="" TRACE_NAME="" TRACE_STATUS="$TRACE_STATUS" python3 .gitea/scripts/ci_trace_report.py --start-time "$CI_TRACE_START_TIME"\n\nexit 0\n"
@@ -1,34 +0,0 @@
"""add user_id to generated_videos
Revision ID: 044_user_id_generated_videos
Revises: 043_updated_at_generation_tasks
Create Date: 2026-07-19 08:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "044_user_id_generated_videos"
down_revision = "043_updated_at_generation_tasks"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generated_videos",
sa.Column(
"user_id",
sa.String(36),
nullable=False,
server_default="",
index=True,
),
)
def downgrade() -> None:
op.drop_column("generated_videos", "user_id")
-36
View File
@@ -1,36 +0,0 @@
"""backfill user_id for generated_videos from generation_tasks
Revision ID: 045_backfill_user_id_generated_videos
Revises: 044_user_id_generated_videos
Create Date: 2026-07-19 10:50:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "045_backfill_user_id"
down_revision = "044_user_id_generated_videos"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 回填 generated_videos.user_id:通过 generation_task_id 关联 generation_tasks 表
# 取 generation_tasks.created_by_user_id 作为 user_id
# 回填不到的(无关联task的兜底记录)保持空字符串
op.execute("""
UPDATE generated_videos gv
SET user_id = gt.created_by_user_id
FROM generation_tasks gt
WHERE gv.generation_task_id = gt.id
AND gv.user_id = ''
AND gt.created_by_user_id != ''
""")
def downgrade() -> None:
# 降级不做处理(无法精确区分哪些是回填的)
pass
-33
View File
@@ -1,33 +0,0 @@
"""add video_title to generation_tasks
Revision ID: 046_add_video_title_to_generation_tasks
Revises: 045_backfill_user_id_generated_videos
Create Date: 2026-07-19 11:20:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "046_task_title"
down_revision = "045_backfill_user_id"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"generation_tasks",
sa.Column(
"video_title",
sa.String(255),
nullable=False,
server_default="",
),
)
def downgrade() -> None:
op.drop_column("generation_tasks", "video_title")
+25 -59
View File
@@ -201,7 +201,7 @@ def list_assets(
items = asset_repository.find_by_library_and_file_type(
library_id, ft, skip=skip, limit=limit, status=status_list
)
total = asset_repository.count_by_library_and_file_type(library_id, ft, status=status_list)
total = len(items)
else:
items = asset_repository.find_by_library(library_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(library.project_id, status=status_list)
@@ -216,11 +216,11 @@ def list_assets(
if project_id:
check_project_access(project_id, user_id, project_repository)
if ft:
items = asset_repository.find_by_project_and_file_type(
project_id, ft, skip=skip, limit=limit, status=status_list
)
total = asset_repository.count_by_project_and_file_type(project_id, ft, status=status_list)
paged = items
# 无直接方法,加载后按 file_type 过滤(仍比全量加载好)
all_items = asset_repository.find_by_project(project_id, status=status_list)
items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft)]
total = len(items)
paged = items[skip : skip + limit]
else:
items = asset_repository.find_by_project(project_id, skip=skip, limit=limit, status=status_list)
total = asset_repository.count_by_project(project_id, status=status_list)
@@ -243,43 +243,22 @@ def list_assets(
if not project_ids:
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
if ft:
# 有 kind 过滤:逐项目查 file_type凑够一页
total = 0
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project_and_file_type(pid, ft, status=status_list)
total += proj_total
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project_and_file_type(
pid, ft, skip=offset, limit=remaining, status=status_list
)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
else:
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid, status=status_list)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
total = asset_repository.count_by_project_ids(project_ids, status=status_list)
# 跨项目分页:逐项目累积直到凑够一页
paged_items: list = []
offset = skip
remaining = limit
for pid in project_ids:
proj_total = asset_repository.count_by_project(pid, status=status_list)
if offset >= proj_total:
offset -= proj_total
continue
proj_items = asset_repository.find_by_project(pid, skip=offset, limit=remaining, status=status_list)
paged_items.extend(proj_items)
remaining -= len(proj_items)
offset = 0
if remaining <= 0:
break
return ListAssetsResponse(
items=[_to_asset_response(item) for item in paged_items],
@@ -302,14 +281,7 @@ def list_assets(
all_items = asset_repository.find_by_library(library_id, status=status_list)
elif project_id:
check_project_access(project_id, user_id, project_repository)
if kind:
ft = kind_to_file_type.get(kind)
if ft:
all_items = asset_repository.find_by_project_and_file_type(project_id, ft, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
else:
all_items = asset_repository.find_by_project(project_id, status=status_list)
all_items = asset_repository.find_by_project(project_id, status=status_list)
else:
try:
projects = project_repository.find_accessible_projects(user_id)
@@ -318,18 +290,12 @@ def list_assets(
return ListAssetsResponse(items=[], total=0, skip=skip, limit=limit)
all_items = []
for proj in projects:
if kind and kind_to_file_type.get(kind):
all_items.extend(
asset_repository.find_by_project_and_file_type(proj.id, kind_to_file_type[kind], status=status_list)
)
else:
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
all_items.extend(asset_repository.find_by_project(proj.id, status=status_list))
# 应用 kind 过滤(如果有)+ keyword/gender/style
if kind:
ft = kind_to_file_type.get(kind)
if ft:
all_items = [i for i in all_items if i.file_type == ft]
all_items = [i for i in all_items if i.mime_type and i.mime_type.startswith(ft or "")]
filtered = _apply_memory_filters(all_items)
total = len(filtered)
paged = filtered[skip : skip + limit]
@@ -58,7 +58,6 @@ def _to_generation_task_response(task) -> GenerationTaskResponse:
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
batch_id=getattr(task, "batch_id", ""),
video_title=getattr(task, "video_title", ""),
logs=getattr(task, "logs", "[]"),
status=task.status,
progress=task.progress,
@@ -269,7 +268,6 @@ def create_generation_task(
source_edit_plan_id=request.source_edit_plan_id,
asset_select_mode=request.asset_select_mode,
batch_id=batch_id,
video_title=request.video_title,
auto_retry_enabled=request.auto_retry_enabled,
auto_retry_max=request.auto_retry_max,
)
@@ -407,7 +405,6 @@ def retry_generation_task(
created_by_user_id=user_id,
source_edit_plan_id=task.source_edit_plan_id or "",
asset_select_mode=getattr(task, "asset_select_mode", ""),
video_title=getattr(task, "video_title", ""),
)
)
try:
+2 -3
View File
@@ -57,7 +57,7 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI
@router.get("/videos", response_model=ListVideosResponse)
def list_videos(
project_id: str | None = Query(None, description="项目ID可选过滤"),
project_id: str | None = Query(None, description="项目ID不传则返回所有项目"),
status: str | None = Query(None, description="按状态筛选"),
review_status: str | None = Query(None, description="按复核状态筛选"),
page: int = Query(1, ge=1, description="页码"),
@@ -66,10 +66,9 @@ def list_videos(
storage: OSSStorageService = Depends(get_storage_service),
current_user: AuthenticatedUser = Depends(get_current_user),
):
"""成片列表,默认返回当前用户的所有成片,支持按项目/状态/复核状态筛选。"""
"""成片列表,支持分页、按项目/状态/复核状态筛选。"""
use_case = ListGeneratedVideosPaginatedUseCase(repo)
items, total = use_case.execute(
user_id=current_user.user.id,
project_id=project_id,
status=status,
review_status=review_status,
+2 -62
View File
@@ -9,7 +9,7 @@ from typing import Literal, Optional
from app.api.routes._helpers import get_user_plan
from app.auth import AuthenticatedUser, get_current_user
from app.dependencies import get_audio_url_signer, get_cosyvoice_service, get_db_session, get_user_repository
from app.dependencies import get_audio_url_signer, get_db_session, get_user_repository
from app.schemas.voice import (
PresetVoiceItemResponse,
PresetVoiceListResponse,
@@ -27,7 +27,6 @@ from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
from packages.adapters.sqlalchemy_impl.voice_library_repository import SQLAlchemyVoiceLibraryRepository
from packages.application.cosyvoice_service import CosyVoiceError, CosyVoiceService
from packages.application.voice_library.commands import CreateVoiceLibraryCommand, UpdateVoiceLibraryCommand
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
@@ -38,18 +37,11 @@ from packages.application.voice_library.use_cases import (
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain.preset_voices import PRESET_VOICES, get_preset_voice_by_id
from packages.domain.preset_voices import PRESET_VOICES
from packages.ports.user_repository import UserRepository
router = APIRouter()
# 预置音色试听音频缓存(内存缓存,减少重复TTS调用)
# key: voice_id, value: (audio_url, timestamp)
_preset_preview_cache: dict[str, tuple[str, float]] = {}
PREVIEW_CACHE_TTL = 7 * 24 * 3600 # 7天TTL
# 每个预置音色的默认试听文本
PREVIEW_TEMPLATE = "你好,我是{name},很高兴认识你。"
def _get_voice_repository(session: Session = Depends(get_db_session)) -> SQLAlchemyVoiceLibraryRepository:
return SQLAlchemyVoiceLibraryRepository(session)
@@ -224,58 +216,6 @@ def list_preset_voices() -> PresetVoiceListResponse:
return PresetVoiceListResponse(items=items, total=len(items))
@router.get("/presets/{voice_id}/preview")
def get_preset_voice_preview(
voice_id: str,
text: str = Query("", description="自定义试听文本,为空则使用默认示例"),
cosyvoice: CosyVoiceService = Depends(get_cosyvoice_service),
) -> dict:
"""获取预置音色试听音频(实时 TTS 合成)。
- 首次调用会合成并缓存7天
- 相同 voice_id 重复调用直接返回缓存的音频URL
- 可传入自定义 text 参数试听不同文本
"""
import time
preset = get_preset_voice_by_id(voice_id)
if preset is None:
raise HTTPException(status_code=404, detail=f"预置音色不存在: {voice_id}")
# 有自定义文本时不缓存
use_cache = not text.strip()
if use_cache and voice_id in _preset_preview_cache:
audio_url, cached_at = _preset_preview_cache[voice_id]
if time.time() - cached_at < PREVIEW_CACHE_TTL:
return {"voice_id": voice_id, "audio_url": audio_url, "cached": True}
# 合成试听音频
preview_text = text.strip() or PREVIEW_TEMPLATE.format(name=preset.name)
try:
result = cosyvoice.synthesize_speech(
text=preview_text,
voice_id=preset.voice_id,
format="mp3",
speed=1.0,
)
except CosyVoiceError as e:
raise HTTPException(status_code=502, detail=f"TTS 合成失败: {e}") from e
audio_url = result.audio_url
# 缓存(仅默认试听文本)
if use_cache:
_preset_preview_cache[voice_id] = (audio_url, time.time())
return {
"voice_id": voice_id,
"audio_url": audio_url,
"text": preview_text,
"cached": False,
}
# ==================== 原有 CRUD 端点(保持向后兼容)====================
-3
View File
@@ -23,8 +23,6 @@ class CreateGenerationTaskRequest(BaseModel):
voice_ids: list[str] = Field(default_factory=list)
# ── 来源剪辑计划 ──
source_edit_plan_id: str = ""
# ── 视频标题 ──
video_title: str = Field(default="", description="生成视频的标题/名称,为空则使用默认命名")
# ── 批量生成 ──
count: int = Field(default=1, ge=1, le=50, description="批量生成数量,默认1,最大50")
# ── 素材库自动匹配 ──
@@ -73,7 +71,6 @@ class GenerationTaskResponse(BaseModel):
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
status: str
progress: float
result_count: int
+1 -7
View File
@@ -184,19 +184,13 @@ export const getAssetsByKind = async (
gender?: string
style?: string
tag_ids?: string[]
limit?: number
page?: number
page_size?: number
},
): Promise<AssetItem[]> => {
const params: Record<string, string | number> = { kind }
const params: Record<string, string> = { kind }
if (filters?.keyword) params.keyword = filters.keyword
if (filters?.gender) params.gender = filters.gender
if (filters?.style) params.style = filters.style
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
if (filters?.limit) params.limit = filters.limit
if (filters?.page) params.page = filters.page
if (filters?.page_size) params.page_size = filters.page_size
const response = await apiClient.get("/assets", { params })
return response.data.items || []
}
Regular → Executable
+1 -1
View File
@@ -143,7 +143,7 @@ export interface CreateEditPlanRequest {
name: string
config?: EditPlanConfig
total_duration?: number
/** 来源剪辑计划 ID(从剪辑计划跳转到智能剪辑时关联) */
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
source_edit_plan_id?: string
}
+3 -3
View File
@@ -20,7 +20,7 @@ export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
/** 模式显示名称映射 */
export const MODE_LABELS: Record<TemplateMode, string> = {
pip: "混剪",
pip: "画中画",
voice_over: "人物口播",
one_take: "一镜到底",
voice_pip: "口播+混剪",
@@ -85,7 +85,7 @@ export interface EditingTemplate {
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
/** 画中画配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
@@ -122,7 +122,7 @@ export interface SaveTemplatePayload {
watermark_config?: WatermarkConfig
/** 片头片尾配置(后端就绪后启用) */
intro_outro_config?: IntroOutroConfig
/** 混剪配置 */
/** 画中画配置 */
pip_config?: PipConfig
/** 滤镜调色配置 */
filter_config?: FilterConfig
+1 -1
View File
@@ -88,7 +88,7 @@ export interface CreateGenerationTaskResponse {
/* ──────────── API 函数 ──────────── */
/** 创建生成任务(智能剪辑 */
/** 创建生成任务(一键生成 */
export const createGenerationTask = async (
params: CreateGenerationTaskRequest,
): Promise<CreateGenerationTaskResponse> => {
+1 -1
View File
@@ -130,7 +130,7 @@ export interface SaveTtsToLibraryRequest {
tag_ids?: string[]
}
/** 将 TTS 合成结果保存到配音库 */
/** 将 TTS 合成结果保存到配音素材库 */
export const saveTtsToLibrary = async (
jobId: string,
data?: SaveTtsToLibraryRequest,
+3 -3
View File
@@ -43,8 +43,8 @@ export interface PageHeadProps {
const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/dashboard": "首页",
"/app/generate": "智能剪辑",
"/app/assets": "视频库",
"/app/generate": "一键生成",
"/app/assets": "素材库",
"/app/voices": "配音库",
"/app/titles": "标题库",
"/app/products": "成片库",
@@ -62,7 +62,7 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
"/app/editing-planner": "剪辑规划",
"/app/my-templates": "我的模板",
"/app/voice-clone": "我的音色",
"/app/voice-materials": "配音库",
"/app/voice-materials": "配音素材库",
"/app/accounts": "账号管理",
"/app/duplication": "查重",
"/app/duplication/results": "查重结果",
+4 -4
View File
@@ -47,7 +47,7 @@ export const NAV_ITEMS: NavItem[] = [
},
{
key: "assets",
label: "视频库",
label: "素材库",
path: "/app/assets",
icon: React.createElement(FileOutlined),
},
@@ -89,7 +89,7 @@ export const NAV_ITEMS: NavItem[] = [
},
{
key: "generate",
label: "智能剪辑",
label: "一键生成",
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
@@ -132,7 +132,7 @@ export const NAV_GROUPS: NavGroup[] = [
},
{
key: "generate",
label: "智能剪辑",
label: "一键生成",
path: "/app/generate",
icon: React.createElement(VideoCameraOutlined),
},
@@ -155,7 +155,7 @@ export const NAV_GROUPS: NavGroup[] = [
items: [
{
key: "assets",
label: "视频库",
label: "素材库",
path: "/app/assets",
icon: React.createElement(FileOutlined),
},
+26 -37
View File
@@ -1,6 +1,6 @@
/**
* 视频库页面 — V21 设计系统
* 两栏布局:左侧视频库列表(260px+ 右侧素材网格
* 素材库页面 — V21 设计系统
* 两栏布局:左侧素材库列表(260px+ 右侧素材网格
* 使用 useQuery 对接后端真实 APIapi/assets.ts
*/
import React, { useMemo, useState } from "react"
@@ -32,7 +32,6 @@ import {
ThunderboltOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
AudioOutlined,
} from "@ant-design/icons"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import {
@@ -57,7 +56,7 @@ import "./assets.css"
/* ============================================================
* 类型
* ============================================================ */
type AssetKind = "video" | "image" | "voice"
type AssetKind = "video" | "image"
type StatusType = "ok" | "warn" | "bad" | "info"
interface LibraryItem {
@@ -89,7 +88,6 @@ interface AssetItem {
/** 根据 mime_type 推断前端 AssetKind */
const inferKind = (mimeType: string): AssetKind => {
if (mimeType.startsWith("video/")) return "video"
if (mimeType.startsWith("audio/")) return "voice"
return "image"
}
@@ -144,7 +142,7 @@ const formatDuration = (seconds: number): string => {
const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({
id: item.id,
name: item.name,
kind: item.kind || inferKind("video"),
kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"),
count: item.asset_count ?? 0,
})
@@ -193,8 +191,6 @@ const kindIcon = (kind: AssetKind) => {
return <VideoCameraOutlined />
case "image":
return <PictureOutlined />
case "voice":
return <AudioOutlined />
}
}
@@ -204,8 +200,6 @@ const kindLabel = (kind: AssetKind) => {
return "视频"
case "image":
return "图片"
case "voice":
return "配音"
}
}
@@ -216,8 +210,6 @@ const thumbGradient = (kind: AssetKind): string => {
return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"
case "image":
return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"
case "voice":
return "linear-gradient(135deg, #064e3b 0%, #059669 50%, #10b981 100%)"
}
}
@@ -358,7 +350,7 @@ const AssetCard: React.FC<{
const AssetLibrary: React.FC = () => {
const queryClient = useQueryClient()
/* ── 获取视频库列表 ── */
/* ── 获取素材库列表 ── */
const { data: apiLibraries = [], isLoading: libLoading } = useQuery<AssetLibraryItem[], Error>({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
@@ -366,14 +358,11 @@ const AssetLibrary: React.FC = () => {
})
const libraries = useMemo(
() =>
(Array.isArray(apiLibraries) ? apiLibraries : [])
.map(mapLibrary)
.filter((lib) => lib.kind === "video"),
() => (Array.isArray(apiLibraries) ? apiLibraries : []).map(mapLibrary),
[apiLibraries],
)
/* ── 当前选中的视频库 ── */
/* ── 当前选中的素材库 ── */
const [activeLibId, setActiveLibId] = useState<string>("")
// 当库列表加载完成后,自动选中第一个
@@ -407,10 +396,10 @@ const AssetLibrary: React.FC = () => {
mutationFn: createAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库创建成功")
message.success("素材库创建成功")
},
onError: () => {
message.error("创建视频库失败")
message.error("创建素材库失败")
},
})
@@ -418,10 +407,10 @@ const AssetLibrary: React.FC = () => {
mutationFn: deleteAssetLibrary,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
message.success("视频库已删除")
message.success("素材库已删除")
},
onError: () => {
message.error("删除视频库失败")
message.error("删除素材库失败")
},
})
@@ -439,7 +428,7 @@ const AssetLibrary: React.FC = () => {
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
/* 新建视频库 */
/* 新建素材库 */
const [createModalOpen, setCreateModalOpen] = useState(false)
const [newLibName, setNewLibName] = useState("")
const [newLibKind, setNewLibKind] = useState<AssetKind>("video")
@@ -480,7 +469,7 @@ const AssetLibrary: React.FC = () => {
const filteredAssets = useMemo(() => {
let list = assets
/* 按视频库类型过滤(如果筛选类型不是 all) */
/* 按素材库类型过滤(如果筛选类型不是 all) */
if (filterType !== "all") {
list = list.filter((a) => a.kind === filterType)
}
@@ -532,7 +521,7 @@ const AssetLibrary: React.FC = () => {
return
}
if (!effectiveLibId) {
message.warning("请先选择或创建一个视频库")
message.warning("请先选择或创建一个素材库")
return
}
@@ -562,10 +551,10 @@ const AssetLibrary: React.FC = () => {
}
}
/* 新建视频库 */
/* 新建素材库 */
const handleCreateLibrary = async () => {
if (!newLibName.trim()) {
message.warning("请输入视频库名称")
message.warning("请输入素材库名称")
return
}
try {
@@ -582,7 +571,7 @@ const AssetLibrary: React.FC = () => {
}
}
/* 删除视频库 */
/* 删除素材库 */
const handleDeleteLibrary = async (id: string) => {
try {
await deleteLibMutation.mutateAsync(id)
@@ -838,7 +827,7 @@ const AssetLibrary: React.FC = () => {
{/* 两栏布局 */}
<div className="xx-assets-layout">
{/* ─── 左侧:视频库列表 ─── */}
{/* ─── 左侧:素材库列表 ─── */}
<div className="xx-asset-library-list">
{libraries.map((lib) => (
<div
@@ -851,7 +840,7 @@ const AssetLibrary: React.FC = () => {
{kindIcon(lib.kind)} {lib.name}
</h4>
<Popconfirm
title={`确定删除视频库 "${lib.name}"`}
title={`确定删除素材库 "${lib.name}"`}
onConfirm={(e) => {
e?.stopPropagation()
handleDeleteLibrary(lib.id)
@@ -863,7 +852,7 @@ const AssetLibrary: React.FC = () => {
<button
className="xx-asset-library-delete"
onClick={(e) => e.stopPropagation()}
title="删除视频库"
title="删除素材库"
>
<DeleteOutlined />
</button>
@@ -875,10 +864,10 @@ const AssetLibrary: React.FC = () => {
</div>
))}
{/* 新建视频库 */}
{/* 新建素材库 */}
<div className="xx-asset-library-add" onClick={() => setCreateModalOpen(true)}>
<PlusOutlined />
</div>
</div>
@@ -1029,15 +1018,15 @@ const AssetLibrary: React.FC = () => {
<div className="xx-assets-empty-icon">
<PictureOutlined />
</div>
<p className="xx-assets-empty-title"></p>
<p className="xx-assets-empty-title"></p>
</div>
)}
</div>
</div>
{/* ─── 新建视频库弹窗 ─── */}
{/* ─── 新建素材库弹窗 ─── */}
<AntModal
title="新建视频库"
title="新建素材库"
open={createModalOpen}
onCancel={() => setCreateModalOpen(false)}
onOk={handleCreateLibrary}
@@ -1050,7 +1039,7 @@ const AssetLibrary: React.FC = () => {
<div>
<div className="xx-asset-form-label"></div>
<Input
placeholder="请输入视频库名称"
placeholder="请输入素材库名称"
value={newLibName}
onChange={(e) => setNewLibName(e.target.value)}
maxLength={50}
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* 视频库页面 - V21 设计系统样式
* 两栏布局:左侧视频库列表(260px+ 右侧素材网格
* 素材库页面 - V21 设计系统样式
* 两栏布局:左侧素材库列表(260px+ 右侧素材网格
* 统一使用 CSS 变量,支持深色/浅色主题
*/
@import "../../styles/global.css";
@@ -24,7 +24,7 @@
}
/* ============================================================
左侧视频库列表
左侧素材库列表
============================================================ */
.xx-asset-library-list {
display: flex;
+1 -1
View File
@@ -203,7 +203,7 @@ const DuplicationUpload: React.FC = () => {
<div className="dup-info-card">
<h3>📋 </h3>
<ul className="dup-info-list">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
+2 -2
View File
@@ -560,7 +560,7 @@ const PlanClipsManager: React.FC = () => {
{/* 素材导入抽屉 */}
<Drawer
title="从视频库导入"
title="从素材库导入"
open={importDrawerOpen}
onClose={() => setImportDrawerOpen(false)}
width={480}
@@ -611,7 +611,7 @@ const PlanClipsManager: React.FC = () => {
))}
</div>
) : (
<Empty description="视频库为空" />
<Empty description="素材库为空" />
)}
</Drawer>
</div>
@@ -4257,7 +4257,7 @@
}
/* ═══════════════════════════════════════════════
混剪配置面板 (PiP Configuration Panel)
画中画配置面板 (PiP Configuration Panel)
═══════════════════════════════════════════════ */
.pip-config-panel-drawer .ant-drawer-body {
@@ -26,11 +26,11 @@ import type {
GeneratedVideo,
MediaAsset,
TransitionEffect,
TitleConfig,
} from "@/api/editPlans"
import {
getMediaAssets,
getEditPlanGenerations,
generateCover,
getEditPlan,
createEditPlan,
updateEditPlan,
@@ -61,6 +61,7 @@ import type {
ChromaKeyConfig,
StickerConfig,
CoverConfig,
TitleSettings,
} from "./types"
import {
DEFAULT_TRANSITION,
@@ -95,7 +96,7 @@ import PipConfigPanel from "./components/PipConfigPanel"
import FilterPanel from "./components/FilterPanel"
import GreenScreenPanel from "./components/GreenScreenPanel"
import StickerPanel from "./components/StickerPanel"
import CoverSelector from "./components/CoverSelector"
import SaveModal from "./components/SaveModal"
import GenerationHistoryModal from "./components/GenerationHistoryModal"
import { DEFAULT_BGM_MIX_CONFIG, type BgmMixConfig } from "@/api/bgm"
@@ -104,10 +105,17 @@ import "./EditingPlanner.css"
/* ──────────── 常量 ──────────── */
const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [
{ key: "pip", label: "混剪", icon: "🖼️" },
{ key: "pip", label: "画中画", icon: "🖼️" },
{ key: "voice_over", label: "人物口播", icon: "🎙️" },
{ key: "one_take", label: "一镜到底", icon: "🎥" },
{ key: "voice_pip", label: "口播+混剪", icon: "🎭" },
{ key: "voice_pip", label: "口播+画中画", icon: "🎭" },
]
const COVER_SCHEMES = [
{ key: "ai_frame", label: "AI选帧" },
{ key: "manual", label: "手动选" },
{ key: "upload", label: "上传" },
{ key: "ai_reselect", label: "AI重选" },
]
const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"]
@@ -138,18 +146,29 @@ const EditingPlanner: React.FC = () => {
} = useUndoRedo<ClipData[]>([])
const [selectedClipId, setSelectedClipId] = useState<string | null>(null)
/* ── AI 操作状态 ── */
const [aiCoverLoading, setAiCoverLoading] = useState(false)
/* ── 封面方案 ── */
const [currentCoverScheme, setCurrentCoverScheme] = useState<string>("ai_frame")
/* ── 左栏筛选 ── */
const [currentFilter, setCurrentFilter] = useState("全部")
const [searchQuery, setSearchQuery] = useState("")
/* ── 标题配置(只读,从模板/计划继承) ── */
const [titleConfig, setTitleConfig] = useState<TitleConfig>({
ai_auto_select: false,
content: "",
position: "bottom",
font_preset: "思源黑体",
font_color: "#ffffff",
font_size: 28,
/* ── 标题/字幕/BGM 设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>({
aiAutoSelect: false,
title: "",
position: "top",
font: "思源黑体",
size: 24,
bold: false,
italic: false,
stroke: true,
shadow: true,
color: "#ffffff",
})
const [subtitleSettings, setSubtitleSettings] = useState<SubtitleStyleConfig>({
@@ -184,7 +203,7 @@ const EditingPlanner: React.FC = () => {
const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false)
const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false)
/* ── 混剪 ── */
/* ── 画中画 ── */
const [pipSettings, setPipSettings] = useState<PipConfig>({
...DEFAULT_PIP_CONFIG,
})
@@ -208,10 +227,11 @@ const EditingPlanner: React.FC = () => {
})
const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false)
/* ── 封面配置(只读,从模板/计划继承) ── */
const [coverConfig, setCoverConfig] = useState<CoverConfig>({
/* ── 封面 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>({
...DEFAULT_COVER_CONFIG,
})
const [coverDrawerOpen, setCoverDrawerOpen] = useState(false)
/* ── 右侧栏 Tab ── */
const [rightTab, setRightTab] = useState<"properties" | "clips">("properties")
@@ -348,14 +368,15 @@ const EditingPlanner: React.FC = () => {
}))
resetClips(mapped)
setTitleConfig({
ai_auto_select: tpl.title_config.ai_auto_select,
content: tpl.title_config.content,
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config.ai_auto_select,
title: tpl.title_config.content,
position: tpl.title_config.position,
font_preset: tpl.title_config.font_preset,
font_size: tpl.title_config.font_size,
font_color: tpl.title_config.font_color || "#ffffff",
})
font: tpl.title_config.font_preset,
size: tpl.title_config.font_size,
color: tpl.title_config.font_color || "#ffffff",
}))
setSubtitleSettings((prev) => ({
...prev,
enabled: tpl.subtitle_config.enabled,
@@ -402,14 +423,15 @@ const EditingPlanner: React.FC = () => {
// 还原 config 中的编辑器状态
const cfg = plan.config
if (cfg.title_config) {
setTitleConfig({
ai_auto_select: cfg.title_config!.ai_auto_select,
content: cfg.title_config!.content,
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content,
position: cfg.title_config!.position,
font_preset: cfg.title_config!.font_preset,
font_size: cfg.title_config!.font_size,
font_color: cfg.title_config!.font_color || "#ffffff",
})
font: cfg.title_config!.font_preset,
size: cfg.title_config!.font_size,
color: cfg.title_config!.font_color || "#ffffff",
}))
}
if (cfg.subtitle_config) {
setSubtitleSettings((prev) => ({
@@ -432,7 +454,7 @@ const EditingPlanner: React.FC = () => {
}
// 还原封面配置
if (cfg.cover_config) {
setCoverConfig((prev) => ({
setCoverSettings((prev) => ({
...prev,
enabled: cfg.cover_config!.enabled ?? prev.enabled,
mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
@@ -752,7 +774,7 @@ const EditingPlanner: React.FC = () => {
setIntroOutroSettings(config)
}, [])
/* ── 混剪配置变更 ── */
/* ── 画中画配置变更 ── */
const handlePipChange = useCallback((config: PipConfig) => {
setPipSettings(config)
}, [])
@@ -772,9 +794,44 @@ const EditingPlanner: React.FC = () => {
setStickerSettings(config)
}, [])
/* ── 封面配置变更 ── */
const handleCoverChange = useCallback((config: CoverConfig) => {
setCoverSettings(config)
}, [])
/* AI 封面生成 */
const handleAiGenerateCover = async (coverType: "ai_frame" | "ai_regenerate") => {
if (!loadedTemplateId) return
const assetIds = selectedAssetIds
if (assetIds.length === 0) {
message.warning("请先在素材库中选择素材")
return
}
setAiCoverLoading(true)
try {
await generateCover(loadedTemplateId, {
asset_ids: assetIds,
cover_type: coverType,
})
setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect")
message.success("AI 封面生成成功")
} catch {
message.error("AI 封面生成失败")
} finally {
setAiCoverLoading(false)
}
}
/** 构建剪辑计划 config(编辑器状态 → API config */
const buildPlanConfig = (): EditPlanConfig => ({
title_config: titleConfig,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -821,7 +878,7 @@ const EditingPlanner: React.FC = () => {
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
cover_config: { ...coverSettings },
})
/* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */
@@ -844,7 +901,14 @@ const EditingPlanner: React.FC = () => {
.split(",")
.map((t) => t.trim())
.filter(Boolean),
title_config: titleConfig,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
position: titleSettings.position,
},
subtitle_config: {
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -891,7 +955,7 @@ const EditingPlanner: React.FC = () => {
filter_config: { ...filterSettings },
green_screen_config: { ...chromaKeySettings },
sticker_config: { ...stickerSettings },
cover_config: { ...coverConfig },
cover_config: { ...coverSettings },
}
if (loadedTemplateId) {
await updateEditingTemplate(loadedTemplateId, payload)
@@ -1231,8 +1295,10 @@ const EditingPlanner: React.FC = () => {
clips={clips}
selectedClipId={selectedClipId}
isPlaying={isPlaying}
titleConfig={titleConfig}
coverConfig={coverConfig}
currentCoverScheme={currentCoverScheme}
coverSchemes={COVER_SCHEMES}
aiCoverLoading={aiCoverLoading}
titleSettings={titleSettings}
subtitleSettings={{
enabled: subtitleSettings.enabled,
position: subtitleSettings.position,
@@ -1241,7 +1307,9 @@ const EditingPlanner: React.FC = () => {
animation: subtitleSettings.animation,
}}
onClipSelect={handleClipSelect}
onCoverSchemeChange={setCurrentCoverScheme}
onPlayPause={() => setIsPlaying(!isPlaying)}
onAiGenerateCover={handleAiGenerateCover}
/>
{/* 下半部:水平时间线 */}
@@ -1287,11 +1355,15 @@ const EditingPlanner: React.FC = () => {
<div className="ep-right-tab-content">
<ClipPropertiesPanel
selectedClip={selectedClip}
titleSettings={titleSettings}
subtitleSettings={subtitleSettings}
bgmSettings={bgmSettings}
clipsCount={clips.length}
totalDuration={totalDuration}
currentMode={currentMode}
onTitleSettingsChange={(partial) =>
setTitleSettings((prev) => ({ ...prev, ...partial }))
}
onSubtitleSettingsChange={(partial) =>
setSubtitleSettings((prev) => ({ ...prev, ...partial }) as SubtitleStyleConfig)
}
@@ -1314,6 +1386,7 @@ const EditingPlanner: React.FC = () => {
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenGreenScreenDrawer={() => setChromaKeyDrawerOpen(true)}
onOpenStickerDrawer={() => setStickerDrawerOpen(true)}
onOpenCoverDrawer={() => setCoverDrawerOpen(true)}
/>
</div>
)}
@@ -1650,7 +1723,7 @@ const EditingPlanner: React.FC = () => {
onChange={handleIntroOutroChange}
/>
{/* ═══ 混剪配置面板 ═══ */}
{/* ═══ 画中画配置面板 ═══ */}
<PipConfigPanel
open={pipDrawerOpen}
onClose={() => setPipDrawerOpen(false)}
@@ -1683,6 +1756,15 @@ const EditingPlanner: React.FC = () => {
onChange={handleStickerChange}
totalDuration={totalDuration}
/>
{/* ═══ 封面选择器 ═══ */}
<CoverSelector
open={coverDrawerOpen}
onClose={() => setCoverDrawerOpen(false)}
config={coverSettings}
onChange={handleCoverChange}
totalDuration={totalDuration}
/>
</div>
)
}
+335 -10
View File
@@ -1,11 +1,11 @@
/**
* 右栏设置面板 — V8 原型 1:1 还原
* 字幕设置 + BGM设置 + 片段详情
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
*/
import React, { useRef, useState, useCallback } from "react"
import { useNavigate } from "react-router-dom"
import type { TemplateMode } from "@/api/editingPlanner"
import type { ClipData, ClipType } from "../types"
import type { ClipData, ClipType, TitleSettings } from "../types"
import { TRANSITION_OPTIONS } from "@/api/editPlans"
import type { AssetItem } from "@/api/assets"
@@ -33,11 +33,13 @@ interface BgmSettings {
interface ClipPropertiesPanelProps {
selectedClip: ClipData | null
titleSettings: TitleSettings
subtitleSettings: SubtitleSettings
bgmSettings: BgmSettings
clipsCount: number
totalDuration: number
currentMode: TemplateMode
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
@@ -45,7 +47,7 @@ interface ClipPropertiesPanelProps {
onOpenBgmDrawer?: () => void
/** 打开字幕样式配置 Drawer */
onOpenSubtitleDrawer?: () => void
/** 配音素材列表(从配音库 API 获取) */
/** 配音素材列表(从配音素材库 API 获取) */
voiceMaterials?: AssetItem[]
/** 配音素材加载中 */
voiceMaterialsLoading?: boolean
@@ -63,7 +65,7 @@ interface ClipPropertiesPanelProps {
onOpenWatermarkDrawer?: () => void
/** 打开片头片尾设置面板 Drawer */
onOpenIntroOutroDrawer?: () => void
/** 打开混剪设置面板 Drawer */
/** 打开画中画设置面板 Drawer */
onOpenPipDrawer?: () => void
/** 打开滤镜调色面板 Drawer */
onOpenFilterDrawer?: () => void
@@ -72,6 +74,7 @@ interface ClipPropertiesPanelProps {
/** 打开贴纸面板 Drawer */
onOpenStickerDrawer?: () => void
/** 打开封面选择器 Drawer */
onOpenCoverDrawer?: () => void
}
const POSITION_OPTIONS = [
@@ -89,20 +92,190 @@ const ANIMATION_OPTIONS = [
{ value: "typewriter", label: "打字机" },
]
/**
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
* 不绑定字体,用户可自由搭配任意字体
* 预览统一用系统字体展示效果
*/
const TITLE_PRESETS = [
{
key: "classic_white",
label: "经典白字",
style: {
size: 28,
color: "#ffffff",
bold: true,
italic: false,
stroke: true,
shadow: false,
},
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "black_gold",
label: "黑金质感",
style: {
size: 32,
color: "#d4a843",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: {
size: 24,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
fontSize: "18px",
},
},
{
key: "variety_show",
label: "综艺花字",
style: {
size: 36,
color: "#ff4081",
bold: true,
italic: false,
stroke: true,
shadow: true,
},
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: {
size: 24,
color: "#1a1a1a",
bold: false,
italic: false,
stroke: false,
shadow: false,
},
previewStyle: {
fontWeight: 400,
color: "#1a1a1a",
fontSize: "17px",
},
},
{
key: "retro_film",
label: "复古胶片",
style: {
size: 28,
color: "#e8d5b7",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: {
size: 32,
color: "#00e5ff",
bold: true,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: {
size: 28,
color: "#333333",
bold: false,
italic: false,
stroke: false,
shadow: true,
},
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
]
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
/** 片段类型图标/标签 */
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
pip: "画中画",
}
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
selectedClip,
titleSettings,
subtitleSettings,
bgmSettings,
clipsCount,
totalDuration,
currentMode,
onTitleSettingsChange,
onSubtitleSettingsChange,
onBgmSettingsChange: _onBgmSettingsChange,
onClipUpdate,
@@ -121,6 +294,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
onOpenFilterDrawer,
onOpenGreenScreenDrawer,
onOpenStickerDrawer,
onOpenCoverDrawer,
}) => {
const navigate = useNavigate()
@@ -159,6 +333,142 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
}
return (
<div className="ep-right-panel">
{/* ═══ 标题设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">📝</span>
</div>
<div className="ep-toggle-row">
<span className="ep-toggle-label">AI </span>
<div
className={`ep-toggle ${titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={() =>
onTitleSettingsChange({
aiAutoSelect: !titleSettings.aiAutoSelect,
})
}
>
<div className="ep-toggle-knob" />
</div>
</div>
{!titleSettings.aiAutoSelect && (
<>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={titleSettings.position}
onChange={(e) => onTitleSettingsChange({ position: e.target.value })}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<select
className="ep-form-select"
value={titleSettings.font}
onChange={(e) => onTitleSettingsChange({ font: e.target.value })}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-slider-row">
<input
className="ep-slider"
type="range"
min={12}
max={48}
value={titleSettings.size}
onChange={(e) => onTitleSettingsChange({ size: Number(e.target.value) })}
/>
<span className="ep-slider-value">{titleSettings.size}px</span>
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key
return (
<button
key={p.key}
className={`ep-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
onTitleSettingsChange({
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
})
}
title={p.label}
>
<span className="ep-title-preset-preview-text" style={p.previewStyle}>
</span>
<span className="ep-title-preset-card-label">{p.label}</span>
</button>
)
})}
</div>
</div>
<div className="ep-field">
<label className="ep-field-label"></label>
<div className="ep-style-btns">
<button
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ bold: !titleSettings.bold })}
title="粗体"
>
<b>B</b>
</button>
<button
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ italic: !titleSettings.italic })}
title="斜体"
>
<i>I</i>
</button>
<button
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ stroke: !titleSettings.stroke })}
title="描边"
>
S
</button>
<button
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
onClick={() => onTitleSettingsChange({ shadow: !titleSettings.shadow })}
title="阴影"
>
</button>
</div>
</div>
</>
)}
</div>
{/* ═══ 字幕设置 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
@@ -312,16 +622,16 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
)}
</div>
{/* ═══ 混剪 ═══ */}
{/* ═══ 画中画 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenPipDrawer && (
<button className="ep-advanced-btn" onClick={onOpenPipDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
@@ -372,6 +682,21 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
)}
</div>
{/* ═══ 封面 ═══ */}
<div className="ep-settings-section">
<div className="ep-section-title">
<span className="ep-section-icon">🖼</span>
</div>
{onOpenCoverDrawer && (
<button className="ep-advanced-btn" onClick={onOpenCoverDrawer}>
<span className="ep-advanced-btn-icon">🖼</span>
<span className="ep-advanced-btn-label"></span>
<span className="ep-advanced-btn-arrow"></span>
</button>
)}
</div>
{/* ═══ 片段详情(选中时显示) ═══ */}
{selectedClip && (
<div className="ep-settings-section">
@@ -616,12 +941,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
<div className="ep-clip-detail-label"></div>
<div className="ep-clip-detail-value">
{currentMode === "pip"
? "混剪"
? "画中画"
: currentMode === "voice_over"
? "人物口播"
: currentMode === "one_take"
? "一镜到底"
: "口播+混剪"}
: "口播+画中画"}
</div>
</div>
</div>
@@ -37,7 +37,7 @@ const clipTypeLabel: Record<ClipType | string, string> = {
video: "视频",
image: "图片",
voice: "配音",
pip: "混剪",
pip: "画中画",
}
const formatDuration = (sec: number) => {
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* 混剪配置面板 — Drawer 形式
* 画中画配置面板 — Drawer 形式
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
*/
import React, { useCallback, useMemo } from "react"
@@ -187,7 +187,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
return (
<Drawer
title="🖼️ 混剪设置"
title="🖼️ 画中画设置"
placement="right"
width={520}
open={open}
+68 -38
View File
@@ -1,12 +1,15 @@
/**
* 预览区 — V8 原型 1:1 还原
* 手机模型预览 + 封面预览 并排
* 封面为只读展示(从模板/计划继承)
* 手机模型预览(150x267) + 封面预览(150x267) 并排
* 封面右侧竖排4个方案按钮
*/
import React from "react"
import type { ClipData, ClipType } from "../types"
import type { TitleConfig } from "@/api/editPlans"
import type { CoverConfig } from "../types"
import type { ClipData, ClipType, TitleSettings } from "../types"
interface CoverScheme {
key: string
label: string
}
interface SubtitleSettings {
enabled: boolean
@@ -20,11 +23,15 @@ interface PreviewPlayerProps {
clips: ClipData[]
selectedClipId: string | null
isPlaying: boolean
titleConfig?: TitleConfig
coverConfig?: CoverConfig
currentCoverScheme: string
coverSchemes: CoverScheme[]
aiCoverLoading: boolean
titleSettings?: TitleSettings
subtitleSettings?: SubtitleSettings
onClipSelect: (clipId: string) => void
onCoverSchemeChange: (scheme: string) => void
onPlayPause: () => void
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void
}
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
@@ -34,23 +41,21 @@ const CLIP_TYPE_ICONS: Record<ClipType, string> = {
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
}
const COVER_MODE_LABELS: Record<string, string> = {
auto: "智能封面",
frame: "抽帧封面",
upload: "上传封面",
pip: "画中画",
}
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
clips,
selectedClipId,
isPlaying,
titleConfig,
coverConfig,
currentCoverScheme,
coverSchemes,
aiCoverLoading,
titleSettings,
subtitleSettings,
onCoverSchemeChange,
onPlayPause,
onAiGenerateCover,
}) => {
const selectedClip = clips.find((c) => c.id === selectedClipId)
const displayClip = selectedClip || clips[0]
@@ -85,28 +90,27 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
)}
{/* 标题实时预览 */}
{titleConfig && !titleConfig.ai_auto_select && titleConfig.content && (
{titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && (
<div
className="ep-preview-title"
style={{
fontSize: `${Math.min(titleConfig.font_size, 20)}px`,
fontFamily: titleConfig.font_preset,
fontWeight: "bold",
fontStyle: "normal",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
WebkitTextStroke: "1px rgba(0,0,0,0.6)",
fontSize: `${Math.min(titleSettings.size, 20)}px`,
fontFamily: titleSettings.font,
fontWeight: titleSettings.bold ? "bold" : "normal",
fontStyle: titleSettings.italic ? "italic" : "normal",
textShadow: titleSettings.shadow ? "2px 2px 4px rgba(0,0,0,0.5)" : "none",
WebkitTextStroke: titleSettings.stroke ? "1px rgba(0,0,0,0.6)" : "none",
top:
titleConfig.position === "top"
titleSettings.position === "top"
? "8px"
: titleConfig.position === "center"
: titleSettings.position === "center"
? "50%"
: "auto",
bottom: titleConfig.position === "bottom" ? "30px" : "auto",
transform: titleConfig.position === "center" ? "translateY(-50%)" : "none",
color: titleConfig.font_color,
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
transform: titleSettings.position === "center" ? "translateY(-50%)" : "none",
}}
>
{titleConfig.content}
{titleSettings.title}
</div>
)}
@@ -133,24 +137,50 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
</div>
</div>
{/* 封面预览(只读) */}
{/* 封面预览 */}
<div className="ep-cover-preview">
<div className="ep-cover-image">
{coverConfig?.thumbnail_url || coverConfig?.upload_url ? (
<img
src={coverConfig.thumbnail_url || coverConfig.upload_url}
alt="封面预览"
className="ep-cover-img"
/>
) : displayClip ? (
{displayClip ? (
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
) : (
<span></span>
)}
</div>
<div className="ep-cover-label">
{coverConfig?.enabled ? COVER_MODE_LABELS[coverConfig.mode] || "封面预览" : "未启用封面"}
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"}
</div>
{/* AI 封面操作按钮 */}
<div className="ep-cover-ai-btns">
<button
className="ep-cover-ai-btn"
onClick={() => onAiGenerateCover("ai_frame")}
disabled={aiCoverLoading}
title="AI 智能选帧"
>
{aiCoverLoading ? "⏳" : "🤖"} AI
</button>
<button
className="ep-cover-ai-btn"
onClick={() => onAiGenerateCover("ai_regenerate")}
disabled={aiCoverLoading}
title="AI 重新生成封面"
>
{aiCoverLoading ? "⏳" : "🔄"} AI
</button>
</div>
</div>
{/* 封面方案按钮(竖排4个) */}
<div className="ep-cover-tags">
{coverSchemes.map((scheme) => (
<button
key={scheme.key}
className={`ep-cover-tag ${currentCoverScheme === scheme.key ? "active" : ""}`}
onClick={() => onCoverSchemeChange(scheme.key)}
>
{scheme.label}
</button>
))}
</div>
</div>
)
@@ -47,7 +47,7 @@ const CLIP_TYPE_ICONS: Record<ClipType, string> = {
/** 片段类型标签 */
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
voice: "口播",
pip: "混剪",
pip: "画中画",
}
/** 裁剪拖拽方向 */
@@ -313,7 +313,7 @@ const TtsPanel: React.FC<TtsPanelProps> = ({ open, onClose, config, onChange })
{config.mode === "upload" && (
<div className="tts-upload-hint">
<p></p>
<p></p>
<p></p>
</div>
)}
+5 -5
View File
@@ -176,7 +176,7 @@ export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = {
outro: { kind: "none", duration: 3 },
}
/* ──────── 混剪配置 ──────── */
/* ──────── 画中画配置 ──────── */
/** 九宫格位置 */
export type PipGridPosition =
@@ -196,7 +196,7 @@ export type PipAnimType = "none" | "fade_in" | "slide_in"
/** 入场方向 */
export type PipSlideDirection = "left" | "right" | "up" | "down"
/** 混剪图层 */
/** 画中画图层 */
export interface PipLayer {
id: string
/** 图层名称(用户可编辑) */
@@ -235,9 +235,9 @@ export interface PipLayer {
z_index: number
}
/** 混剪配置 */
/** 画中画配置 */
export interface PipConfig {
/** 是否启用混剪 */
/** 是否启用画中画 */
enabled: boolean
/** 图层列表 */
layers: PipLayer[]
@@ -507,7 +507,7 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
export interface ClipData {
id: string
type: ClipType // 片段类型:voice(口播)或 pip(混剪
type: ClipType // 片段类型:voice(口播)或 pip(画中画
duration: number // 时长(秒)
startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒)
/** 素材库素材 IDmain/pip 类型片段使用) */
+78 -682
View File
@@ -1,5 +1,5 @@
/**
* 智能剪辑页面 — V21 原型 1:1 还原
* 一键生成页面 — V21 原型 1:1 还原
* 5 步向导:选择模板 → 选择素材 → 选择标题 → 选择配音 → 确认生成
* 左右布局:左侧 generate-form + 右侧 generate-preview
* 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等)
@@ -31,8 +31,7 @@ import {
updateEditPlan,
getGenerationTaskResults,
} from "@/api/editPlans"
import type { GeneratedVideo, EditPlanConfig, TitleConfig } from "@/api/editPlans"
import type { CoverConfig } from "../editing-planner/types"
import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans"
import { getEditingTemplates } from "@/api/editingPlanner"
import { getTitles } from "@/api/titles"
import apiClient from "@/api/client"
@@ -76,167 +75,12 @@ const VOICE_GENDER_ICON: Record<string, string> = {
const STEPS = [
{ key: 1, label: "选择模板" },
{ key: 2, label: "选择素材" },
{ key: 3, label: "生成预览" },
{ key: 4, label: "选择标题" },
{ key: 5, label: "选择配音" },
{ key: 6, label: "选择封面" },
{ key: 7, label: "确认生成" },
{ key: 3, label: "选择标题" },
{ key: 4, label: "选择配音" },
{ key: 5, label: "确认生成" },
]
/* ── 标题设置常量 ── */
const POSITION_OPTIONS = [
{ value: "top", label: "顶部" },
{ value: "center", label: "居中" },
{ value: "bottom", label: "底部" },
]
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
interface TitleSettings {
aiAutoSelect: boolean
title: string
position: string
font: string
size: number
bold: boolean
italic: boolean
stroke: boolean
shadow: boolean
color: string
}
const DEFAULT_TITLE_SETTINGS: TitleSettings = {
aiAutoSelect: false,
title: "",
position: "bottom",
font: "思源黑体",
size: 28,
bold: true,
italic: false,
stroke: true,
shadow: false,
color: "#ffffff",
}
const TITLE_PRESETS = [
{
key: "classic_white",
label: "经典白字",
style: { size: 28, color: "#ffffff", bold: true, italic: false, stroke: true, shadow: false },
previewStyle: {
fontWeight: 700,
color: "#ffffff",
WebkitTextStroke: "1px #000000",
fontSize: "20px",
},
},
{
key: "black_gold",
label: "黑金质感",
style: { size: 32, color: "#d4a843", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#d4a843",
textShadow: "1px 1px 3px rgba(0,0,0,0.8)",
fontSize: "20px",
},
},
{
key: "fresh_minimal",
label: "清新简约",
style: { size: 24, color: "#333333", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#333333", fontSize: "18px" },
},
{
key: "variety_show",
label: "综艺花字",
style: { size: 36, color: "#ff4081", bold: true, italic: false, stroke: true, shadow: true },
previewStyle: {
fontWeight: 900,
color: "#ff4081",
WebkitTextStroke: "1.5px #ffffff",
textShadow: "2px 2px 4px rgba(0,0,0,0.5)",
fontSize: "22px",
},
},
{
key: "business",
label: "商务极简",
style: { size: 24, color: "#1a1a1a", bold: false, italic: false, stroke: false, shadow: false },
previewStyle: { fontWeight: 400, color: "#1a1a1a", fontSize: "17px" },
},
{
key: "retro_film",
label: "复古胶片",
style: { size: 28, color: "#e8d5b7", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#e8d5b7",
textShadow: "2px 2px 6px rgba(0,0,0,0.7)",
fontSize: "18px",
},
},
{
key: "neon_glow",
label: "霓虹发光",
style: { size: 32, color: "#00e5ff", bold: true, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 700,
color: "#00e5ff",
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
fontSize: "20px",
},
},
{
key: "handwriting",
label: "手写字",
style: { size: 28, color: "#333333", bold: false, italic: false, stroke: false, shadow: true },
previewStyle: {
fontWeight: 400,
color: "#333333",
textShadow: "1px 1px 2px rgba(0,0,0,0.3)",
fontSize: "20px",
},
},
]
/* ── 封面设置常量 ── */
const COVER_MODE_LABELS: Record<string, string> = {
auto: "智能封面",
frame: "抽帧选封面",
upload: "上传封面",
}
const COVER_MODE_ICONS: Record<string, string> = {
auto: "🤖",
frame: "🎞️",
upload: "📤",
}
const DEFAULT_COVER_SETTINGS: CoverConfig = {
enabled: true,
mode: "auto",
frame_time: 0,
upload_url: "",
ai_suggested_time: null,
thumbnail_url: "",
}
function getActivePreset(settings: TitleSettings): string | null {
for (const p of TITLE_PRESETS) {
if (
settings.size === p.style.size &&
settings.color === p.style.color &&
settings.bold === p.style.bold &&
settings.italic === p.style.italic &&
settings.stroke === p.style.stroke &&
settings.shadow === p.style.shadow
) {
return p.key
}
}
return null
}
/* ── 标题选项从 API 加载,不再硬编码 ── */
/* ================================================================
组件
@@ -248,7 +92,7 @@ const GeneratePage: React.FC = () => {
/* ── 步骤状态 ── */
const [currentStep, setCurrentStep] = useState(1)
/* ── 模板(从 API 加载) ── */
/* ── 模板(从 API 加载用户自制模板 ── */
const [selectedTemplate, setSelectedTemplate] = useState("")
const { data: userTemplates = [] } = useQuery({
queryKey: ["generate-templates"],
@@ -267,11 +111,8 @@ const GeneratePage: React.FC = () => {
/* 素材选择模式:手动选择 / 自动匹配 */
const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual")
/* ── 标题设置 ── */
const [titleSettings, setTitleSettings] = useState<TitleSettings>(DEFAULT_TITLE_SETTINGS)
/* ── 封面设置 ── */
const [coverSettings, setCoverSettings] = useState<CoverConfig>(DEFAULT_COVER_SETTINGS)
/* ── 标题 ── */
const [title, setTitle] = useState("")
const { data: userTitles = [] } = useQuery({
queryKey: ["titles"],
queryFn: () => getTitles(),
@@ -280,27 +121,8 @@ const GeneratePage: React.FC = () => {
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
useEffect(() => {
const tpl = userTemplates.find((t) => t.id === selectedTemplate)
if (tpl?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: tpl.title_config!.ai_auto_select,
title: tpl.title_config!.content || prev.title,
position: tpl.title_config!.position || prev.position,
font: tpl.title_config!.font_preset || prev.font,
size: tpl.title_config!.font_size || prev.size,
color: tpl.title_config!.font_color || prev.color,
}))
}
if (tpl?.cover_config) {
setCoverSettings((prev) => ({
...prev,
enabled: tpl.cover_config!.enabled ?? prev.enabled,
mode: (tpl.cover_config!.mode as CoverConfig["mode"]) || prev.mode,
frame_time: tpl.cover_config!.frame_time ?? prev.frame_time,
upload_url: tpl.cover_config!.upload_url || prev.upload_url,
ai_suggested_time: tpl.cover_config!.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: tpl.cover_config!.thumbnail_url || prev.thumbnail_url,
}))
if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) {
setTitle(tpl.title_config.content)
}
}, [selectedTemplate, userTemplates])
@@ -355,17 +177,8 @@ const GeneratePage: React.FC = () => {
segments?: Array<{ media_asset_id?: string; material_type?: string }>
}
if (config.title_config) {
const tc = config.title_config as TitleConfig
setTitleSettings((prev) => ({
...prev,
title: tc.content || "",
aiAutoSelect: tc.ai_auto_select || false,
position: tc.position || prev.position,
font: tc.font_preset || prev.font,
size: tc.font_size || prev.size,
color: tc.font_color || prev.color,
}))
if (config.title_config?.content) {
setTitle(config.title_config.content)
}
if (config.segments && config.segments.length > 0) {
const assetIds = config.segments
@@ -386,31 +199,8 @@ const GeneratePage: React.FC = () => {
const loadPlanConfig = async () => {
try {
const plan = await getEditPlan(editPlanId)
if (plan.name) setTitleSettings((prev) => ({ ...prev, title: plan.name }))
if (plan.name) setTitle(plan.name)
const cfg = plan.config
if (cfg?.title_config) {
setTitleSettings((prev) => ({
...prev,
aiAutoSelect: cfg.title_config!.ai_auto_select,
title: cfg.title_config!.content || prev.title,
position: cfg.title_config!.position || prev.position,
font: cfg.title_config!.font_preset || prev.font,
size: cfg.title_config!.font_size || prev.size,
color: cfg.title_config!.font_color || prev.color,
}))
}
if (cfg?.cover_config) {
const cc = cfg.cover_config as CoverConfig
setCoverSettings((prev) => ({
...prev,
enabled: cc.enabled ?? prev.enabled,
mode: cc.mode || prev.mode,
frame_time: cc.frame_time ?? prev.frame_time,
upload_url: cc.upload_url || prev.upload_url,
ai_suggested_time: cc.ai_suggested_time ?? prev.ai_suggested_time,
thumbnail_url: cc.thumbnail_url || prev.thumbnail_url,
}))
}
if (cfg?.asset_ids) {
setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string"))
}
@@ -455,7 +245,7 @@ const GeneratePage: React.FC = () => {
})
const [selectedLibraryId, setSelectedLibraryId] = useState<string>("")
// 自动选中第一个视频
// 自动选中第一个素材
useEffect(() => {
if (libraries.length > 0 && !selectedLibraryId) {
setSelectedLibraryId(libraries[0].id)
@@ -583,7 +373,7 @@ const GeneratePage: React.FC = () => {
message.success({
content: (
<span>
{" "}
{" "}
<a
onClick={handleGoToLibrary}
style={{
@@ -591,7 +381,7 @@ const GeneratePage: React.FC = () => {
cursor: "pointer",
}}
>
</a>
</span>
),
@@ -650,19 +440,19 @@ const GeneratePage: React.FC = () => {
[allTags, saveTagIds],
)
/** 保存成功后跳转到视频库 */
/** 保存成功后跳转到素材库 */
const handleGoToLibrary = useCallback(() => {
navigate("/app/voice-materials")
}, [navigate])
const handleGenerate = useCallback(async () => {
console.log("[handleGenerate] 开始生成, 参数:", {
titleSettings,
title,
selectedTemplate,
selectedMaterials,
voiceMode,
})
if (!titleSettings.title.trim()) {
if (!title.trim()) {
message.warning("请先选择或输入标题")
return
}
@@ -698,18 +488,9 @@ const GeneratePage: React.FC = () => {
const plan = await createEditPlan({
template_id: selectedTemplate,
name: titleSettings.title.trim(),
name: title.trim(),
config: {
asset_ids: selectedMaterials,
title_config: {
ai_auto_select: titleSettings.aiAutoSelect,
content: titleSettings.title,
position: titleSettings.position,
font_preset: titleSettings.font,
font_color: titleSettings.color,
font_size: titleSettings.size,
},
cover_config: coverSettings,
...voiceConfig,
ratio: videoRatio,
style,
@@ -873,7 +654,7 @@ const GeneratePage: React.FC = () => {
return "所选模板或素材不可用,请重新选择"
}
if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) {
return "素材数据异常,请返回视频库重新检查"
return "素材数据异常,请返回素材库重新检查"
}
// 网络/超时
if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) {
@@ -892,7 +673,7 @@ const GeneratePage: React.FC = () => {
message.error(finalMsg)
}
}, [
titleSettings,
title,
selectedMaterials,
selectedVoice,
voiceMode,
@@ -908,7 +689,6 @@ const GeneratePage: React.FC = () => {
selectedTemplate,
generateCount,
materialMode,
coverSettings,
])
/* ── 下载视频 ── */
@@ -957,14 +737,14 @@ const GeneratePage: React.FC = () => {
message.warning("请至少选择一个素材")
return
}
if (currentStep === 4 && !titleSettings.title.trim()) {
if (currentStep === 3 && !title.trim()) {
message.warning("请选择或输入标题")
return
}
if (currentStep < 7) {
if (currentStep < 5) {
setCurrentStep((s) => s + 1)
}
}, [currentStep, selectedTemplate, selectedMaterials.length, titleSettings, materialMode])
}, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode])
const goPrev = useCallback(() => {
if (currentStep > 1) {
@@ -1081,13 +861,13 @@ const GeneratePage: React.FC = () => {
onClick={() => setMaterialMode("auto")}
type="button"
>
</button>
</div>
{/* ── 视频库选择(两种模式共用) ── */}
{/* ── 素材库选择(两种模式共用) ── */}
<div className="xx-form-field" style={{ marginTop: 12 }}>
<label></label>
<label></label>
<select value={selectedLibraryId} onChange={(e) => setSelectedLibraryId(e.target.value)}>
{libraries.map((lib) => (
<option key={lib.id} value={lib.id}>
@@ -1117,7 +897,7 @@ const GeneratePage: React.FC = () => {
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}></Text>
) : materials.items.length === 0 ? (
<Text style={{ color: "var(--text-secondary)", padding: "16px 0" }}>
</Text>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
@@ -1185,7 +965,7 @@ const GeneratePage: React.FC = () => {
<div className="xx-auto-match-body">
<h4 className="xx-auto-match-title"></h4>
<p className="xx-auto-match-desc">
AI
</p>
<div className="xx-auto-match-features">
@@ -1202,7 +982,7 @@ const GeneratePage: React.FC = () => {
marginTop: 8,
}}
>
</Text>
) : (
<Text
@@ -1212,7 +992,7 @@ const GeneratePage: React.FC = () => {
marginTop: 8,
}}
>
{materials.items.length}
{materials.items.length}
</Text>
)}
</div>
@@ -1220,236 +1000,54 @@ const GeneratePage: React.FC = () => {
</div>
)
/** 步骤 3生成预览 */
/** 步骤 3选择标题 */
const renderStep3 = () => (
<div className="xx-form-section">
<h3>🎬 </h3>
<div className="xx-preview-tip">
<CheckCircleFilled style={{ color: "#52c41a", marginRight: 8 }} />
<span>AI </span>
</div>
<div className="xx-preview-plan-card">
<div className="xx-preview-plan-title"></div>
<div className="xx-preview-plan-info">
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label"></span>
<span className="xx-preview-plan-value">{getTemplateName()}</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label"></span>
<span className="xx-preview-plan-value">
{materialMode === "auto" ? "AI自动匹配" : `${selectedMaterials.length} 个素材`}
</span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label"></span>
<span className="xx-preview-plan-value">{duration} </span>
</div>
<div className="xx-preview-plan-row">
<span className="xx-preview-plan-label"></span>
<span className="xx-preview-plan-value">{videoRatio}</span>
</div>
</div>
<div className="xx-preview-plan-hint">
💡 AI
</div>
</div>
</div>
)
/** 步骤 4:选择标题 + 标题样式设置 */
const renderStep4 = () => (
<div className="xx-form-section">
<h3>📝 </h3>
{/* AI 自动选择开关 */}
<div className="xx-title-ai-toggle">
<span className="xx-toggle-label">AI </span>
<div
className={`xx-switch ${titleSettings.aiAutoSelect ? "active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({ ...prev, aiAutoSelect: !prev.aiAutoSelect }))
<div className="xx-form-field">
<label></label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={title || undefined}
onChange={(val) => setTitle(val || "")}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
}
>
<div className="xx-switch-knob" />
</div>
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
{!titleSettings.aiAutoSelect && (
<>
{/* 标题内容选择 */}
<div className="xx-form-field">
<label></label>
<Select
placeholder="请选择标题…"
allowClear
showSearch
style={{ width: "100%" }}
value={titleSettings.title || undefined}
onChange={(val) => setTitleSettings((prev) => ({ ...prev, title: val || "" }))}
options={userTitles.map((t) => ({
label: t.content,
value: t.content,
}))}
filterOption={(input, option) =>
((option?.label as string) || "").toLowerCase().includes(input.toLowerCase())
}
notFoundContent={
userTitles.length === 0 ? (
<span style={{ color: "var(--text-tertiary)", fontSize: 13 }}>
</span>
) : null
}
/>
</div>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
<input
placeholder="输入自定义标题…"
value={titleSettings.title}
onChange={(e) => setTitleSettings((prev) => ({ ...prev, title: e.target.value }))}
maxLength={50}
/>
</div>
{/* 标题样式设置区 */}
<div className="xx-title-style-section">
<h4 className="xx-section-subtitle"></h4>
{/* 位置 + 字体 一行 */}
<div className="xx-title-style-row">
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={titleSettings.position}
onChange={(e) =>
setTitleSettings((prev) => ({ ...prev, position: e.target.value }))
}
>
{POSITION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="xx-form-field xx-half-field">
<label></label>
<select
className="xx-form-select"
value={titleSettings.font}
onChange={(e) => setTitleSettings((prev) => ({ ...prev, font: e.target.value }))}
>
{FONT_OPTIONS.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
</div>
</div>
{/* 字号滑块 */}
<div className="xx-form-field">
<div className="xx-field-label-row">
<label></label>
<span className="xx-field-value">{titleSettings.size}px</span>
</div>
<input
className="xx-slider"
type="range"
min={12}
max={48}
value={titleSettings.size}
onChange={(e) =>
setTitleSettings((prev) => ({
...prev,
size: Number(e.target.value),
}))
}
/>
</div>
{/* 预设样式 */}
<div className="xx-form-field">
<label></label>
<div className="xx-title-presets-grid">
{TITLE_PRESETS.map((p) => {
const isActive = getActivePreset(titleSettings) === p.key
return (
<button
key={p.key}
className={`xx-title-preset-card${isActive ? " active" : ""}`}
onClick={() =>
setTitleSettings((prev) => ({
...prev,
size: p.style.size,
color: p.style.color,
bold: p.style.bold,
italic: p.style.italic,
stroke: p.style.stroke,
shadow: p.style.shadow,
}))
}
title={p.label}
>
<span
className="xx-title-preset-preview-text"
style={p.previewStyle as React.CSSProperties}
>
</span>
<span className="xx-title-preset-card-label">{p.label}</span>
</button>
)
})}
</div>
</div>
{/* 样式按钮:粗体/斜体/描边/阴影 */}
<div className="xx-form-field">
<label></label>
<div className="xx-style-btns">
<button
className={`xx-style-btn ${titleSettings.bold ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, bold: !prev.bold }))}
title="粗体"
>
<b>B</b>
</button>
<button
className={`xx-style-btn ${titleSettings.italic ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, italic: !prev.italic }))}
title="斜体"
>
<i>I</i>
</button>
<button
className={`xx-style-btn ${titleSettings.stroke ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, stroke: !prev.stroke }))}
title="描边"
>
S
</button>
<button
className={`xx-style-btn ${titleSettings.shadow ? "active" : ""}`}
onClick={() => setTitleSettings((prev) => ({ ...prev, shadow: !prev.shadow }))}
title="阴影"
>
</button>
</div>
</div>
</div>
</>
<div className="xx-form-field" style={{ marginTop: 14 }}>
<label></label>
<input
placeholder="输入自定义标题…"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={50}
/>
</div>
{userTitles.length === 0 && (
<p style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 8 }}>
</p>
)}
</div>
)
/** 步骤 4:选择配音 */
const renderStep5 = () => (
const renderStep4 = () => (
<div className="xx-form-section">
<h3>🎙 </h3>
@@ -1633,7 +1231,7 @@ const GeneratePage: React.FC = () => {
<div className="xx-save-modal-overlay" onClick={() => setSaveModalOpen(false)}>
<div className="xx-save-modal" onClick={(e) => e.stopPropagation()}>
<div className="xx-save-modal-header">
<span></span>
<span></span>
<button className="xx-save-modal-close" onClick={() => setSaveModalOpen(false)}>
<CloseOutlined />
</button>
@@ -1824,198 +1422,8 @@ const GeneratePage: React.FC = () => {
</div>
)
/** 步骤 5选择封面 */
const renderStep6 = () => {
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds % 1) * 10)
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
}
const totalDuration = duration || 30
return (
<div className="xx-form-section">
<h3>🖼 </h3>
{/* 启用开关 */}
<div className="xx-cover-header">
<span className="xx-cover-header-label"></span>
<label className="xx-switch">
<input
type="checkbox"
checked={coverSettings.enabled}
onChange={(e) => setCoverSettings((prev) => ({ ...prev, enabled: e.target.checked }))}
/>
<span className="xx-switch-slider" />
</label>
</div>
{coverSettings.enabled && (
<>
{/* 模式选择 */}
<div className="xx-section-title"></div>
<div className="xx-cover-mode-tabs">
{(["auto", "frame", "upload"] as const).map((m) => (
<button
key={m}
className={`xx-cover-mode-tab${coverSettings.mode === m ? " active" : ""}`}
onClick={() => setCoverSettings((prev) => ({ ...prev, mode: m }))}
>
<span className="xx-cover-mode-icon">{COVER_MODE_ICONS[m]}</span>
<span className="xx-cover-mode-label">{COVER_MODE_LABELS[m]}</span>
</button>
))}
</div>
{/* 智能封面 */}
{coverSettings.mode === "auto" && (
<div className="xx-cover-auto">
<div className="xx-cover-auto-desc">
AI
</div>
<div className="xx-cover-auto-badge">
<span style={{ fontSize: 24 }}>🤖</span>
<span>AI </span>
</div>
</div>
)}
{/* 抽帧选封面 */}
{coverSettings.mode === "frame" && (
<div className="xx-cover-frame">
<div className="xx-cover-frame-preview">
<div className="xx-cover-frame-placeholder">
<span className="xx-cover-frame-icon">🎞</span>
<span className="xx-cover-frame-time">
{formatTime(coverSettings.frame_time)}
</span>
</div>
</div>
<div className="xx-cover-frame-slider">
<div className="xx-cover-frame-slider-header">
<span></span>
<span className="xx-cover-frame-value">
{formatTime(coverSettings.frame_time)}
</span>
</div>
<input
type="range"
min={0}
max={Math.max(totalDuration, 1)}
step={0.1}
value={coverSettings.frame_time}
onChange={(e) =>
setCoverSettings((prev) => ({
...prev,
frame_time: Number(e.target.value),
}))
}
className="xx-cover-range"
/>
<div className="xx-cover-frame-range">
<span>00:00</span>
<span>{formatTime(totalDuration)}</span>
</div>
</div>
<div className="xx-cover-frame-quick">
<span className="xx-cover-quick-label"></span>
{[0, 0.25, 0.5, 0.75].map((ratio) => {
const t = totalDuration * ratio
return (
<button
key={ratio}
className="xx-cover-quick-btn"
onClick={() => setCoverSettings((prev) => ({ ...prev, frame_time: t }))}
>
{formatTime(t)}
</button>
)
})}
</div>
</div>
)}
{/* 上传封面 */}
{coverSettings.mode === "upload" && (
<div className="xx-cover-upload">
<div
className="xx-cover-upload-area"
onClick={() => {
const input = document.getElementById("cover-upload-input")
input?.click()
}}
>
{coverSettings.upload_url ? (
<div className="xx-cover-upload-preview">
<img src={coverSettings.upload_url} alt="封面预览" />
<div className="xx-cover-upload-overlay"></div>
</div>
) : (
<div className="xx-cover-upload-placeholder">
<span style={{ fontSize: 32 }}>📤</span>
<span className="xx-cover-upload-text"></span>
<span className="xx-cover-upload-hint"> JPG / PNG 16:9 </span>
</div>
)}
<input
id="cover-upload-input"
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
const reader = new FileReader()
reader.onload = (ev) => {
const url = ev.target?.result as string
setCoverSettings((prev) => ({
...prev,
upload_url: url,
thumbnail_url: url,
mode: "upload",
}))
}
reader.readAsDataURL(file)
}
}}
/>
</div>
</div>
)}
{/* 封面预览 */}
<div className="xx-section-title"></div>
<div className="xx-cover-preview-box">
{coverSettings.upload_url ? (
<img
src={coverSettings.upload_url}
alt="封面预览"
className="xx-cover-preview-img"
/>
) : (
<div className="xx-cover-preview-placeholder">
<span style={{ fontSize: 28 }}>🖼</span>
<span>
{coverSettings.mode === "auto"
? "AI 智能选择"
: coverSettings.mode === "frame"
? `${formatTime(coverSettings.frame_time)}`
: "未上传封面"}
</span>
</div>
)}
<div className="xx-cover-preview-ratio">16:9</div>
</div>
</>
)}
</div>
)
}
/** 步骤 6:确认生成 */
const renderStep7 = () => (
/** 步骤 5确认生成 */
const renderStep5 = () => (
<div className="xx-form-section">
<h3> </h3>
<div className="xx-summary-card">
@@ -2031,18 +1439,12 @@ const GeneratePage: React.FC = () => {
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{titleSettings.title || "未选择"}</span>
<span className="xx-summary-value">{title || "未选择"}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">{getVoiceName()}</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
{coverSettings.enabled ? COVER_MODE_LABELS[coverSettings.mode] || "智能封面" : "不使用"}
</span>
</div>
<div className="xx-summary-row">
<span className="xx-summary-label"></span>
<span className="xx-summary-value">
@@ -2158,10 +1560,6 @@ const GeneratePage: React.FC = () => {
return renderStep4()
case 5:
return renderStep5()
case 6:
return renderStep6()
case 7:
return renderStep7()
default:
return null
}
@@ -2178,7 +1576,7 @@ const GeneratePage: React.FC = () => {
<div>
<h2>
<ThunderboltOutlined style={{ marginRight: 8 }} />
</h2>
<p></p>
</div>
@@ -2238,7 +1636,7 @@ const GeneratePage: React.FC = () => {
<button className="xx-btn xx-btn-ghost" onClick={goPrev} disabled={currentStep === 1}>
</button>
{currentStep < 7 ? (
{currentStep < 5 ? (
<button className="xx-btn xx-btn-primary" onClick={goNext}>
</button>
@@ -2292,9 +1690,7 @@ const GeneratePage: React.FC = () => {
</div>
{/* 字幕预览 */}
<div className="xx-preview-caption">
{titleSettings.title || "3秒抓住注意力,30秒讲清卖点"}
</div>
<div className="xx-preview-caption">{title || "3秒抓住注意力,30秒讲清卖点"}</div>
{/* 时间线标题 */}
<div className="xx-preview-title"></div>
+1 -614
View File
@@ -1,5 +1,5 @@
/**
* 智能剪辑页面 — V21 原型 1:1 还原样式
* 一键生成页面 — V21 原型 1:1 还原样式
* 对照 frontend-v21-ui-prototype-final.html #view-generate
*/
@import "../../styles/global.css";
@@ -1136,616 +1136,3 @@
border: 1px solid var(--border-light, #f1f5f9);
border-radius: 20px;
}
/* ============================================================
标题设置(选择标题步骤)
============================================================ */
.xx-title-ai-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--bg-secondary);
border-radius: var(--radius-sm);
margin-bottom: 18px;
}
.xx-toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.xx-switch {
width: 44px;
height: 24px;
background: var(--border-color);
border-radius: 12px;
position: relative;
cursor: pointer;
transition: background 0.2s;
}
.xx-switch.active {
background: var(--primary-color);
}
.xx-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
}
.xx-switch.active .xx-switch-knob {
transform: translateX(20px);
}
.xx-title-style-section {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid var(--border-light);
}
.xx-section-subtitle {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 16px;
}
.xx-title-style-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.xx-half-field {
margin-bottom: 0;
}
.xx-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.xx-field-label-row label {
margin-bottom: 0;
}
.xx-field-value {
font-size: 13px;
font-weight: 600;
color: var(--primary-color);
}
.xx-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 3px;
outline: none;
cursor: pointer;
}
.xx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
.xx-slider::-moz-range-thumb {
width: 18px;
height: 18px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.3);
}
/* 标题预设卡片网格 */
.xx-title-presets-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.xx-title-preset-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 14px 8px;
background: var(--bg-secondary);
border: 2px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
text-align: center;
}
.xx-title-preset-card:hover {
border-color: var(--primary-200);
background: var(--bg-primary);
}
.xx-title-preset-card.active {
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-title-preset-preview-text {
line-height: 1.4;
margin-bottom: 6px;
user-select: none;
}
.xx-title-preset-card-label {
font-size: 11px;
color: var(--text-secondary);
}
.xx-title-preset-card.active .xx-title-preset-card-label {
color: var(--primary-color);
font-weight: 500;
}
/* 样式按钮组 */
.xx-style-btns {
display: flex;
gap: 8px;
}
.xx-style-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
cursor: pointer;
font-size: 15px;
color: var(--text-secondary);
transition: all 0.15s;
}
.xx-style-btn:hover {
border-color: var(--primary-300);
color: var(--primary-color);
}
.xx-style-btn.active {
background: var(--primary-color);
border-color: var(--primary-color);
color: #fff;
}
/* ================================================================
封面设置
================================================================ */
.xx-cover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
margin-bottom: 16px;
}
.xx-cover-header-label {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.xx-switch {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.xx-switch input {
opacity: 0;
width: 0;
height: 0;
}
.xx-switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--border-color);
transition: 0.2s;
border-radius: 22px;
}
.xx-switch-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
}
.xx-switch input:checked + .xx-switch-slider {
background-color: var(--primary-color);
}
.xx-switch input:checked + .xx-switch-slider:before {
transform: translateX(18px);
}
.xx-section-title {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
margin: 16px 0 10px;
}
.xx-cover-mode-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.xx-cover-mode-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 8px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--bg-primary);
cursor: pointer;
transition: all 0.15s;
}
.xx-cover-mode-tab:hover {
border-color: var(--primary-300);
}
.xx-cover-mode-tab.active {
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-cover-mode-icon {
font-size: 20px;
}
.xx-cover-mode-label {
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
}
.xx-cover-auto {
padding: 20px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
text-align: center;
}
.xx-cover-auto-desc {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.6;
}
.xx-cover-auto-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--primary-50);
border: 1px solid var(--primary-200);
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: var(--primary-color);
}
.xx-cover-frame {
margin-bottom: 16px;
}
.xx-cover-frame-preview {
margin-bottom: 16px;
}
.xx-cover-frame-placeholder {
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #fff;
}
.xx-cover-frame-icon {
font-size: 32px;
}
.xx-cover-frame-time {
font-size: 18px;
font-weight: 600;
font-family: monospace;
}
.xx-cover-frame-slider {
margin-bottom: 12px;
}
.xx-cover-frame-slider-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.xx-cover-frame-value {
font-weight: 600;
color: var(--text-primary);
font-family: monospace;
}
.xx-cover-range {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: var(--border-color);
border-radius: 2px;
outline: none;
}
.xx-cover-range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--primary-color);
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.xx-cover-frame-range {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-tertiary);
margin-top: 4px;
}
.xx-cover-frame-quick {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.xx-cover-quick-label {
font-size: 12px;
color: var(--text-secondary);
}
.xx-cover-quick-btn {
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
font-family: monospace;
}
.xx-cover-quick-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.xx-cover-upload {
margin-bottom: 16px;
}
.xx-cover-upload-area {
aspect-ratio: 16 / 9;
border: 2px dashed var(--border-color);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
position: relative;
overflow: hidden;
}
.xx-cover-upload-area:hover {
border-color: var(--primary-color);
background: var(--primary-50);
}
.xx-cover-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--text-secondary);
}
.xx-cover-upload-text {
font-size: 14px;
font-weight: 500;
}
.xx-cover-upload-hint {
font-size: 12px;
opacity: 0.7;
}
.xx-cover-upload-preview {
position: relative;
width: 100%;
height: 100%;
}
.xx-cover-upload-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-cover-upload-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 12px;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.6));
color: #fff;
font-size: 13px;
text-align: center;
}
.xx-cover-preview-box {
position: relative;
aspect-ratio: 16 / 9;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
overflow: hidden;
border: 1px solid var(--border-color);
}
.xx-cover-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.xx-cover-preview-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-tertiary);
font-size: 13px;
}
.xx-cover-preview-ratio {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
}
/* ================================================================
生成预览步骤
================================================================ */
.xx-preview-tip {
display: flex;
align-items: center;
padding: 12px 16px;
background: rgba(82, 196, 26, 0.08);
border: 1px solid rgba(82, 196, 26, 0.2);
border-radius: var(--radius-md);
margin-bottom: 20px;
font-size: 13px;
color: var(--text-primary);
}
.xx-preview-plan-card {
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
padding: 20px;
border: 1px solid var(--border-color);
}
.xx-preview-plan-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.xx-preview-plan-info {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 16px;
}
.xx-preview-plan-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.xx-preview-plan-label {
font-size: 13px;
color: var(--text-secondary);
}
.xx-preview-plan-value {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
}
.xx-preview-plan-hint {
font-size: 12px;
color: var(--text-tertiary);
padding: 10px 12px;
background: var(--bg-primary);
border-radius: var(--radius-sm);
line-height: 1.5;
}
+2 -2
View File
@@ -26,7 +26,7 @@ const HeroSection: React.FC = () => {
<h1 className="hp-hero-title">
AI自动剪辑
<br />
</h1>
<p className="hp-hero-desc">
AI 30
@@ -71,7 +71,7 @@ const FEATURES = [
{
icon: "🤖",
title: "AI 智能剪辑",
desc: "自动识别视频高光片段,智能去除冗余内容,智能剪辑精彩短视频。",
desc: "自动识别视频高光片段,智能去除冗余内容,一键生成精彩短视频。",
},
{
icon: "🎙️",
@@ -1058,7 +1058,7 @@ const ProductLibrary: React.FC = () => {
<div className="xx-products-empty-icon">
<VideoCameraOutlined />
</div>
<p></p>
<p></p>
<Button
buttonType="primary"
buttonSize="sm"
@@ -927,7 +927,7 @@ const VoiceMaterialLibrary: React.FC = () => {
// 自动创建 voice 素材库(如果不存在)
const createLibMutation = useMutation({
mutationFn: () => createAssetLibrary({ name: "配音库", kind: "voice" }),
mutationFn: () => createAssetLibrary({ name: "配音素材库", kind: "voice" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["asset-libraries"] })
},
@@ -1032,7 +1032,7 @@ const VoiceMaterialLibrary: React.FC = () => {
queryFn: getAssetLibraries,
})
lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("无法创建配音库")
if (!lib) throw new Error("无法创建配音素材库")
}
// 2. 上传文件(带进度)
@@ -1474,7 +1474,7 @@ const VoiceMaterialLibrary: React.FC = () => {
await saveTtsToLibrary(ttsJobId, {
name: ttsText.slice(0, 20) || "AI配音",
})
message.success("已保存到配音库")
message.success("已保存到配音素材库")
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
setTtsOpen(false)
} catch {
@@ -1513,7 +1513,7 @@ const VoiceMaterialLibrary: React.FC = () => {
return (
<div className="vmat-page">
<PageHead
title="配音库"
title="配音素材库"
description="管理配音音频素材,支持上传、试听、编辑元信息"
actions={pageActions}
/>
+6 -96
View File
@@ -36,13 +36,7 @@ import {
type VoiceClone,
} from "@/api/voiceClone"
import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"
import {
getAssetsByKind,
type AssetItem,
uploadAssetDirect,
getAssetLibraries,
createAsset,
} from "@/api/assets"
import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets"
import CloneModal from "@/components/voice/CloneModal"
import "./voices.css"
@@ -51,7 +45,7 @@ import "./voices.css"
* ============================================================ */
type VoiceGender = "male" | "female" | "child" | "elderly"
type VoiceLanguage = "zh" | "en" | "ja" | "ko"
type TabKey = "preset" | "cloned" | "material"
type TabKey = "preset" | "cloned"
/** 前端展示用的预置音色(从 PresetVoiceItem 映射) */
interface PresetVoiceDisplay {
@@ -676,13 +670,13 @@ const VoiceLibrary: React.FC = () => {
}) => {
setUploadProgress(0)
try {
/* 获取或创建默认配音库 */
/* 获取或创建默认配音素材库 */
const libs = await queryClient.fetchQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
})
const lib = libs.find((l) => l.kind === "voice")
if (!lib) throw new Error("配音库不存在,请先在配音库页面创建")
if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建")
/* 直传文件 */
const { storage_key } = await uploadAssetDirect({
@@ -775,7 +769,7 @@ const VoiceLibrary: React.FC = () => {
try {
await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) })
queryClient.invalidateQueries({ queryKey: ["assets", "voice"] })
showToast("已保存到配音库", "success")
showToast("已保存到配音素材库", "success")
setTtsOpen(false)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "保存失败"
@@ -804,12 +798,6 @@ const VoiceLibrary: React.FC = () => {
queryFn: () => getVoiceClonesWithTotal({ limit: 50 }),
})
/** 配音素材列表(用户上传音频) */
const { data: materialData, isLoading: materialLoading } = useQuery({
queryKey: ["voice-materials"],
queryFn: () => getAssetsByKind("voice", { limit: 50 }),
})
/** 统一统计(preset_count / clone_count */
const { data: unifiedStats } = useQuery({
queryKey: ["voices-unified"],
@@ -826,7 +814,6 @@ const VoiceLibrary: React.FC = () => {
)
const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0
const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0
const materialCount = materialData?.length ?? 0
const filteredPreset = useMemo(() => {
let list = presetVoices
@@ -997,17 +984,6 @@ const VoiceLibrary: React.FC = () => {
<span className="xx-voices-tab-count">{cloneCount}</span>
</button>
<button
className={`xx-voices-tab${activeTab === "material" ? " active" : ""}`}
onClick={() => {
setActiveTab("material")
handlePause()
}}
>
<SoundOutlined />
<span className="xx-voices-tab-count">{materialCount}</span>
</button>
</div>
{activeTab === "preset" && (
@@ -1157,72 +1133,6 @@ const VoiceLibrary: React.FC = () => {
</div>
)}
{activeTab === "material" && (
<div className="xx-voices-tab-content">
{/* 骨架屏加载 */}
{materialLoading && (
<div className="xx-voice-grid">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="vmat-card vmat-card--skeleton">
<div className="vmat-thumb" />
<div className="vmat-info">
<div className="vmat-skeleton-line vmat-skeleton-title" />
<div className="vmat-skeleton-line" />
</div>
</div>
))}
</div>
)}
{/* 卡片列表 */}
{!materialLoading && (materialData?.length || 0) > 0 && (
<div className="xx-voice-grid">
{(materialData || []).map((asset: AssetItem) => {
const duration = (asset.metadata?.duration as number) || 0
const minutes = Math.floor(duration / 60)
const seconds = Math.floor(duration % 60)
return (
<div key={asset.id} className="vmat-card">
<div className="vmat-thumb">
<AudioOutlined className="vmat-thumb-icon" />
<span className="vmat-duration">
{minutes}:{seconds.toString().padStart(2, "0")}
</span>
</div>
<div className="vmat-info">
<div className="vmat-name" title={asset.name}>
{asset.name}
</div>
<div className="vmat-meta">
<span>
{asset.file_size
? `${(asset.file_size / 1024 / 1024).toFixed(1)} MB`
: "--"}
</span>
</div>
</div>
</div>
)
})}
</div>
)}
{/* 空状态 */}
{!materialLoading && (materialData?.length || 0) === 0 && (
<div className="xx-voices-empty">
<div className="xx-voices-empty-icon">
<SoundOutlined />
</div>
<h3></h3>
<p></p>
<Button buttonType="primary" buttonSize="md" onClick={() => setUploadOpen(true)}>
<UploadOutlined />
</Button>
</div>
)}
</div>
)}
{/* 克隆音色弹窗 */}
<CloneModal
open={cloneModalOpen}
@@ -1759,7 +1669,7 @@ const VoiceLibrary: React.FC = () => {
cursor: "pointer",
}}
>
</button>
</div>
)}
-102
View File
@@ -970,105 +970,3 @@
font-size: var(--font-size-xs);
}
}
/* ================================================================
配音素材卡片与配音库Tab集成
================================================================ */
.vmat-card {
background: var(--bg-primary);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--border-color);
transition: all 0.2s;
cursor: pointer;
}
.vmat-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.08);
border-color: var(--primary-300);
}
.vmat-thumb {
position: relative;
aspect-ratio: 16 / 9;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
display: flex;
align-items: center;
justify-content: center;
}
.vmat-thumb-icon {
font-size: 32px;
color: rgba(255, 255, 255, 0.9);
}
.vmat-duration {
position: absolute;
bottom: 8px;
right: 8px;
padding: 2px 8px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 11px;
border-radius: 4px;
font-family: monospace;
}
.vmat-info {
padding: 12px 14px;
}
.vmat-name {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 4px;
}
.vmat-meta {
font-size: 12px;
color: var(--text-tertiary);
}
/* 骨架屏 */
.vmat-card--skeleton {
pointer-events: none;
}
.vmat-card--skeleton .vmat-thumb {
background: var(--bg-tertiary);
}
.vmat-skeleton-line {
height: 12px;
background: var(--bg-tertiary);
border-radius: 6px;
margin-bottom: 8px;
animation: vmat-shimmer 1.5s infinite linear;
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--border-color) 50%,
var(--bg-tertiary) 75%
);
background-size: 200% 100%;
}
.vmat-skeleton-title {
width: 70%;
height: 14px;
}
@keyframes vmat-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
-1
View File
@@ -7,6 +7,5 @@ module.exports = {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-non-null-asserted-optional-chain": "off",
},
}
+2 -221
View File
@@ -1,36 +1,5 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import {
normalizeUser,
login,
register,
logout,
getCurrentUser,
refreshAccessToken,
requestPasswordReset,
resetPassword,
verifyEmail,
} from "@/api/auth"
const mockPost = vi.fn()
const mockGet = vi.fn()
const mockAxiosPost = vi.fn()
vi.mock("@/api/client", () => ({
default: {
post: (...args: unknown[]) => mockPost(...args),
get: (...args: unknown[]) => mockGet(...args),
defaults: { baseURL: "/api/v1" },
},
}))
vi.mock("axios", () => ({
default: {
post: (...args: unknown[]) => mockAxiosPost(...args),
},
post: (...args: unknown[]) => mockAxiosPost(...args),
}))
vi.mock("antd", () => ({ message: { error: vi.fn(), success: vi.fn() } }))
import { describe, expect, it } from "vitest"
import { normalizeUser } from "@/api/auth"
describe("normalizeUser", () => {
it("normalizes canonical API current-user fields", () => {
@@ -75,192 +44,4 @@ describe("normalizeUser", () => {
created_at: "2026-06-22T00:00:00Z",
})
})
it("prefers id over user_id when both present", () => {
const result = normalizeUser({
id: "id-first",
user_id: "userid-second",
email: "test@test.com",
username: "test",
display_name: "Test",
})
expect(result.id).toBe("id-first")
expect(result.user_id).toBe("id-first")
})
it("prefers is_email_verified over email_verified", () => {
const result = normalizeUser({
email: "test@test.com",
username: "test",
display_name: "Test",
is_email_verified: true,
email_verified: false,
})
expect(result.is_email_verified).toBe(true)
expect(result.email_verified).toBe(true)
})
it("defaults email verified to false when both missing", () => {
const result = normalizeUser({
email: "test@test.com",
username: "test",
display_name: "Test",
})
expect(result.is_email_verified).toBe(false)
expect(result.email_verified).toBe(false)
})
})
describe("auth API functions", () => {
beforeEach(() => {
vi.clearAllMocks()
mockPost.mockResolvedValue({ data: { success: true } })
mockGet.mockResolvedValue({ data: {} })
mockAxiosPost.mockResolvedValue({ data: { access_token: "tok" } })
})
describe("login", () => {
it("calls login API with correct params", async () => {
mockPost.mockResolvedValue({
data: { access_token: "acc", refresh_token: "ref", user_id: "1" },
})
const result = await login({ email: "test@test.com", password: "pass" })
expect(mockPost).toHaveBeenCalledWith("/auth/login", {
email: "test@test.com",
password: "pass",
})
expect(result.access_token).toBe("acc")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("login failed"))
await expect(login({ email: "t", password: "p" })).rejects.toThrow()
})
})
describe("register", () => {
it("calls register API", async () => {
mockPost.mockResolvedValue({ data: { message: "ok" } })
const result = await register({
email: "test@test.com",
password: "pass",
username: "testuser",
})
expect(mockPost).toHaveBeenCalledWith("/auth/register", {
email: "test@test.com",
password: "pass",
username: "testuser",
})
expect(result.message).toBe("ok")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("register failed"))
await expect(register({ email: "t", password: "p", username: "u" })).rejects.toThrow()
})
})
describe("logout", () => {
it("calls logout API", async () => {
mockPost.mockResolvedValue({ data: {} })
await logout()
expect(mockPost).toHaveBeenCalledWith("/auth/logout")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("logout failed"))
await expect(logout()).rejects.toThrow()
})
})
describe("getCurrentUser", () => {
it("fetches and normalizes user", async () => {
mockGet.mockResolvedValue({
data: {
user_id: "u1",
email: "user@test.com",
username: "user1",
display_name: "User One",
email_verified: true,
},
})
const result = await getCurrentUser()
expect(mockGet).toHaveBeenCalledWith("/auth/me")
expect(result.id).toBe("u1")
expect(result.email).toBe("user@test.com")
expect(result.is_email_verified).toBe(true)
})
it("rejects on error", async () => {
mockGet.mockRejectedValue(new Error("fetch failed"))
await expect(getCurrentUser()).rejects.toThrow()
})
})
describe("refreshAccessToken", () => {
it("calls refresh endpoint with raw axios", async () => {
mockAxiosPost.mockResolvedValue({
data: { access_token: "new-acc", refresh_token: "new-ref" },
})
const result = await refreshAccessToken("old-refresh")
expect(mockAxiosPost).toHaveBeenCalledWith("/api/v1/auth/refresh", {
refresh_token: "old-refresh",
})
expect(result.access_token).toBe("new-acc")
})
it("rejects on error", async () => {
mockAxiosPost.mockRejectedValue(new Error("refresh failed"))
await expect(refreshAccessToken("tok")).rejects.toThrow()
})
})
describe("requestPasswordReset", () => {
it("calls forgot-password API", async () => {
mockPost.mockResolvedValue({ data: { message: "sent" } })
const result = await requestPasswordReset("test@test.com")
expect(mockPost).toHaveBeenCalledWith("/auth/forgot-password", {
email: "test@test.com",
})
expect(result.message).toBe("sent")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("failed"))
await expect(requestPasswordReset("e")).rejects.toThrow()
})
})
describe("resetPassword", () => {
it("calls reset-password API", async () => {
mockPost.mockResolvedValue({ data: { message: "reset ok" } })
const result = await resetPassword("token123", "newpass")
expect(mockPost).toHaveBeenCalledWith("/auth/reset-password", {
token: "token123",
new_password: "newpass",
})
expect(result.message).toBe("reset ok")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("failed"))
await expect(resetPassword("t", "p")).rejects.toThrow()
})
})
describe("verifyEmail", () => {
it("calls verify-email API", async () => {
mockPost.mockResolvedValue({ data: { message: "verified" } })
const result = await verifyEmail("verify-token")
expect(mockPost).toHaveBeenCalledWith("/auth/verify-email", {
token: "verify-token",
})
expect(result.message).toBe("verified")
})
it("rejects on error", async () => {
mockPost.mockRejectedValue(new Error("verify failed"))
await expect(verifyEmail("t")).rejects.toThrow()
})
})
})
-324
View File
@@ -1,324 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
vi.mock("antd", () => ({
message: { error: vi.fn(), success: vi.fn() },
}))
vi.mock("@/store/authStore", () => ({
useAuthStore: {
getState: vi.fn(() => ({
user: { id: "1", email: "test@test.com" },
accessToken: "old-access",
refreshToken: "old-refresh",
clearAuth: vi.fn(),
setAuth: vi.fn(),
})),
},
}))
vi.mock("@/api/auth", () => ({
refreshAccessToken: vi.fn(),
}))
import { message } from "antd"
import { useAuthStore } from "@/store/authStore"
import { refreshAccessToken } from "@/api/auth"
import apiClient from "@/api/client"
// 从真实实例取出拦截器回调
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestHandlers = (apiClient as any).interceptors.request.handlers as Array<{
fulfilled: (config: unknown) => unknown
rejected: (error: unknown) => unknown
}>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseHandlers = (apiClient as any).interceptors.response.handlers as Array<{
fulfilled: (response: unknown) => unknown
rejected: (error: unknown) => Promise<unknown>
}>
const requestInterceptor = requestHandlers[0]?.fulfilled!
const requestErrorInterceptor = requestHandlers[0]?.rejected!
const responseInterceptor = responseHandlers[0]?.fulfilled!
const responseErrorInterceptor = responseHandlers[0]?.rejected!
function makeAxiosError(status?: number, data?: unknown, code?: string, hasResponse = true) {
const err = {
config: { headers: {} },
message: "error",
} as {
config: { headers: Record<string, string>; _retry?: boolean; url?: string }
response?: { status: number; data: unknown }
code?: string
message: string
}
if (hasResponse && status !== undefined) {
err.response = { status, data }
}
if (code) err.code = code
return err
}
describe("apiClient", () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
})
})
describe("request interceptor", () => {
it("adds Authorization header when token exists", () => {
localStorage.setItem("access_token", "test-token")
const config = { headers: {} }
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
expect(result.headers.Authorization).toBe("Bearer test-token")
})
it("skips Authorization header when no token", () => {
const config = { headers: {} }
const result = requestInterceptor(config) as { headers: { Authorization?: string } }
expect(result.headers.Authorization).toBeUndefined()
})
it("rejects on request error", async () => {
const error = new Error("request error")
await expect(requestErrorInterceptor(error) as Promise<never>).rejects.toThrow(
"request error",
)
})
})
describe("response interceptor - success", () => {
it("passes through successful response", () => {
const response = { data: { success: true }, status: 200 }
expect(responseInterceptor(response)).toBe(response)
})
})
describe("response interceptor - timeout & network", () => {
it("shows timeout message for ECONNABORTED", async () => {
const err = makeAxiosError(undefined, undefined, "ECONNABORTED")
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
})
it("shows timeout message for timeout string", async () => {
const err = { ...makeAxiosError(), message: "timeout of 10000ms exceeded" }
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("请求超时,请检查网络后重试")
})
it("shows network error when no response", async () => {
const err = makeAxiosError(undefined, undefined, undefined, false)
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("网络连接异常,请检查网络设置")
})
})
describe("response interceptor - server error messages", () => {
it("shows detail field", async () => {
const err = makeAxiosError(400, { detail: "参数错误" })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("参数错误")
})
it("shows message field", async () => {
const err = makeAxiosError(400, { message: "操作失败" })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("操作失败")
})
it("shows msg field", async () => {
const err = makeAxiosError(400, { msg: "出错了" })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("出错了")
})
it("handles nested message object", async () => {
const err = makeAxiosError(400, { message: { message: "深层错误" } })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("深层错误")
})
it("handles nested msg object", async () => {
const err = makeAxiosError(400, { msg: { msg: "嵌套错误" } })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("嵌套错误")
})
it("stringifies object with no string fields", async () => {
const err = makeAxiosError(400, { detail: { code: 123, foo: "bar" } })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith('{"code":123,"foo":"bar"}')
})
it("marks __msgShown when message displayed", async () => {
const err = makeAxiosError(400, { detail: "test" }) as {
config: { headers: Record<string, string> }
response: { status: number; data: { detail: string } }
message: string
__msgShown?: boolean
}
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(err.__msgShown).toBe(true)
})
})
describe("response interceptor - HTTP status codes", () => {
it("shows file too large for 413", async () => {
const err = makeAxiosError(413, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("文件过大,请缩小后重试")
})
it("shows unsupported format for 415", async () => {
const err = makeAxiosError(415, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("不支持的文件格式")
})
it("shows service unavailable for 503", async () => {
const err = makeAxiosError(503, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("服务暂不可用,请稍后再试")
})
it("shows server busy for 500", async () => {
const err = makeAxiosError(500, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
})
it("shows server busy for 502", async () => {
const err = makeAxiosError(502, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("服务器繁忙,请稍后再试")
})
it("no message for 4xx without server msg", async () => {
const err = makeAxiosError(403, {})
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).not.toHaveBeenCalled()
})
it("no __msgShown for unhandled 4xx", async () => {
const err = makeAxiosError(403, {}) as {
config: { headers: Record<string, string> }
response: { status: number; data: Record<string, never> }
message: string
__msgShown?: boolean
}
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(err.__msgShown).toBeUndefined()
})
})
describe("safeExtractString edge cases", () => {
it("returns empty string for numeric message", async () => {
const err = makeAxiosError(400, { message: 123 })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).not.toHaveBeenCalled()
})
it("returns empty string for null data", async () => {
const err = makeAxiosError(400, null)
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).not.toHaveBeenCalled()
})
it("handles detail with nested detail object", async () => {
const err = makeAxiosError(400, { detail: { detail: "nested detail" } })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(message.error).toHaveBeenCalledWith("nested detail")
})
})
})
describe("apiClient - 401 token refresh", () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
localStorage.setItem("access_token", "old-access")
localStorage.setItem("refresh_token", "old-refresh")
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
})
})
it("logs out when no refresh token on 401", async () => {
const mockClearAuth = vi.fn()
vi.mocked(useAuthStore.getState).mockReturnValue({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
clearAuth: mockClearAuth,
setAuth: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
const err = makeAxiosError(401, { detail: "Unauthorized" })
await expect(responseErrorInterceptor(err) as Promise<never>).rejects.toThrow()
expect(mockClearAuth).toHaveBeenCalled()
expect(window.location.href).toBe("/")
})
it("refreshes token on 401 and calls setAuth", async () => {
const mockSetAuth = vi.fn()
vi.mocked(useAuthStore.getState).mockReturnValue({
user: { id: "1", email: "test@test.com" },
accessToken: "old-access",
refreshToken: "old-refresh",
isAuthenticated: true,
clearAuth: vi.fn(),
setAuth: mockSetAuth,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
vi.mocked(refreshAccessToken).mockResolvedValue({
access_token: "new-access",
refresh_token: "new-refresh",
} as never)
// 拦截器重试时会调用 apiClient(config),会真的发请求,最终会 reject
// 但我们只关心刷新逻辑是否正确执行
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
await responseErrorInterceptor(err)
} catch {
// 重试会因为没有真实网络而失败,忽略
}
expect(refreshAccessToken).toHaveBeenCalledWith("old-refresh")
expect(mockSetAuth).toHaveBeenCalled()
})
it("handles refresh failure by logging out", async () => {
const mockClearAuth = vi.fn()
vi.mocked(useAuthStore.getState).mockReturnValue({
user: { id: "1", email: "test@test.com" },
accessToken: "old-access",
refreshToken: "old-refresh",
isAuthenticated: true,
clearAuth: mockClearAuth,
setAuth: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
vi.mocked(refreshAccessToken).mockRejectedValue(new Error("refresh failed") as never)
const err = makeAxiosError(401, { detail: "Unauthorized" })
try {
await responseErrorInterceptor(err)
} catch {
// expected
}
expect(mockClearAuth).toHaveBeenCalled()
expect(window.location.href).toBe("/")
})
})
@@ -1,39 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import CloneModal from "@/components/voice/CloneModal"
vi.mock("@/api/voiceClone", () => ({
createVoiceClone: vi.fn(),
toVoiceClone: vi.fn(),
}))
vi.mock("@/api/assets", () => ({
uploadAsset: vi.fn(),
}))
vi.mock("@/components/ui", () => ({
Modal: ({ open, children, onCancel, onOk, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Button: ({ children, onClick, disabled, buttonType }: any) =>
React.createElement("button", { onClick, disabled, "data-type": buttonType }, children),
}))
describe("CloneModal", () => {
it("should render when closed", () => {
const { container } = render(<CloneModal open={false} onClose={vi.fn()} />)
expect(container).toBeTruthy()
})
it("should render input phase when open", () => {
const { container } = render(<CloneModal open={true} onClose={vi.fn()} />)
expect(container).toBeTruthy()
})
it("should call onClose when cancel", () => {
const onClose = vi.fn()
render(<CloneModal open={true} onClose={onClose} />)
// just verify render doesn't crash
expect(onClose).toBeDefined()
})
})
@@ -1,148 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title }: { title: string }) => <div data-testid="page-head">{title}</div>,
}))
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: { items: [], total: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
useInfiniteQuery: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})),
}))
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
Select: () => <select />,
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
Empty: () => <div>Empty</div>,
Card: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Tooltip: ({ children }: any) => <span>{children}</span>,
Upload: ({ children }: any) => <div>{children}</div>,
Progress: () => <div />,
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
}))
vi.mock("antd", () => ({
Table: () => <div />,
Pagination: () => <div />,
Tabs: () => <div />,
Modal: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
Popconfirm: ({ children }: any) => <span>{children}</span>,
Form: ({ children }: any) => <form>{children}</form>,
Input: ({ placeholder }: any) => <input placeholder={placeholder} />,
InputNumber: () => <input type="number" />,
Select: () => <select />,
Empty: () => <div>Empty</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
Badge: ({ children }: any) => <span>{children}</span>,
Tooltip: ({ children }: any) => <span>{children}</span>,
Upload: { Dragger: ({ children }: any) => <div>{children}</div> },
Progress: () => <div />,
Switch: () => <input type="checkbox" />,
Radio: ({ children }: any) => <span>{children}</span>,
RadioGroup: ({ children }: any) => <div>{children}</div>,
Drawer: ({ open, children }: any) => (open ? <div role="dialog">{children}</div> : null),
Popover: ({ children }: any) => <span>{children}</span>,
Divider: () => <hr />,
Dropdown: ({ children }: any) => <span>{children}</span>,
Menu: () => <div />,
Checkbox: ({ children }: any) => <span>{children}</span>,
List: () => <div />,
Avatar: ({ children }: any) => <span>{children}</span>,
Button: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
Result: ({ status, title }: any) => <div data-status={status}>{title}</div>,
Spin: () => <div>Loading</div>,
}))
vi.mock("@ant-design/icons", () => ({
PlusOutlined: () => <span>+</span>,
SearchOutlined: () => <span>S</span>,
InboxOutlined: () => <span>I</span>,
VideoCameraOutlined: () => <span>V</span>,
PictureOutlined: () => <span>P</span>,
PlayCircleOutlined: () => <span></span>,
CheckOutlined: () => <span></span>,
DeleteOutlined: () => <span>×</span>,
ExperimentOutlined: () => <span>E</span>,
LoadingOutlined: () => <span>L</span>,
ExclamationCircleOutlined: () => <span>!</span>,
TagsOutlined: () => <span>T</span>,
EditOutlined: () => <span>E</span>,
DownloadOutlined: () => <span>D</span>,
MoreOutlined: () => <span>M</span>,
FolderOutlined: () => <span>F</span>,
FolderAddOutlined: () => <span>FA</span>,
UploadOutlined: () => <span>U</span>,
AudioOutlined: () => <span>A</span>,
}))
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn().mockResolvedValue({ items: [] }),
createAssetLibrary: vi.fn().mockResolvedValue({}),
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
deleteAsset: vi.fn().mockResolvedValue({}),
uploadAssetDirect: vi.fn().mockResolvedValue({}),
getAssetDiagnosis: vi.fn().mockResolvedValue({}),
batchDeleteAssets: vi.fn().mockResolvedValue({}),
batchTagAssets: vi.fn().mockResolvedValue({}),
batchClassifyAssets: vi.fn().mockResolvedValue({}),
batchMarkAssets: vi.fn().mockResolvedValue({}),
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio" },
}))
vi.mock("@/api/tags", () => ({
getTags: vi.fn().mockResolvedValue({ items: [] }),
createTag: vi.fn().mockResolvedValue({}),
tagAsset: vi.fn().mockResolvedValue({}),
untagAsset: vi.fn().mockResolvedValue({}),
}))
import AssetLibrary from "@/pages/assets/AssetLibrary"
describe("AssetLibrary", () => {
it("renders without crashing", () => {
const { container } = render(
<MemoryRouter>
<AssetLibrary />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
it("shows empty state when no assets", () => {
const { getByText } = render(
<MemoryRouter>
<AssetLibrary />
</MemoryRouter>,
)
// 空状态文案应该出现
expect(getByText(/暂无素材/)).toBeTruthy()
})
})
+3 -16
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, act, cleanup } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
vi.mock("@/components/layout/PageHead", () => ({
@@ -13,25 +13,12 @@ vi.mock("@/components/layout/PageHead", () => ({
import Billing from "@/pages/subscription/Billing"
describe("Billing Page", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
cleanup()
vi.useRealTimers()
})
it("should render without crashing", async () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<Billing />
</MemoryRouter>,
)
// 跑完所有pending的timers和microtasks,确保异步状态更新都执行完
await act(async () => {
await vi.runAllTimersAsync()
})
expect(container).toBeTruthy()
})
})
@@ -1,74 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import EditPlans from "@/pages/edit-plans/EditPlans"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
}
})
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn().mockImplementation((opts: any) => {
const key = opts?.queryKey?.[0] || ""
if (key === "templates-list-simple") {
return { data: [], isLoading: false, isError: false, refetch: vi.fn() }
}
return {
data: { items: [], total: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
}
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}))
vi.mock("@ant-design/icons", () => ({
CheckCircleOutlined: () => <span>CheckCircleOutlined</span>,
ClockCircleOutlined: () => <span>ClockCircleOutlined</span>,
SyncOutlined: () => <span>SyncOutlined</span>,
CloseCircleOutlined: () => <span>CloseCircleOutlined</span>,
EditOutlined: () => <span>EditOutlined</span>,
DeleteOutlined: () => <span>DeleteOutlined</span>,
FileTextOutlined: () => <span>FileTextOutlined</span>,
ThunderboltOutlined: () => <span>ThunderboltOutlined</span>,
CopyOutlined: () => <span>CopyOutlined</span>,
UnorderedListOutlined: () => <span>UnorderedListOutlined</span>,
StopOutlined: () => <span>StopOutlined</span>,
}))
vi.mock("@/api/templates", () => ({
getTemplatesList: vi.fn().mockResolvedValue([]),
}))
vi.mock("@/api/editPlans", () => ({
getEditPlans: vi.fn().mockResolvedValue({ items: [], total: 0 }),
deleteEditPlan: vi.fn(),
generateEditPlan: vi.fn(),
cancelGeneration: vi.fn(),
copyEditPlan: vi.fn(),
}))
describe("EditPlans", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<EditPlans />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -1,327 +0,0 @@
import React from "react"
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, act } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
// === React Query mock ===
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
}))
// === Ant Design Icons mock ===
vi.mock("@ant-design/icons", () => ({
VideoCameraOutlined: () => React.createElement("span", null, "V"),
PictureOutlined: () => React.createElement("span", null, "P"),
SoundOutlined: () => React.createElement("span", null, "S"),
PlusOutlined: () => React.createElement("span", null, "+"),
DeleteOutlined: () => React.createElement("span", null, "D"),
EditOutlined: () => React.createElement("span", null, "E"),
CopyOutlined: () => React.createElement("span", null, "C"),
DownloadOutlined: () => React.createElement("span", null, "D"),
PlayCircleOutlined: () => React.createElement("span", null, ">"),
PauseCircleOutlined: () => React.createElement("span", null, "||"),
LeftOutlined: () => React.createElement("span", null, "<"),
RightOutlined: () => React.createElement("span", null, ">"),
UpOutlined: () => React.createElement("span", null, "^"),
DownOutlined: () => React.createElement("span", null, "v"),
SaveOutlined: () => React.createElement("span", null, "S"),
UndoOutlined: () => React.createElement("span", null, "U"),
RedoOutlined: () => React.createElement("span", null, "R"),
CloseOutlined: () => React.createElement("span", null, "X"),
CheckOutlined: () => React.createElement("span", null, "v"),
SettingOutlined: () => React.createElement("span", null, "S"),
AppstoreOutlined: () => React.createElement("span", null, "#"),
UnorderedListOutlined: () => React.createElement("span", null, "="),
HistoryOutlined: () => React.createElement("span", null, "H"),
UploadOutlined: () => React.createElement("span", null, "U"),
SearchOutlined: () => React.createElement("span", null, "S"),
FilterOutlined: () => React.createElement("span", null, "F"),
FontColorsOutlined: () => React.createElement("span", null, "A"),
BgColorsOutlined: () => React.createElement("span", null, "B"),
AudioOutlined: () => React.createElement("span", null, "A"),
MusicOutlined: () => React.createElement("span", null, "M"),
ScissorOutlined: () => React.createElement("span", null, "X"),
ThunderboltOutlined: () => React.createElement("span", null, "T"),
ExperimentOutlined: () => React.createElement("span", null, "E"),
BulbOutlined: () => React.createElement("span", null, "B"),
FundOutlined: () => React.createElement("span", null, "F"),
LayoutOutlined: () => React.createElement("span", null, "L"),
ColumnHeightOutlined: () => React.createElement("span", null, "C"),
SwapOutlined: () => React.createElement("span", null, "S"),
}))
// === Ant Design mock ===
vi.mock("antd", () => ({
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
Modal: ({ open, children, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Progress: () => React.createElement("div"),
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Tabs: ({ items }: any) =>
React.createElement(
"div",
null,
items?.map?.(() => React.createElement("div")),
),
TabPane: () => React.createElement("div"),
Drawer: ({ open, children, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Select: ({ children }: any) => React.createElement("select", null, children),
Option: ({ children }: any) => React.createElement("option", null, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
InputNumber: () => React.createElement("input", { type: "number" }),
Switch: () => React.createElement("input", { type: "checkbox" }),
Slider: () => React.createElement("div"),
ColorPicker: () => React.createElement("div"),
Upload: ({ children }: any) => React.createElement("div", null, children),
Space: ({ children }: any) => React.createElement("div", null, children),
Row: ({ children }: any) => React.createElement("div", null, children),
Col: ({ children }: any) => React.createElement("div", null, children),
Card: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Popover: ({ children }: any) => React.createElement("span", null, children),
Dropdown: ({ children }: any) => React.createElement("span", null, children),
Menu: () => React.createElement("div"),
Divider: () => React.createElement("hr"),
Empty: () => React.createElement("div", null, "Empty"),
Spin: () => React.createElement("div", null, "Loading"),
Badge: ({ children }: any) => React.createElement("span", null, children),
Avatar: ({ children }: any) => React.createElement("span", null, children),
Checkbox: ({ children }: any) => React.createElement("span", null, children),
Radio: ({ children }: any) => React.createElement("span", null, children),
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
Segmented: () => React.createElement("div"),
Collapse: ({ children }: any) => React.createElement("div", null, children),
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
Form: ({ children }: any) => React.createElement("form", null, children),
FormItem: ({ children }: any) => React.createElement("div", null, children),
List: () => React.createElement("div"),
Table: () => React.createElement("div"),
Pagination: () => React.createElement("div"),
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
}))
// === UI Components mock ===
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
Modal: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Drawer: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Empty: () => React.createElement("div", null, "Empty"),
Card: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Select: ({ children }: any) => React.createElement("select", null, children),
Progress: () => React.createElement("div"),
Upload: ({ children }: any) => React.createElement("div", null, children),
}))
// === API mocks ===
vi.mock("@/api/editingPlanner", () => ({
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getEditingTemplate: vi.fn().mockResolvedValue({}),
createEditingTemplate: vi.fn().mockResolvedValue({}),
updateEditingTemplate: vi.fn().mockResolvedValue({}),
getTemplateCategories: vi.fn().mockResolvedValue({ items: [] }),
MODE_LABELS: { pip: "画中画", intro_outro: "片头片尾", watermark: "水印" },
}))
vi.mock("@/api/editPlans", () => ({
getMediaAssets: vi.fn().mockResolvedValue({ items: [] }),
getEditPlanGenerations: vi.fn().mockResolvedValue({ items: [] }),
generateCover: vi.fn().mockResolvedValue({}),
getEditPlan: vi.fn().mockResolvedValue({}),
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
getGenerationStatus: vi.fn().mockResolvedValue({ status: "completed" }),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
cancelGeneration: vi.fn().mockResolvedValue({}),
getEditPlanClips: vi.fn().mockResolvedValue({ items: [] }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({}),
}))
vi.mock("@/api/assets", () => ({
ensureDefaultLibrary: vi.fn().mockResolvedValue({ id: "default-lib" }),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/projects", () => ({
getOrCreateDefaultProject: vi.fn().mockResolvedValue({ id: "default-project" }),
}))
vi.mock("@/api/bgm", () => ({
DEFAULT_BGM_MIX_CONFIG: { volume: 1, fade_in: 0, fade_out: 0 },
}))
vi.mock("@/pages/editing-planner/components/MediaPanel", () => ({
default: () => React.createElement("div", { "data-testid": "MediaPanel" }),
}))
vi.mock("@/pages/editing-planner/components/PreviewPlayer", () => ({
default: () => React.createElement("div", { "data-testid": "PreviewPlayer" }),
}))
vi.mock("@/pages/editing-planner/components/TimelinePanel", () => ({
default: () => React.createElement("div", { "data-testid": "TimelinePanel" }),
}))
vi.mock("@/pages/editing-planner/components/ClipPropertiesPanel", () => ({
default: () => React.createElement("div", { "data-testid": "ClipPropertiesPanel" }),
}))
vi.mock("@/pages/editing-planner/components/EditorClipList", () => ({
default: () => React.createElement("div", { "data-testid": "EditorClipList" }),
}))
vi.mock("@/pages/editing-planner/components/BgmSelector", () => ({
default: () => React.createElement("div", { "data-testid": "BgmSelector" }),
}))
vi.mock("@/pages/editing-planner/components/SubtitleStylePanel", () => ({
default: () => React.createElement("div", { "data-testid": "SubtitleStylePanel" }),
}))
vi.mock("@/pages/editing-planner/components/TransitionSelector", () => ({
default: () => React.createElement("div", { "data-testid": "TransitionSelector" }),
}))
vi.mock("@/pages/editing-planner/components/SpeedPanel", () => ({
default: () => React.createElement("div", { "data-testid": "SpeedPanel" }),
}))
vi.mock("@/pages/editing-planner/components/TtsPanel", () => ({
default: () => React.createElement("div", { "data-testid": "TtsPanel" }),
}))
vi.mock("@/pages/editing-planner/components/WatermarkPanel", () => ({
default: () => React.createElement("div", { "data-testid": "WatermarkPanel" }),
}))
vi.mock("@/pages/editing-planner/components/IntroOutroPanel", () => ({
default: () => React.createElement("div", { "data-testid": "IntroOutroPanel" }),
}))
vi.mock("@/pages/editing-planner/components/PipConfigPanel", () => ({
default: () => React.createElement("div", { "data-testid": "PipConfigPanel" }),
}))
vi.mock("@/pages/editing-planner/components/FilterPanel", () => ({
default: () => React.createElement("div", { "data-testid": "FilterPanel" }),
}))
vi.mock("@/pages/editing-planner/components/GreenScreenPanel", () => ({
default: () => React.createElement("div", { "data-testid": "GreenScreenPanel" }),
}))
vi.mock("@/pages/editing-planner/components/StickerPanel", () => ({
default: () => React.createElement("div", { "data-testid": "StickerPanel" }),
}))
vi.mock("@/pages/editing-planner/components/CoverSelector", () => ({
default: () => React.createElement("div", { "data-testid": "CoverSelector" }),
}))
vi.mock("@/pages/editing-planner/components/SaveModal", () => ({
default: () => React.createElement("div", { "data-testid": "SaveModal" }),
}))
vi.mock("@/pages/editing-planner/components/GenerationHistoryModal", () => ({
default: () => React.createElement("div", { "data-testid": "GenerationHistoryModal" }),
}))
vi.mock("@/pages/editing-planner/components/GenerationProgressModal", () => ({
default: () => React.createElement("div", { "data-testid": "GenerationProgressModal" }),
}))
// === useUndoRedo hook mock ===
vi.mock("@/pages/editing-planner/hooks/useUndoRedo", () => ({
useUndoRedo: vi.fn((initial: any) => ({
state: initial,
setState: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
canUndo: false,
canRedo: false,
reset: vi.fn(),
})),
}))
// === Types mock ===
vi.mock("@/pages/editing-planner/types", () => ({
DEFAULT_TRANSITION: { type: "fade", duration: 0.5 },
DEFAULT_SPEED: { rate: 1 },
DEFAULT_TTS_CONFIG: { enabled: false },
DEFAULT_WATERMARK: { enabled: false },
DEFAULT_INTRO_OUTRO: { enabled: false },
DEFAULT_PIP_CONFIG: { enabled: false },
DEFAULT_FILTER_CONFIG: { enabled: false },
DEFAULT_CHROMA_KEY_CONFIG: { enabled: false },
DEFAULT_STICKER_CONFIG: { enabled: false },
DEFAULT_COVER_CONFIG: { enabled: false },
}))
vi.mock("@/pages/editing-planner/types/subtitle", () => ({
DEFAULT_SUBTITLE_STYLE: {
font_size: 24,
font_color: "#ffffff",
background_color: "#000000",
},
}))
// === PageHead mock ===
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title }: { title: string }) =>
React.createElement("div", { "data-testid": "page-head" }, title),
}))
import EditingPlanner from "@/pages/editing-planner/EditingPlanner"
describe("EditingPlanner", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("renders without crashing", () => {
const { container } = render(
React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)),
)
expect(container).toBeTruthy()
})
it("renders with templateId query param", () => {
const { container } = render(
React.createElement(
MemoryRouter,
{ initialEntries: ["?templateId=tpl-123"] },
React.createElement(EditingPlanner),
),
)
expect(container).toBeTruthy()
})
it("renders with planId query param", () => {
const { container } = render(
React.createElement(
MemoryRouter,
{ initialEntries: ["?planId=plan-456"] },
React.createElement(EditingPlanner),
),
)
expect(container).toBeTruthy()
})
it("advances timers without errors", () => {
render(React.createElement(MemoryRouter, null, React.createElement(EditingPlanner)))
act(() => {
vi.advanceTimersByTime(10000)
})
expect(true).toBe(true)
})
})
@@ -1,287 +0,0 @@
import React from "react"
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, act } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
// Hoisted mock icons factory
const hoistedIcons = vi.hoisted(() => {
const iconNames = [
"AudioOutlined",
"ThunderboltOutlined",
"CheckCircleFilled",
"CheckCircleOutlined",
"CloseCircleOutlined",
"LoadingOutlined",
"PlayCircleOutlined",
"PauseCircleOutlined",
"DownloadOutlined",
"ShareAltOutlined",
"SaveOutlined",
"PlusOutlined",
"MinusOutlined",
"CloseOutlined",
"SearchOutlined",
"EditOutlined",
"DeleteOutlined",
"UploadOutlined",
"FolderOutlined",
"FolderAddOutlined",
"MoreOutlined",
"ExperimentOutlined",
"ExclamationCircleOutlined",
"InboxOutlined",
"VideoCameraOutlined",
"PictureOutlined",
"SoundOutlined",
"UserOutlined",
"ManOutlined",
"WomanOutlined",
"TagsOutlined",
"MutedOutlined",
"RobotOutlined",
"UnorderedListOutlined",
"AppstoreOutlined",
"UndoOutlined",
"RedoOutlined",
"SettingOutlined",
"HistoryOutlined",
"FilterOutlined",
"FontColorsOutlined",
"BgColorsOutlined",
"MusicOutlined",
"ScissorOutlined",
"BulbOutlined",
"FundOutlined",
"LayoutOutlined",
"ColumnHeightOutlined",
"SwapOutlined",
"LeftOutlined",
"RightOutlined",
"UpOutlined",
"DownOutlined",
"CopyOutlined",
]
const icons: Record<string, React.FC> = {}
iconNames.forEach((name) => {
icons[name] = () => React.createElement("span", null, name.charAt(0))
})
return icons
})
// === React Query mock ===
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
useInfiniteQuery: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})),
}))
// === Ant Design Icons mock ===
vi.mock("@ant-design/icons", () => hoistedIcons)
// === Ant Design mock ===
vi.mock("antd", () => {
const Typography = {
Text: ({ children }: any) => React.createElement("span", null, children),
Title: ({ children }: any) => React.createElement("h1", null, children),
Paragraph: ({ children }: any) => React.createElement("p", null, children),
}
return {
Typography,
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
Modal: ({ open, children, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Progress: () => React.createElement("div"),
Popover: ({ children }: any) => React.createElement("span", null, children),
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Tabs: () => React.createElement("div"),
Drawer: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Upload: ({ children }: any) => React.createElement("div", null, children),
Slider: () => React.createElement("div"),
Switch: () => React.createElement("input", { type: "checkbox" }),
Segmented: () => React.createElement("div"),
Spin: () => React.createElement("div", null, "Loading"),
Empty: () => React.createElement("div", null, "Empty"),
Divider: () => React.createElement("hr"),
Space: ({ children }: any) => React.createElement("div", null, children),
Dropdown: ({ children }: any) => React.createElement("span", null, children),
Menu: () => React.createElement("div"),
Badge: ({ children }: any) => React.createElement("span", null, children),
Radio: ({ children }: any) => React.createElement("span", null, children),
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
Checkbox: ({ children }: any) => React.createElement("span", null, children),
InputNumber: () => React.createElement("input", { type: "number" }),
Form: ({ children }: any) => React.createElement("form", null, children),
FormItem: ({ children }: any) => React.createElement("div", null, children),
Result: ({ status, title }: any) =>
React.createElement("div", { "data-status": status }, title),
List: () => React.createElement("div"),
Table: () => React.createElement("div"),
Pagination: () => React.createElement("div"),
Card: ({ children }: any) => React.createElement("div", null, children),
Avatar: ({ children }: any) => React.createElement("span", null, children),
Collapse: ({ children }: any) => React.createElement("div", null, children),
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
Select: ({ children }: any) => React.createElement("select", null, children),
Option: ({ children }: any) => React.createElement("option", null, children),
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
Steps: () => React.createElement("div"),
Step: () => React.createElement("div"),
}
})
// === UI Components mock ===
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
Modal: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Drawer: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Empty: () => React.createElement("div", null, "Empty"),
Card: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Select: ({ children }: any) => React.createElement("select", null, children),
Progress: () => React.createElement("div"),
Upload: ({ children }: any) => React.createElement("div", null, children),
Slider: () => React.createElement("div"),
Switch: () => React.createElement("input", { type: "checkbox" }),
}))
// === API mocks ===
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn().mockResolvedValue([]),
createAssetLibrary: vi.fn().mockResolvedValue({}),
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
createAsset: vi.fn().mockResolvedValue({}),
updateAsset: vi.fn().mockResolvedValue({}),
deleteAsset: vi.fn().mockResolvedValue({}),
uploadAssetDirect: vi.fn().mockResolvedValue({}),
batchDeleteAssets: vi.fn().mockResolvedValue({}),
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
}))
vi.mock("@/api/tags", () => ({
getTags: vi.fn().mockResolvedValue({ items: [] }),
createTag: vi.fn().mockResolvedValue({}),
tagAsset: vi.fn().mockResolvedValue({}),
untagAsset: vi.fn().mockResolvedValue({}),
}))
vi.mock("@/api/tts", () => ({
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
}))
vi.mock("@/api/voices", () => ({
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/editingPlanner", () => ({
getEditingTemplates: vi.fn().mockResolvedValue({ items: [], total: 0 }),
MODE_LABELS: { pip: "画中画" },
}))
vi.mock("@/api/titles", () => ({
getTitles: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/editPlans", () => ({
createEditPlan: vi.fn().mockResolvedValue({ id: "test-plan" }),
generateEditPlan: vi.fn().mockResolvedValue({ task_id: "test-task" }),
updateEditPlan: vi.fn().mockResolvedValue({}),
getEditPlan: vi.fn().mockResolvedValue({}),
getGenerationTaskResults: vi.fn().mockResolvedValue({ items: [] }),
}))
vi.mock("@/api/voiceClone", () => ({
formatDuration: vi.fn((s: number) => `${s}s`),
}))
vi.mock("@/api/client", () => ({
default: {
interceptors: { request: { handlers: [] }, response: { handlers: [] } },
get: vi.fn().mockResolvedValue({ data: {} }),
post: vi.fn().mockResolvedValue({ data: {} }),
put: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} }),
},
}))
// === Hooks mock ===
vi.mock("@/hooks/useCloneProgress", () => ({
useCloneProgress: vi.fn(() => ({
progress: 0,
status: "idle",
start: vi.fn(),
reset: vi.fn(),
})),
}))
// === Components mock ===
vi.mock("@/components/voice/CloneModal", () => ({
default: () => React.createElement("div", { "data-testid": "CloneModal" }),
}))
// === PageHead mock ===
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title }: { title: string }) =>
React.createElement("div", { "data-testid": "page-head" }, title),
}))
// CSS mock
vi.mock("@/pages/generate/generate.css", () => ({}))
import GeneratePage from "@/pages/generate/GeneratePage"
describe("GeneratePage", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("renders without crashing", () => {
const { container } = render(
React.createElement(MemoryRouter, null, React.createElement(GeneratePage)),
)
expect(container).toBeTruthy()
})
it("advances timers without errors", () => {
render(React.createElement(MemoryRouter, null, React.createElement(GeneratePage)))
act(() => {
vi.advanceTimersByTime(30000)
})
expect(true).toBe(true)
})
})
@@ -1,61 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import MyTemplates from "@/pages/my-templates/MyTemplates"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
}
})
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: [],
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}))
vi.mock("@ant-design/icons", () => ({
SearchOutlined: () => <span>SearchOutlined</span>,
EditOutlined: () => <span>EditOutlined</span>,
CopyOutlined: () => <span>CopyOutlined</span>,
DeleteOutlined: () => <span>DeleteOutlined</span>,
VideoCameraOutlined: () => <span>VideoCameraOutlined</span>,
AppstoreOutlined: () => <span>AppstoreOutlined</span>,
PlusOutlined: () => <span>PlusOutlined</span>,
}))
vi.mock("@/api/editingPlanner", () => ({
getEditingTemplates: vi.fn(),
getTemplateCategories: vi.fn().mockResolvedValue([]),
deleteEditingTemplate: vi.fn(),
createEditingTemplate: vi.fn(),
MODE_LABELS: { template: "模板", clip: "剪辑" } as Record<string, string>,
MODE_COLORS: { template: "blue", clip: "green" } as Record<string, string>,
}))
describe("MyTemplates", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<MyTemplates />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -1,60 +0,0 @@
import React from "react"
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import PlanClipsManager from "@/pages/edit-plans/PlanClipsManager"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ templateId: "test-123" }),
}
})
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: { clips: [], name: "Test Plan" },
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
}),
useQueryClient: () => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
}),
}))
vi.mock("@ant-design/icons", () => ({
ArrowLeftOutlined: () => <span>ArrowLeftOutlined</span>,
PlusOutlined: () => <span>PlusOutlined</span>,
DeleteOutlined: () => <span>DeleteOutlined</span>,
EditOutlined: () => <span>EditOutlined</span>,
UploadOutlined: () => <span>UploadOutlined</span>,
OrderedListOutlined: () => <span>OrderedListOutlined</span>,
SaveOutlined: () => <span>SaveOutlined</span>,
}))
vi.mock("@/api/editPlans", () => ({
getPlanClips: vi.fn().mockResolvedValue({ clips: [], name: "" }),
updatePlanClipsOrder: vi.fn(),
deletePlanClip: vi.fn(),
createPlanClip: vi.fn(),
}))
describe("PlanClipsManager", () => {
it("should render without crashing", () => {
const { container } = render(
<MemoryRouter>
<PlanClipsManager />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -1,225 +0,0 @@
import React from "react"
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"
import { render, act } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
// Hoisted mock icons factory - must be before all vi.mock calls
const hoistedIcons = vi.hoisted(() => {
const iconNames = [
"AudioOutlined",
"PlayCircleOutlined",
"PauseCircleOutlined",
"SearchOutlined",
"PlusOutlined",
"EditOutlined",
"DeleteOutlined",
"UploadOutlined",
"UnorderedListOutlined",
"AppstoreOutlined",
"CloseOutlined",
"SoundOutlined",
"UserOutlined",
"ManOutlined",
"WomanOutlined",
"CheckOutlined",
"TagsOutlined",
"MutedOutlined",
"RobotOutlined",
"LoadingOutlined",
"FolderOutlined",
"FolderAddOutlined",
"MoreOutlined",
"ExperimentOutlined",
"ExclamationCircleOutlined",
"DownloadOutlined",
"InboxOutlined",
"VideoCameraOutlined",
"PictureOutlined",
"SaveOutlined",
"UndoOutlined",
"RedoOutlined",
"SettingOutlined",
"HistoryOutlined",
"FilterOutlined",
"FontColorsOutlined",
"BgColorsOutlined",
"MusicOutlined",
"ScissorOutlined",
"ThunderboltOutlined",
"BulbOutlined",
"FundOutlined",
"LayoutOutlined",
"ColumnHeightOutlined",
"SwapOutlined",
"LeftOutlined",
"RightOutlined",
"UpOutlined",
"DownOutlined",
"CopyOutlined",
]
const icons: Record<string, React.FC> = {}
iconNames.forEach((name) => {
icons[name] = () => React.createElement("span", null, name.charAt(0))
})
return icons
})
// === React Query mock ===
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: undefined,
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
useInfiniteQuery: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
fetchNextPage: vi.fn(),
hasNextPage: false,
})),
}))
// === Ant Design Icons mock ===
vi.mock("@ant-design/icons", () => hoistedIcons)
// === Ant Design mock ===
vi.mock("antd", () => ({
message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
Modal: ({ open, children, title }: any) =>
open ? React.createElement("div", { role: "dialog", "data-title": title }, children) : null,
Progress: () => React.createElement("div"),
Popover: ({ children }: any) => React.createElement("span", null, children),
Popconfirm: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Tabs: () => React.createElement("div"),
Drawer: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Upload: ({ children }: any) => React.createElement("div", null, children),
Slider: () => React.createElement("div"),
Switch: () => React.createElement("input", { type: "checkbox" }),
Segmented: () => React.createElement("div"),
Spin: () => React.createElement("div", null, "Loading"),
Empty: () => React.createElement("div", null, "Empty"),
Divider: () => React.createElement("hr"),
Space: ({ children }: any) => React.createElement("div", null, children),
Dropdown: ({ children }: any) => React.createElement("span", null, children),
Menu: () => React.createElement("div"),
Badge: ({ children }: any) => React.createElement("span", null, children),
Radio: ({ children }: any) => React.createElement("span", null, children),
RadioGroup: ({ children }: any) => React.createElement("div", null, children),
Checkbox: ({ children }: any) => React.createElement("span", null, children),
InputNumber: () => React.createElement("input", { type: "number" }),
Form: ({ children }: any) => React.createElement("form", null, children),
FormItem: ({ children }: any) => React.createElement("div", null, children),
Result: ({ status, title }: any) => React.createElement("div", { "data-status": status }, title),
List: () => React.createElement("div"),
Table: () => React.createElement("div"),
Pagination: () => React.createElement("div"),
Card: ({ children }: any) => React.createElement("div", null, children),
Avatar: ({ children }: any) => React.createElement("span", null, children),
Collapse: ({ children }: any) => React.createElement("div", null, children),
CollapsePanel: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
Select: ({ children }: any) => React.createElement("select", null, children),
Option: ({ children }: any) => React.createElement("option", null, children),
ConfigProvider: ({ children }: any) => React.createElement(React.Fragment, null, children),
TextArea: ({ placeholder }: any) => React.createElement("textarea", { placeholder }),
}))
// === UI Components mock ===
vi.mock("@/components/ui", () => ({
Button: ({ children, onClick }: any) => React.createElement("button", { onClick }, children),
Input: ({ placeholder }: any) => React.createElement("input", { placeholder }),
Modal: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Drawer: ({ open, children }: any) =>
open ? React.createElement("div", { role: "dialog" }, children) : null,
Empty: () => React.createElement("div", null, "Empty"),
Card: ({ children }: any) => React.createElement("div", null, children),
Tag: ({ children }: any) => React.createElement("span", null, children),
Tooltip: ({ children }: any) => React.createElement("span", null, children),
Select: ({ children }: any) => React.createElement("select", null, children),
Progress: () => React.createElement("div"),
Upload: ({ children }: any) => React.createElement("div", null, children),
Slider: () => React.createElement("div"),
Switch: () => React.createElement("input", { type: "checkbox" }),
}))
// === API mocks ===
vi.mock("@/api/assets", () => ({
getAssetLibraries: vi.fn().mockResolvedValue([]),
createAssetLibrary: vi.fn().mockResolvedValue({}),
deleteAssetLibrary: vi.fn().mockResolvedValue({}),
getAssetsByKind: vi.fn().mockResolvedValue({ items: [], total: 0 }),
getAssets: vi.fn().mockResolvedValue({ items: [], total: 0 }),
createAsset: vi.fn().mockResolvedValue({}),
updateAsset: vi.fn().mockResolvedValue({}),
deleteAsset: vi.fn().mockResolvedValue({}),
uploadAssetDirect: vi.fn().mockResolvedValue({}),
batchDeleteAssets: vi.fn().mockResolvedValue({}),
batchTagAssets: vi.fn().mockResolvedValue({}),
AssetType: { VIDEO: "video", IMAGE: "image", AUDIO: "audio", VOICE: "voice" },
}))
vi.mock("@/api/tags", () => ({
getTags: vi.fn().mockResolvedValue({ items: [] }),
createTag: vi.fn().mockResolvedValue({}),
tagAsset: vi.fn().mockResolvedValue({}),
untagAsset: vi.fn().mockResolvedValue({}),
}))
vi.mock("@/api/tts", () => ({
synthesizeSpeech: vi.fn().mockResolvedValue({ job_id: "test-job" }),
getTTSJobStatus: vi.fn().mockResolvedValue({ status: "completed", audio_url: "" }),
saveTtsToLibrary: vi.fn().mockResolvedValue({}),
}))
vi.mock("@/api/voices", () => ({
fetchPresetVoices: vi.fn().mockResolvedValue({ items: [] }),
}))
// === PageHead mock ===
vi.mock("@/components/layout/PageHead", () => ({
default: ({ title }: { title: string }) =>
React.createElement("div", { "data-testid": "page-head" }, title),
}))
import VoiceMaterialLibrary from "@/pages/voice-materials/VoiceMaterialLibrary"
describe("VoiceMaterialLibrary", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("renders without crashing", () => {
const { container } = render(
React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)),
)
expect(container).toBeTruthy()
})
it("advances timers without errors", () => {
render(React.createElement(MemoryRouter, null, React.createElement(VoiceMaterialLibrary)))
act(() => {
vi.advanceTimersByTime(30000)
})
expect(true).toBe(true)
})
})
@@ -1,30 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import BgmSelector from "@/pages/editing-planner/components/BgmSelector"
vi.mock("@/api/bgm", () => ({
getBgmPresets: vi.fn().mockResolvedValue([]),
}))
const defaultProps = {
open: true,
onClose: vi.fn(),
config: {
enabled: true,
music_id: "",
volume: 50,
} as any,
onChange: vi.fn(),
}
describe("BgmSelector", () => {
it("should render without crashing", () => {
const { container } = render(<BgmSelector {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<BgmSelector {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,63 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import ClipPropertiesPanel from "@/pages/editing-planner/components/ClipPropertiesPanel"
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom")
return {
...actual,
useNavigate: () => vi.fn(),
}
})
const mockClip = {
id: "clip-1",
type: "voice",
duration: 10,
startOffset: 0,
} as any
const defaultProps = {
selectedClip: mockClip,
titleSettings: { enabled: true, text: "Test Title" } as any,
subtitleSettings: { enabled: true } as any,
bgmSettings: { enabled: false } as any,
clipsCount: 3,
totalDuration: 60,
currentMode: "template" as const,
onTitleSettingsChange: vi.fn(),
onSubtitleSettingsChange: vi.fn(),
onBgmSettingsChange: vi.fn(),
onClipUpdate: vi.fn(),
onOpenBgmDrawer: vi.fn(),
onOpenSubtitleDrawer: vi.fn(),
voiceMaterials: [],
voiceMaterialsLoading: false,
onRefreshVoiceMaterials: vi.fn(),
onClipVoiceSelect: vi.fn(),
onOpenTransitionDrawer: vi.fn(),
onOpenSpeedDrawer: vi.fn(),
onOpenTtsDrawer: vi.fn(),
onOpenWatermarkDrawer: vi.fn(),
}
describe("ClipPropertiesPanel", () => {
it("should render with selected clip", () => {
const { container } = render(
<MemoryRouter>
<ClipPropertiesPanel {...defaultProps} />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
it("should render without selected clip", () => {
const { container } = render(
<MemoryRouter>
<ClipPropertiesPanel {...defaultProps} selectedClip={null} />
</MemoryRouter>,
)
expect(container).toBeTruthy()
})
})
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import CoverSelector from "@/pages/editing-planner/components/CoverSelector"
import { DEFAULT_COVER_CONFIG } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_COVER_CONFIG,
onChange: vi.fn(),
totalDuration: 60,
}
describe("CoverSelector", () => {
it("should render without crashing", () => {
const { container } = render(<CoverSelector {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<CoverSelector {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,102 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import GenerationProgressModal from "@/pages/editing-planner/components/GenerationProgressModal"
const baseProps = {
open: true,
voiceoverDuration: null,
estimatedDuration: 60,
onDurationChange: vi.fn(),
onGenerate: vi.fn(),
task: null,
submitting: false,
onCancel: vi.fn(),
onRetry: vi.fn(),
onClose: vi.fn(),
}
describe("GenerationProgressModal", () => {
it("should render setup phase", () => {
const { container } = render(<GenerationProgressModal {...baseProps} phase="setup" />)
expect(container).toBeTruthy()
})
it("should render progress phase without task", () => {
const { container } = render(<GenerationProgressModal {...baseProps} phase="progress" />)
expect(container).toBeTruthy()
})
it("should render progress phase with task data", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="progress"
task={
{
id: "task-123",
status: "generating_video",
progress: 50,
current_step: "generating_video",
user_message: "正在生成视频",
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should render completed phase", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="completed"
task={{ id: "task-1", status: "completed", progress: 100 } as any}
/>,
)
expect(container).toBeTruthy()
})
it("should render failed phase with retry", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="failed"
task={
{
id: "task-1",
status: "failed",
progress: 30,
error_message: "生成失败",
retryable: true,
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should render failed phase without retry", () => {
const { container } = render(
<GenerationProgressModal
{...baseProps}
phase="failed"
task={
{
id: "task-1",
status: "failed",
progress: 30,
retryable: false,
} as any
}
/>,
)
expect(container).toBeTruthy()
})
it("should not render when closed", () => {
const { container } = render(
<GenerationProgressModal {...baseProps} phase="setup" open={false} />,
)
expect(container).toBeTruthy()
})
})
@@ -1,23 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import IntroOutroPanel from "@/pages/editing-planner/components/IntroOutroPanel"
import { DEFAULT_INTRO_OUTRO } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_INTRO_OUTRO,
onChange: vi.fn(),
}
describe("IntroOutroPanel", () => {
it("should render without crashing", () => {
const { container } = render(<IntroOutroPanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<IntroOutroPanel {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import PipConfigPanel from "@/pages/editing-planner/components/PipConfigPanel"
import { DEFAULT_PIP_CONFIG } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_PIP_CONFIG,
onChange: vi.fn(),
totalDuration: 60,
}
describe("PipConfigPanel", () => {
it("should render without crashing", () => {
const { container } = render(<PipConfigPanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<PipConfigPanel {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,24 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import StickerPanel from "@/pages/editing-planner/components/StickerPanel"
import { DEFAULT_STICKER_CONFIG } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_STICKER_CONFIG,
onChange: vi.fn(),
totalDuration: 60,
}
describe("StickerPanel", () => {
it("should render without crashing", () => {
const { container } = render(<StickerPanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<StickerPanel {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import TimelinePanel from "@/pages/editing-planner/components/TimelinePanel"
const mockClips = [
{ id: "clip-1", type: "voice", duration: 10, startOffset: 0 } as any,
{ id: "clip-2", type: "pip", duration: 5, startOffset: 0 } as any,
{ id: "clip-3", type: "voice", duration: 15, startOffset: 0 } as any,
]
const defaultProps = {
clips: mockClips,
selectedClipId: "clip-1",
currentMode: "template",
onClipSelect: vi.fn(),
onClipReorder: vi.fn(),
onClipRemove: vi.fn(),
onAddClip: vi.fn(),
onClipTrim: vi.fn(),
onClipSplit: vi.fn(),
onClipResetTrim: vi.fn(),
currentTime: 0,
pixelsPerSecond: 30,
onZoomChange: vi.fn(),
onSeek: vi.fn(),
}
describe("TimelinePanel", () => {
it("should render with clips", () => {
const { container } = render(<TimelinePanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render with empty clips", () => {
const { container } = render(
<TimelinePanel {...defaultProps} clips={[]} selectedClipId={null} />,
)
expect(container).toBeTruthy()
})
it("should render without selected clip", () => {
const { container } = render(<TimelinePanel {...defaultProps} selectedClipId={null} />)
expect(container).toBeTruthy()
})
})
@@ -1,28 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import TtsPanel from "@/pages/editing-planner/components/TtsPanel"
import { DEFAULT_TTS_CONFIG } from "@/pages/editing-planner/types"
vi.mock("@/api/tts", () => ({
getTtsVoices: vi.fn().mockResolvedValue([]),
previewTts: vi.fn().mockResolvedValue({ url: "" }),
}))
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_TTS_CONFIG,
onChange: vi.fn(),
}
describe("TtsPanel", () => {
it("should render without crashing", () => {
const { container } = render(<TtsPanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<TtsPanel {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,23 +0,0 @@
import { describe, expect, it, vi } from "vitest"
import { render } from "@testing-library/react"
import WatermarkPanel from "@/pages/editing-planner/components/WatermarkPanel"
import { DEFAULT_WATERMARK } from "@/pages/editing-planner/types"
const defaultProps = {
open: true,
onClose: vi.fn(),
config: DEFAULT_WATERMARK,
onChange: vi.fn(),
}
describe("WatermarkPanel", () => {
it("should render without crashing", () => {
const { container } = render(<WatermarkPanel {...defaultProps} />)
expect(container).toBeTruthy()
})
it("should render when closed", () => {
const { container } = render(<WatermarkPanel {...defaultProps} open={false} />)
expect(container).toBeTruthy()
})
})
@@ -1,493 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest"
import { renderHook, act } from "@testing-library/react"
vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(() => ({
data: { items: [], total: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
onSuccess: undefined,
onError: undefined,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
}))
vi.mock("antd", () => ({
message: {
success: vi.fn(),
error: vi.fn(),
warning: vi.fn(),
},
}))
vi.mock("@/api/editPlans", () => ({
getEditPlanClips: vi.fn().mockResolvedValue({ items: [], total: 0 }),
createEditPlanClip: vi.fn().mockResolvedValue({}),
updateEditPlanClip: vi.fn().mockResolvedValue({}),
deleteEditPlanClip: vi.fn().mockResolvedValue({}),
reorderEditPlanClips: vi.fn().mockResolvedValue({}),
batchDeleteEditPlanClips: vi.fn().mockResolvedValue({ deleted_count: 0 }),
createClipsFromAssets: vi.fn().mockResolvedValue({ created_count: 0 }),
}))
vi.mock("./useUndoRedo", () => ({
useUndoRedo: vi.fn(() => ({
state: [],
set: vi.fn(),
undo: vi.fn(),
redo: vi.fn(),
canUndo: false,
canRedo: false,
reset: vi.fn(),
})),
}))
import { useEditPlanClips } from "@/pages/editing-planner/hooks/useEditPlanClips"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { message } from "antd"
describe("useEditPlanClips", () => {
beforeEach(() => {
vi.clearAllMocks()
})
it("returns default state with planId", () => {
vi.mocked(useQuery).mockReturnValue({
data: { items: [], total: 0 },
isLoading: false,
isError: false,
refetch: vi.fn(),
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-123"))
expect(result.current.clips).toEqual([])
expect(result.current.clipsTotal).toBe(0)
expect(result.current.clipsLoading).toBe(false)
expect(result.current.selectedClipId).toBeNull()
expect(result.current.selectedClip).toBeNull()
})
it("returns clips from query data", () => {
const mockClips = [
{ id: "clip-1", type: "video", asset_id: "a1", order: 0 },
{ id: "clip-2", type: "video", asset_id: "a2", order: 1 },
]
vi.mocked(useQuery).mockReturnValue({
data: { items: mockClips, total: 2 },
isLoading: false,
isError: false,
refetch: vi.fn(),
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-123"))
expect(result.current.clips).toHaveLength(2)
expect(result.current.clipsTotal).toBe(2)
expect(result.current.clips[0].id).toBe("clip-1")
})
it("handles loading state", () => {
vi.mocked(useQuery).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
refetch: vi.fn(),
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-123"))
expect(result.current.clipsLoading).toBe(true)
expect(result.current.clips).toEqual([])
})
it("disables query when no planId", () => {
const { result } = renderHook(() => useEditPlanClips(undefined))
expect(result.current.clips).toEqual([])
})
it("setSelectedClipId updates selection", () => {
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.setSelectedClipId("clip-1")
})
expect(result.current.selectedClipId).toBe("clip-1")
})
it("selectedClip finds matching clip", () => {
const mockClips = [{ id: "clip-1", type: "video", order: 0 }]
vi.mocked(useQuery).mockReturnValue({
data: { items: mockClips, total: 1 },
isLoading: false,
isError: false,
refetch: vi.fn(),
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.setSelectedClipId("clip-1")
})
expect(result.current.selectedClip?.id).toBe("clip-1")
})
it("selectedClip returns null when no match", () => {
vi.mocked(useQuery).mockReturnValue({
data: { items: [{ id: "c1", order: 0 }], total: 1 },
isLoading: false,
isError: false,
refetch: vi.fn(),
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.setSelectedClipId("nonexistent")
})
expect(result.current.selectedClip).toBeNull()
})
it("addClip calls createMutation with order", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => {
// 捕获 onSuccess/onError 回调
return {
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}
})
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.addClip({ type: "video", asset_id: "a1" })
})
expect(mockMutate).toHaveBeenCalled()
})
it("addClip does nothing when no planId", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips(undefined))
act(() => {
result.current.addClip({ type: "video", asset_id: "a1" })
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("removeClip calls deleteMutation", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.removeClip("clip-1")
})
expect(mockMutate).toHaveBeenCalledWith("clip-1")
})
it("removeClip clears selection if selected clip is deleted", () => {
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.setSelectedClipId("clip-1")
})
expect(result.current.selectedClipId).toBe("clip-1")
act(() => {
result.current.removeClip("clip-1")
})
expect(result.current.selectedClipId).toBeNull()
})
it("removeClip does nothing when no planId", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips(undefined))
act(() => {
result.current.removeClip("clip-1")
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("updateClip calls updateMutation", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.updateClip("clip-1", { duration: 10 })
})
expect(mockMutate).toHaveBeenCalledWith({
clipId: "clip-1",
data: { duration: 10 },
})
})
it("updateClip does nothing when no planId", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips(undefined))
act(() => {
result.current.updateClip("c1", {})
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("batchRemoveClips calls batchDeleteMutation", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.batchRemoveClips(["clip-1", "clip-2"])
})
expect(mockMutate).toHaveBeenCalledWith(["clip-1", "clip-2"])
})
it("batchRemoveClips does nothing with empty array", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.batchRemoveClips([])
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("batchRemoveClips clears selection if selected is in batch", () => {
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.setSelectedClipId("clip-1")
})
act(() => {
result.current.batchRemoveClips(["clip-1", "clip-2"])
})
expect(result.current.selectedClipId).toBeNull()
})
it("reorderClips calls reorderMutation", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.reorderClips([{ id: "c1", order: 0 }])
})
expect(mockMutate).toHaveBeenCalledWith([{ id: "c1", order: 0 }])
})
it("reorderClips does nothing with empty items", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.reorderClips([])
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("importFromAssets calls importMutation", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.importFromAssets(["asset-1", "asset-2"])
})
expect(mockMutate).toHaveBeenCalledWith(["asset-1", "asset-2"])
})
it("importFromAssets does nothing with empty array", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips("plan-1"))
act(() => {
result.current.importFromAssets([])
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("importFromAssets does nothing when no planId", () => {
const mockMutate = vi.fn()
vi.mocked(useMutation).mockReturnValue({
mutate: mockMutate,
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
} as any)
const { result } = renderHook(() => useEditPlanClips(undefined))
act(() => {
result.current.importFromAssets(["a1"])
})
expect(mockMutate).not.toHaveBeenCalled()
})
it("returns local undo redo state", () => {
const { result } = renderHook(() => useEditPlanClips("plan-1"))
expect(result.current.localClips).toEqual([])
expect(typeof result.current.setLocalClips).toBe("function")
expect(typeof result.current.undo).toBe("function")
expect(typeof result.current.redo).toBe("function")
expect(result.current.canUndo).toBe(false)
expect(result.current.canRedo).toBe(false)
expect(typeof result.current.resetLocalClips).toBe("function")
})
it("exposes mutation status flags", () => {
vi.mocked(useMutation).mockImplementation((options: any) => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isLoading: false,
isError: false,
}))
const { result } = renderHook(() => useEditPlanClips("plan-1"))
expect(result.current.isCreating).toBe(false)
expect(result.current.isUpdating).toBe(false)
expect(result.current.isDeleting).toBe(false)
expect(result.current.isReordering).toBe(false)
expect(result.current.isImporting).toBe(false)
})
})
@@ -1,73 +0,0 @@
import { describe, expect, it } from "vitest"
import {
DEFAULT_SUBTITLE_STYLE,
type SubtitleStyleConfig,
type SubtitleMode,
} from "@/pages/editing-planner/types/subtitle"
describe("subtitle types & defaults", () => {
it("DEFAULT_SUBTITLE_STYLE has correct shape", () => {
expect(DEFAULT_SUBTITLE_STYLE).toMatchObject<SubtitleStyleConfig>({
enabled: true,
mode: "asr",
fontSize: 16,
fontColor: "#ffffff",
stroke: true,
shadow: false,
position: "bottom",
font: "思源黑体",
animation: "none",
asrLanguage: "zh",
})
})
it("DEFAULT_SUBTITLE_STYLE enabled is boolean", () => {
expect(typeof DEFAULT_SUBTITLE_STYLE.enabled).toBe("boolean")
})
it("DEFAULT_SUBTITLE_STYLE fontSize is number", () => {
expect(typeof DEFAULT_SUBTITLE_STYLE.fontSize).toBe("number")
expect(DEFAULT_SUBTITLE_STYLE.fontSize).toBeGreaterThan(0)
})
it("DEFAULT_SUBTITLE_STYLE position is valid", () => {
expect(["top", "center", "bottom"]).toContain(DEFAULT_SUBTITLE_STYLE.position)
})
it("DEFAULT_SUBTITLE_STYLE mode is valid SubtitleMode", () => {
const mode: SubtitleMode = DEFAULT_SUBTITLE_STYLE.mode
expect(["manual", "asr"]).toContain(mode)
})
it("DEFAULT_SUBTITLE_STYLE asrLanguage is valid", () => {
expect(["zh", "en"]).toContain(DEFAULT_SUBTITLE_STYLE.asrLanguage)
})
it("DEFAULT_SUBTITLE_STYLE has all required fields", () => {
const keys = Object.keys(DEFAULT_SUBTITLE_STYLE)
expect(keys.length).toBeGreaterThanOrEqual(10)
expect(keys).toContain("enabled")
expect(keys).toContain("mode")
expect(keys).toContain("fontSize")
expect(keys).toContain("fontColor")
expect(keys).toContain("stroke")
expect(keys).toContain("shadow")
expect(keys).toContain("position")
expect(keys).toContain("font")
expect(keys).toContain("animation")
expect(keys).toContain("asrLanguage")
})
it("fontColor is valid hex color", () => {
expect(DEFAULT_SUBTITLE_STYLE.fontColor).toMatch(/^#[0-9a-fA-F]{6}$/)
})
it("animation is string", () => {
expect(typeof DEFAULT_SUBTITLE_STYLE.animation).toBe("string")
})
it("font is non-empty string", () => {
expect(typeof DEFAULT_SUBTITLE_STYLE.font).toBe("string")
expect(DEFAULT_SUBTITLE_STYLE.font.length).toBeGreaterThan(0)
})
})
-220
View File
@@ -1,220 +0,0 @@
import React from "react"
import { describe, expect, it, vi, beforeEach } from "vitest"
import { render, screen } from "@testing-library/react"
import { MemoryRouter, Routes, Route, Navigate } from "react-router-dom"
vi.mock("@/store/authStore", () => ({
useAuthStore: vi.fn((selector: (state: any) => unknown) =>
selector({
isAuthenticated: false,
user: null,
accessToken: null,
refreshToken: null,
clearAuth: vi.fn(),
setAuth: vi.fn(),
}),
),
}))
vi.mock("@/pages/home/HomePage", () => ({
default: () => <div data-testid="home-page">Home</div>,
}))
vi.mock("@/components/layout/MainLayout", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="main-layout">{children}</div>
),
}))
import { useAuthStore } from "@/store/authStore"
import { router } from "@/router"
// 模拟 ProtectedRoute 逻辑(和 router/index.tsx 一致)
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (!isAuthenticated || !hasAccessToken) {
return <Navigate to="/login" replace />
}
return <>{children}</>
}
const HomeRoute = () => {
const isAuthenticated = useAuthStore((state: any) => state.isAuthenticated)
const hasAccessToken = Boolean(localStorage.getItem("access_token"))
if (isAuthenticated && hasAccessToken) {
return <Navigate to="/app/dashboard" replace />
}
return <div data-testid="home-page">Home</div>
}
describe("router - ProtectedRoute", () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it("redirects to login when not authenticated", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: false }),
)
render(
<MemoryRouter initialEntries={["/app"]}>
<Routes>
<Route
path="/app"
element={
<ProtectedRoute>
<div data-testid="protected">Protected</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div data-testid="login">Login</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("login")).toBeTruthy()
})
it("redirects to login when authenticated but no token", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: true }),
)
localStorage.removeItem("access_token")
render(
<MemoryRouter initialEntries={["/app"]}>
<Routes>
<Route
path="/app"
element={
<ProtectedRoute>
<div data-testid="protected">Protected</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div data-testid="login">Login</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("login")).toBeTruthy()
})
it("renders children when authenticated and has token", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: true }),
)
localStorage.setItem("access_token", "test-token")
render(
<MemoryRouter initialEntries={["/app"]}>
<Routes>
<Route
path="/app"
element={
<ProtectedRoute>
<div data-testid="protected">Protected</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div data-testid="login">Login</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("protected")).toBeTruthy()
})
})
describe("router - HomeRoute", () => {
beforeEach(() => {
localStorage.clear()
vi.clearAllMocks()
})
it("shows home page when not authenticated", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: false }),
)
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<HomeRoute />} />
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("home-page")).toBeTruthy()
})
it("redirects to dashboard when authenticated with token", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: true }),
)
localStorage.setItem("access_token", "test-token")
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<HomeRoute />} />
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("dashboard")).toBeTruthy()
})
it("shows home when authenticated but no localStorage token", () => {
vi.mocked(useAuthStore).mockImplementation((selector: any) =>
selector({ isAuthenticated: true }),
)
render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route path="/" element={<HomeRoute />} />
<Route path="/app/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByTestId("home-page")).toBeTruthy()
})
})
describe("router config", () => {
it("exports router", () => {
expect(router).toBeDefined()
})
it("router has correct number of top-level routes", () => {
const routes = router.routes
expect(Array.isArray(routes)).toBe(true)
expect(routes.length).toBeGreaterThan(5)
})
it("includes login route", () => {
const loginRoute = router.routes.find((r: any) => r.path === "/login")
expect(loginRoute).toBeDefined()
})
it("includes register route", () => {
const registerRoute = router.routes.find((r: any) => r.path === "/register")
expect(registerRoute).toBeDefined()
})
it("includes root route", () => {
const rootRoute = router.routes.find((r: any) => r.path === "/")
expect(rootRoute).toBeDefined()
})
})
+5 -5
View File
@@ -17,12 +17,12 @@ export default defineConfig({
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["node_modules/", "src/test/", "e2e/", "**/*.d.ts", "**/*.config.*", "**/mockData"],
// CI 覆盖率门禁(Phase 4 后提升,逐步逼近目标
// 当前实际:行 ~62% / 分支 ~61% / 函数 ~25%
// CI 覆盖率门禁(极低门槛起步,逐步提升
// 当前实际覆盖率约 0.8%/0.9%/1.1%,先设极低门槛确保CI跑通
thresholds: {
lines: 50,
branches: 50,
functions: 20,
lines: 0.5,
functions: 0.5,
branches: 0.5,
},
},
},
+1 -21
View File
@@ -35,20 +35,7 @@ def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
ValueError: 不支持的供应商
"""
if provider is None:
provider = os.environ.get("TTS_PROVIDER", "")
if not provider:
# 自动检测:配置了 CosyVoice API Key 则默认用 cosyvoice,否则用 mock
try:
from packages.shared.config import get_shared_settings
settings = get_shared_settings()
if getattr(settings, "cosyvoice_api_key", ""):
provider = "cosyvoice"
else:
provider = "mock"
except Exception:
provider = "mock"
provider = os.environ.get("TTS_PROVIDER", "mock")
provider = provider.lower()
@@ -58,13 +45,6 @@ def get_tts_service(provider: str | None = None, **kwargs) -> TtsService:
from packages.adapters.tts.mock_tts_service import MockTtsService
_PROVIDERS["mock"] = MockTtsService
elif provider in ("cosyvoice", "aliyun", "dashscope"):
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
_PROVIDERS["cosyvoice"] = CosyVoiceTtsService
_PROVIDERS["aliyun"] = CosyVoiceTtsService
_PROVIDERS["dashscope"] = CosyVoiceTtsService
provider = "cosyvoice"
else:
logger.warning("未知 TTS provider: %s,回退到 mock", provider)
from packages.adapters.tts.mock_tts_service import MockTtsService
+11 -22
View File
@@ -18,7 +18,6 @@ def create_video_record_and_dedup(
*,
generation_task_id: str,
project_id: str,
user_id: str = "",
batch_id: str,
file_url: str,
file_size: int,
@@ -29,8 +28,6 @@ def create_video_record_and_dedup(
width: int = 1280,
height: int = 720,
fps: float = 25.0,
name: str = "",
thumbnail_url: str = "",
) -> int:
"""创建 GeneratedVideo 记录,计算指纹并执行查重(历史 + 批次)。
@@ -60,14 +57,11 @@ def create_video_record_and_dedup(
try:
video_id = uuid4().hex
# 使用传入的名称,没有则 fallback 到默认命名
video_name = name.strip() if name else f"generated-{generation_task_id[:8]}.mp4"
generated_video = GeneratedVideo(
id=video_id,
project_id=project_id,
user_id=user_id,
generation_task_id=generation_task_id,
name=video_name,
name=f"generated-{generation_task_id[:8]}.mp4",
file_url=file_url,
file_size=file_size,
duration=duration,
@@ -82,22 +76,17 @@ def create_video_record_and_dedup(
video_repo.create(generated_video)
# 生成封面缩略图
if thumbnail_url:
generated_video.thumbnail_url = thumbnail_url
video_repo.update_thumbnail(video_id, thumbnail_url)
logger.info("Thumbnail reused (pre-generated) for video %s", video_id)
else:
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumbnail_storage_key = f"generated/projects/{project_id}/thumbnails/{video_id}.jpg"
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
_thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
if _thumbnail_url:
generated_video.thumbnail_url = _thumbnail_url
video_repo.update_thumbnail(video_id, _thumbnail_url)
logger.info("Thumbnail generated for video %s: %s", video_id, _thumbnail_url)
except Exception as thumb_err:
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
thumbnail_url = generate_and_upload_thumbnail(video_path, thumbnail_storage_key)
if thumbnail_url:
generated_video.thumbnail_url = thumbnail_url
video_repo.update_thumbnail(video_id, thumbnail_url)
logger.info("Thumbnail generated for video %s: %s", video_id, thumbnail_url)
except Exception as thumb_err:
logger.warning("Thumbnail generation failed for %s: %s", video_id, thumb_err)
# 计算视频指纹
deduplicator = VideoDeduplicator()
+2 -3
View File
@@ -142,20 +142,19 @@ def _download_via_http(url: str, local_path: Path) -> bool:
return False
def upload_to_oss(local_path: Path | str, storage_key: str) -> str | None:
def upload_to_oss(local_path: Path, storage_key: str) -> str | None:
"""上传文件到 OSS,返回公开 URL。
大文件>100MB自动走分片上传降低内存峰值减少 OOM 风险
上传加总超时保护默认 300s防止网络异常时无限挂死
Args:
local_path: 本地文件路径Path str 均可
local_path: 本地文件路径
storage_key: 目标存储键
Returns:
公开访问 URL上传失败或 OSS 未配置时返回 None
"""
local_path = Path(local_path) # 统一转 Path,兼容 str 调用
bucket = oss_bucket()
if bucket is None:
return None
@@ -63,7 +63,6 @@ class RenderAdapterResult:
success: bool
output_url: str = ""
output_path: Path | None = None
thumbnail_url: str = ""
duration: float = 0.0
file_size: int = 0
width: int = 0
@@ -225,22 +224,6 @@ class RenderAdapter:
storage_key = f"rendered/{plan_id}/{job_id or plan_id}.mp4"
output_url = upload_to_oss(result.output_path, storage_key)
self._report_progress(progress_cb, 90.0, "生成封面缩略图")
# 5. 生成缩略图(在清理临时目录前)
thumbnail_url = ""
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"rendered/{plan_id}/thumbnail.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(result.output_path), thumb_storage_key)
except Exception as thumb_err:
logger.warning(
"[render-adapter] 缩略图生成失败(不影响主流程): plan_id=%s error=%s",
plan_id,
thumb_err,
)
self._report_progress(progress_cb, 100.0, "渲染完成")
logger.info(
@@ -259,7 +242,6 @@ class RenderAdapter:
success=True,
output_url=output_url or "",
output_path=result.output_path,
thumbnail_url=thumbnail_url,
duration=result.duration,
file_size=result.file_size,
width=result.width,
@@ -16,10 +16,8 @@ def extract_first_frame(
width: int = 640,
height: int = -1,
timeout: int = 30,
seek_ratio: float = 0.15,
min_seek_seconds: float = 1.0,
) -> str:
"""抽取视频封面图(默认取视频时长 15% 处的帧,避开片头纯色画面)
"""抽取视频第一帧作为封面图。
Args:
video_path: 视频文件路径
@@ -27,8 +25,6 @@ def extract_first_frame(
width: 输出宽度默认 640-1 表示按比例缩放
height: 输出高度默认 -1按比例缩放
timeout: 超时时间
seek_ratio: 抽帧位置占视频时长的比例默认 0.15 15%
min_seek_seconds: 最小抽帧时间避免极短视频 seek 0
Returns:
生成的缩略图文件路径
@@ -36,38 +32,44 @@ def extract_first_frame(
Raises:
subprocess.CalledProcessError: ffmpeg 执行失败
"""
from video_processing.ffmpeg_utils import FFMPEG_BIN, probe_duration, run_ffmpeg
from video_processing.ffmpeg_utils import FFMPEG_BIN, run_ffmpeg
_is_temp_output = False
if output_path is None:
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.close()
output_path = tmp.name
_is_temp_output = True
# -ss 00:00:01 取第1秒帧(避免首帧黑屏)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
cmd = [
FFMPEG_BIN,
"-y",
"-i",
video_path,
"-ss",
"00:00:01",
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
try:
# 计算抽帧时间点:取视频时长 * seek_ratio,最少 min_seek_seconds 秒
try:
duration = probe_duration(video_path)
seek_time = max(min_seek_seconds, duration * seek_ratio)
except Exception:
# probe 失败时 fallback 到第1秒
seek_time = min_seek_seconds
# 格式化为 HH:MM:SS.xx
seek_str = _format_seek_time(seek_time)
# -ss 放在 -i 前面(input seeking,更快但精度稍低,缩略图够用)
# -vframes 1 只取一帧
# -q:v 2 jpeg 高质量
scale_filter = f"scale={width}:{height}:force_original_aspect_ratio=decrease"
cmd = [
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
except Exception:
# 短视频可能没有第1秒,退回到第0帧
cmd2 = [
FFMPEG_BIN,
"-y",
"-ss",
seek_str,
"-i",
video_path,
"-ss",
"00:00:00",
"-vframes",
"1",
"-vf",
@@ -76,48 +78,12 @@ def extract_first_frame(
"2",
output_path,
]
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
try:
run_ffmpeg(cmd, capture_output=True, timeout=timeout)
except Exception:
# 失败时退回到第0帧兜底
cmd2 = [
FFMPEG_BIN,
"-y",
"-i",
video_path,
"-ss",
"00:00:00",
"-vframes",
"1",
"-vf",
scale_filter,
"-q:v",
"2",
output_path,
]
run_ffmpeg(cmd2, capture_output=True, timeout=timeout)
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
raise RuntimeError(f"Thumbnail generation failed: {output_path}")
return output_path
except Exception:
# 失败时清理自己创建的临时文件
if _is_temp_output and output_path:
try:
Path(output_path).unlink(missing_ok=True)
except Exception:
pass
raise
def _format_seek_time(seconds: float) -> str:
"""将秒数格式化为 HH:MM:SS.xx 格式。"""
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = seconds % 60
return f"{h:02d}:{m:02d}:{s:05.2f}"
return output_path
def generate_and_upload_thumbnail(
@@ -801,16 +801,6 @@ class UnifiedRenderService:
if ass_path is not None:
return False, "有字幕叠加"
# 有调速 → 需要重编码 → 不能 copy
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
return False, f"有调速: speed={speed:.2f}x"
# 有倒放 → 需要重编码 → 不能 copy
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and (reverse_config.reverse_video or reverse_config.reverse_audio):
return False, "有倒放效果"
# 探测输入视频参数
info = probe_video_info(str(clip.local_path))
@@ -1078,9 +1068,8 @@ class UnifiedRenderService:
# background 以外的视频素材,默认带音频
has_audio = role != "background"
if has_audio:
# 检查是否需要音频降噪
af_parts: list[str] = []
# 音频降噪
try:
from video_processing.noise_reduction_engine import NoiseReductionConfig, NoiseReductionEngine
@@ -1094,29 +1083,17 @@ class UnifiedRenderService:
except Exception as e:
logger.warning("[unified-render] 直通模式音频降噪应用失败,跳过: %s", e)
# 音频调速(与视频setpts对应,保持音画同步)
speed = UnifiedRenderService._clip_speed(clip)
if abs(speed - 1.0) >= 1e-6:
try:
from video_processing.speed_engine import SpeedConfig, SpeedEngine
if af_parts:
command.extend(["-af", ",".join(af_parts)])
speed_cfg = SpeedConfig(speed=speed)
speed_engine = SpeedEngine()
af_parts.append(speed_engine.build_audio_filter(speed_cfg))
except Exception as e:
logger.warning("[unified-render] 直通模式音频调速应用失败,跳过: %s", e)
command.extend(["-c:a", "aac", "-b:a", "128k"])
# 音频倒放
reverse_config = ReverseConfig.from_dict(clip.config.get("reverse"))
if reverse_config.enabled and reverse_config.reverse_audio:
af_filter = ReverseEngine.build_audio_filter(reverse_config, duration=effective_duration)
if af_filter:
af_parts.append(af_filter)
if af_parts:
command.extend(["-af", ",".join(af_parts)])
command.extend(["-c:a", "aac", "-b:a", "128k"])
command.extend(["-af", af_filter])
# 统一截断时长(同时作用于视频和音频)
if final_duration > 0:
@@ -135,23 +135,17 @@ def _finalize_render_success(
generation_task_id: str,
output_path: Path,
engine: str,
thumbnail_url: str = "",
) -> dict:
"""渲染成功后的统一收尾:查重 + 更新状态 + 返回结果。"""
# 创建 GeneratedVideo 记录 + 查重
project_id = plan.project_id or ""
batch_id = plan.config.get("batch_id", "")
mode = plan.config.get("mode", "edit_plan")
# 从 plan.config.title.text 读取视频名称
plan_config = plan.config or {}
title_cfg = plan_config.get("title", {}) or {}
video_name = (title_cfg.get("text") or "").strip() or f"generated-{generation_task_id[:8]}.mp4"
if generation_task_id:
try:
create_video_record_and_dedup(
generation_task_id=generation_task_id,
project_id=project_id,
user_id=plan.created_by_user_id or "",
batch_id=batch_id,
file_url=output_url or "",
file_size=file_size,
@@ -162,8 +156,6 @@ def _finalize_render_success(
width=width,
height=height,
fps=OUTPUT_FPS,
name=video_name,
thumbnail_url=thumbnail_url,
)
except Exception as dedup_err:
logger.warning("查重失败(不影响渲染结果): %s", dedup_err)
@@ -282,7 +274,6 @@ def _render_with_unified(
output_path = result.output_path or Path("")
output_url = result.output_url
thumbnail_url = result.thumbnail_url or ""
storage_key = f"rendered/{plan_id}/output.mp4"
# 用 adapter 返回的 clip 明细(以 adapter 的结果为准)
@@ -307,7 +298,6 @@ def _render_with_unified(
generation_task_id=generation_task_id,
output_path=output_path,
engine="unified",
thumbnail_url=thumbnail_url,
)
@@ -372,23 +362,6 @@ def _render_with_legacy(
)
logger.info("执行 FFmpeg (legacy): plan_id=%s cmd=%s", plan_id, " ".join(compose_cmd.command)[:500])
# 开始渲染,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 40.0:
gen_task.progress = 40.0
gen_task.append_log(
stage="render_start",
message="开始FFmpeg渲染(legacy",
level="INFO",
progress=40.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
try:
from video_processing.ffmpeg_utils import run_ffmpeg
@@ -422,192 +395,14 @@ def _render_with_legacy(
_mark_plan_failed(plan_repo, plan_id, gen_task_repo, generation_task_id, error_msg)
return {"status": "error", "message": error_msg}
# 获取文件大小 + 实际时长
# 获取文件大小
file_size = output_path.stat().st_size if output_path.exists() else 0
duration = compose_cmd.estimated_duration or 0.0
try:
from video_processing.ffmpeg_utils import probe_duration
actual_duration = probe_duration(str(output_path))
if actual_duration > 0:
duration = actual_duration
except Exception:
pass
# ── 标题/字幕叠加(legacy 引擎补齐) ────────────────────────────────
plan_config = plan.config or {}
title_cfg = plan_config.get("title", {}) or {}
subtitle_cfg = plan_config.get("subtitle", {}) or {}
title_text = title_cfg.get("text", "") or ""
subtitle_text = subtitle_cfg.get("text", "") or ""
title_enabled = title_cfg.get("enabled", True) and bool(title_text.strip())
subtitle_enabled = subtitle_cfg.get("enabled", True) and bool(subtitle_text.strip())
# ASR 自动字幕 legacy 暂不支持(需要额外 ASR 服务,统一用 unified 引擎)
has_subtitle_overlay = title_enabled or subtitle_enabled
if has_subtitle_overlay and output_path.exists() and duration > 0:
try:
from video_processing.ffmpeg_utils import run_ffmpeg
from video_processing.render_subtitles import generate_ass_subtitles
ass_path = tmpdir_path / f"subtitles_{plan_id}.ass"
generate_ass_subtitles(
ass_path,
video_width=output_width,
video_height=output_height,
video_duration=duration,
title_text=title_text,
title_config=title_cfg,
subtitle_text=subtitle_text,
subtitle_config=subtitle_cfg,
)
# 用 subtitles 滤镜叠加 ASS 字幕,音频直接 copy
subtitled_path = tmpdir_path / f"{plan_id}_subtitled.mp4"
# 处理 Windows 路径下的 ass 滤镜转义问题
ass_filter_path = str(ass_path).replace("\\", "/").replace(":", r"\:")
run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(output_path),
"-vf",
f"subtitles='{ass_filter_path}'",
"-c:a",
"copy",
str(subtitled_path),
],
timeout=1800,
)
if subtitled_path.exists() and subtitled_path.stat().st_size > 0:
output_path = subtitled_path
file_size = subtitled_path.stat().st_size
logger.info(
"legacy 标题/字幕叠加完成: plan_id=%s title=%s subtitle=%s",
plan_id,
title_enabled,
subtitle_enabled,
)
except Exception as sub_err:
logger.warning("legacy 标题/字幕叠加失败(不影响主流程): plan_id=%s err=%s", plan_id, sub_err)
# ── TTS 配音混音(legacy 引擎补齐) ────────────────────────────────
tts_cfg = plan_config.get("tts", {}) or {}
tts_enabled = tts_cfg.get("enabled", False) and bool(tts_cfg.get("text", "").strip())
if tts_enabled and output_path.exists() and duration > 0:
try:
from packages.domain.tts_config import TtsConfig
tts_config = TtsConfig.parse(tts_cfg)
if tts_config.enabled and tts_config.text.strip():
from apps.worker.services.tts_service_factory import get_tts_service
tts_service = get_tts_service()
voiceover_path = tmpdir_path / f"voiceover_{plan_id}.wav"
# 生成配音音频
audio_path = tts_service.synthesize(
text=tts_config.text,
voice_id=tts_config.voice_id,
speed=tts_config.speed,
pitch=tts_config.pitch,
output_path=voiceover_path,
)
if audio_path and audio_path.exists() and audio_path.stat().st_size > 0:
from video_processing.ffmpeg_utils import run_ffmpeg
mixed_path = tmpdir_path / f"{plan_id}_with_voiceover.mp4"
# 混音:配音音量按配置调整
voice_volume = max(0.0, min(1.0, tts_config.volume))
if tts_config.overlap_mode == "mix":
# 混音模式:原音 + 配音混合
filter_complex = (
f"[0:a]volume=1.0[a0];"
f"[1:a]volume={voice_volume:.2f}[a1];"
f"[a0][a1]amix=inputs=2:duration=first:dropout_transition=0[aout]"
)
else:
# replace 模式:配音替换原音
filter_complex = f"[1:a]volume={voice_volume:.2f}[aout]"
run_ffmpeg(
[
"ffmpeg",
"-y",
"-i",
str(output_path),
"-i",
str(audio_path),
"-filter_complex",
filter_complex,
"-map",
"0:v",
"-map",
"[aout]",
"-c:v",
"copy",
"-c:a",
"aac",
"-b:a",
"128k",
"-shortest",
str(mixed_path),
],
timeout=1800,
)
if mixed_path.exists() and mixed_path.stat().st_size > 0:
output_path = mixed_path
file_size = mixed_path.stat().st_size
logger.info(
"legacy TTS 配音混音完成: plan_id=%s voice_id=%s mode=%s",
plan_id,
tts_config.voice_id,
tts_config.overlap_mode,
)
except Exception as tts_err:
logger.warning("legacy TTS 配音混音失败(不影响主流程): plan_id=%s err=%s", plan_id, tts_err)
# 渲染完成,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 80.0:
gen_task.progress = 80.0
gen_task.append_log(
stage="render_done",
message="FFmpeg渲染完成(legacy",
level="INFO",
progress=80.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
# 上传到 OSS
storage_key = f"rendered/{plan_id}/output.mp4"
output_url = upload_to_oss(output_path, storage_key)
# 上传完成,更新进度
if generation_task_id:
try:
gen_task = gen_task_repo.get(generation_task_id)
if gen_task and gen_task.progress < 95.0:
gen_task.progress = 95.0
gen_task.append_log(
stage="upload_done",
message="OSS上传完成(legacy",
level="INFO",
progress=95.0,
)
gen_task_repo.update(gen_task)
except Exception:
pass
return _finalize_render_success(
plan=plan,
plan_repo=plan_repo,
+8 -72
View File
@@ -174,7 +174,6 @@ class _VirtualClip:
start_time: float = 0.0
duration: float = 0.0
transition_effect: str = "cut"
playback_speed: float = 1.0
status: str = "ready"
config: dict[str, Any] = field(default_factory=dict)
@@ -303,16 +302,6 @@ def _apply_template_clip_effects(
existing_config[key] = template_clip_config[key]
clip.config = existing_config
# 3. 调速:同步到 clip.playback_speed 顶级字段(渲染引擎读此字段)
template_speed = template_clip_config.get("playback_speed") or template_clip_config.get("speed")
if template_speed:
try:
speed_val = float(template_speed)
if speed_val > 0:
clip.playback_speed = speed_val
except (ValueError, TypeError):
pass
def _build_plan_and_clips_from_task(
task_id: str,
@@ -484,44 +473,9 @@ def _mux_audio_track(video_path: Path, audio_path: str, output_path: Path) -> No
def _download_voice_asset(voice_library_id: str, local_path: Path) -> bool:
"""下载配音文件
支持两种来源按优先级
1. 配音素材库 asset voice_library_id asset_id asset
找到则用 asset.storage_key 下载用户上传到配音库的音频
2. 旧版 voice/{id}.mp3 路径 向后兼容
"""
"""下载配音文件"""
if not voice_library_id:
return False
# 方式1:先尝试当 asset_id 查素材库(用户上传到配音库的音频)
try:
from worker_app.db import SessionLocal
from packages.adapters.sqlalchemy_impl.asset_repository import (
SQLAlchemyAssetRepository,
)
session = SessionLocal()
try:
repo = SQLAlchemyAssetRepository(session)
asset = repo.find_by_id(voice_library_id)
if asset and asset.storage_key:
# 是素材库的配音 asset,用 storage_key 下载
logger.info(
"配音素材来自素材库: asset_id=%s storage_key=%s",
voice_library_id,
asset.storage_key,
)
ok = download_asset(asset.storage_key, local_path)
if ok and local_path.exists() and local_path.stat().st_size > 0:
return True
finally:
session.close()
except Exception as e:
logger.warning("查询配音asset失败,fallback旧路径: %s", e)
# 方式2:旧版路径(向后兼容)
storage_key = f"voice/{voice_library_id}.mp3"
return download_asset(storage_key, local_path)
@@ -967,10 +921,8 @@ def _validate_template_exists(template_id: str) -> None:
def _load_template_plan_config(template_id: str) -> dict:
"""从模板加载 plan 级配置(BGM、字幕、标题等效果层)。
"""从模板加载 plan 级配置(BGM、字幕、滤镜等效果层)。
TemplateModel bgm_config / subtitle_config / title_config 是独立字段
需要组装成 plan.config 的格式{bgm, subtitle, title}后再注入
模板不存在时返回空 dict不阻塞主流程
"""
if not template_id:
@@ -991,26 +943,17 @@ def _load_template_plan_config(template_id: str) -> dict:
if template is None:
logger.warning("模板不存在,跳过配置加载: template_id=%s", template_id)
return {}
config = template.config or {}
if isinstance(config, str):
import json
# 从独立字段组装成 plan.config 格式
plan_config: dict[str, Any] = {}
title_cfg = template.title_config or {}
subtitle_cfg = template.subtitle_config or {}
bgm_cfg = template.bgm_config or {}
if title_cfg:
plan_config["title"] = title_cfg
if subtitle_cfg:
plan_config["subtitle"] = subtitle_cfg
if bgm_cfg:
plan_config["bgm"] = bgm_cfg
config = json.loads(config)
logger.info(
"模板配置加载成功: template_id=%s keys=%s",
template_id,
list(plan_config.keys()),
list(config.keys()),
)
return plan_config
return config
finally:
session.close()
except Exception as e:
@@ -1202,7 +1145,6 @@ def _load_task_info(task_id: str) -> dict | None:
"task_asset_ids": list(gen_task.asset_ids or []),
"batch_id": getattr(gen_task, "batch_id", "") or "",
"user_id": getattr(gen_task, "created_by_user_id", "") or "",
"video_title": getattr(gen_task, "video_title", "") or "",
}
finally:
session.close()
@@ -1377,8 +1319,6 @@ def _upload_and_record(
project_id: str,
batch_id: str,
editing_mode,
user_id: str = "",
video_name: str = "",
) -> tuple[str, float, int, int]:
"""上传 OSS、创建视频记录并查重。
@@ -1426,7 +1366,6 @@ def _upload_and_record(
video_count = create_video_record_and_dedup(
generation_task_id=task_id,
project_id=project_id,
user_id=user_id,
batch_id=batch_id,
file_url=file_url,
file_size=file_size,
@@ -1434,7 +1373,6 @@ def _upload_and_record(
video_path=str(output_path),
mode=editing_mode.value,
session=dedup_session,
name=video_name,
)
finally:
dedup_session.close()
@@ -1573,8 +1511,6 @@ def generate_video(self, task_id: str) -> dict:
project_id=project_id,
batch_id=batch_id,
editing_mode=editing_mode,
user_id=user_id,
video_name=task_info.get("video_title", ""),
)
if gen_task:
-23
View File
@@ -280,7 +280,6 @@ def ingest_asset(job_id: str) -> dict:
# 先从 OSS 下载文件到本地临时目录,再提取元数据
# storage_key 是 OSS 内部路径,不能直接传给 ffprobe/Pillow
local_file = None
thumbnail_url = None
try:
suffix = Path(job.storage_key).suffix or ".bin"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
@@ -292,27 +291,6 @@ def ingest_asset(job_id: str) -> dict:
metadata, extract_success = {}, False
else:
metadata, extract_success = extract_media_metadata(str(local_file), media_type)
# 视频类型:生成缩略图(文件还在的时候生成)
thumbnail_url = None
if media_type == "video" and extract_success:
try:
from video_processing.thumbnail_generator import generate_and_upload_thumbnail
thumb_storage_key = f"assets/{job.project_id}/thumbnails/{job_id}.jpg"
thumbnail_url = generate_and_upload_thumbnail(str(local_file), thumb_storage_key)
if thumbnail_url:
logger.info(
"素材缩略图生成成功: job_id=%s url=%s",
job_id,
thumbnail_url[:80],
)
except Exception as thumb_err:
logger.warning(
"素材缩略图生成失败(不影响主流程): job_id=%s error=%s",
job_id,
thumb_err,
)
finally:
if local_file and local_file.exists():
try:
@@ -381,7 +359,6 @@ def ingest_asset(job_id: str) -> dict:
codec=metadata.get("codec") or None,
status=AssetStatus.READY,
file_hash=job.file_hash,
thumbnail_url=thumbnail_url,
)
asset_repo.create(asset)
-23
View File
@@ -1232,14 +1232,6 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "user_id",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(36)",
"unique": false
},
{
"index": true,
"name": "generation_task_id",
@@ -1413,13 +1405,6 @@
],
"name": "ix_generated_videos_status",
"unique": false
},
{
"columns": [
"user_id"
],
"name": "ix_generated_videos_user_id",
"unique": false
}
],
"primary_key": [
@@ -1620,14 +1605,6 @@
"type": "VARCHAR(36)",
"unique": false
},
{
"index": false,
"name": "video_title",
"nullable": false,
"primary_key": false,
"type": "VARCHAR(255)",
"unique": false
},
{
"index": false,
"name": "metadata",
@@ -53,48 +53,6 @@ class SQLAlchemyAssetRepository:
models = query.order_by(AssetModel.created_at.desc()).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
def count_by_library_and_file_type(
self,
library_id: str,
file_type: str,
status: list[str] | None = None,
) -> int:
query = self.session.query(AssetModel).filter(
AssetModel.asset_library_id == library_id, AssetModel.file_type == file_type
)
if status:
query = query.filter(AssetModel.status.in_(status))
return query.count()
def find_by_project_and_file_type(
self,
project_id: str,
file_type: str,
skip: int = 0,
limit: int = 100,
status: list[str] | None = None,
) -> list[Asset]:
query = self.session.query(AssetModel).filter(
AssetModel.project_id == project_id, AssetModel.file_type == file_type
)
if status:
query = query.filter(AssetModel.status.in_(status))
models = query.order_by(AssetModel.created_at.desc()).offset(skip).limit(limit).all()
return [self._to_domain(model) for model in models]
def count_by_project_and_file_type(
self,
project_id: str,
file_type: str,
status: list[str] | None = None,
) -> int:
query = self.session.query(AssetModel).filter(
AssetModel.project_id == project_id, AssetModel.file_type == file_type
)
if status:
query = query.filter(AssetModel.status.in_(status))
return query.count()
def find_by_id(self, asset_id: str) -> Asset | None:
model = self.session.query(AssetModel).filter(AssetModel.id == asset_id).first()
if model is None:
@@ -14,7 +14,6 @@ class SQLAlchemyGeneratedVideoRepository:
model = GeneratedVideoModel(
id=video.id,
project_id=video.project_id,
user_id=video.user_id,
generation_task_id=video.generation_task_id,
name=video.name,
file_url=video.file_url,
@@ -44,7 +43,6 @@ class SQLAlchemyGeneratedVideoRepository:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
user_id=getattr(model, "user_id", ""),
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
@@ -105,18 +103,15 @@ class SQLAlchemyGeneratedVideoRepository:
def list_paginated(
self,
*,
user_id: str | None = None,
project_id: str | None = None,
status: str | None = None,
review_status: str | None = None,
page: int = 1,
page_size: int = 20,
) -> tuple[list[GeneratedVideo], int]:
"""分页查询成片列表,支持按用户、项目、状态、复核状态筛选。"""
"""分页查询成片列表,支持按项目、状态、复核状态筛选。"""
query = self.session.query(GeneratedVideoModel)
if user_id:
query = query.filter(GeneratedVideoModel.user_id == user_id)
if project_id:
query = query.filter(GeneratedVideoModel.project_id == project_id)
if status:
@@ -188,7 +183,6 @@ class SQLAlchemyGeneratedVideoRepository:
return GeneratedVideo(
id=model.id,
project_id=model.project_id,
user_id=getattr(model, "user_id", ""),
generation_task_id=model.generation_task_id,
name=model.name,
file_url=model.file_url,
@@ -33,7 +33,6 @@ def _to_domain(model: GenerationTaskModel) -> GenerationTask:
source_edit_plan_id=model.source_edit_plan_id or "",
asset_select_mode=model.asset_select_mode or "",
batch_id=model.batch_id or "",
video_title=getattr(model, "video_title", "") or "",
logs=model.logs or "[]",
created_at=model.created_at,
updated_at=model.updated_at,
@@ -69,7 +68,6 @@ class SQLAlchemyGenerationTaskRepository:
source_edit_plan_id=task.source_edit_plan_id or None,
asset_select_mode=task.asset_select_mode or "",
batch_id=task.batch_id or "",
video_title=task.video_title or "",
logs=task.logs,
created_at=task.created_at,
updated_at=task.updated_at,
@@ -228,8 +226,6 @@ class SQLAlchemyGenerationTaskRepository:
model.source_edit_plan_id = task.source_edit_plan_id or None
model.asset_select_mode = task.asset_select_mode or ""
model.batch_id = task.batch_id or ""
if hasattr(model, "video_title"):
model.video_title = task.video_title or ""
model.logs = task.logs
self.session.commit()
return task
@@ -266,7 +266,6 @@ class GenerationTaskModel(Base):
source_edit_plan_id = Column(String(36), nullable=True, index=True)
asset_select_mode = Column(String(20), nullable=False, default="")
batch_id = Column(String(36), nullable=False, default="", index=True)
video_title = Column(String(255), nullable=False, default="")
extra_meta = Column("metadata", JSON, nullable=False, default=dict)
logs = Column(Text, nullable=False, default="[]", server_default="[]")
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
@@ -283,7 +282,6 @@ class GeneratedVideoModel(Base):
id = Column(String(36), primary_key=True)
project_id = Column(String(36), nullable=False, index=True)
user_id = Column(String(36), nullable=False, default="", index=True)
generation_task_id = Column(String(36), nullable=False, index=True)
name = Column(String(255), nullable=False)
# file_url: 完整可访问的 URL,用于客户端直接访问视频
@@ -1,249 +0,0 @@
"""CosyVoice TTS 服务适配器.
CosyVoiceService 包装为 TtsService 接口供统一渲染管道的 TtsEngine 使用
支持阿里云百炼 CosyVoice 真实音色合成
"""
from __future__ import annotations
import logging
import tempfile
from pathlib import Path
from urllib.parse import urlparse
from packages.ports.tts_service import TtsError, TtsService
logger = logging.getLogger(__name__)
class CosyVoiceTtsService(TtsService):
"""CosyVoice TTS 服务适配器.
包装 CosyVoiceService实现 TtsService 接口
合成流程调用 CosyVoice API 获取音频 URL 下载到本地 可选转码为目标格式
"""
def __init__(
self,
api_key: str = "",
base_url: str = "",
model: str = "",
sample_rate: int = 24000,
format: str = "mp3",
ffmpeg_bin: str = "ffmpeg",
) -> None:
"""初始化 CosyVoice TTS 服务.
Args:
api_key: DashScope API Key为空时从配置读取
base_url: DashScope API Base URL
model: 语音合成模型
sample_rate: 默认采样率
format: 默认输出格式
ffmpeg_bin: ffmpeg 可执行文件路径用于转码
"""
# 延迟导入避免循环依赖
from packages.application.cosyvoice_service import CosyVoiceService
self._service = CosyVoiceService(
api_key=api_key,
base_url=base_url,
model=model,
)
self._default_sample_rate = sample_rate
self._default_format = format
self._ffmpeg_bin = ffmpeg_bin
@property
def provider_name(self) -> str:
return "cosyvoice"
def synthesize(
self,
text: str,
*,
voice_id: str = "",
speed: float = 1.0,
pitch: float = 0.0,
output_path: Path | None = None,
sample_rate: int = 22050,
format: str = "wav",
) -> Path:
"""调用 CosyVoice 合成语音并下载到本地.
Args:
text: 输入文本
voice_id: 音色 IDCosyVoice 音色名 longxiaochun_v3
speed: 语速 (0.5 ~ 2.0)
pitch: 语调半音-12 ~ 12 CosyVoice 原生不支持 ffmpeg 后处理实现
output_path: 输出文件路径None 则自动生成
sample_rate: 采样率
format: 输出格式 (wav/mp3)
Returns:
输出音频文件路径
"""
if not text.strip():
raise TtsError("文本不能为空")
if not voice_id:
voice_id = "longxiaochun_v3" # 默认音色
# 语速边界
speed = max(0.5, min(2.0, speed))
# 输出路径
if output_path is None:
suffix = f".{format}"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.close()
output_path = Path(tmp.name)
output_path.parent.mkdir(parents=True, exist_ok=True)
try:
# 1. 调用 CosyVoice 合成(默认 mp3 格式,兼容性最好)
result = self._service.synthesize_speech(
text=text,
voice_id=voice_id,
sample_rate=sample_rate or self._default_sample_rate,
format="mp3", # 先下mp3,后面按需转码
speed=speed,
)
if not result.audio_url:
raise TtsError("CosyVoice 未返回音频 URL")
# 2. 下载音频文件
downloaded = self._download_audio(result.audio_url, output_path.parent / "_cosyvoice_tmp.mp3")
if not downloaded.exists() or downloaded.stat().st_size == 0:
raise TtsError("音频下载失败或文件为空")
# 3. 如需转码(wav)或 pitch 调整,用 ffmpeg 处理
need_transcode = (format != "mp3") or abs(pitch) > 0.01
if need_transcode:
self._post_process(downloaded, output_path, format=format, pitch=pitch, sample_rate=sample_rate)
else:
# 直接移动文件
downloaded.rename(output_path)
if not output_path.exists() or output_path.stat().st_size == 0:
raise TtsError("输出文件为空或不存在")
return output_path
except TtsError:
raise
except Exception as e:
logger.error("CosyVoice TTS 合成失败: %s", e)
raise TtsError(f"CosyVoice TTS 合成失败: {e}") from e
def estimate_duration(self, text: str, *, speed: float = 1.0) -> float:
"""估算音频时长(秒).
CosyVoice 不返回预估时长按中文语速经验值估算
- 正常语速约 4 /
"""
if not text:
return 0.0
char_count = len([c for c in text if not c.isspace()])
if char_count == 0:
return 0.0
base_duration = char_count / 4.0 # 4 字/秒
return base_duration / max(0.1, speed)
def available_voices(self) -> list[str]:
"""支持的音色列表."""
from packages.domain.preset_voices import get_preset_voices
return [v.voice_id for v in get_preset_voices()]
def _download_audio(self, url: str, save_path: Path) -> Path:
"""下载音频文件.
Args:
url: 音频 URL
save_path: 保存路径
Returns:
保存路径
"""
import httpx
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
raise TtsError(f"不支持的音频 URL scheme: {parsed.scheme}")
save_path.parent.mkdir(parents=True, exist_ok=True)
with httpx.Client(timeout=120.0) as client:
with client.stream("GET", url) as response:
response.raise_for_status()
with open(save_path, "wb") as f:
for chunk in response.iter_bytes():
f.write(chunk)
return save_path
def _post_process(
self,
input_path: Path,
output_path: Path,
*,
format: str = "wav",
pitch: float = 0.0,
sample_rate: int = 22050,
) -> None:
"""后处理:转码 + pitch 调整.
Args:
input_path: 输入文件路径
output_path: 输出文件路径
format: 输出格式
pitch: 语调偏移半音
sample_rate: 输出采样率
"""
import subprocess
# 构建滤镜
filter_parts = []
# pitch 调整:通过 asetrate 实现
if abs(pitch) > 0.01:
pitch_factor = 2 ** (pitch / 12)
new_rate = int(sample_rate * pitch_factor)
filter_parts.append(f"asetrate={new_rate}")
filter_parts.append(f"aresample={sample_rate}")
filter_str = ",".join(filter_parts) if filter_parts else None
# 编码参数
if format == "mp3":
codec_args = ["-acodec", "libmp3lame", "-b:a", "128k"]
else: # wav
codec_args = ["-acodec", "pcm_s16le"]
command = [
self._ffmpeg_bin,
"-y",
"-i",
str(input_path),
]
if filter_str:
command.extend(["-af", filter_str])
command.extend(codec_args)
command.extend(["-ar", str(sample_rate), "-ac", "1", str(output_path)])
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
raise TtsError(f"音频后处理失败: {result.stderr[-500:]}")
-2
View File
@@ -21,7 +21,6 @@ class ListGeneratedVideosPaginatedUseCase:
def execute(
self,
*,
user_id: str | None = None,
project_id: str | None = None,
status: str | None = None,
review_status: str | None = None,
@@ -33,7 +32,6 @@ class ListGeneratedVideosPaginatedUseCase:
if page_size < 1 or page_size > 100:
page_size = 20
return self.generated_video_repository.list_paginated(
user_id=user_id,
project_id=project_id,
status=status,
review_status=review_status,
-2
View File
@@ -21,7 +21,6 @@ class CreateGenerationTaskCommand:
source_edit_plan_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
auto_retry_enabled: bool = False
auto_retry_max: int = 0
@@ -49,7 +48,6 @@ class CreateGenerationTaskUseCase:
source_edit_plan_id=command.source_edit_plan_id,
asset_select_mode=command.asset_select_mode,
batch_id=command.batch_id,
video_title=command.video_title,
auto_retry_enabled=command.auto_retry_enabled,
auto_retry_max=command.auto_retry_max,
)
+1 -8
View File
@@ -204,13 +204,6 @@ class Asset:
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@property
def file_type(self) -> str:
"""文件类型(从 mime_type 推导,如 video/audio/image."""
if "/" in self.mime_type:
return self.mime_type.split("/")[0]
return self.mime_type
@classmethod
def create(
cls,
@@ -249,7 +242,7 @@ class Asset:
storage_key=storage_key.strip(),
mime_type=mime_type.strip(),
file_size=file_size,
thumbnail_url=str(thumbnail_url) if thumbnail_url else None,
thumbnail_url=thumbnail_url,
duration=duration,
width=width,
height=height,
-3
View File
@@ -18,7 +18,6 @@ class GeneratedVideo:
width: int
height: int
fps: float
user_id: str = ""
thumbnail_url: str | None = None
status: str = "completed"
review_status: str = "pending_review"
@@ -37,7 +36,6 @@ class GeneratedVideo:
name: str,
file_url: str,
*,
user_id: str = "",
file_size: int = 0,
duration: float = 0.0,
width: int = 0,
@@ -57,7 +55,6 @@ class GeneratedVideo:
return cls(
id=uuid4().hex,
project_id=project_id.strip(),
user_id=user_id.strip(),
generation_task_id=generation_task_id.strip(),
name=name.strip(),
file_url=file_url.strip(),
-3
View File
@@ -90,7 +90,6 @@ class GenerationTask:
created_by_user_id: str = ""
asset_select_mode: str = ""
batch_id: str = ""
video_title: str = ""
logs: str = "[]"
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@@ -111,7 +110,6 @@ class GenerationTask:
source_edit_plan_id: str = "",
asset_select_mode: str = "",
batch_id: str = "",
video_title: str = "",
auto_retry_enabled: bool = False,
auto_retry_max: int = 0,
) -> "GenerationTask":
@@ -133,7 +131,6 @@ class GenerationTask:
source_edit_plan_id=source_edit_plan_id.strip(),
asset_select_mode=asset_select_mode,
batch_id=batch_id,
video_title=video_title.strip(),
auto_retry_enabled=auto_retry_enabled,
auto_retry_max=auto_retry_max,
)
@@ -19,7 +19,6 @@ class GeneratedVideoRepository(Protocol):
def list_paginated(
self,
*,
user_id: str | None = None,
project_id: str | None = None,
status: str | None = None,
review_status: str | None = None,
-417
View File
@@ -1,417 +0,0 @@
#!/usr/bin/env python3
"""
ACR 镜像清理脚本
策略
- 版本tag (v*): 永久保留
- 固定tag (latest, main, develop, master): 永久保留
- 缓存镜像 (*-cache): 永久保留
- PR预览tag (pr-*): 保留 N 默认7天
- 普通commit hash tag: 保留最近 N 默认20老的删除
使用方式
python3 acr_cleanup.py --dry-run # 预览,不实际删除
python3 acr_cleanup.py --execute # 实际执行删除
python3 acr_cleanup.py --keep 20 --execute # 保留最近20个
"""
import argparse
import base64
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
# 配置
REGISTRY = os.environ.get("ACR_REGISTRY", "xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com")
AUTH_URL = "https://dockerauth.cn-hangzhou.aliyuncs.com/auth"
SERVICE = os.environ.get("ACR_SERVICE", "registry.aliyuncs.com:cn-hangzhou:china:cri-fvec8o9q4mmxrkaa")
NAMESPACE = os.environ.get("ACR_NAMESPACE", "xiaoxiakeji")
USERNAME = os.environ.get("ACR_USERNAME", "")
PASSWORD = os.environ.get("ACR_PASSWORD", "")
REPOS = [
"xiaoxia-saas-api",
"xiaoxia-saas-worker",
"xiaoxia-saas-web",
"api-cache",
"worker-cache",
"web-cache",
]
# 缓存镜像仓库(所有tag永久保留)
CACHE_REPOS = {"api-cache", "worker-cache", "web-cache"}
# OCI / Docker manifest types
ACCEPT_INDEX = "application/vnd.oci.image.index.v1+json"
ACCEPT_MANIFEST_OCI = "application/vnd.oci.image.manifest.v1+json"
ACCEPT_MANIFEST_V2 = "application/vnd.docker.distribution.manifest.v2+json"
def get_token(repo, action="pull"):
"""获取仓库访问token"""
scope = "repository:" + NAMESPACE + "/" + repo + ":" + action
token_url = AUTH_URL + "?service=" + SERVICE + "&scope=" + scope
req = urllib.request.Request(token_url)
req.add_header("Authorization", "Basic " + base64.b64encode((USERNAME + ":" + PASSWORD).encode()).decode())
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return data.get("token", "")
def get_tags(repo, token):
"""获取仓库所有tag"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/tags/list?n=1000"
req = urllib.request.Request(url)
req.add_header("Authorization", "Bearer " + token)
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
return data.get("tags", []) or []
def http_get_json(url, token, accept_header):
"""带Authorization的GET请求,返回(json_data, headers)"""
req = urllib.request.Request(url)
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", accept_header)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read()), resp.headers
def get_manifest_info(repo, tag, token):
"""
获取tag的manifest信息支持OCI index和普通manifest两种格式
返回: {digest, created, media_type}
- digest: 顶层manifest的digest用于删除
- created: 镜像创建时间
"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + tag
result = {"digest": "", "created": "", "media_type": "", "error": ""}
# 先尝试 OCI index 格式(ACR多用这种)
try:
data, headers = http_get_json(url, token, ACCEPT_INDEX)
top_digest = headers.get("Docker-Content-Digest", "")
result["digest"] = top_digest
result["media_type"] = data.get("mediaType", ACCEPT_INDEX)
# OCI index:找amd64的manifest,再取config blob
manifests = data.get("manifests", [])
amd64_manifest = None
for m in manifests:
arch = m.get("platform", {}).get("architecture", "")
if arch == "amd64":
amd64_manifest = m
break
# 没有amd64就用第一个
if not amd64_manifest and manifests:
amd64_manifest = manifests[0]
if amd64_manifest:
inner_digest = amd64_manifest["digest"]
inner_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + inner_digest
try:
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_OCI)
except Exception:
# 退而求其次用v2格式
inner_data, _ = http_get_json(inner_url, token, ACCEPT_MANIFEST_V2)
config_digest = inner_data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError:
pass
# 再尝试普通 OCI manifest 格式
try:
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_OCI)
result["digest"] = headers.get("Docker-Content-Digest", "")
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_OCI)
config_digest = data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError:
pass
# 最后试 Docker v2 格式
try:
data, headers = http_get_json(url, token, ACCEPT_MANIFEST_V2)
result["digest"] = headers.get("Docker-Content-Digest", "")
result["media_type"] = data.get("mediaType", ACCEPT_MANIFEST_V2)
config_digest = data.get("config", {}).get("digest", "")
if config_digest:
blob_url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/blobs/" + config_digest
try:
blob_data, _ = http_get_json(blob_url, token, "application/json")
result["created"] = blob_data.get("created", "")
except Exception:
pass
return result
except urllib.error.HTTPError as e:
result["error"] = "HTTP " + str(e.code) + " " + e.read().decode()[:200]
return result
def delete_manifest(repo, digest, token):
"""按digest删除manifest(会级联删除所有指向它的tag)"""
url = "https://" + REGISTRY + "/v2/" + NAMESPACE + "/" + repo + "/manifests/" + digest
req = urllib.request.Request(url, method="DELETE")
req.add_header("Authorization", "Bearer " + token)
req.add_header("Accept", ACCEPT_INDEX)
req.add_header("Accept", ACCEPT_MANIFEST_OCI)
req.add_header("Accept", ACCEPT_MANIFEST_V2)
try:
with urllib.request.urlopen(req) as resp:
return True, resp.status
except urllib.error.HTTPError as e:
return False, str(e.code) + " " + e.read().decode()[:200]
def parse_time(created_str):
"""解析ISO时间字符串"""
if not created_str:
return datetime.min.replace(tzinfo=timezone.utc)
try:
if created_str.endswith("Z"):
created_str = created_str[:-1] + "+00:00"
return datetime.fromisoformat(created_str)
except Exception:
return datetime.min.replace(tzinfo=timezone.utc)
def is_version_tag(tag):
"""判断是否是版本tag (v1.2.3, v0.1.0-alpha等)"""
return tag.startswith("v") and len(tag) > 1 and tag[1].isdigit()
def is_fixed_tag(tag):
"""判断是否是固定tag"""
return tag in ("latest", "main", "develop", "master", "dev", "stable")
def is_pr_tag(tag):
"""判断是否是PR预览tag"""
return tag.startswith("pr-")
def cleanup_repo(repo, keep_count, pr_days, dry_run):
"""清理单个仓库"""
print("=" * 60)
print("仓库:", repo)
print("=" * 60)
# 缓存仓库不清理
if repo in CACHE_REPOS:
token_pull = get_token(repo, "pull")
tags = get_tags(repo, token_pull)
print(" 缓存仓库,跳过清理 (共", len(tags), "个tag)")
return len(tags), 0
token_pull = get_token(repo, "pull")
tags = get_tags(repo, token_pull)
print(" 总tag数:", len(tags))
# 分类
version_tags = []
fixed_tags = []
pr_tags_list = []
commit_tags = []
for tag in tags:
if is_version_tag(tag):
version_tags.append(tag)
elif is_fixed_tag(tag):
fixed_tags.append(tag)
elif is_pr_tag(tag):
pr_tags_list.append(tag)
else:
commit_tags.append(tag)
print(" 版本tag (v*):", len(version_tags), "-> 永久保留")
print(" 固定tag:", len(fixed_tags), "-> 永久保留")
print(" PR预览tag (pr-*):", len(pr_tags_list), "-> 保留", pr_days, "")
print(" Commit hash tag:", len(commit_tags), "-> 保留最近", keep_count, "")
# 获取所有commit tag的创建时间
print()
print(" 获取commit tag创建时间...")
tag_info_list = []
errors = 0
for i, tag in enumerate(commit_tags):
info = get_manifest_info(repo, tag, token_pull)
if info["error"] or not info["digest"]:
errors += 1
# 取不到信息的tag,放到最后(最旧处理),但标记一下
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": "", "error": info.get("error", "")})
else:
tag_info_list.append({"tag": tag, "digest": info["digest"], "created": info["created"], "error": ""})
if (i + 1) % 20 == 0:
print(" 已获取", i + 1, "/", len(commit_tags), "...")
if errors:
print(" 注意:", errors, "个tag获取manifest失败")
# 按时间倒序排序(空时间放最后)
tag_info_list.sort(key=lambda x: parse_time(x["created"]), reverse=True)
# 确定要删除的commit tag
to_delete = []
if len(tag_info_list) > keep_count:
to_delete = tag_info_list[keep_count:]
print(" 保留前", keep_count, "个commit tag,删除", len(to_delete), "")
# 打印保留范围
kept = tag_info_list[:keep_count]
valid_kept = [t for t in kept if t["created"]]
if valid_kept:
print(" 最早保留:", valid_kept[-1]["tag"][:12], "(" + valid_kept[-1]["created"][:10] + ")")
to_del_valid = [t for t in to_delete if t["digest"]]
print(" 可删除(有digest):", len(to_del_valid), "")
else:
print(" commit tag数量不足", keep_count, ",无需清理")
# PR tag按时间清理
pr_to_delete = []
if pr_tags_list:
cutoff = datetime.now(timezone.utc) - timedelta(days=pr_days)
print()
print(" 检查PR预览tag(超过", pr_days, "天删除)...")
for tag in pr_tags_list:
info = get_manifest_info(repo, tag, token_pull)
created = parse_time(info["created"])
if created < cutoff:
pr_to_delete.append({"tag": tag, "digest": info["digest"], "created": info["created"]})
print(" PR tag将删除:", len(pr_to_delete), "")
all_to_delete = [t for t in to_delete if t["digest"]] + [t for t in pr_to_delete if t["digest"]]
if not all_to_delete:
print()
print(" 无需删除任何tag")
return len(tags), 0
# 执行删除
print()
if dry_run:
print(" [DRY RUN] 将删除", len(all_to_delete), "个tag(预览模式,不实际删除)")
# 去重digest
unique_digests = set(t["digest"] for t in all_to_delete if t["digest"])
print(" 去重后唯一digest数:", len(unique_digests))
for item in all_to_delete[:5]:
created_str = item.get("created", "")[:10] or "未知"
print(" -", item["tag"][:20], "(" + created_str + ")")
if len(all_to_delete) > 5:
print(" ... 还有", len(all_to_delete) - 5, "")
return len(tags), len(unique_digests)
token_delete = get_token(repo, "delete")
deleted = 0
failed = 0
# 按digest去重,避免重复删除同一镜像
seen_digests = set()
unique_delete = []
for item in all_to_delete:
if item["digest"] and item["digest"] not in seen_digests:
seen_digests.add(item["digest"])
unique_delete.append(item)
print(" 开始删除", len(unique_delete), "个唯一manifest...")
for item in unique_delete:
success, result = delete_manifest(repo, item["digest"], token_delete)
if success:
deleted += 1
print(" 已删除:", item["tag"][:20])
else:
failed += 1
print(" 删除失败:", item["tag"][:20], "-", result)
print()
print(" 删除完成: 成功", deleted, "个,失败", failed, "")
return len(tags), deleted
def main():
parser = argparse.ArgumentParser(description="ACR镜像清理工具")
parser.add_argument("--keep", type=int, default=20, help="保留最近N个commit hash tag(默认20")
parser.add_argument("--pr-days", type=int, default=7, help="PR预览tag保留天数(默认7天)")
parser.add_argument("--dry-run", action="store_true", help="预览模式,不实际删除")
parser.add_argument("--execute", action="store_true", help="实际执行删除")
parser.add_argument("--repo", type=str, default="", help="只清理指定仓库")
args = parser.parse_args()
# 必须指定 --dry-run 或 --execute
if not args.dry_run and not args.execute:
print("请指定 --dry-run(预览)或 --execute(执行)")
print()
print("示例:")
print(" python3 acr_cleanup.py --dry-run # 预览清理效果")
print(" python3 acr_cleanup.py --execute # 实际执行清理")
print(" python3 acr_cleanup.py --keep 20 --execute # 保留最近20个")
sys.exit(1)
# 凭证检查
global USERNAME, PASSWORD
if not USERNAME or not PASSWORD:
# 尝试从docker config读取
try:
docker_config_path = os.path.expanduser("~/.docker/config.json")
with open(docker_config_path) as f:
config = json.load(f)
auth = config.get("auths", {}).get(REGISTRY, {}).get("auth", "")
if auth:
creds = base64.b64decode(auth).decode().strip()
USERNAME, PASSWORD = creds.split(":", 1)
except Exception:
pass
if not USERNAME or not PASSWORD:
print("错误: 缺少ACR凭证,请设置 ACR_USERNAME 和 ACR_PASSWORD 环境变量")
print("或确保已执行 docker login", REGISTRY)
sys.exit(1)
dry_run = args.dry_run or not args.execute
mode = "预览模式" if dry_run else "执行模式"
print("ACR 镜像清理工具 -", mode)
print("Registry:", REGISTRY)
print("Namespace:", NAMESPACE)
print("保留commit tag数:", args.keep)
print("PR预览保留天数:", args.pr_days)
print()
repos_to_clean = REPOS
if args.repo:
repos_to_clean = [args.repo]
total_deleted = 0
total_tags = 0
for repo in repos_to_clean:
count, deleted = cleanup_repo(repo, args.keep, args.pr_days, dry_run)
total_tags += count
total_deleted += deleted
print()
print("=" * 60)
print("清理完成")
print(" 总tag数:", total_tags)
if dry_run:
print(" 预览将删除(去重后):", total_deleted, "个manifest")
else:
print(" 已删除:", total_deleted, "个manifest")
print("=" * 60)
if __name__ == "__main__":
main()
-174
View File
@@ -1,174 +0,0 @@
"""CosyVoice TTS 适配器单元测试."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from packages.domain.tts_config import TtsConfig
class TestTtsConfig:
"""TTS 配置解析测试."""
def test_parse_full_config(self):
"""完整配置解析."""
cfg = TtsConfig.parse(
{
"enabled": True,
"voice_id": "longxiaochun_v3",
"text": "你好世界",
"speed": 1.2,
"pitch": 2.0,
"volume": 0.7,
"align_mode": "full",
"overlap_mode": "mix",
}
)
assert cfg.enabled is True
assert cfg.voice_id == "longxiaochun_v3"
assert cfg.text == "你好世界"
assert cfg.speed == 1.2
assert cfg.pitch == 2.0
assert cfg.volume == 0.7
assert cfg.align_mode == "full"
assert cfg.overlap_mode == "mix"
def test_parse_disabled(self):
"""禁用状态."""
cfg = TtsConfig.parse({"enabled": False})
assert cfg.enabled is False
def test_parse_none(self):
"""空配置."""
cfg = TtsConfig.parse(None)
assert cfg.enabled is False
def test_parse_empty_text_still_enabled(self):
"""有 enabled 但无 text,配置仍然有效(调用方判断是否有文本)."""
cfg = TtsConfig.parse({"enabled": True, "voice_id": "test"})
assert cfg.enabled is True
assert cfg.text == ""
def test_speed_clamp(self):
"""语速边界钳制."""
cfg = TtsConfig.parse({"enabled": True, "speed": 3.0})
assert cfg.speed == 2.0
cfg2 = TtsConfig.parse({"enabled": True, "speed": 0.1})
assert cfg2.speed == 0.5
def test_volume_clamp(self):
"""音量边界钳制."""
cfg = TtsConfig.parse({"enabled": True, "volume": 2.0})
assert cfg.volume == 1.0
def test_invalid_align_mode(self):
"""无效对齐模式回退到默认."""
cfg = TtsConfig.parse({"enabled": True, "align_mode": "invalid"})
assert cfg.align_mode == "full"
class TestCosyVoiceTtsAdapter:
"""CosyVoice TTS 适配器测试."""
def test_import_ok(self):
"""适配器能正常导入."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
assert CosyVoiceTtsService is not None
def test_provider_name(self):
"""provider_name 属性."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
# 用 mock 替换底层 CosyVoiceService
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = CosyVoiceTtsService()
assert svc.provider_name == "cosyvoice"
def test_available_voices(self):
"""可用音色列表来自预设音色."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = CosyVoiceTtsService()
voices = svc.available_voices()
assert len(voices) > 0
assert "longxiaochun_v3" in voices
assert "longxiaoxia_v3" in voices
def test_estimate_duration(self):
"""时长估算."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = CosyVoiceTtsService()
d = svc.estimate_duration("你好世界", speed=1.0)
assert d > 0
# 4 个字,4 字/秒 = 1 秒
assert abs(d - 1.0) < 0.1
def test_estimate_duration_speed(self):
"""语速影响时长估算."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = CosyVoiceTtsService()
d_normal = svc.estimate_duration("你好世界", speed=1.0)
d_fast = svc.estimate_duration("你好世界", speed=2.0)
assert d_fast < d_normal
assert abs(d_fast - d_normal / 2.0) < 0.01
def test_synthesize_empty_text_raises(self):
"""空文本抛出异常."""
from packages.adapters.tts.cosyvoice_tts_service import CosyVoiceTtsService
from packages.ports.tts_service import TtsError
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = CosyVoiceTtsService()
with pytest.raises(TtsError, match="文本不能为空"):
svc.synthesize(" ")
class TestTtsServiceFactory:
"""TTS 服务工厂测试."""
def test_mock_provider(self):
"""mock provider 正常."""
from apps.worker.services.tts_service_factory import get_tts_service
svc = get_tts_service("mock")
assert svc.provider_name == "mock"
def test_cosyvoice_provider(self):
"""cosyvoice provider 注册正常."""
from apps.worker.services.tts_service_factory import get_tts_service
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = get_tts_service("cosyvoice")
assert svc.provider_name == "cosyvoice"
def test_aliyun_alias(self):
"""aliyun 别名映射到 cosyvoice."""
from apps.worker.services.tts_service_factory import get_tts_service
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = get_tts_service("aliyun")
assert svc.provider_name == "cosyvoice"
def test_dashscope_alias(self):
"""dashscope 别名映射到 cosyvoice."""
from apps.worker.services.tts_service_factory import get_tts_service
with patch("packages.application.cosyvoice_service.CosyVoiceService"):
svc = get_tts_service("dashscope")
assert svc.provider_name == "cosyvoice"
def test_unknown_fallback_to_mock(self):
"""未知 provider 回退到 mock."""
from apps.worker.services.tts_service_factory import get_tts_service
svc = get_tts_service("unknown_provider")
assert svc.provider_name == "mock"
@@ -111,358 +111,3 @@ class TestVideoCreationLogic:
generation_task_id = ""
project_id = "proj-456"
assert bool(generation_task_id) is False
class TestVideoNameParameter:
"""验证成片库视频名称逻辑:用户设置标题时用标题,没设置时用默认命名。"""
def test_name_from_user_title(self):
"""用户设置了标题 → 用标题作为视频名称。"""
# 验证函数签名包含 name 参数
import inspect
from video_processing.dedup_helpers import create_video_record_and_dedup
sig = inspect.signature(create_video_record_and_dedup)
assert "name" in sig.parameters, "create_video_record_and_dedup 应支持 name 参数"
def test_generated_video_requires_name(self):
"""GeneratedVideo.create 要求 name 非空。"""
from packages.domain import GeneratedVideo
with pytest.raises(ValueError, match="name cannot be empty"):
GeneratedVideo.create(
project_id="proj-1",
user_id="user-1",
generation_task_id="task-1",
name="",
file_url="https://example.com/test.mp4",
)
def test_name_fallback_when_empty(self):
"""name 为空或纯空格时,调用方应 fallback 到默认命名。"""
# 模拟 _finalize_render_success 中的逻辑
title_text_empty = ""
title_text_spaces = " "
generation_task_id = "task-abcdef123456"
# 空标题 → fallback
video_name_1 = title_text_empty.strip() or f"generated-{generation_task_id[:8]}.mp4"
assert video_name_1 == f"generated-{generation_task_id[:8]}.mp4"
# 纯空格 → fallback
video_name_2 = title_text_spaces.strip() or f"generated-{generation_task_id[:8]}.mp4"
assert video_name_2 == f"generated-{generation_task_id[:8]}.mp4"
# 有标题 → 用标题
title_text = "我的旅行vlog"
video_name_3 = title_text.strip() or f"generated-{generation_task_id[:8]}.mp4"
assert video_name_3 == "我的旅行vlog"
class TestUserIdFilter:
"""验证成片库按 user_id 过滤的核心逻辑。"""
def _setup_repo(self):
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from packages.adapters.sqlalchemy_impl.generated_video_repository import (
SQLAlchemyGeneratedVideoRepository,
)
from packages.adapters.sqlalchemy_impl.models import Base
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine)()
return SQLAlchemyGeneratedVideoRepository(session), session
def test_list_paginated_filters_by_user_id(self):
"""list_paginated 传入 user_id 时只返回该用户的视频。"""
from packages.domain import GeneratedVideo
repo, session = self._setup_repo()
try:
# 用户A的2个视频
for i in range(2):
v = GeneratedVideo.create(
project_id=f"proj-a-{i}",
user_id="user-a",
generation_task_id=f"task-a-{i}",
name=f"video-a-{i}.mp4",
file_url=f"https://oss.example.com/a-{i}.mp4",
)
repo.create(v)
# 用户B的3个视频
for i in range(3):
v = GeneratedVideo.create(
project_id=f"proj-b-{i}",
user_id="user-b",
generation_task_id=f"task-b-{i}",
name=f"video-b-{i}.mp4",
file_url=f"https://oss.example.com/b-{i}.mp4",
)
repo.create(v)
# 查用户A → 2条
items, total = repo.list_paginated(user_id="user-a", page=1, page_size=10)
assert total == 2
assert len(items) == 2
assert all(it.user_id == "user-a" for it in items)
# 查用户B → 3条
items, total = repo.list_paginated(user_id="user-b", page=1, page_size=10)
assert total == 3
assert len(items) == 3
assert all(it.user_id == "user-b" for it in items)
finally:
session.close()
def test_list_paginated_user_id_plus_project_id(self):
"""同时传 user_id 和 project_id 时两个条件同时过滤。"""
from packages.domain import GeneratedVideo
repo, session = self._setup_repo()
try:
# 用户A的proj-1视频
v1 = GeneratedVideo.create(
project_id="proj-1",
user_id="user-a",
generation_task_id="task-1",
name="v1.mp4",
file_url="https://oss.example.com/v1.mp4",
)
repo.create(v1)
# 用户B的proj-1视频(不同用户同项目)
v2 = GeneratedVideo.create(
project_id="proj-1",
user_id="user-b",
generation_task_id="task-2",
name="v2.mp4",
file_url="https://oss.example.com/v2.mp4",
)
repo.create(v2)
# 用户A + proj-1 → 只有1条
items, total = repo.list_paginated(user_id="user-a", project_id="proj-1", page=1, page_size=10)
assert total == 1
assert items[0].user_id == "user-a"
assert items[0].generation_task_id == "task-1"
finally:
session.close()
def test_generated_video_has_user_id_field(self):
"""GeneratedVideo domain 对象有 user_id 字段。"""
from packages.domain import GeneratedVideo
v = GeneratedVideo.create(
project_id="proj-1",
user_id="user-123",
generation_task_id="task-1",
name="test.mp4",
file_url="https://example.com/test.mp4",
)
assert v.user_id == "user-123"
def test_created_video_persists_user_id(self):
"""创建视频后 user_id 能正确持久化和读取。"""
from packages.domain import GeneratedVideo
repo, session = self._setup_repo()
try:
v = GeneratedVideo.create(
project_id="proj-1",
user_id="user-persist-test",
generation_task_id="task-persist",
name="persist.mp4",
file_url="https://example.com/persist.mp4",
)
repo.create(v)
fetched = repo.get(v.id)
assert fetched is not None
assert fetched.user_id == "user-persist-test"
finally:
session.close()
class TestThumbnailInDedupHelpers:
"""验证 dedup_helpers 中缩略图相关逻辑(全 mock,不依赖 cv2)。"""
@classmethod
def setup_class(cls):
"""用 mock 模块替代需要 cv2 的 dedup 模块,避免导入失败。
注意光往 sys.modules 塞不够patch() 走属性访问链
必须给 video_processing 包设置对应子模块属性
"""
import sys
from unittest.mock import MagicMock
# 先 mock 掉 cv2
if "cv2" not in sys.modules:
sys.modules["cv2"] = MagicMock()
# mock video_processing.dedup
mock_dedup = MagicMock()
mock_dedup.VideoDeduplicator = MagicMock()
sys.modules["video_processing.dedup"] = mock_dedup
# mock video_processing.thumbnail_generator
mock_thumb = MagicMock()
mock_thumb.generate_and_upload_thumbnail = MagicMock()
sys.modules["video_processing.thumbnail_generator"] = mock_thumb
# 关键:给 video_processing 包设置子模块属性,让 patch() 能通过属性访问找到
import video_processing
video_processing.dedup = mock_dedup
video_processing.thumbnail_generator = mock_thumb
def test_pre_generated_thumbnail_url_is_reused(self):
"""传入 thumbnail_url 时直接复用,不调用 generate_and_upload_thumbnail。"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
pre_thumb_url = "https://oss.example.com/pre-thumb.jpg"
try:
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
mock_dedup = mock_dedup_cls.return_value
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail") as mock_gen:
result = create_video_record_and_dedup(
generation_task_id="task-thumb-reuse",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
thumbnail_url=pre_thumb_url,
)
assert result == 1
# 预生成缩略图时不应调用 generate_and_upload_thumbnail
mock_gen.assert_not_called()
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-reuse").first()
assert video is not None
assert video.thumbnail_url == pre_thumb_url
finally:
session.close()
def test_thumbnail_generated_when_not_provided(self):
"""未传 thumbnail_url 时调用 generate_and_upload_thumbnail 生成。"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
generated_thumb_url = "https://oss.example.com/generated-thumb.jpg"
try:
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
mock_dedup = mock_dedup_cls.return_value
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
return_value=generated_thumb_url,
) as mock_gen:
result = create_video_record_and_dedup(
generation_task_id="task-thumb-gen",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
assert result == 1
# 应调用一次缩略图生成
mock_gen.assert_called_once()
# 验证参数:video_path 和 storage_key
call_args = mock_gen.call_args
assert call_args[0][0] == "/tmp/fake.mp4"
assert "thumbnails" in call_args[0][1]
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-gen").first()
assert video is not None
assert video.thumbnail_url == generated_thumb_url
finally:
session.close()
def test_thumbnail_generation_failure_does_not_block(self):
"""缩略图生成失败不影响主流程。"""
from video_processing.dedup_helpers import create_video_record_and_dedup
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
try:
with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls:
mock_dedup = mock_dedup_cls.return_value
mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {})
mock_dedup.check_duplicate.return_value = None
mock_dedup.check_batch_duplicate.return_value = None
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
side_effect=RuntimeError("cv2 not found"),
):
result = create_video_record_and_dedup(
generation_task_id="task-thumb-fail",
project_id="proj-1",
batch_id="",
file_url="https://oss.example.com/video.mp4",
file_size=1024,
duration=10.0,
video_path="/tmp/fake.mp4",
mode="edit_plan",
session=session,
width=1280,
height=720,
fps=25.0,
)
assert result == 1 # 不阻断
from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel
video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-fail").first()
assert video is not None
# 缩略图生成失败时 thumbnail_url 为 None 或空串
assert not video.thumbnail_url
finally:
session.close()
+2 -3
View File
@@ -339,10 +339,9 @@ class TestIngestAssetValidation:
True,
)
with patch("worker_app.tasks.ingest.SessionLocal", return_value=db):
with patch("video_processing.thumbnail_generator.generate_and_upload_thumbnail", return_value=None):
from worker_app.tasks.ingest import ingest_asset
from worker_app.tasks.ingest import ingest_asset
result = ingest_asset("job-valid-001")
result = ingest_asset("job-valid-001")
assert result["status"] == "completed"
assert "asset_id" in result
-105
View File
@@ -408,108 +408,3 @@ class TestTemplateClipEffectMapping:
result = _extract_intro_outro_from_clip_configs(clip_configs)
assert result == {}
class TestTemplatePlanConfigLoading:
"""验证从模板加载 plan 级配置(BGM、字幕、标题)的逻辑。"""
def _mock_template(
self,
title_config=None,
subtitle_config=None,
bgm_config=None,
is_active=True,
):
template = MagicMock()
template.id = "tmpl_001"
template.name = "Test Template"
template.is_active = is_active
template.title_config = title_config or {}
template.subtitle_config = subtitle_config or {}
template.bgm_config = bgm_config or {}
return template
def _mock_session(self, template):
session = MagicMock()
mock_query = MagicMock()
session.query.return_value = mock_query
filter_result = MagicMock()
mock_query.filter.return_value = filter_result
filter_result.first.return_value = template
return session
def test_load_template_config_assembles_three_fields(self):
"""模板的三个独立字段正确组装成 plan.config 格式。"""
from worker_app.tasks.generation import _load_template_plan_config
title_cfg = {"enabled": True, "text": "我的标题", "font_size": 36}
subtitle_cfg = {"enabled": True, "auto_generated": True, "language": "zh"}
bgm_cfg = {"enabled": True, "preset_id": "bgm-001", "volume": 0.5}
template = self._mock_template(
title_config=title_cfg,
subtitle_config=subtitle_cfg,
bgm_config=bgm_cfg,
)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result["title"] == title_cfg
assert result["subtitle"] == subtitle_cfg
assert result["bgm"] == bgm_cfg
def test_load_template_config_empty_template_returns_empty(self):
"""模板三个字段都为空时返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
template = self._mock_template()
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert result == {}
def test_load_template_config_only_bgm(self):
"""只有 BGM 配置时只返回 bgm 字段。"""
from worker_app.tasks.generation import _load_template_plan_config
bgm_cfg = {"enabled": True, "audio_url": "https://example.com/bgm.mp3"}
template = self._mock_template(bgm_config=bgm_cfg)
session = self._mock_session(template)
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_001")
assert "bgm" in result
assert result["bgm"] == bgm_cfg
assert "title" not in result
assert "subtitle" not in result
def test_load_template_config_empty_template_id(self):
"""空 template_id 直接返回空 dict。"""
from worker_app.tasks.generation import _load_template_plan_config
result = _load_template_plan_config("")
assert result == {}
result = _load_template_plan_config(None)
assert result == {}
def test_load_template_config_not_found_returns_empty(self):
"""模板不存在时返回空 dict,不抛异常。"""
from worker_app.tasks.generation import _load_template_plan_config
session = MagicMock()
mock_query = MagicMock()
session.query.return_value = mock_query
filter_result = MagicMock()
mock_query.filter.return_value = filter_result
filter_result.first.return_value = None
with patch("worker_app.tasks.generation.SessionLocal", return_value=session):
result = _load_template_plan_config("tmpl_nonexist")
assert result == {}
+2 -88
View File
@@ -308,99 +308,13 @@ class TestRenderPlan:
assert result.width == 1280
assert result.height == 720
assert result.clip_count == 2
# 缩略图URL(即使生成失败也应该是空串,不为None)
assert hasattr(result, "thumbnail_url")
@patch("video_processing.render_adapter.upload_to_oss")
@patch("video_processing.render_adapter.UnifiedRenderService")
@patch("video_processing.render_adapter.download_asset")
def test_thumbnail_generated_on_success(self, mock_download, mock_render_cls, mock_upload, tmp_path):
"""渲染成功后生成缩略图,thumbnail_url 正确返回。"""
def _fake_download(storage_key, local_path):
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(b"fake video")
return True
mock_download.side_effect = _fake_download
mock_render = MagicMock()
mock_render.render.return_value = MagicMock(
output_path=tmp_path / "out.mp4",
duration=5.0,
file_size=1024,
width=1280,
height=720,
)
mock_render_cls.return_value = mock_render
mock_upload.return_value = "https://oss.example.com/out.mp4"
fake_thumb = "https://oss.example.com/rendered/plan_thumb/thumbnail.jpg"
plan = FakePlan(id="plan_thumb")
clips = [_make_clip("c1", order=0, duration=5.0)]
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
return_value=fake_thumb,
):
result = adapter.render_plan(
"plan_thumb",
work_dir=tmp_path / "work",
)
assert result.success
assert result.thumbnail_url == fake_thumb
@patch("video_processing.render_adapter.upload_to_oss")
@patch("video_processing.render_adapter.UnifiedRenderService")
@patch("video_processing.render_adapter.download_asset")
def test_thumbnail_failure_does_not_block(self, mock_download, mock_render_cls, mock_upload, tmp_path):
"""缩略图生成失败不影响主流程,thumbnail_url 为空串。"""
def _fake_download(storage_key, local_path):
local_path.parent.mkdir(parents=True, exist_ok=True)
local_path.write_bytes(b"fake video")
return True
mock_download.side_effect = _fake_download
mock_render = MagicMock()
mock_render.render.return_value = MagicMock(
output_path=tmp_path / "out.mp4",
duration=5.0,
file_size=1024,
width=1280,
height=720,
)
mock_render_cls.return_value = mock_render
mock_upload.return_value = "https://oss.example.com/out.mp4"
plan = FakePlan(id="plan_thumb_fail")
clips = [_make_clip("c1", order=0, duration=5.0)]
asset_url_map = {"asset_c1.mp4": "https://test-bucket.oss.com/assets/asset_c1.mp4"}
adapter, _, _ = _make_adapter(plan=plan, clips=clips, asset_url_map=asset_url_map)
with patch(
"video_processing.thumbnail_generator.generate_and_upload_thumbnail",
side_effect=RuntimeError("cv2 not available"),
):
result = adapter.render_plan(
"plan_thumb_fail",
work_dir=tmp_path / "work",
)
assert result.success # 不阻断
assert result.thumbnail_url == ""
# 验证 UnifiedRenderService 被正确调用
mock_render_cls.assert_called_once()
call_kwargs = mock_render_cls.call_args
assert call_kwargs.kwargs["plan"] is plan
assert len(call_kwargs.kwargs["clips"]) == 1
assert len(call_kwargs.kwargs["asset_path_map"]) == 1
assert len(call_kwargs.kwargs["clips"]) == 2
assert len(call_kwargs.kwargs["asset_path_map"]) == 2
@patch("video_processing.render_adapter.upload_to_oss")
@patch("video_processing.render_adapter.UnifiedRenderService")