diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 0bf021900..8e7c63e13 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "SmokePass123!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -20,7 +20,7 @@ const routeBrowserApiToTestApi = async ( }; async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, @@ -40,6 +40,7 @@ async function loginWithRetry( type ProjectResponse = { id: string }; type LibraryResponse = { id: string }; +type TemplateResponse = { id: string }; type AssetListResponse = { items: Array<{ id: string; @@ -109,7 +110,6 @@ test.describe("Core generation flow", () => { expect(upload.status()).toBe(200); // Wait for asset to be ready - let sourceAssetId = ""; await expect .poll( async () => { @@ -121,13 +121,35 @@ test.describe("Core generation flow", () => { const data = (await assets.json()) as AssetListResponse; const asset = data.items.find((a) => a.name === sourceFileName); if (!asset) return "missing"; - sourceAssetId = asset.id; return asset.status; }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, ) .toBe("ready"); + // Create an editing template so the generate page has at least one template + // (templates are now loaded from API; new users have none by default) + const template = await request.post(`${apiBase}/templates`, { + headers, + data: { + name: `E2E 测试模板 ${suffix}`, + mode: "pip", + estimated_duration: 30, + segments: [ + { + segment_order: 1, + duration_min: 5, + duration_max: 30, + material_type: "video", + }, + ], + tags: ["e2e"], + }, + }); + expect(template.status(), await template.text()).toBe(201); + const templateData = (await template.json()) as TemplateResponse; + expect(templateData.id).toBeTruthy(); + // Set auth in localStorage await page.addInitScript( ({ token, user }) => { @@ -165,38 +187,30 @@ test.describe("Core generation flow", () => { await page.getByRole("button", { name: "下一步" }).click(); // Step 2: select material - await expect( - page.getByRole("heading", { name: /选择素材/ }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible(); const librarySelect = page.locator("select").first(); await librarySelect.selectOption({ label: libraryName }); const materialLabel = page.getByText(sourceFileName).locator(".."); - await expect( - materialLabel.locator("input[type='checkbox']"), - ).toBeVisible({ timeout: 10_000 }); + await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({ + timeout: 10_000, + }); await materialLabel.locator("input[type='checkbox']").check(); await page.getByRole("button", { name: "下一步" }).click(); // Step 3: title - await expect( - page.getByRole("heading", { name: /选择标题/ }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible(); const titleText = `E2E Test ${suffix}`; await page.getByPlaceholder("输入自定义标题…").fill(titleText); await page.getByRole("button", { name: "下一步" }).click(); // Step 4: voice - await expect( - page.getByRole("heading", { name: /选择配音/ }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible(); const firstVoiceCard = page.locator(".xx-voice-choice-item").first(); await firstVoiceCard.click(); await page.getByRole("button", { name: "下一步" }).click(); // Step 5: confirm and generate - await expect( - page.getByRole("heading", { name: /确认生成/ }), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible(); // Wait for plan creation API to be called const createPlanPromise = page.waitForResponse( @@ -222,7 +236,7 @@ test.describe("Core generation flow", () => { // Generation may fail in test env (no worker), that's OK // Just verify the flow started - check page shows generation-related UI - const hasProgress = await page + await page .getByText(/生成中|生成完成|生成失败/) .isVisible({ timeout: 15_000 }) .catch(() => false); diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts index 4919c4eb1..fff7e94a1 100755 --- a/apps/web/e2e/core-titles.spec.ts +++ b/apps/web/e2e/core-titles.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "SmokePass123!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async ( /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, @@ -60,6 +60,19 @@ test.describe("Title library flow", () => { const login = await loginWithRetry(request, email, PASSWORD); expect(login.status(), await login.text()).toBe(200); const loginData = (await login.json()) as { access_token: string }; + const headers = { Authorization: `Bearer ${loginData.access_token}` }; + + // Create a title so the titles page has at least one title card to display + // (titles are loaded from API; new users have none by default) + const createTitle = await request.post(`${apiBase}/titles`, { + headers, + data: { + name: `E2E 测试标题 ${suffix}`, + text: `E2E 测试标题内容 ${suffix}`, + category: "default", + }, + }); + expect(createTitle.status(), await createTitle.text()).toBe(201); await page.addInitScript( ({ token, user }) => { diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 5717aa69b..cbaafc072 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "SmokePass123!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async ( /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, diff --git a/apps/web/e2e/subscription.spec.ts b/apps/web/e2e/subscription.spec.ts index a5e0d7adb..2e8075c83 100755 --- a/apps/web/e2e/subscription.spec.ts +++ b/apps/web/e2e/subscription.spec.ts @@ -3,7 +3,7 @@ * * 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查 */ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -18,7 +18,7 @@ function uniqueUsername(prefix: string): string { /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, @@ -37,7 +37,7 @@ async function loginWithRetry( } /** 注册并登录,返回 { headers, email, username, userId } */ -async function createAuthedUser(request: any, label: string) { +async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); diff --git a/apps/web/e2e/test_asset.spec.ts b/apps/web/e2e/test_asset.spec.ts index 48b42eaca..c5938bccc 100755 --- a/apps/web/e2e/test_asset.spec.ts +++ b/apps/web/e2e/test_asset.spec.ts @@ -4,7 +4,7 @@ * 覆盖:创建素材库、列出素材库、创建素材记录 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string { /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, @@ -38,7 +38,7 @@ async function loginWithRetry( } /** 注册并登录,返回 { headers, email, username, userId } */ -async function createAuthedUser(request: any, label: string) { +async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); @@ -62,7 +62,7 @@ async function createAuthedUser(request: any, label: string) { /** 创建一个项目并返回 project id */ async function createProject( - request: any, + request: APIRequestContext, headers: Record, suffix: string, ): Promise { @@ -185,7 +185,7 @@ test.describe("素材库流程", () => { const items = data.items || []; expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2); - const kinds = items.map((i: any) => i.kind); + const kinds = items.map((i: { kind: string }) => i.kind); expect(kinds).toContain("video"); expect(kinds).toContain("image"); }); diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index 121600034..6132d6f90 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -4,7 +4,7 @@ * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 每个测试独立,使用随机邮箱避免冲突。 */ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -18,6 +18,7 @@ function uniqueUsername(prefix: string): string { } /** 从错误响应中提取错误消息文本,兼容新老格式 */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any function extractErrorMessage(body: any): string { if (!body) return ""; // 新格式: { error: { code: "...", message: "..." } } @@ -30,7 +31,7 @@ function extractErrorMessage(body: any): string { /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, diff --git a/apps/web/e2e/test_project.spec.ts b/apps/web/e2e/test_project.spec.ts index 851d95f30..dd74be2e0 100755 --- a/apps/web/e2e/test_project.spec.ts +++ b/apps/web/e2e/test_project.spec.ts @@ -4,7 +4,7 @@ * 覆盖:创建项目、列出项目、获取项目详情 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "Test123456!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; @@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string { /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( - request: any, + request: APIRequestContext, email: string, password: string, maxRetries = 2, @@ -38,7 +38,7 @@ async function loginWithRetry( } /** 注册并登录,返回 { headers, email, username, userId } */ -async function createAuthedUser(request: any, label: string) { +async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index db23fbb99..60afbfad8 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -16,10 +16,12 @@ import Sidebar from "./Sidebar"; import "./MainLayout.css"; /** 侧边栏上下文 —— 子组件可读取折叠状态 */ +// eslint-disable-next-line react-refresh/only-export-components export interface SidebarContextValue { collapsed: boolean; } +// eslint-disable-next-line react-refresh/only-export-components export const SidebarContext = React.createContext({ collapsed: false, }); diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx index 405af59ca..3ff5f75e1 100644 --- a/apps/web/src/components/ui/Input.tsx +++ b/apps/web/src/components/ui/Input.tsx @@ -2,6 +2,7 @@ * V21 Input 输入框 * 封装 Ant Design Input,应用 V21 设计系统样式 */ +/* eslint-disable react-refresh/only-export-components */ import React from "react"; import { Input as AntInput } from "antd"; import type { InputProps as AntInputProps } from "antd"; diff --git a/apps/web/src/pages/auth/Login.css b/apps/web/src/pages/auth/Login.css index 60f56656e..7237d2ed4 100644 --- a/apps/web/src/pages/auth/Login.css +++ b/apps/web/src/pages/auth/Login.css @@ -31,7 +31,11 @@ font-size: 15px; font-weight: 700; border-radius: var(--radius-sm); - background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600)); + background: linear-gradient( + 135deg, + var(--color-primary-500), + var(--color-primary-600) + ); border: none; box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent); diff --git a/apps/web/src/pages/auth/Register.css b/apps/web/src/pages/auth/Register.css index 1a9ec9d26..c3ec61896 100644 --- a/apps/web/src/pages/auth/Register.css +++ b/apps/web/src/pages/auth/Register.css @@ -31,7 +31,11 @@ font-size: 15px; font-weight: 700; border-radius: var(--radius-sm); - background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600)); + background: linear-gradient( + 135deg, + var(--color-primary-500), + var(--color-primary-600) + ); border: none; box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent); diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 0c534f9f4..f5d79e833 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -347,6 +347,53 @@ background: var(--primary-soft, #eef2ff); } +/* 左侧 Tab 切换 */ +.ep-left-tabs { + display: flex; + border-bottom: 1px solid var(--border-color, #e2e8f0); + background: var(--bg-secondary, #f8fafc); +} + +.ep-left-tab { + flex: 1; + padding: 12px 16px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary, #64748b); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.ep-left-tab:hover { + color: var(--text-primary, #1e293b); + background: var(--bg-primary, #ffffff); +} + +.ep-left-tab.active { + color: var(--primary, #6366f1); + border-bottom-color: var(--primary, #6366f1); + background: var(--bg-primary, #ffffff); +} + +/* 素材 Tab 容器 */ +.ep-assets-tab { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.ep-assets-tab::-webkit-scrollbar { + width: 6px; +} + +.ep-assets-tab::-webkit-scrollbar-thumb { + background: var(--border-color, #e2e8f0); + border-radius: 3px; +} + /* 模板列表 */ .ep-template-list { flex: 1; @@ -504,7 +551,11 @@ display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-deepest, #0a0a14)); + background: linear-gradient( + 135deg, + var(--ep-bg-card-hover, #1a1a2e), + var(--ep-bg-deepest, #0a0a14) + ); height: calc(100% - 20px); position: relative; } @@ -554,6 +605,34 @@ color: var(--ep-text-secondary, #9ca3af); } +/* 标题/字幕实时预览叠加层 */ +.ep-preview-title, +.ep-preview-subtitle { + position: absolute; + left: 4px; + right: 4px; + text-align: center; + color: #fff; + line-height: 1.3; + pointer-events: none; + z-index: 5; + word-break: break-all; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ep-preview-title { + text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7); +} + +.ep-preview-subtitle { + background: rgba(0, 0, 0, 0.55); + padding: 2px 4px; + border-radius: 3px; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5); +} + /* 封面预览 */ .ep-cover-preview { width: 150px; @@ -572,7 +651,11 @@ .ep-cover-image { width: 100%; height: 100%; - background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-card, #13131f)); + background: linear-gradient( + 135deg, + var(--ep-bg-card-hover, #1a1a2e), + var(--ep-bg-card, #13131f) + ); display: flex; align-items: center; justify-content: center; @@ -805,7 +888,9 @@ .ep-clip-card.selected { border-color: var(--primary, #6366f1); - box-shadow: 0 0 0 2px var(--primary-soft, #eef2ff), 0 4px 12px rgba(99, 102, 241, 0.2); + box-shadow: + 0 0 0 2px var(--primary-soft, #eef2ff), + 0 4px 12px rgba(99, 102, 241, 0.2); } .ep-clip-card.drag-over { @@ -819,7 +904,11 @@ .ep-clip-thumbnail { height: 52px; - background: linear-gradient(135deg, var(--bg-secondary, #f8fafc), var(--border-light, #f1f5f9)); + background: linear-gradient( + 135deg, + var(--bg-secondary, #f8fafc), + var(--border-light, #f1f5f9) + ); display: flex; align-items: center; justify-content: center; @@ -898,13 +987,190 @@ .ep-track-empty { flex: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 8px; color: var(--text-tertiary, #94a3b8); font-size: 12px; min-height: 96px; } +.ep-track-empty-icon { + font-size: 28px; + margin-bottom: 2px; +} + +.ep-track-empty-text { + color: var(--text-tertiary, #94a3b8); + font-size: 12px; +} + +.ep-track-empty-add-btn { + margin-top: 4px; + padding: 6px 18px; + background: var(--primary, #6366f1); + color: #fff; + border: none; + border-radius: var(--radius-sm, 14px); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast, 0.15s ease); +} + +.ep-track-empty-add-btn:hover { + background: var(--primary-hover, #4f46e5); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} + +/* 轨道末尾 "+" 添加卡片 — 对齐 V21 原型虚线 "+" 卡 */ +.ep-track-add-card-wrapper { + position: relative; + flex-shrink: 0; +} + +.ep-track-add-card { + min-width: 60px; + height: 96px; + border: 2px dashed var(--line, #e2e8f0); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 24px; + color: #94a3b8; + transition: all 0.2s ease; + user-select: none; +} + +.ep-track-add-card:hover { + border-color: var(--primary, #6366f1); + color: var(--primary, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +.ep-track-add-card.locked { + cursor: not-allowed; + opacity: 0.5; + font-size: 18px; +} + +.ep-track-add-card.locked:hover { + border-color: var(--line, #e2e8f0); + color: #94a3b8; + background: none; +} + +.ep-track-add-card-wrapper > .ep-add-clip-picker { + top: calc(100% + 6px); + right: 0; +} + +/* 添加片段按钮 + 选择器 */ +.ep-add-clip-wrapper { + position: relative; + display: inline-block; +} + +.ep-add-clip-btn { + padding: 4px 12px; + background: var(--primary, #6366f1); + color: #fff; + border: none; + border-radius: var(--radius-sm, 14px); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast, 0.15s ease); + white-space: nowrap; +} + +.ep-add-clip-btn:hover { + background: var(--primary-hover, #4f46e5); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3); +} + +.ep-add-clip-picker { + position: absolute; + top: calc(100% + 6px); + right: 0; + width: 260px; + max-height: 320px; + background: var(--bg-primary, #ffffff); + border: 1px solid var(--line, #e2e8f0); + border-radius: var(--radius-md, 18px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 100; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.ep-add-clip-picker-title { + padding: 10px 14px 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #64748b); + border-bottom: 1px solid var(--line, #e2e8f0); +} + +.ep-add-clip-picker-empty { + padding: 20px 14px; + text-align: center; + color: var(--text-tertiary, #94a3b8); + font-size: 12px; +} + +.ep-add-clip-picker-list { + overflow-y: auto; + max-height: 260px; + padding: 4px 0; +} + +.ep-add-clip-picker-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 14px; + background: none; + border: none; + cursor: pointer; + text-align: left; + font-size: 12px; + color: var(--text-primary, #0f172a); + transition: background var(--transition-fast, 0.15s ease); +} + +.ep-add-clip-picker-item:hover { + background: var(--bg-hover, #f1f5f9); +} + +.ep-add-clip-picker-icon { + font-size: 16px; + flex-shrink: 0; +} + +.ep-add-clip-picker-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ep-add-clip-picker-type { + flex-shrink: 0; + font-size: 10px; + color: var(--text-tertiary, #94a3b8); + background: var(--bg-secondary, #f8fafc); + padding: 2px 6px; + border-radius: 4px; +} + /* 一镜到底提示 */ .ep-one-take-hint { padding: 6px 16px; @@ -1119,6 +1385,57 @@ color: var(--primary, #6366f1); } +/* 标题预设样式按钮 — 方形 T 字预览 */ +.ep-title-presets { + display: flex; + gap: 8px; +} + +.ep-title-preset-btn { + flex: 1; + min-width: 44px; + height: 48px; + background: var(--bg-primary, #ffffff); + border: 2px solid var(--border-color, #e2e8f0); + border-radius: var(--radius-xs, 8px); + color: var(--text-primary, #1e293b); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 4px 6px; + transition: all 0.15s ease; +} + +.ep-title-preset-btn:hover { + border-color: var(--primary, #6366f1); + background: var(--primary-soft, #eef2ff); +} + +.ep-title-preset-btn.active { + border-color: var(--primary, #6366f1); + background: var(--primary-soft, #eef2ff); + box-shadow: 0 0 0 1px var(--primary, #6366f1); +} + +.ep-title-preset-t { + line-height: 1; + font-family: inherit; +} + +.ep-title-preset-label { + font-size: 10px; + color: var(--text-tertiary, #94a3b8); + line-height: 1; +} + +.ep-title-preset-btn.active .ep-title-preset-label { + color: var(--primary, #6366f1); + font-weight: 500; +} + /* AI推荐按钮 */ .ep-ai-recommend-btn { width: 100%; @@ -1333,7 +1650,9 @@ stroke: var(--primary, #6366f1); stroke-width: 8; stroke-linecap: round; - transition: stroke-dashoffset 0.5s ease, stroke 0.3s; + transition: + stroke-dashoffset 0.5s ease, + stroke 0.3s; } .ep-gen-progress-pct { @@ -1475,7 +1794,9 @@ font-size: 11px; padding: 2px 4px; border-radius: 3px; - transition: color 0.2s, background 0.2s; + transition: + color 0.2s, + background 0.2s; display: inline-flex; align-items: center; gap: 3px; @@ -1490,11 +1811,15 @@ color: var(--primary-dark); } - /* Skeleton loading */ @keyframes ep-skeleton-pulse { - 0%, 100% { opacity: 0.4; } - 50% { opacity: 0.8; } + 0%, + 100% { + opacity: 0.4; + } + 50% { + opacity: 0.8; + } } .ep-skeleton { @@ -1654,8 +1979,12 @@ } @keyframes ep-fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .ep-modal { @@ -1668,8 +1997,14 @@ } @keyframes ep-scale-in { - from { opacity: 0; transform: scale(0.95); } - to { opacity: 1; transform: scale(1); } + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } } .ep-modal-header { @@ -1751,4 +2086,3 @@ text-align: center; min-height: 80px; } - diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 50cf96ac8..5822c00a3 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -160,6 +160,11 @@ const EditingPlanner: React.FC = () => { /* ── 素材库 ── */ const [mediaAssets, setMediaAssets] = useState([]); + const [selectedAssetIds, setSelectedAssetIds] = useState([]); + + const handleAssetSelect = (ids: string[]) => { + setSelectedAssetIds(ids); + }; /* ── 生成历史 ── */ const [genHistoryOpen, setGenHistoryOpen] = useState(false); @@ -245,7 +250,7 @@ const EditingPlanner: React.FC = () => { setDraftTags(tpl.tags.join(", ")); }) .catch(() => msg.error("加载模板详情失败")); - }, [loadedTemplateId]); + }, [loadedTemplateId, resetClips]); /* ──────────── 计算 ──────────── */ @@ -688,6 +693,9 @@ const EditingPlanner: React.FC = () => { onLoadTemplate={handleLoadTemplate} onSearchChange={setSearchQuery} onFilterChange={setCurrentFilter} + mediaAssets={mediaAssets} + onAssetSelect={handleAssetSelect} + selectedAssetIds={selectedAssetIds} /> {/* 中栏 flex-1 */} @@ -700,6 +708,8 @@ const EditingPlanner: React.FC = () => { currentCoverScheme={currentCoverScheme} coverSchemes={COVER_SCHEMES} aiCoverLoading={aiCoverLoading} + titleSettings={titleSettings} + subtitleSettings={subtitleSettings} onClipSelect={handleClipSelect} onCoverSchemeChange={setCurrentCoverScheme} onPlayPause={() => setIsPlaying(!isPlaying)} diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 2533c8781..8a890291b 100644 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -81,6 +81,73 @@ const BGM_OPTIONS = [ { value: "bgm_04", label: "🎵 科技感" }, ]; +/** + * 标题样式预设 — 一键应用一组样式 + * 注意:预设只绑定样式属性(粗细、描边、阴影、字号),不绑定字体家族 + */ +const TITLE_PRESETS = [ + { + key: "default", + label: "默认", + style: { + size: 24, + bold: false, + italic: false, + stroke: false, + shadow: false, + }, + }, + { + key: "classic", + label: "经典", + style: { + size: 24, + bold: true, + italic: false, + stroke: true, + shadow: false, + }, + }, + { + key: "bold", + label: "醒目", + style: { + size: 32, + bold: true, + italic: false, + stroke: true, + shadow: true, + }, + }, + { + key: "elegant", + label: "柔和", + style: { + size: 20, + bold: false, + italic: false, + stroke: false, + shadow: true, + }, + }, +]; + +/** 判断当前设置匹配哪个预设(不含 font 字段) */ +function getActivePreset(settings: TitleSettings): string | null { + for (const p of TITLE_PRESETS) { + if ( + settings.size === p.style.size && + 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 ClipPropertiesPanel: React.FC = ({ selectedClip, titleSettings, @@ -172,6 +239,46 @@ const ClipPropertiesPanel: React.FC = ({ +
+ +
+ {TITLE_PRESETS.map((p) => { + const isActive = getActivePreset(titleSettings) === p.key; + return ( + + ); + })} +
+
+
diff --git a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx index a83e96484..33da4806a 100755 --- a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx @@ -1,10 +1,12 @@ /** - * 左侧模板面板 — V8 原型 1:1 还原 - * 纯模板列表 + chip 分类筛选(无素材 Tab) + * 左侧面板 — V8 原型 1:1 还原 + * Tab 切换:模板列表 + 素材库 */ -import React from "react"; +import React, { useState } from "react"; import type { EditingTemplate } from "@/api/editingPlanner"; import { MODE_LABELS } from "@/api/editingPlanner"; +import type { MediaAsset } from "@/api/editPlans"; +import AssetSelector from "@/components/AssetSelector/AssetSelector"; interface MediaPanelProps { templates: EditingTemplate[]; @@ -16,6 +18,10 @@ interface MediaPanelProps { onLoadTemplate: (id: string) => void; onSearchChange: (q: string) => void; onFilterChange: (f: string) => void; + // 素材相关 + mediaAssets?: MediaAsset[]; + onAssetSelect?: (ids: string[]) => void; + selectedAssetIds?: string[]; } const MediaPanel: React.FC = ({ @@ -28,80 +34,117 @@ const MediaPanel: React.FC = ({ onLoadTemplate, onSearchChange, onFilterChange, + mediaAssets = [], + onAssetSelect, + selectedAssetIds = [], }) => { + const [activeTab, setActiveTab] = useState<"templates" | "assets">( + "templates", + ); + return (
- {/* 标题栏 */} -
- 📋 已保存模板 + {/* Tab 切换 */} +
+ +
- {/* 搜索 */} -
- 🔍 - onSearchChange(e.target.value)} - /> -
- - {/* Chip 分类筛选 */} -
- {filterCategories.map((cat) => ( - - ))} -
- - {/* 模板列表 */} -
- {loading ? ( -
- - 加载中... + {/* 模板 Tab */} + {activeTab === "templates" && ( + <> + {/* 搜索 */} +
+ 🔍 + onSearchChange(e.target.value)} + />
- ) : templates.length === 0 ? ( -
- 📭 - 暂无模板 + + {/* Chip 分类筛选 */} +
+ {filterCategories.map((cat) => ( + + ))}
- ) : ( - templates.map((tpl) => ( -
onLoadTemplate(tpl.id)} - > -
- {tpl.name} - - {MODE_LABELS[tpl.mode]} - + + {/* 模板列表 */} +
+ {loading ? ( +
+ + 加载中...
-
- ⏱️ {tpl.estimated_duration}s - 📐 {tpl.segments.length}片段 + ) : templates.length === 0 ? ( +
+ 📭 + 暂无模板
- {tpl.tags.length > 0 && ( -
- {tpl.tags.map((tag) => ( - - {tag} + ) : ( + templates.map((tpl) => ( +
onLoadTemplate(tpl.id)} + > +
+ {tpl.name} + + {MODE_LABELS[tpl.mode]} - ))} +
+
+ ⏱️ {tpl.estimated_duration}s + 📐 {tpl.segments.length}片段 +
+ {tpl.tags.length > 0 && ( +
+ {tpl.tags.map((tag) => ( + + {tag} + + ))} +
+ )}
- )} -
- )) - )} -
+ )) + )} +
+ + )} + + {/* 素材 Tab */} + {activeTab === "assets" && ( +
+ +
+ )}
); }; diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index e64d8f4b5..d67fbb226 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -19,6 +19,26 @@ interface CoverScheme { label: string; } +interface TitleSettings { + aiAutoSelect: boolean; + title: string; + position: string; + font: string; + size: number; + bold: boolean; + italic: boolean; + stroke: boolean; + shadow: boolean; +} + +interface SubtitleSettings { + enabled: boolean; + position: string; + font: string; + size: number; + animation: string; +} + interface PreviewPlayerProps { clips: ClipData[]; selectedClipId: string | null; @@ -26,6 +46,8 @@ interface PreviewPlayerProps { currentCoverScheme: string; coverSchemes: CoverScheme[]; aiCoverLoading: boolean; + titleSettings?: TitleSettings; + subtitleSettings?: SubtitleSettings; onClipSelect: (clipId: string) => void; onCoverSchemeChange: (scheme: string) => void; onPlayPause: () => void; @@ -46,6 +68,8 @@ const PreviewPlayer: React.FC = ({ currentCoverScheme, coverSchemes, aiCoverLoading, + titleSettings, + subtitleSettings, onCoverSchemeChange, onPlayPause, onAiGenerateCover, @@ -78,6 +102,64 @@ const PreviewPlayer: React.FC = ({ ) : ( 暂无片段 )} + + {/* 标题实时预览 */} + {titleSettings && + !titleSettings.aiAutoSelect && + titleSettings.title && ( +
+ {titleSettings.title} +
+ )} + + {/* 字幕实时预览 */} + {subtitleSettings?.enabled && ( +
+ 字幕预览文字 +
+ )}
diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index b7d5f8a71..3cdd2b7b7 100755 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -1,6 +1,7 @@ /** * 水平轨道时间线 — V8 原型 1:1 还原 * 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序 + * 支持「添加片段」按钮直接选择素材添加,不依赖拖拽 */ import React, { useState, useRef } from "react"; import type { MediaAsset } from "@/api/editPlans"; @@ -30,6 +31,12 @@ interface TimelinePanelProps { mediaAssets: MediaAsset[]; } +const ASSET_TYPE_ICONS: Record = { + video: "🎬", + image: "🖼️", + audio: "🎵", +}; + const MATERIAL_ICONS: Record = { video: "🎬", image: "🖼️", @@ -46,10 +53,32 @@ const TimelinePanel: React.FC = ({ onClipRemove, onAssetDropToTimeline, onAssetDropToClip, + mediaAssets, }) => { const [dragIdx, setDragIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(null); const dragRef = useRef(null); + const [showAddPicker, setShowAddPicker] = useState(false); + const pickerRef = useRef(null); + + /* ── 点击外部关闭素材选择器 ── */ + React.useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { + setShowAddPicker(false); + } + }; + if (showAddPicker) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showAddPicker]); + + /* ── 选择素材添加片段 ── */ + const handlePickAsset = (asset: MediaAsset) => { + onAssetDropToTimeline(asset); + setShowAddPicker(false); + }; /* ── 片段拖拽排序 ── */ const handleDragStart = (e: React.DragEvent, idx: number) => { @@ -193,7 +222,12 @@ const TimelinePanel: React.FC = ({ onDragOver={handleEmptyDragOver} > {clips.length === 0 ? ( -
🎬 拖入素材或从模板加载片段
+
+
🎬
+
+ 点击右侧 + 添加片段,或拖入素材 +
+
) : ( clips.map((clip, idx) => (
= ({
)) )} + + {/* ── 轨道末尾 "+" 添加卡片(对齐 V21 原型 epAddSegment) ── */} +
+
{ + if (currentMode !== "one_take") setShowAddPicker((v) => !v); + }} + title={ + currentMode === "one_take" ? "一镜到底模式下不可添加" : "添加片段" + } + > + {currentMode === "one_take" ? "🔒" : "+"} +
+ + {/* 素材选择面板 */} + {showAddPicker && ( +
+
选择素材添加为片段
+ {mediaAssets.length === 0 ? ( +
+ 暂无可用素材,请先在左侧素材库导入 +
+ ) : ( +
+ {mediaAssets.map((asset) => ( + + ))} +
+ )} +
+ )} +
); diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index da0ebc082..d20cd8785 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -19,6 +19,9 @@ import { import type { AssetItem } from "@/api/assets"; import { getAssets, getAssetLibraries } from "@/api/assets"; import { createEditPlan, generateEditPlan } from "@/api/editPlans"; +import { getEditingTemplates } from "@/api/editingPlanner"; +import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner"; +import { getTitles } from "@/api/titles"; import apiClient from "@/api/client"; import { fetchPresetVoices } from "@/api/voices"; import type { PresetVoiceItem } from "@/api/voices"; @@ -40,42 +43,19 @@ const CLONE_STATUS_CONFIG: Record = { failed: { label: "失败", color: "var(--error-color, #ef4444)" }, }; -/* ── 模板定义 ── */ -interface TemplateOption { - id: string; - label: string; - desc: string; - thumb: string; - gradient: string; - abbr: string; -} - -const TEMPLATE_OPTIONS: TemplateOption[] = [ - { - id: "pip", - label: "画中画混剪", - desc: "产品展示 · 30秒", - thumb: "PIP", - gradient: "linear-gradient(135deg, #fbbf24, #f59e0b)", - abbr: "PIP", - }, - { - id: "one-shot", - label: "一镜到底", - desc: "口播 · 45秒", - thumb: "ONE", - gradient: "linear-gradient(135deg, #3b82f6, #1d4ed8)", - abbr: "ONE", - }, - { - id: "person-talking", - label: "人物口播", - desc: "知识分享 · 60秒", - thumb: "PER", - gradient: "linear-gradient(135deg, #6366f1, #4f46e5)", - abbr: "PER", - }, -]; +/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */ +const MODE_GRADIENTS: Record = { + pip: "linear-gradient(135deg, #fbbf24, #f59e0b)", + one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)", + voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", + voice_pip: "linear-gradient(135deg, #10b981, #059669)", +}; +const MODE_ABBRS: Record = { + pip: "PIP", + one_take: "ONE", + voice_over: "VOI", + voice_pip: "VP", +}; /* ── 配音预设卡片 ── */ interface VoicePresetCard { @@ -116,14 +96,7 @@ const STEPS = [ { key: 5, label: "确认生成" }, ]; -/* ── 标题选项 Mock ── */ -const TITLE_OPTIONS = [ - "3秒抓住注意力,30秒讲清卖点", - "产品种草 | 一分钟了解核心优势", - "客户真实反馈,看完你就懂了", - "行业干货分享,建议收藏", - "新品首发,限时优惠不容错过", -]; +/* ── 标题选项从 API 加载,不再硬编码 ── */ /* ================================================================ 组件 @@ -133,14 +106,37 @@ const GeneratePage: React.FC = () => { /* ── 步骤状态 ── */ const [currentStep, setCurrentStep] = useState(1); - /* ── 模板 ── */ - const [selectedTemplate, setSelectedTemplate] = useState("pip"); + /* ── 模板(从 API 加载用户自制模板) ── */ + const [selectedTemplate, setSelectedTemplate] = useState(""); + const { data: userTemplates = [] } = useQuery({ + queryKey: ["generate-templates"], + queryFn: () => getEditingTemplates(), + staleTime: 60_000, + }); + /* 模板加载完成后自动选中第一个 */ + useEffect(() => { + if (userTemplates.length > 0 && !selectedTemplate) { + setSelectedTemplate(userTemplates[0].id); + } + }, [userTemplates, selectedTemplate]); /* ── 素材 ── */ const [selectedMaterials, setSelectedMaterials] = useState([]); /* ── 标题 ── */ const [title, setTitle] = useState(""); + const { data: userTitles = [] } = useQuery({ + queryKey: ["generate-titles"], + queryFn: () => getTitles(), + staleTime: 60_000, + }); + /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */ + useEffect(() => { + const tpl = userTemplates.find((t) => t.id === selectedTemplate); + if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) { + setTitle(tpl.title_config.content); + } + }, [selectedTemplate, userTemplates]); /* ── 配音 ── */ const [selectedVoice, setSelectedVoice] = useState(""); @@ -412,43 +408,74 @@ const GeneratePage: React.FC = () => { await generateEditPlan(plan.id); const poll = async () => { - const status = await apiClient.get( - `/edit-plans/${plan.id}/generation-status`, - ); - const data = status.data; + try { + const status = await apiClient.get( + `/edit-plans/${plan.id}/generation-status`, + ); + const data = status.data; - if (data.plan_status === "completed") { - setProgress(100); - setGenerating(false); - setGenerated(true); - message.success("视频生成完成!"); - return; + if (data.plan_status === "completed") { + setProgress(100); + setGenerating(false); + setGenerated(true); + message.success("视频生成完成!"); + return; + } + if (data.plan_status === "failed") { + setGenerating(false); + // 提取后端返回的错误详情,便于排查 + const errorMsg = + data.error_message || + data.error || + data.message || + (data.clips || []).find( + (c: { status: string }) => c.status === "failed", + )?.error_message || + "视频生成失败,请联系管理员或重试"; + console.error("[生成失败] planId:", plan.id, "响应:", data); + message.error(errorMsg); + return; + } + + const clips = data.clips || []; + const total = clips.length || 1; + const done = clips.filter( + (c: { status: string }) => c.status === "completed", + ).length; + setProgress(Math.round((done / total) * 100)); + + progressTimer.current = setTimeout( + poll, + 2000, + ) as unknown as ReturnType; + } catch (pollErr) { + // 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次 + console.error("[轮询出错] planId:", plan.id, pollErr); + progressTimer.current = setTimeout( + poll, + 3000, + ) as unknown as ReturnType; } - if (data.plan_status === "failed") { - setGenerating(false); - message.error("视频生成失败"); - return; - } - - const clips = data.clips || []; - const total = clips.length || 1; - const done = clips.filter( - (c: { status: string }) => c.status === "completed", - ).length; - setProgress(Math.round((done / total) * 100)); - - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< - typeof setInterval - >; }; progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< typeof setInterval >; - } catch (err) { + } catch (err: unknown) { console.error("生成失败:", err); setGenerating(false); - message.error("生成失败,请重试"); + // 提取 axios 响应中的后端错误信息 + const axiosErr = err as { + response?: { + data?: { message?: string; error?: string; detail?: string }; + }; + }; + const backendMsg = + axiosErr.response?.data?.message || + axiosErr.response?.data?.error || + axiosErr.response?.data?.detail || + ""; + message.error(backendMsg || "生成失败,请重试"); } }, [ title, @@ -494,7 +521,7 @@ const GeneratePage: React.FC = () => { /* ── 辅助 ── */ const getTemplateName = () => - TEMPLATE_OPTIONS.find((t) => t.id === selectedTemplate)?.label ?? "未选择"; + userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择"; const getVoiceName = () => { if (voiceMode === "clone") { @@ -513,34 +540,73 @@ const GeneratePage: React.FC = () => { const renderStep1 = () => (

🎨 选择模板

-
- {TEMPLATE_OPTIONS.map((tpl) => ( -
setSelectedTemplate(tpl.id)} - role="button" - tabIndex={0} - aria-pressed={selectedTemplate === tpl.id} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedTemplate(tpl.id); - } - }} - > - + {userTemplates.length === 0 ? ( +
+

暂无可用模板

+

+ 请先在「剪辑计划编辑器」中创建模板 +

+
+ ) : ( +
+ {userTemplates.map((tpl) => (
setSelectedTemplate(tpl.id)} + role="button" + tabIndex={0} + aria-pressed={selectedTemplate === tpl.id} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setSelectedTemplate(tpl.id); + } + }} > - {tpl.abbr} + +
+ {MODE_ABBRS[tpl.mode] || "TPL"} +
+

{tpl.name}

+

+ {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "} + {tpl.estimated_duration}s · {tpl.segments.length}片段 +

+ {tpl.tags.length > 0 && ( +
+ {tpl.tags.map((tag) => ( + + {tag} + + ))} +
+ )}
-

{tpl.label}

-

{tpl.desc}

-
- ))} -
+ ))} +
+ )}
); @@ -653,12 +719,12 @@ const GeneratePage: React.FC = () => {

📝 选择标题

- + @@ -672,6 +738,13 @@ const GeneratePage: React.FC = () => { maxLength={50} />
+ {userTitles.length === 0 && ( +

+ 标题库为空,请前往「标题管理」添加标题,或手动输入 +

+ )}
); diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 296eec80b..76331e052 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -408,24 +408,41 @@ } .xx-clone-avatar.ready { - background: linear-gradient(135deg, var(--color-secondary-500), var(--color-secondary-600)); + background: linear-gradient( + 135deg, + var(--color-secondary-500), + var(--color-secondary-600) + ); color: var(--text-inverse); } .xx-clone-avatar.processing { - background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-600)); + background: linear-gradient( + 135deg, + var(--color-accent-500), + var(--color-accent-600) + ); color: var(--text-inverse); animation: xx-clone-pulse 2s ease-in-out infinite; } .xx-clone-avatar.failed { - background: linear-gradient(135deg, var(--color-gray-400), var(--color-gray-500)); + background: linear-gradient( + 135deg, + var(--color-gray-400), + var(--color-gray-500) + ); color: var(--text-inverse); } @keyframes xx-clone-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } } .xx-clone-info { @@ -465,7 +482,11 @@ .xx-clone-progress-bar { height: 100%; - background: linear-gradient(90deg, var(--warning-color), var(--success-color)); + background: linear-gradient( + 90deg, + var(--warning-color), + var(--success-color) + ); border-radius: 3px; transition: width 0.5s ease; } @@ -474,8 +495,10 @@ width: 100%; background: repeating-linear-gradient( 90deg, - var(--warning-color) 0%, var(--warning-color) 25%, - var(--success-color) 25%, var(--success-color) 50%, + var(--warning-color) 0%, + var(--warning-color) 25%, + var(--success-color) 25%, + var(--success-color) 50%, var(--warning-color) 50% ); background-size: 60px 100%; @@ -483,8 +506,12 @@ } @keyframes xx-clone-progress-flow { - from { background-position: 0 0; } - to { background-position: 60px 0; } + from { + background-position: 0 0; + } + to { + background-position: 60px 0; + } } .xx-clone-progress-text { @@ -518,13 +545,23 @@ } @keyframes xx-fade { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.7; + } } @keyframes xx-blink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } } /* ============================================================ @@ -640,7 +677,11 @@ aspect-ratio: 9 / 16; max-height: 400px; border-radius: var(--radius-md); - background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900)); + background: linear-gradient( + 135deg, + var(--color-gray-900), + var(--color-primary-900) + ); display: grid; place-items: center; color: var(--text-inverse); @@ -654,7 +695,11 @@ content: ""; position: absolute; inset: 0; - background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%); + background: radial-gradient( + circle at 72% 28%, + rgba(255, 255, 255, 0.2), + transparent 40% + ); pointer-events: none; } @@ -777,7 +822,11 @@ .xx-progress-bar-fill { height: 100%; - background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-600)); + background: linear-gradient( + 90deg, + var(--color-primary-500), + var(--color-primary-600) + ); border-radius: 3px; transition: width 0.3s ease; } diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx index 7891d8c43..6af1503e6 100644 --- a/apps/web/src/pages/my-templates/MyTemplates.tsx +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -226,6 +226,7 @@ const MyTemplates: React.FC = () => { > {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} + 用户自制
diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index b3aa90de9..ed3e4c80e 100644 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -16,6 +16,7 @@ import { ClockCircleOutlined, } from "@ant-design/icons"; import { Button, Modal, Input, Tooltip } from "@/components/ui"; +import type { ButtonProps } from "antd"; import PageHead from "@/components/layout/PageHead"; import { useCloneProgress } from "@/hooks/useCloneProgress"; import { @@ -378,7 +379,7 @@ const MyVoices: React.FC = () => { onOk={handleDeleteConfirm} okText="删除" cancelText="取消" - okButtonProps={{ danger: true } as any} + okButtonProps={{ danger: true } as ButtonProps} >

确定要删除这个克隆音色吗?删除后无法恢复。

diff --git a/apps/web/src/pages/my-voices/my-voices.css b/apps/web/src/pages/my-voices/my-voices.css index 3561799a2..d24d55155 100644 --- a/apps/web/src/pages/my-voices/my-voices.css +++ b/apps/web/src/pages/my-voices/my-voices.css @@ -112,19 +112,11 @@ } .xx-mv-card--processing { - border-color: color-mix( - in srgb, - var(--accent-color) 30%, - transparent - ); + border-color: color-mix(in srgb, var(--accent-color) 30%, transparent); } .xx-mv-card--failed { - border-color: color-mix( - in srgb, - var(--error-color) 25%, - transparent - ); + border-color: color-mix(in srgb, var(--error-color) 25%, transparent); opacity: 0.85; } diff --git a/apps/web/src/pages/products/ProductDetail.tsx b/apps/web/src/pages/products/ProductDetail.tsx new file mode 100644 index 000000000..f722af8b5 --- /dev/null +++ b/apps/web/src/pages/products/ProductDetail.tsx @@ -0,0 +1,491 @@ +/** + * 成品库 — 产品详情页 + * 路由:/app/products/:id + * 展示视频播放器 + 完整元数据 + 下载/分享/删除操作 + */ +import React, { useRef, useState, useEffect, useCallback } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeftOutlined, + DownloadOutlined, + ShareAltOutlined, + DeleteOutlined, + PlayCircleFilled, + PauseCircleFilled, + SoundOutlined, + MutedOutlined, + ExpandOutlined, + LoadingOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import { + getProduct, + deleteProduct, + getProductDownloadUrl, + type ProductItem, +} from "../../api/products"; +import { Button } from "../../components/ui"; +import "./products.css"; + +/* ============================================================ + * 工具函数 + * ============================================================ */ + +/** 格式化时长(秒 → "MM:SS") */ +const formatDuration = (seconds: number): string => { + if (!seconds || seconds <= 0) return "00:00"; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; +}; + +/** 格式化文件大小(MB) */ +const formatFileSize = (mb: number): string => { + if (!mb || mb <= 0) return "-"; + if (mb < 1024) return `${mb.toFixed(1)} MB`; + return `${(mb / 1024).toFixed(2)} GB`; +}; + +/** 格式化日期 */ +const formatDate = (dateStr: string): string => { + if (!dateStr) return "-"; + const d = new Date(dateStr); + return d.toLocaleDateString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +}; + +/** 状态标签 */ +const STATUS_MAP: Record = { + completed: { label: "已完成", color: "#10b981" }, + processing: { label: "处理中", color: "#6366f1" }, + pending: { label: "待处理", color: "#f59e0b" }, + failed: { label: "失败", color: "#ef4444" }, +}; + +/* ============================================================ + * 主组件 + * ============================================================ */ +const ProductDetail: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + /* ── 获取产品详情 ── */ + const { + data: product, + isLoading, + isError, + error, + } = useQuery({ + queryKey: ["product", id], + queryFn: () => getProduct(id!), + enabled: !!id, + staleTime: 10_000, + }); + + /* ── 删除 mutation ── */ + const deleteMutation = useMutation({ + mutationFn: () => deleteProduct(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }); + navigate("/app/products"); + }, + }); + + /* ── 视频播放器状态 ── */ + const videoRef = useRef(null); + const progressRef = useRef(null); + const hideTimerRef = useRef>(); + + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [buffered, setBuffered] = useState(0); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [showControls, setShowControls] = useState(true); + const [isFullscreen, setIsFullscreen] = useState(false); + const containerRef = useRef(null); + + /* ── 自动隐藏控制条 ── */ + const resetHideTimer = useCallback(() => { + setShowControls(true); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + if (isPlaying) { + hideTimerRef.current = setTimeout(() => setShowControls(false), 3000); + } + }, [isPlaying]); + + /* ── 播放控制 ── */ + const togglePlay = useCallback(() => { + const v = videoRef.current; + if (!v) return; + if (v.paused) { + v.play().catch(() => {}); + } else { + v.pause(); + } + }, []); + + const handleSeek = useCallback( + (e: React.MouseEvent) => { + const v = videoRef.current; + const bar = progressRef.current; + if (!v || !bar || !duration) return; + const rect = bar.getBoundingClientRect(); + const ratio = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); + v.currentTime = ratio * duration; + }, + [duration], + ); + + const handleVolumeChange = useCallback( + (e: React.ChangeEvent) => { + const v = videoRef.current; + const val = parseFloat(e.target.value); + if (v) v.volume = val; + setVolume(val); + setIsMuted(val === 0); + }, + [], + ); + + const toggleMute = useCallback(() => { + const v = videoRef.current; + if (!v) return; + if (isMuted) { + v.muted = false; + v.volume = volume || 1; + setIsMuted(false); + } else { + v.muted = true; + setIsMuted(true); + } + }, [isMuted, volume]); + + const toggleFullscreen = useCallback(() => { + const el = containerRef.current; + if (!el) return; + if (!document.fullscreenElement) { + el.requestFullscreen?.().catch(() => {}); + } else { + document.exitFullscreen?.().catch(() => {}); + } + }, []); + + /* ── 视频事件监听 ── */ + useEffect(() => { + const v = videoRef.current; + if (!v) return; + + const onPlay = () => setIsPlaying(true); + const onPause = () => setIsPlaying(false); + const onTimeUpdate = () => setCurrentTime(v.currentTime); + const onLoadedMetadata = () => setDuration(v.duration); + const onProgress = () => { + if (v.buffered.length > 0) { + setBuffered(v.buffered.end(v.buffered.length - 1)); + } + }; + const onEnded = () => setIsPlaying(false); + const onFSChange = () => setIsFullscreen(!!document.fullscreenElement); + + v.addEventListener("play", onPlay); + v.addEventListener("pause", onPause); + v.addEventListener("timeupdate", onTimeUpdate); + v.addEventListener("loadedmetadata", onLoadedMetadata); + v.addEventListener("progress", onProgress); + v.addEventListener("ended", onEnded); + document.addEventListener("fullscreenchange", onFSChange); + + return () => { + v.removeEventListener("play", onPlay); + v.removeEventListener("pause", onPause); + v.removeEventListener("timeupdate", onTimeUpdate); + v.removeEventListener("loadedmetadata", onLoadedMetadata); + v.removeEventListener("progress", onProgress); + v.removeEventListener("ended", onEnded); + document.removeEventListener("fullscreenchange", onFSChange); + }; + }, []); + + /* 播放时自动隐藏/显示控制条 */ + useEffect(() => { + resetHideTimer(); + return () => { + if (hideTimerRef.current) clearTimeout(hideTimerRef.current); + }; + }, [isPlaying, resetHideTimer]); + + /* ── 下载 ── */ + const handleDownload = async () => { + if (!product || product.status !== "completed") return; + try { + const { url } = await getProductDownloadUrl(product.id); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + } catch { + // message.error handled by caller + } + }; + + /* ── 分享 ── */ + const handleShare = () => { + if (!product) return; + const link = `${window.location.origin}/app/products/${product.id}`; + navigator.clipboard?.writeText(link).then( + () => {}, + () => {}, + ); + }; + + /* ── 删除 ── */ + const handleDelete = () => { + if (!id) return; + deleteMutation.mutate(); + }; + + /* ── 加载状态 ── */ + if (isLoading) { + return ( +
+
+ +

+ 加载中… +

+
+
+ ); + } + + /* ── 错误状态 ── */ + if (isError || !product) { + return ( +
+
+ +

加载失败

+

{error?.message || "无法获取产品信息"}

+ +
+
+ ); + } + + const statusInfo = STATUS_MAP[product.status] || { + label: product.status, + color: "#94a3b8", + }; + const progress = Math.round((currentTime / (duration || 1)) * 100); + const bufferedPct = Math.round((buffered / (duration || 1)) * 100); + + return ( +
+ {/* ── 顶部导航 ── */} +
+ +
+ + + +
+
+ + {/* ── 主体内容 ── */} +
+ {/* 视频播放器 */} +
+ {product.video_url ? ( +