From 0fcb77b99178c82ac3d074ccef44efda97bbbbe6 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Sat, 18 Jul 2026 18:08:19 +0800 Subject: [PATCH] =?UTF-8?q?ci:=20Prettier=E7=BA=B3=E5=85=A5=E4=B8=A4?= =?UTF-8?q?=E5=B1=82=E9=98=B2=E5=BE=A1=E4=BD=93=E7=B3=BB=20(#520)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xiaoxia Co-committed-by: xiaoxia --- .pre-commit-config.yaml | 9 + .vscode/settings.json | 37 + apps/web/.eslintrc.cjs | 8 +- apps/web/.prettierignore | 12 + apps/web/.prettierrc.json | 12 + apps/web/README.md | 8 +- apps/web/e2e/assets-full.spec.ts | 632 ++++----- apps/web/e2e/auth-guard.spec.ts | 10 +- apps/web/e2e/auth.spec.ts | 14 +- apps/web/e2e/core-generation.spec.ts | 233 ++-- apps/web/e2e/core-titles.spec.ts | 142 +- apps/web/e2e/core-upload.spec.ts | 157 +-- apps/web/e2e/duplication.spec.ts | 437 +++---- apps/web/e2e/editing-planner.spec.ts | 341 +++-- apps/web/e2e/products.spec.ts | 517 ++++---- apps/web/e2e/profile.spec.ts | 342 +++-- apps/web/e2e/register.spec.ts | 279 ++-- apps/web/e2e/subscription-full.spec.ts | 476 +++---- apps/web/e2e/subscription.spec.ts | 234 ++-- apps/web/e2e/templates.spec.ts | 436 +++---- apps/web/e2e/test_asset.spec.ts | 192 ++- apps/web/e2e/test_auth.spec.ts | 240 ++-- apps/web/e2e/test_project.spec.ts | 178 ++- apps/web/e2e/titles-full.spec.ts | 406 +++--- apps/web/e2e/voice-clone.spec.ts | 373 +++--- apps/web/e2e/voices.spec.ts | 333 +++-- apps/web/package-lock.json | 22 +- apps/web/package.json | 13 +- apps/web/playwright.config.ts | 6 +- apps/web/src/api/assets.ts | 461 ++++--- apps/web/src/api/auth.test.ts | 14 +- apps/web/src/api/auth.ts | 136 +- apps/web/src/api/bgm.ts | 52 +- apps/web/src/api/client.ts | 167 ++- apps/web/src/api/duplication.ts | 97 +- apps/web/src/api/editPlans.ts | 639 +++++---- apps/web/src/api/editingPlanner.ts | 216 ++- apps/web/src/api/products.ts | 128 +- apps/web/src/api/projects.ts | 43 +- apps/web/src/api/subscription.ts | 108 +- apps/web/src/api/tags.ts | 46 +- apps/web/src/api/tasks.ts | 132 +- apps/web/src/api/templates.ts | 158 ++- apps/web/src/api/titles.ts | 126 +- apps/web/src/api/tts.ts | 205 ++- apps/web/src/api/voiceClone.ts | 221 ++-- apps/web/src/api/voices.ts | 168 ++- .../AssetSelector/AssetSelector.tsx | 338 ++--- .../web/src/components/AssetSelector/index.ts | 4 +- apps/web/src/components/layout/AppLayout.tsx | 14 +- apps/web/src/components/layout/Header.tsx | 63 +- apps/web/src/components/layout/MainLayout.tsx | 39 +- apps/web/src/components/layout/PageHead.tsx | 92 +- apps/web/src/components/layout/Sidebar.tsx | 50 +- apps/web/src/components/ui/Button.tsx | 52 +- apps/web/src/components/ui/Card.tsx | 24 +- apps/web/src/components/ui/Input.tsx | 79 +- apps/web/src/components/ui/Modal.tsx | 59 +- apps/web/src/components/ui/Select.tsx | 34 +- apps/web/src/components/ui/Tag.tsx | 39 +- apps/web/src/components/ui/Tooltip.tsx | 44 +- apps/web/src/components/ui/index.ts | 28 +- apps/web/src/components/voice/CloneModal.tsx | 433 +++--- apps/web/src/components/voice/clone-modal.css | 3 +- apps/web/src/config/navigation.ts | 20 +- apps/web/src/hooks/useAuth.ts | 82 +- apps/web/src/hooks/useCloneProgress.ts | 64 +- apps/web/src/index.css | 5 +- apps/web/src/main.tsx | 24 +- apps/web/src/pages/accounts/Accounts.tsx | 41 +- apps/web/src/pages/admin/AdminComingSoon.tsx | 18 +- apps/web/src/pages/assets/AssetLibrary.tsx | 698 +++++----- apps/web/src/pages/auth/ForgotPassword.css | 12 +- apps/web/src/pages/auth/ForgotPassword.tsx | 52 +- apps/web/src/pages/auth/Login.css | 12 +- apps/web/src/pages/auth/Login.tsx | 48 +- apps/web/src/pages/auth/Register.css | 12 +- apps/web/src/pages/auth/Register.tsx | 77 +- apps/web/src/pages/auth/ResetPassword.css | 12 +- apps/web/src/pages/auth/ResetPassword.tsx | 68 +- apps/web/src/pages/dashboard/Dashboard.tsx | 38 +- .../pages/duplication/DuplicationDetail.tsx | 169 +-- .../pages/duplication/DuplicationResults.tsx | 155 +-- .../pages/duplication/DuplicationUpload.tsx | 143 +- apps/web/src/pages/edit-plans/EditPlans.tsx | 175 ++- .../src/pages/edit-plans/PlanClipsManager.tsx | 275 ++-- .../pages/editing-planner/EditingPlanner.css | 21 +- .../pages/editing-planner/EditingPlanner.tsx | 940 +++++++------ .../components/BgmSelector.tsx | 147 +-- .../components/ClipPropertiesPanel.tsx | 324 ++--- .../components/CoverSelector.tsx | 121 +- .../components/EditorClipList.tsx | 64 +- .../components/FilterPanel.tsx | 56 +- .../components/GenerationHistoryModal.tsx | 52 +- .../components/GenerationProgressModal.tsx | 94 +- .../components/GreenScreenPanel.tsx | 67 +- .../components/IntroOutroPanel.tsx | 74 +- .../editing-planner/components/MediaPanel.tsx | 48 +- .../components/PipConfigPanel.tsx | 167 +-- .../components/PreviewPlayer.tsx | 123 +- .../editing-planner/components/SaveModal.tsx | 38 +- .../editing-planner/components/SpeedPanel.tsx | 56 +- .../components/StickerPanel.tsx | 151 +-- .../components/SubtitleStylePanel.tsx | 50 +- .../components/TimelinePanel.tsx | 566 ++++---- .../components/TransitionSelector.tsx | 49 +- .../editing-planner/components/TtsPanel.tsx | 153 +-- .../components/WatermarkPanel.tsx | 138 +- .../editing-planner/hooks/useEditPlanClips.ts | 141 +- .../editing-planner/hooks/useUndoRedo.ts | 85 +- apps/web/src/pages/editing-planner/types.ts | 290 ++--- .../pages/editing-planner/types/subtitle.ts | 24 +- apps/web/src/pages/generate/GeneratePage.tsx | 960 ++++++-------- apps/web/src/pages/generate/generate.css | 42 +- apps/web/src/pages/history/TaskHistory.tsx | 121 +- apps/web/src/pages/home/HomePage.tsx | 121 +- apps/web/src/pages/home/home-page.css | 9 +- .../src/pages/my-templates/MyTemplates.tsx | 106 +- apps/web/src/pages/my-voices/MyVoices.tsx | 192 ++- apps/web/src/pages/my-voices/my-voices.css | 15 +- apps/web/src/pages/products/ProductDetail.tsx | 323 ++--- .../web/src/pages/products/ProductLibrary.tsx | 604 ++++----- apps/web/src/pages/profile/Settings.tsx | 33 +- apps/web/src/pages/subscription/Billing.tsx | 98 +- apps/web/src/pages/subscription/Plans.css | 12 +- apps/web/src/pages/subscription/Plans.tsx | 86 +- .../subscription/UpgradeSubscription.css | 3 +- .../subscription/UpgradeSubscription.tsx | 176 ++- apps/web/src/pages/tasks/TaskCenter.tsx | 177 ++- .../src/pages/templates/TemplateLibrary.tsx | 318 ++--- apps/web/src/pages/titles/TitleLibrary.tsx | 353 +++-- apps/web/src/pages/voice-clone/VoiceClone.tsx | 166 ++- .../web/src/pages/voice-clone/voice-clone.css | 6 +- .../voice-materials/VoiceMaterialLibrary.tsx | 1160 +++++++---------- apps/web/src/pages/voices/VoiceLibrary.tsx | 787 +++++------ apps/web/src/pages/voices/voices.css | 12 +- apps/web/src/router/index.tsx | 42 +- apps/web/src/store/authStore.ts | 54 +- apps/web/src/styles/global.css | 47 +- apps/web/src/test/components/Login.test.tsx | 50 +- apps/web/src/test/hooks/useAuth.test.tsx | 80 +- apps/web/src/test/setup.ts | 16 +- apps/web/vite.config.ts | 8 +- apps/web/vitest.config.ts | 16 +- scripts/ci/auto_fix_formatting.py | 136 +- 145 files changed, 10310 insertions(+), 12869 deletions(-) mode change 100644 => 100755 .vscode/settings.json mode change 100644 => 100755 apps/web/.eslintrc.cjs create mode 100755 apps/web/.prettierignore create mode 100755 apps/web/.prettierrc.json mode change 100755 => 100644 apps/web/src/api/products.ts mode change 100755 => 100644 apps/web/src/pages/products/ProductLibrary.tsx mode change 100644 => 100755 scripts/ci/auto_fix_formatting.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1218407a..b6bfb246c 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,3 +18,12 @@ repos: - id: ruff args: [--fix] language_version: python3.12 + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v4.0.0-alpha.11 + hooks: + - id: prettier + name: prettier (frontend) + files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$ + additional_dependencies: + - prettier@3.4.2 diff --git a/.vscode/settings.json b/.vscode/settings.json old mode 100644 new mode 100755 index bd9401ace..c918eee61 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,43 @@ "source.organizeImports": "explicit" } }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[javascriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[css]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[scss]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[html]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "prettier.requireConfig": true, "isort.args": ["--profile", "black"], "python.linting.ruffEnabled": true, "python.analysis.typeCheckingMode": "basic" diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs old mode 100644 new mode 100755 index c776019ef..e7e2c0c69 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -8,16 +8,14 @@ module.exports = { "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", + "prettier", // 关掉与 Prettier 冲突的 ESLint 规则 ], ignorePatterns: ["dist", ".eslintrc.cjs"], parser: "@typescript-eslint/parser", plugins: ["react-refresh"], rules: { - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], + "react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], }, -}; +} diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100755 index 000000000..af3abceac --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,12 @@ +node_modules +dist +dist-ssr +*.local +.vscode/* +!.vscode/extensions.json +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/apps/web/.prettierrc.json b/apps/web/.prettierrc.json new file mode 100755 index 000000000..ca2204ebb --- /dev/null +++ b/apps/web/.prettierrc.json @@ -0,0 +1,12 @@ +{ + "semi": false, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "arrowParens": "always", + "endOfLine": "lf", + "bracketSpacing": true, + "bracketSameLine": false +} diff --git a/apps/web/README.md b/apps/web/README.md index 066fc4ceb..e190c0bf6 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000 使用 Zustand 创建 Store: ```typescript -import { create } from "zustand"; +import { create } from "zustand" interface MyStore { - data: any; - setData: (data: any) => void; + data: any + setData: (data: any) => void } export const useMyStore = create((set) => ({ data: null, setData: (data) => set({ data }), -})); +})) ``` ## 部署 diff --git a/apps/web/e2e/assets-full.spec.ts b/apps/web/e2e/assets-full.spec.ts index 2d9f427bc..1bc03d4ab 100644 --- a/apps/web/e2e/assets-full.spec.ts +++ b/apps/web/e2e/assets-full.spec.ts @@ -5,33 +5,29 @@ * 删除素材、批量删除、空状态 * 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -44,30 +40,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -75,7 +71,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 创建项目 */ @@ -90,10 +86,10 @@ async function createProject( name: `Assets Test Proj ${suffix}`, description: "E2E assets test", }, - }); - expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } /** 创建素材库 */ @@ -107,10 +103,10 @@ async function createLibrary( const resp = await request.post(`${apiBase}/asset-libraries`, { headers, data: { project_id: projectId, name, kind }, - }); - expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } /** 创建素材记录 */ @@ -136,10 +132,10 @@ async function createAsset( uploaded_by_user_id: userId, metadata: { duration: 15.5, resolution: "1080p" }, }, - }); - expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } /** 在浏览器中设置登录态 */ @@ -150,14 +146,14 @@ async function setupAuthInBrowser( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -171,544 +167,400 @@ async function setupAuthInBrowser( email_verified: true, }, }, - ); + ) } test.describe("素材库页面 - 完整交互测试", () => { - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) // ─── 页面加载 ────────────────────────────────────── test("素材库列表页面加载", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-load"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - await createLibrary(request, headers, projectId, "默认视频库", "video"); + "assets-load", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + await createLibrary(request, headers, projectId, "默认视频库", "video") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") // 页面布局容器 await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000, - }); + }) await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) // 左侧素材库列表 - await expect(page.locator(".xx-asset-library-list")).toBeVisible(); + await expect(page.locator(".xx-asset-library-list")).toBeVisible() // 右侧内容区(上传区 + 筛选 + 素材网格) - await expect(page.locator(".xx-assets-content")).toBeVisible(); - await expect(page.locator(".xx-asset-upload-zone")).toBeVisible(); - await expect(page.locator(".xx-assets-filters")).toBeVisible(); + await expect(page.locator(".xx-assets-content")).toBeVisible() + await expect(page.locator(".xx-asset-upload-zone")).toBeVisible() + await expect(page.locator(".xx-assets-filters")).toBeVisible() // 无错误提示 await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000, - }); - }); + }) + }) // ─── 创建素材库 ──────────────────────────────────── test("创建新素材库 - 通过 UI", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-create"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - await createLibrary(request, headers, projectId, "初始库", "video"); + "assets-create", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + await createLibrary(request, headers, projectId, "初始库", "video") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) // 点击新建素材库 - await page.locator(".xx-asset-library-add").click(); + await page.locator(".xx-asset-library-add").click() // 弹窗出现 - const modal = page - .locator(".ant-modal-content") - .filter({ hasText: "新建素材库" }); - await expect(modal).toBeVisible(); + const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" }) + await expect(modal).toBeVisible() // 填写表单 - const newLibName = `E2E 新建库 ${Date.now()}`; - await modal.getByPlaceholder("请输入素材库名称").fill(newLibName); + const newLibName = `E2E 新建库 ${Date.now()}` + await modal.getByPlaceholder("请输入素材库名称").fill(newLibName) // 类型选择默认是 video,保持即可 // 监听创建请求 const createPromise = page.waitForResponse( - (resp) => - resp.url().includes("/asset-libraries") && - resp.request().method() === "POST", + (resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST", { timeout: 10_000 }, - ); + ) // 点击创建 - await modal.getByRole("button", { name: "创建" }).click(); + await modal.getByRole("button", { name: "创建" }).click() - const resp = await createPromise; - expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy(); + const resp = await createPromise + expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy() // 新素材库应出现在列表中 await expect( page.locator(".xx-asset-library-item").filter({ hasText: newLibName }), - ).toBeVisible({ timeout: 10_000 }); - }); + ).toBeVisible({ timeout: 10_000 }) + }) // ─── 切换素材库 ──────────────────────────────────── test("切换不同素材库", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-switch"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); + "assets-switch", + ) + const projectId = await createProject(request, headers, Date.now().toString()) - const videoLibName = "视频素材库 A"; - const imageLibName = "图片素材库 B"; - const videoLibId = await createLibrary( - request, - headers, - projectId, - videoLibName, - "video", - ); + const videoLibName = "视频素材库 A" + const imageLibName = "图片素材库 B" + const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video") // 在视频库里创建一个素材 - await createAsset( - request, - headers, - projectId, - videoLibId, - userId, - "demo_video.mp4", - ); + await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) // 点击视频库,应显示素材 - const videoLibItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: videoLibName }); - await videoLibItem.click({ force: true }); - await expect(videoLibItem).toHaveClass(/active/); + const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName }) + await videoLibItem.click({ force: true }) + await expect(videoLibItem).toHaveClass(/active/) // 验证视频素材出现 await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000, - }); + }) // 点击图片库,应切换且不显示视频 - const imageLibItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: imageLibName }); - await imageLibItem.click({ force: true }); - await expect(imageLibItem).toHaveClass(/active/); + const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName }) + await imageLibItem.click({ force: true }) + await expect(imageLibItem).toHaveClass(/active/) // 空状态或图片库内容 await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000, - }); - }); + }) + }) // ─── 素材搜索 ────────────────────────────────────── test("素材搜索功能", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-search"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - const libraryId = await createLibrary( - request, - headers, - projectId, - "搜索测试库", - "video", - ); + "assets-search", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video") // 创建两个不同名称的素材 - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "apple_clip.mp4", - ); - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "banana_clip.mp4", - ); + await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4") + await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) // 确保在测试库中 - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "搜索测试库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" }) + await libItem.click({ force: true }) // 两个素材都应可见 await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000, - }); - await expect(page.getByText("banana_clip.mp4")).toBeVisible(); + }) + await expect(page.getByText("banana_clip.mp4")).toBeVisible() // 搜索 apple,只显示 apple - await page.getByPlaceholder("搜索素材名称...").fill("apple"); - await expect(page.getByText("apple_clip.mp4")).toBeVisible(); - await expect(page.getByText("banana_clip.mp4")).toHaveCount(0); + await page.getByPlaceholder("搜索素材名称...").fill("apple") + await expect(page.getByText("apple_clip.mp4")).toBeVisible() + await expect(page.getByText("banana_clip.mp4")).toHaveCount(0) // 清空搜索,两个都显示 - await page.getByPlaceholder("搜索素材名称...").fill(""); + await page.getByPlaceholder("搜索素材名称...").fill("") await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000, - }); - await expect(page.getByText("banana_clip.mp4")).toBeVisible(); - }); + }) + await expect(page.getByText("banana_clip.mp4")).toBeVisible() + }) // ─── 筛选类型 ────────────────────────────────────── test("素材类型筛选", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-filter"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - const libraryId = await createLibrary( - request, - headers, - projectId, - "筛选测试库", - "video", - ); + "assets-filter", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video") // 创建视频素材 - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "video_clip.mp4", - ); + await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "筛选测试库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" }) + await libItem.click({ force: true }) // 素材应可见 await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000, - }); + }) // 筛选类型下拉存在 - const filterSelect = page.locator(".xx-assets-filters-left select").first(); - await expect(filterSelect).toBeVisible(); - }); + const filterSelect = page.locator(".xx-assets-filters-left select").first() + await expect(filterSelect).toBeVisible() + }) // ─── 素材详情/播放 ──────────────────────────────── test("素材详情查看 - 播放弹窗", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-detail"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - const libraryId = await createLibrary( - request, - headers, - projectId, - "详情测试库", - "video", - ); - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "play_test.mp4", - ); + "assets-detail", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video") + await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "详情测试库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" }) + await libItem.click({ force: true }) // 找到素材卡片并点击播放按钮 - const assetCard = page - .locator(".xx-asset-card") - .filter({ hasText: "play_test.mp4" }); - await expect(assetCard).toBeVisible({ timeout: 10_000 }); + const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" }) + await expect(assetCard).toBeVisible({ timeout: 10_000 }) // 点击播放按钮 - await assetCard.locator(".xx-asset-play").click({ force: true }); + await assetCard.locator(".xx-asset-play").click({ force: true }) // 播放弹窗出现 - const modal = page - .locator(".ant-modal-content") - .filter({ hasText: "播放" }); - await expect(modal).toBeVisible(); + const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" }) + await expect(modal).toBeVisible() // 关闭弹窗 - await modal.locator(".ant-modal-close").click(); - await expect(modal).not.toBeVisible({ timeout: 5_000 }); - }); + await modal.locator(".ant-modal-close").click() + await expect(modal).not.toBeVisible({ timeout: 5_000 }) + }) // ─── 删除素材 ────────────────────────────────────── test("删除素材 - 带确认对话框", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-delete"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - const libraryId = await createLibrary( - request, - headers, - projectId, - "删除测试库", - "video", - ); - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "to_delete.mp4", - ); + "assets-delete", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video") + await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "删除测试库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" }) + await libItem.click({ force: true }) - const assetCard = page - .locator(".xx-asset-card") - .filter({ hasText: "to_delete.mp4" }); - await expect(assetCard).toBeVisible({ timeout: 10_000 }); + const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" }) + await expect(assetCard).toBeVisible({ timeout: 10_000 }) // 悬停显示删除按钮 - await assetCard.hover(); + await assetCard.hover() // 点击删除 - const deleteBtn = assetCard.locator(".xx-asset-delete"); - await expect(deleteBtn).toBeVisible(); - await deleteBtn.click({ force: true }); + const deleteBtn = assetCard.locator(".xx-asset-delete") + await expect(deleteBtn).toBeVisible() + await deleteBtn.click({ force: true }) // 确认对话框出现 - const confirmModal = page - .locator(".ant-popover") - .filter({ hasText: "确认删除" }); - await expect(confirmModal).toBeVisible(); + const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" }) + await expect(confirmModal).toBeVisible() // 监听删除请求 const deletePromise = page.waitForResponse( - (resp) => - resp.url().includes("/assets/") && resp.request().method() === "DELETE", + (resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE", { timeout: 10_000 }, - ); + ) // 点击确认删除 - await confirmModal.getByRole("button", { name: "删除" }).click(); + await confirmModal.getByRole("button", { name: "删除" }).click() - const resp = await deletePromise; - expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy(); + const resp = await deletePromise + expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy() // 素材应从列表中消失 await expect(page.getByText("to_delete.mp4")).toHaveCount(0, { timeout: 10_000, - }); - }); + }) + }) // ─── 批量删除素材 ────────────────────────────────── test("批量删除素材", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-batch"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - const libraryId = await createLibrary( - request, - headers, - projectId, - "批量删除库", - "video", - ); + "assets-batch", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video") // 创建多个素材 - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "batch_1.mp4", - ); - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "batch_2.mp4", - ); - await createAsset( - request, - headers, - projectId, - libraryId, - userId, - "batch_3.mp4", - ); + await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4") + await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4") + await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "批量删除库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" }) + await libItem.click({ force: true }) // 所有素材应可见 await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000, - }); - await expect(page.getByText("batch_2.mp4")).toBeVisible(); - await expect(page.getByText("batch_3.mp4")).toBeVisible(); + }) + await expect(page.getByText("batch_2.mp4")).toBeVisible() + await expect(page.getByText("batch_3.mp4")).toBeVisible() // 点击全选 - const selectAllBtn = page.getByRole("button", { name: "全选" }); - await expect(selectAllBtn).toBeVisible(); - await selectAllBtn.click(); + const selectAllBtn = page.getByRole("button", { name: "全选" }) + await expect(selectAllBtn).toBeVisible() + await selectAllBtn.click() // 批量操作栏出现 - const batchBar = page.locator(".xx-assets-batch-bar"); - await expect(batchBar).toBeVisible(); - await expect(batchBar.getByText(/已选 3 项/)).toBeVisible(); + const batchBar = page.locator(".xx-assets-batch-bar") + await expect(batchBar).toBeVisible() + await expect(batchBar.getByText(/已选 3 项/)).toBeVisible() // 点击批量删除 - const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }); - await expect(batchDeleteBtn).toBeVisible(); - await batchDeleteBtn.click(); + const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }) + await expect(batchDeleteBtn).toBeVisible() + await batchDeleteBtn.click() // 确认对话框 - const confirmPop = page - .locator(".ant-popover") - .filter({ hasText: "确定删除" }); - await expect(confirmPop).toBeVisible(); + const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" }) + await expect(confirmPop).toBeVisible() // 确认删除 - await confirmPop.getByRole("button", { name: "删除" }).click(); + await confirmPop.getByRole("button", { name: "删除" }).click() // 验证素材已删除(通过 API 确认) await expect @@ -717,57 +569,53 @@ test.describe("素材库页面 - 完整交互测试", () => { const resp = await request.get(`${apiBase}/assets`, { headers, params: { library_id: libraryId }, - }); - if (!resp.ok()) return "error"; - const data = await resp.json(); - const items = data.items || []; - return items.length; + }) + if (!resp.ok()) return "error" + const data = await resp.json() + const items = data.items || [] + return items.length }, { timeout: 15_000, intervals: [1_000, 2_000, 3_000] }, ) - .toBe(0); - }); + .toBe(0) + }) // ─── 空状态 ──────────────────────────────────────── test("空素材库展示空状态", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "assets-empty"); - const projectId = await createProject( + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( request, - headers, - Date.now().toString(), - ); - await createLibrary(request, headers, projectId, "空素材库", "video"); + "assets-empty", + ) + const projectId = await createProject(request, headers, Date.now().toString()) + await createLibrary(request, headers, projectId, "空素材库", "video") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) - const libItem = page - .locator(".xx-asset-library-item") - .filter({ hasText: "空素材库" }); - await libItem.click({ force: true }); + const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" }) + await libItem.click({ force: true }) // 空状态应显示 await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000, - }); - await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible(); - }); + }) + await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible() + }) // ─── 未登录访问 ──────────────────────────────────── test("未登录访问素材库 - 重定向到登录页", async ({ page }) => { - await page.goto("/app/assets"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/assets") + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/apps/web/e2e/auth-guard.spec.ts b/apps/web/e2e/auth-guard.spec.ts index c6f40fa04..bb73e538f 100755 --- a/apps/web/e2e/auth-guard.spec.ts +++ b/apps/web/e2e/auth-guard.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "@playwright/test" test.describe("App route guard", () => { test("redirects anonymous users to login", async ({ page }) => { - await page.goto("/app/dashboard"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/dashboard") + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index 85c9fdbb0..1205f77dd 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -1,10 +1,10 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "@playwright/test" test.describe("Authentication page", () => { test("renders login form", async ({ page }) => { - await page.goto("/login"); - await expect(page.getByLabel("邮箱")).toBeVisible(); - await expect(page.getByLabel("密码")).toBeVisible(); - await expect(page.getByRole("button", { name: "登录" })).toBeVisible(); - }); -}); + await page.goto("/login") + await expect(page.getByLabel("邮箱")).toBeVisible() + await expect(page.getByLabel("密码")).toBeVisible() + await expect(page.getByRole("button", { name: "登录" })).toBeVisible() + }) +}) diff --git a/apps/web/e2e/core-generation.spec.ts b/apps/web/e2e/core-generation.spec.ts index 3db8d3981..d6134e250 100755 --- a/apps/web/e2e/core-generation.spec.ts +++ b/apps/web/e2e/core-generation.spec.ts @@ -1,23 +1,19 @@ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} async function loginWithRetry( request: APIRequestContext, @@ -28,73 +24,70 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } -type ProjectResponse = { id: string }; -type LibraryResponse = { id: string }; -type TemplateResponse = { id: string }; +type ProjectResponse = { id: string } +type LibraryResponse = { id: string } +type TemplateResponse = { id: string } type AssetListResponse = { items: Array<{ - id: string; - name: string; - status: string; - }>; -}; + id: string + name: string + status: string + }> +} test.describe("Core generation flow", () => { - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) - test("walks through 5-step wizard and starts generation", async ({ - page, - request, - }) => { - test.setTimeout(180_000); + test("walks through 5-step wizard and starts generation", async ({ page, request }) => { + test.setTimeout(180_000) - await routeBrowserApiToTestApi(page); - const suffix = Date.now().toString(36); - const email = `e2e-gen-${suffix}@example.com`; - const username = `e2e_gen_${suffix}`; - const libraryName = `E2E Gen Lib ${suffix}`; + await routeBrowserApiToTestApi(page) + const suffix = Date.now().toString(36) + const email = `e2e-gen-${suffix}@example.com` + const username = `e2e_gen_${suffix}` + const libraryName = `E2E Gen Lib ${suffix}` // Register const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, - }); - expect(register.status()).toBe(201); - const registerData = (await register.json()) as { user_id: string }; + }) + expect(register.status()).toBe(201) + const registerData = (await register.json()) as { user_id: string } // Login - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.status()).toBe(200); - const loginData = (await login.json()) as { access_token: string }; - const headers = { Authorization: `Bearer ${loginData.access_token}` }; + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.status()).toBe(200) + const loginData = (await login.json()) as { access_token: string } + const headers = { Authorization: `Bearer ${loginData.access_token}` } // Create project const project = await request.post(`${apiBase}/projects`, { headers, data: { name: `E2E Gen Proj ${suffix}` }, - }); - expect(project.status()).toBe(200); - const projectData = (await project.json()) as ProjectResponse; + }) + expect(project.status()).toBe(200) + const projectData = (await project.json()) as ProjectResponse // Create asset library const library = await request.post(`${apiBase}/asset-libraries`, { headers, data: { project_id: projectData.id, name: libraryName, kind: "video" }, - }); - expect(library.status()).toBe(200); - const libraryData = (await library.json()) as LibraryResponse; + }) + expect(library.status()).toBe(200) + const libraryData = (await library.json()) as LibraryResponse // Upload source video - const sourceFileName = "e2e-gen-source.mp4"; + const sourceFileName = "e2e-gen-source.mp4" const upload = await request.post(`${apiBase}/upload`, { headers, multipart: { @@ -106,8 +99,8 @@ test.describe("Core generation flow", () => { buffer: Buffer.from("e2e source data"), }, }, - }); - expect(upload.status()).toBe(200); + }) + expect(upload.status()).toBe(200) // Wait for asset to be ready await expect @@ -116,16 +109,16 @@ test.describe("Core generation flow", () => { const assets = await request.get(`${apiBase}/assets`, { headers, params: { library_id: libraryData.id }, - }); - if (!assets.ok()) return `http_${assets.status()}`; - const data = (await assets.json()) as AssetListResponse; - const asset = data.items.find((a) => a.name === sourceFileName); - if (!asset) return "missing"; - return asset.status; + }) + if (!assets.ok()) return `http_${assets.status()}` + const data = (await assets.json()) as AssetListResponse + const asset = data.items.find((a) => a.name === sourceFileName) + if (!asset) return "missing" + return asset.status }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, ) - .toBe("ready"); + .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) @@ -145,22 +138,22 @@ test.describe("Core generation flow", () => { ], tags: ["e2e"], }, - }); - expect(template.status(), await template.text()).toBe(201); - const templateData = (await template.json()) as TemplateResponse; - expect(templateData.id).toBeTruthy(); + }) + 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 }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token: loginData.access_token, @@ -174,43 +167,43 @@ test.describe("Core generation flow", () => { email_verified: true, }, }, - ); + ) // Navigate to generate page - await page.goto("/app/generate"); + await page.goto("/app/generate") await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({ timeout: 20_000, - }); + }) // Step 1: template - default selected, click next - await expect(page.locator(".xx-choice-item.selected")).toBeVisible(); - await page.getByRole("button", { name: "下一步" }).click(); + await expect(page.locator(".xx-choice-item.selected")).toBeVisible() + await page.getByRole("button", { name: "下一步" }).click() // Step 2: select material - 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(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 materialLabel.locator("input[type='checkbox']").check(); - await page.getByRole("button", { name: "下一步" }).click(); + }) + await materialLabel.locator("input[type='checkbox']").check() + await page.getByRole("button", { name: "下一步" }).click() // Step 3: title - await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible(); - const titleText = `E2E Test ${suffix}`; - await page.getByPlaceholder("输入自定义标题…").fill(titleText); - await page.getByRole("button", { name: "下一步" }).click(); + 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(); - const firstVoiceCard = page.locator(".xx-voice-choice-item").first(); - await firstVoiceCard.click(); - await page.getByRole("button", { name: "下一步" }).click(); + 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( @@ -219,68 +212,64 @@ test.describe("Core generation flow", () => { response.request().method() === "POST" && !response.url().includes("/generate"), { timeout: 30_000 }, - ); + ) // Click generate button - await page - .locator(".xx-btn-primary") - .filter({ hasText: "确认生成" }) - .first() - .click(); + await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click() // Verify plan was created successfully - const planResp = await createPlanPromise; - expect(planResp.ok()).toBeTruthy(); - const planData = (await planResp.json()) as { id: string }; - expect(planData.id).toBeTruthy(); + const planResp = await createPlanPromise + expect(planResp.ok()).toBeTruthy() + const planData = (await planResp.json()) as { id: string } + expect(planData.id).toBeTruthy() // Generation may fail in test env (no worker), that's OK // Just verify the flow started - check page shows generation-related UI await page .getByText(/生成中|生成完成|生成失败/) .isVisible({ timeout: 15_000 }) - .catch(() => false); + .catch(() => false) // If we see progress or result, great; if not, flow still reached the end // which is sufficient for an E2E smoke test // Verify product library page loads (smoke: just verify page renders) - await page.goto("/app/products"); - await expect(page).toHaveURL(/\/app\/products/); + await page.goto("/app/products") + await expect(page).toHaveURL(/\/app\/products/) // Verify page container exists = page rendered correctly // (works in all states: loading/error/success - more reliable than checking search input) await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 15_000, - }); + }) // 清理所有路由,避免页面关闭时飞地API请求导致测试报错 - await page.unrouteAll({ behavior: "ignoreErrors" }); - }); + await page.unrouteAll({ behavior: "ignoreErrors" }) + }) test("generation task API creates and lists tasks", async ({ request }) => { - const suffix = Date.now().toString(36); - const email = `e2e-gen-api-${suffix}@example.com`; - const username = `e2e_gen_api_${suffix}`; + const suffix = Date.now().toString(36) + const email = `e2e-gen-api-${suffix}@example.com` + const username = `e2e_gen_api_${suffix}` const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, - }); - expect(register.status()).toBe(201); + }) + expect(register.status()).toBe(201) - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.status()).toBe(200); - const loginData = (await login.json()) as { access_token: string }; - const headers = { Authorization: `Bearer ${loginData.access_token}` }; + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.status()).toBe(200) + const loginData = (await login.json()) as { access_token: string } + const headers = { Authorization: `Bearer ${loginData.access_token}` } const project = await request.post(`${apiBase}/projects`, { headers, data: { name: `E2E API Proj ${suffix}` }, - }); - expect(project.status()).toBe(200); + }) + expect(project.status()).toBe(200) // List generation tasks via task center API - const tasks = await request.get(`${apiBase}/tasks`, { headers }); - expect(tasks.status()).toBe(200); - const tasksData = await tasks.json(); - expect(Array.isArray(tasksData.items)).toBe(true); - }); -}); + const tasks = await request.get(`${apiBase}/tasks`, { headers }) + expect(tasks.status()).toBe(200) + const tasksData = await tasks.json() + expect(Array.isArray(tasksData.items)).toBe(true) + }) +}) diff --git a/apps/web/e2e/core-titles.spec.ts b/apps/web/e2e/core-titles.spec.ts index fff7e94a1..8e0f910bb 100755 --- a/apps/web/e2e/core-titles.spec.ts +++ b/apps/web/e2e/core-titles.spec.ts @@ -1,23 +1,19 @@ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( @@ -29,38 +25,35 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } test.describe("Title library flow", () => { - test.describe.configure({ timeout: 120_000 }); - test("loads title library page and displays titles", async ({ - page, - request, - }) => { - await routeBrowserApiToTestApi(page); - const suffix = Date.now().toString(36); - const email = `e2e-title-${suffix}@example.com`; - const username = `e2e_title_${suffix}`; + test.describe.configure({ timeout: 120_000 }) + test("loads title library page and displays titles", async ({ page, request }) => { + await routeBrowserApiToTestApi(page) + const suffix = Date.now().toString(36) + const email = `e2e-title-${suffix}@example.com` + const username = `e2e_title_${suffix}` const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, - }); - expect(register.status(), await register.text()).toBe(201); + }) + expect(register.status(), await register.text()).toBe(201) - const registerData = (await register.json()) as { user_id: string }; + const registerData = (await register.json()) as { user_id: string } - 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}` }; + 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) @@ -71,19 +64,19 @@ test.describe("Title library flow", () => { text: `E2E 测试标题内容 ${suffix}`, category: "default", }, - }); - expect(createTitle.status(), await createTitle.text()).toBe(201); + }) + expect(createTitle.status(), await createTitle.text()).toBe(201) await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token: loginData.access_token, @@ -97,45 +90,42 @@ test.describe("Title library flow", () => { email_verified: true, }, }, - ); + ) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) await expect(page.locator(".xx-title-card").first()).toBeVisible({ timeout: 10_000, - }); + }) - const firstTitleText = await page - .locator(".xx-title-card-text") - .first() - .textContent(); + const firstTitleText = await page.locator(".xx-title-card-text").first().textContent() if (firstTitleText) { - await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText); - await expect(page.getByText(firstTitleText)).toBeVisible(); + await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText) + await expect(page.getByText(firstTitleText)).toBeVisible() } - await expect(page.locator(".xx-title-card-stat").first()).toBeVisible(); - }); + await expect(page.locator(".xx-title-card-stat").first()).toBeVisible() + }) test("titles API creates and lists titles", async ({ request }) => { - const suffix = Date.now().toString(36); - const email = `e2e-title-api-${suffix}@example.com`; - const username = `e2e_title_api_${suffix}`; + const suffix = Date.now().toString(36) + const email = `e2e-title-api-${suffix}@example.com` + const username = `e2e_title_api_${suffix}` const register = await request.post(`${apiBase}/auth/register`, { data: { email, username, password: PASSWORD, display_name: username }, - }); - expect(register.status()).toBe(201); + }) + expect(register.status()).toBe(201) - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.status()).toBe(200); - const loginData = (await login.json()) as { access_token: string }; - const headers = { Authorization: `Bearer ${loginData.access_token}` }; + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.status()).toBe(200) + const loginData = (await login.json()) as { access_token: string } + const headers = { Authorization: `Bearer ${loginData.access_token}` } - const titleText = `E2E Test Title ${suffix}`; + const titleText = `E2E Test Title ${suffix}` const createResp = await request.post(`${apiBase}/titles`, { headers, data: { @@ -143,20 +133,20 @@ test.describe("Title library flow", () => { text: titleText, category: "default", }, - }); - expect(createResp.status(), await createResp.text()).toBe(201); + }) + expect(createResp.status(), await createResp.text()).toBe(201) const created = (await createResp.json()) as { - id: string; - text: string; - }; - expect(created.id).toBeTruthy(); + id: string + text: string + } + expect(created.id).toBeTruthy() - const listResp = await request.get(`${apiBase}/titles`, { headers }); - expect(listResp.status()).toBe(200); + const listResp = await request.get(`${apiBase}/titles`, { headers }) + expect(listResp.status()).toBe(200) const listData = (await listResp.json()) as { - items: Array<{ id: string; text: string }>; - }; - const found = listData.items.find((t) => t.id === created.id); - expect(found).toBeTruthy(); - }); -}); + items: Array<{ id: string; text: string }> + } + const found = listData.items.find((t) => t.id === created.id) + expect(found).toBeTruthy() + }) +}) diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index 619149ee2..ee1f88e92 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -1,23 +1,19 @@ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( @@ -29,31 +25,28 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } -type ProjectResponse = { id: string }; -type LibraryResponse = { id: string }; +type ProjectResponse = { id: string } +type LibraryResponse = { id: string } test.describe("Core media upload flow", () => { - test.describe.configure({ timeout: 180_000 }); - test("uploads a video asset and shows it in the asset library", async ({ - page, - request, - }) => { - test.setTimeout(120_000); + test.describe.configure({ timeout: 180_000 }) + test("uploads a video asset and shows it in the asset library", async ({ page, request }) => { + test.setTimeout(120_000) - await routeBrowserApiToTestApi(page); - const suffix = Date.now().toString(36); - const email = `e2e-mov-${suffix}@example.com`; - const username = `e2e_mov_${suffix}`; + await routeBrowserApiToTestApi(page) + const suffix = Date.now().toString(36) + const email = `e2e-mov-${suffix}@example.com` + const username = `e2e_mov_${suffix}` const register = await request.post(`${apiBase}/auth/register`, { data: { @@ -62,15 +55,15 @@ test.describe("Core media upload flow", () => { password: PASSWORD, display_name: username, }, - }); - expect(register.status(), await register.text()).toBe(201); + }) + expect(register.status(), await register.text()).toBe(201) - const registerData = (await register.json()) as { user_id: string }; + const registerData = (await register.json()) as { user_id: string } - 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}` }; + 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}` } const project = await request.post(`${apiBase}/projects`, { headers, @@ -78,9 +71,9 @@ test.describe("Core media upload flow", () => { name: `E2E Project ${suffix}`, description: "Playwright upload smoke", }, - }); - expect(project.status(), await project.text()).toBe(200); - const projectData = (await project.json()) as ProjectResponse; + }) + expect(project.status(), await project.text()).toBe(200) + const projectData = (await project.json()) as ProjectResponse const library = await request.post(`${apiBase}/asset-libraries`, { headers, @@ -89,20 +82,20 @@ test.describe("Core media upload flow", () => { name: `E2E Video Library ${suffix}`, kind: "video", }, - }); - expect(library.status(), await library.text()).toBe(200); - const libraryData = (await library.json()) as LibraryResponse; + }) + expect(library.status(), await library.text()).toBe(200) + const libraryData = (await library.json()) as LibraryResponse await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token: loginData.access_token, @@ -116,12 +109,12 @@ test.describe("Core media upload flow", () => { email_verified: true, }, }, - ); + ) - await page.goto("/app/assets"); + await page.goto("/app/assets") await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, - }); + }) const upload = await request.post(`${apiBase}/upload`, { headers, @@ -134,12 +127,12 @@ test.describe("Core media upload flow", () => { buffer: Buffer.from("playwright mov upload smoke"), }, }, - }); - expect(upload.status(), await upload.text()).toBe(200); + }) + expect(upload.status(), await upload.text()).toBe(200) - await expect( - page.getByText(/上传失败|素材列表加载失败|素材库加载失败/), - ).toHaveCount(0, { timeout: 5_000 }); + await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, { + timeout: 5_000, + }) await expect .poll( @@ -147,52 +140,44 @@ test.describe("Core media upload flow", () => { const assets = await request.get(`${apiBase}/assets`, { headers, params: { library_id: libraryData.id }, - }); + }) if (!assets.ok()) { - return `http_${assets.status()}`; + return `http_${assets.status()}` } const data = (await assets.json()) as { items: Array<{ - name: string; - status: string; - file_type?: string; - mime_type?: string; - }>; - }; - const asset = data.items.find( - (item) => item.name === "e2e-sample.MOV", - ); - return asset - ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` - : "missing"; + name: string + status: string + file_type?: string + mime_type?: string + }> + } + const asset = data.items.find((item) => item.name === "e2e-sample.MOV") + return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing" }, { timeout: 30_000, intervals: [1_000, 2_000, 3_000] }, ) - .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/); + .toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/) // Select the test library from sidebar await page .locator(".xx-asset-library-item") .filter({ hasText: `E2E Video Library ${suffix}` }) - .click({ force: true }); + .click({ force: true }) - await page.reload(); + await page.reload() await expect(page.locator(".xx-assets-content")).toBeVisible({ timeout: 20_000, - }); - await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible( - { - timeout: 20_000, - }, - ); + }) + await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({ + timeout: 20_000, + }) // Verify asset card shows status - const assetCard = page - .locator(".xx-asset-card") - .filter({ hasText: "e2e-sample.MOV" }); - await expect(assetCard).toBeVisible(); - await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible(); + const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.MOV" }) + await expect(assetCard).toBeVisible() + await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible() - await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0); - }); -}); + await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0) + }) +}) diff --git a/apps/web/e2e/duplication.spec.ts b/apps/web/e2e/duplication.spec.ts index bcf282606..554cd6e22 100644 --- a/apps/web/e2e/duplication.spec.ts +++ b/apps/web/e2e/duplication.spec.ts @@ -4,33 +4,29 @@ * 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、 * 删除记录、重试去重 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -43,30 +39,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -74,7 +70,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 在浏览器中设置登录态 */ @@ -85,14 +81,14 @@ async function setupAuthInBrowser( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -106,236 +102,216 @@ async function setupAuthInBrowser( email_verified: true, }, }, - ); + ) } test.describe("去重流程", () => { - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) // ─── 上传页面加载 ────────────────────────────────── test("去重上传页面加载", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-load", - ); + await routeBrowserApiToTestApi(page) + const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-load") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication"); + await page.goto("/app/duplication") // 页面容器 - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 页面标题 - await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible() // 描述 - await expect( - page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"), - ).toBeVisible(); - }); + await expect(page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段")).toBeVisible() + }) // ─── 上传区域展示 ────────────────────────────────── test("上传区域展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-upload-zone", - ); + ) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 拖拽上传区域 - const uploadZone = page.locator(".dup-upload-zone"); - await expect(uploadZone).toBeVisible(); + const uploadZone = page.locator(".dup-upload-zone") + await expect(uploadZone).toBeVisible() // 上传图标和文字 - await expect( - uploadZone.getByText("点击或拖拽视频文件到此区域"), - ).toBeVisible(); + await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible() // 格式提示 - await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible(); + await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible() // 格式标签 - await expect(page.locator(".dup-upload-formats")).toBeVisible(); + await expect(page.locator(".dup-upload-formats")).toBeVisible() // 选择文件按钮 - const selectBtn = page.getByRole("button", { name: "选择文件" }); - await expect(selectBtn).toBeVisible(); + const selectBtn = page.getByRole("button", { name: "选择文件" }) + await expect(selectBtn).toBeVisible() // 隐藏的文件 input - const fileInput = page.locator('input[type="file"]'); - await expect(fileInput).toHaveCount(1); - }); + const fileInput = page.locator('input[type="file"]') + await expect(fileInput).toHaveCount(1) + }) // ─── 格式说明区 ──────────────────────────────────── test("格式说明和提示区域展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-info", - ); + await routeBrowserApiToTestApi(page) + const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-info") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 右侧说明区 - const infoCard = page.locator(".dup-info-card"); - await expect(infoCard).toBeVisible(); + const infoCard = page.locator(".dup-info-card") + await expect(infoCard).toBeVisible() // 查重说明 - await expect(infoCard.getByText("查重说明")).toBeVisible(); + await expect(infoCard.getByText("查重说明")).toBeVisible() // 支持格式 - await expect(infoCard.getByText("支持格式")).toBeVisible(); + await expect(infoCard.getByText("支持格式")).toBeVisible() // 温馨提示 - await expect(infoCard.getByText("温馨提示")).toBeVisible(); + await expect(infoCard.getByText("温馨提示")).toBeVisible() // 格式标签 - await expect(page.locator(".dup-format-tags")).toBeVisible(); - }); + await expect(page.locator(".dup-format-tags")).toBeVisible() + }) // ─── 去重记录列表页面 ────────────────────────────── test("去重记录列表页面加载", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-list", - ); + await routeBrowserApiToTestApi(page) + const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-list") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); + await page.goto("/app/duplication/results") // 页面容器 - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 页面标题 - await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible() // 筛选按钮 - await expect(page.locator(".dup-filter")).toBeVisible(); + await expect(page.locator(".dup-filter")).toBeVisible() // 上传查重按钮 - await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible(); - }); + await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible() + }) test("去重记录列表 - 空状态", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "dup-list-empty", - ); + ) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication/results") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 空状态(新用户没有记录) - const emptyState = page.locator(".dup-results-empty"); - await expect(emptyState).toBeVisible({ timeout: 10_000 }); - await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible(); - }); + const emptyState = page.locator(".dup-results-empty") + await expect(emptyState).toBeVisible({ timeout: 10_000 }) + await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible() + }) test("去重记录列表 - 风险等级筛选", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-filter", - ); + await routeBrowserApiToTestApi(page) + const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-filter") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication/results") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 筛选按钮存在 - const filterBtns = page.locator(".dup-filter-btn"); - await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险 + const filterBtns = page.locator(".dup-filter-btn") + await expect(filterBtns).toHaveCount(4) // 全部、低风险、中风险、高风险 // 验证按钮文本 - await expect(filterBtns.nth(0)).toHaveText("全部"); - await expect(filterBtns.nth(1)).toHaveText("低风险"); - await expect(filterBtns.nth(2)).toHaveText("中风险"); - await expect(filterBtns.nth(3)).toHaveText("高风险"); + await expect(filterBtns.nth(0)).toHaveText("全部") + await expect(filterBtns.nth(1)).toHaveText("低风险") + await expect(filterBtns.nth(2)).toHaveText("中风险") + await expect(filterBtns.nth(3)).toHaveText("高风险") // 默认选中"全部" - await expect(filterBtns.nth(0)).toHaveClass(/active/); + await expect(filterBtns.nth(0)).toHaveClass(/active/) // 点击低风险 - await filterBtns.nth(1).click(); - await expect(filterBtns.nth(1)).toHaveClass(/active/); - }); + await filterBtns.nth(1).click() + await expect(filterBtns.nth(1)).toHaveClass(/active/) + }) test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-nav", - ); + await routeBrowserApiToTestApi(page) + const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-nav") await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication/results") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 点击上传查重按钮 - await page.getByRole("button", { name: "上传查重" }).click(); + await page.getByRole("button", { name: "上传查重" }).click() - await expect(page).toHaveURL(/\/app\/duplication$/); - await expect(page.locator(".dup-upload-zone")).toBeVisible(); - }); + await expect(page).toHaveURL(/\/app\/duplication$/) + await expect(page.locator(".dup-upload-zone")).toBeVisible() + }) // ─── 去重详情页 ──────────────────────────────────── - test("去重详情页 - 通过 API 创建测试数据后访问", async ({ - page, - request, - }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "dup-detail"); + test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request }) => { + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( + request, + "dup-detail", + ) // 先上传一个文件进行查重,获取 record id const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -347,44 +323,41 @@ test.describe("去重流程", () => { buffer: Buffer.from("e2e duplication test data"), }, }, - }); + }) // 如果查重 API 不可用,跳过详情页测试 if (!uploadResp.ok()) { - console.log( - `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`, - ); - return; + console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`) + return } - const uploadData = await uploadResp.json(); - const recordId = uploadData.id; - expect(recordId, "应返回查重记录 ID").toBeTruthy(); + const uploadData = await uploadResp.json() + const recordId = uploadData.id + expect(recordId, "应返回查重记录 ID").toBeTruthy() await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) // 访问详情页 - await page.goto(`/app/duplication/${recordId}`); + await page.goto(`/app/duplication/${recordId}`) // 页面应正常渲染 - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 验证无错误 await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { timeout: 5_000, - }); - }); + }) + }) // ─── 删除记录 ────────────────────────────────────── test("去重记录删除 - API 验证", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers } = - await createAuthedUser(request, "dup-delete"); + await routeBrowserApiToTestApi(page) + const { headers } = await createAuthedUser(request, "dup-delete") // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -396,59 +369,53 @@ test.describe("去重流程", () => { buffer: Buffer.from("e2e duplication delete test"), }, }, - }); + }) if (!uploadResp.ok()) { - console.log( - `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`, - ); - return; + console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`) + return } - const uploadData = await uploadResp.json(); - const recordId = uploadData.id; + const uploadData = await uploadResp.json() + const recordId = uploadData.id // 验证记录存在 const listResp = await request.get(`${apiBase}/duplication/records`, { headers, - }); + }) if (listResp.ok()) { - const records = await listResp.json(); + const records = await listResp.json() const recordExists = Array.isArray(records) ? records.some((r: { id: string }) => r.id === recordId) - : (records.items || []).some((r: { id: string }) => r.id === recordId); - expect(recordExists, "记录应存在于列表中").toBeTruthy(); + : (records.items || []).some((r: { id: string }) => r.id === recordId) + expect(recordExists, "记录应存在于列表中").toBeTruthy() } // 删除记录 - const deleteResp = await request.delete( - `${apiBase}/duplication/records/${recordId}`, - { headers }, - ); - expect( - deleteResp.ok(), - `删除查重记录应成功: ${deleteResp.status()}`, - ).toBeTruthy(); + const deleteResp = await request.delete(`${apiBase}/duplication/records/${recordId}`, { + headers, + }) + expect(deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`).toBeTruthy() // 验证记录已删除 const listAfterResp = await request.get(`${apiBase}/duplication/records`, { headers, - }); + }) if (listAfterResp.ok()) { - const recordsAfter = await listAfterResp.json(); + const recordsAfter = await listAfterResp.json() const recordStillExists = Array.isArray(recordsAfter) ? recordsAfter.some((r: { id: string }) => r.id === recordId) - : (recordsAfter.items || []).some( - (r: { id: string }) => r.id === recordId, - ); - expect(recordStillExists, "记录应已被删除").toBeFalsy(); + : (recordsAfter.items || []).some((r: { id: string }) => r.id === recordId) + expect(recordStillExists, "记录应已被删除").toBeFalsy() } - }); + }) test("去重记录删除 - UI 验证", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "dup-delete-ui"); + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( + request, + "dup-delete-ui", + ) // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -460,69 +427,66 @@ test.describe("去重流程", () => { buffer: Buffer.from("e2e duplication ui delete test"), }, }, - }); + }) if (!uploadResp.ok()) { - console.log( - `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`, - ); - return; + console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`) + return } await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication/results") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 记录卡片应存在 - const resultCard = page.locator(".dup-result-card").first(); - const cardVisible = await resultCard - .isVisible({ timeout: 10_000 }) - .catch(() => false); + const resultCard = page.locator(".dup-result-card").first() + const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false) if (cardVisible) { // 删除按钮存在 const deleteBtn = resultCard.getByRole("button").filter({ hasText: "🗑️", - }); - await expect(deleteBtn).toBeVisible(); + }) + await expect(deleteBtn).toBeVisible() // 删除按钮点击 - 会触发 confirm 对话框 // 这里我们通过监听 confirm 来确认删除 page.once("dialog", async (dialog) => { - expect(dialog.message()).toContain("确定删除"); - await dialog.accept(); - }); + expect(dialog.message()).toContain("确定删除") + await dialog.accept() + }) // 监听删除请求 const deletePromise = page .waitForResponse( (resp) => - resp.url().includes("/duplication/records/") && - resp.request().method() === "DELETE", + resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE", { timeout: 10_000 }, ) - .catch(() => null); + .catch(() => null) - await deleteBtn.click(); + await deleteBtn.click() - const deleteResp = await deletePromise; + const deleteResp = await deletePromise if (deleteResp) { - expect(deleteResp.ok(), "删除请求应成功").toBeTruthy(); + expect(deleteResp.ok(), "删除请求应成功").toBeTruthy() } } - }); + }) // ─── 重试去重 ────────────────────────────────────── test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = - await createAuthedUser(request, "dup-retry"); + await routeBrowserApiToTestApi(page) + const { headers, userId, accessToken, email, username } = await createAuthedUser( + request, + "dup-retry", + ) // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -534,59 +498,54 @@ test.describe("去重流程", () => { buffer: Buffer.from("e2e duplication retry test"), }, }, - }); + }) if (!uploadResp.ok()) { - console.log( - `[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`, - ); - return; + console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`) + return } await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/duplication/results"); - await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); + await page.goto("/app/duplication/results") + await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }) // 记录列表中至少有一条记录 - const resultCard = page.locator(".dup-result-card").first(); - const cardVisible = await resultCard - .isVisible({ timeout: 10_000 }) - .catch(() => false); + const resultCard = page.locator(".dup-result-card").first() + const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false) if (cardVisible) { // 验证记录卡片基本结构 - await expect(resultCard.locator(".dup-result-card-body")).toBeVisible(); - await expect(resultCard.locator(".dup-result-card-score")).toBeVisible(); + await expect(resultCard.locator(".dup-result-card-body")).toBeVisible() + await expect(resultCard.locator(".dup-result-card-score")).toBeVisible() // 检查是否有重试按钮(失败状态才显示) // 新上传的记录可能是处理中或完成状态,不一定显示重试按钮 // 这里只验证 API 重试接口可用 - const uploadData = await uploadResp.json(); - const recordId = uploadData.id; + const uploadData = await uploadResp.json() + const recordId = uploadData.id - const retryResp = await request.post( - `${apiBase}/duplication/records/${recordId}/retry`, - { headers }, - ); + const retryResp = await request.post(`${apiBase}/duplication/records/${recordId}/retry`, { + headers, + }) // 重试接口应返回 2xx 或明确的状态码 - expect(retryResp.status()).toBeLessThan(500); + expect(retryResp.status()).toBeLessThan(500) } - }); + }) // ─── 未登录访问 ──────────────────────────────────── test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => { - await page.goto("/app/duplication"); - await expect(page).toHaveURL(/\/login/); - }); + await page.goto("/app/duplication") + await expect(page).toHaveURL(/\/login/) + }) test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => { - await page.goto("/app/duplication/results"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/duplication/results") + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/apps/web/e2e/editing-planner.spec.ts b/apps/web/e2e/editing-planner.spec.ts index 38c5dcd33..f393537a9 100644 --- a/apps/web/e2e/editing-planner.spec.ts +++ b/apps/web/e2e/editing-planner.spec.ts @@ -6,36 +6,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -48,30 +41,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -90,14 +83,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -111,7 +104,7 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } /** 创建一个编辑模板并返回 id */ @@ -146,80 +139,74 @@ async function createEditingTemplate( tags: ["e2e", "test"], category: "default", }, - }); - expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } test.describe("剪辑策划页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { - await page.goto("/app/editing-planner"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/editing-planner") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("剪辑策划页面 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("剪辑策划页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "ep-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-load", - }); + }) - await page.goto("/app/editing-planner"); + await page.goto("/app/editing-planner") await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, - }); + }) // 验证顶栏存在 - await expect(page.locator(".ep-top-bar")).toBeVisible(); + await expect(page.locator(".ep-top-bar")).toBeVisible() // 验证模式栏存在 - await expect(page.locator(".ep-mode-bar")).toBeVisible(); + await expect(page.locator(".ep-mode-bar")).toBeVisible() // 验证主体区域存在 - await expect(page.locator(".ep-main-body")).toBeVisible(); - }); + await expect(page.locator(".ep-main-body")).toBeVisible() + }) test("剪辑模式切换正常显示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "ep-mode", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-mode") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-mode", - }); + }) - await page.goto("/app/editing-planner"); + await page.goto("/app/editing-planner") await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, - }); + }) // 验证模式按钮存在(画中画、人物口播等) - const modeBtns = page.locator(".ep-mode-btn"); - await expect(modeBtns.first()).toBeVisible(); - const modeCount = await modeBtns.count(); - expect(modeCount).toBeGreaterThanOrEqual(2); - }); -}); + const modeBtns = page.locator(".ep-mode-btn") + await expect(modeBtns.first()).toBeVisible() + const modeCount = await modeBtns.count() + expect(modeCount).toBeGreaterThanOrEqual(2) + }) +}) test.describe("剪辑计划 - API 操作", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("创建剪辑计划 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-create"); - const suffix = Date.now().toString(36); - const templateName = `E2E 创建测试 ${suffix}`; + const { headers } = await createAuthedUser(request, "ep-create") + const suffix = Date.now().toString(36) + const templateName = `E2E 创建测试 ${suffix}` const response = await request.post(`${apiBase}/templates`, { headers, @@ -238,22 +225,22 @@ test.describe("剪辑计划 - API 操作", () => { ], tags: ["e2e"], }, - }); + }) expect( response.ok(), `创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id, "应返回模板 ID").toBeTruthy(); - expect(data.name).toBe(templateName); - expect(data.mode).toBe("pip"); - }); + const data = await response.json() + expect(data.id, "应返回模板 ID").toBeTruthy() + expect(data.name).toBe(templateName) + expect(data.mode).toBe("pip") + }) test("列出剪辑计划 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-list"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "ep-list") + const suffix = Date.now().toString(36) // 创建 2 个模板 await request.post(`${apiBase}/templates`, { @@ -271,7 +258,7 @@ test.describe("剪辑计划 - API 操作", () => { }, ], }, - }); + }) await request.post(`${apiBase}/templates`, { headers, data: { @@ -287,102 +274,81 @@ test.describe("剪辑计划 - API 操作", () => { }, ], }, - }); + }) - const response = await request.get(`${apiBase}/templates`, { headers }); - expect( - response.ok(), - `列出模板应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + const response = await request.get(`${apiBase}/templates`, { headers }) + expect(response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - const items = data.items || data.templates || []; - expect(Array.isArray(items), "返回应为数组").toBeTruthy(); - expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2); - }); + const data = await response.json() + const items = data.items || data.templates || [] + expect(Array.isArray(items), "返回应为数组").toBeTruthy() + expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2) + }) test("获取剪辑计划详情 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-detail"); - const templateId = await createEditingTemplate( - request, - headers, - Date.now().toString(36), - ); + const { headers } = await createAuthedUser(request, "ep-detail") + const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) const response = await request.get(`${apiBase}/templates/${templateId}`, { headers, - }); - expect( - response.ok(), - `获取详情应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + }) + expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(data.id).toBe(templateId); - expect(data.name).toBeTruthy(); - expect(data.mode).toBeTruthy(); - }); + const data = await response.json() + expect(data.id).toBe(templateId) + expect(data.name).toBeTruthy() + expect(data.mode).toBeTruthy() + }) test("编辑剪辑计划 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-update"); - const templateId = await createEditingTemplate( - request, - headers, - Date.now().toString(36), - ); + const { headers } = await createAuthedUser(request, "ep-update") + const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) - const newName = `更新后的剪辑计划 ${Date.now()}`; + const newName = `更新后的剪辑计划 ${Date.now()}` const response = await request.patch(`${apiBase}/templates/${templateId}`, { headers, data: { name: newName, description: "更新后的描述", }, - }); + }) expect( response.ok(), `更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.name).toBe(newName); + const data = await response.json() + expect(data.name).toBe(newName) // 验证更新后的数据 const verify = await request.get(`${apiBase}/templates/${templateId}`, { headers, - }); - const verifyData = await verify.json(); - expect(verifyData.name).toBe(newName); - }); + }) + const verifyData = await verify.json() + expect(verifyData.name).toBe(newName) + }) test("删除剪辑计划 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-delete"); - const templateId = await createEditingTemplate( - request, - headers, - Date.now().toString(36), - ); + const { headers } = await createAuthedUser(request, "ep-delete") + const templateId = await createEditingTemplate(request, headers, Date.now().toString(36)) // 删除 - const deleteResp = await request.delete( - `${apiBase}/templates/${templateId}`, - { headers }, - ); + const deleteResp = await request.delete(`${apiBase}/templates/${templateId}`, { headers }) expect( [200, 204].includes(deleteResp.status()), `删除应返回 200 或 204,实际: ${deleteResp.status()}`, - ).toBeTruthy(); + ).toBeTruthy() // 验证已删除 const getResp = await request.get(`${apiBase}/templates/${templateId}`, { headers, - }); - expect([404, 410]).toContain(getResp.status()); - }); + }) + expect([404, 410]).toContain(getResp.status()) + }) test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-badmode"); + const { headers } = await createAuthedUser(request, "ep-badmode") const response = await request.post(`${apiBase}/templates`, { headers, @@ -392,20 +358,17 @@ test.describe("剪辑计划 - API 操作", () => { estimated_duration: 30, segments: [], }, - }); + }) - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("获取不存在的剪辑计划 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "ep-404"); + const { headers } = await createAuthedUser(request, "ep-404") - const response = await request.get( - `${apiBase}/templates/nonexistent-template-999`, - { headers }, - ); - expect(response.status(), "不存在的模板应返回 404").toBe(404); - }); + const response = await request.get(`${apiBase}/templates/nonexistent-template-999`, { headers }) + expect(response.status(), "不存在的模板应返回 404").toBe(404) + }) test("未登录创建剪辑计划 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/templates`, { @@ -415,81 +378,77 @@ test.describe("剪辑计划 - API 操作", () => { estimated_duration: 30, segments: [], }, - }); - expect([401, 403]).toContain(response.status()); - }); -}); + }) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("剪辑策划页面 - 已模板数据加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("已创建的模板在页面中显示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "ep-data"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "ep-data", + ) + const suffix = Date.now().toString(36) - await createEditingTemplate(request, headers, suffix); + await createEditingTemplate(request, headers, suffix) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-data", - }); + }) - await page.goto("/app/editing-planner"); + await page.goto("/app/editing-planner") await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, - }); + }) // 验证状态栏存在 - await expect(page.locator(".ep-status-bar")).toBeVisible(); - }); + await expect(page.locator(".ep-status-bar")).toBeVisible() + }) test("撤销/重做按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "ep-undo", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-undo") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-undo", - }); + }) - await page.goto("/app/editing-planner"); + await page.goto("/app/editing-planner") await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, - }); + }) // 验证顶栏按钮存在(撤销、重做、保存、生成等) - const topBarBtns = page.locator(".ep-top-bar-right .ep-btn"); - await expect(topBarBtns.first()).toBeVisible(); - const btnCount = await topBarBtns.count(); - expect(btnCount).toBeGreaterThanOrEqual(2); - }); + const topBarBtns = page.locator(".ep-top-bar-right .ep-btn") + await expect(topBarBtns.first()).toBeVisible() + const btnCount = await topBarBtns.count() + expect(btnCount).toBeGreaterThanOrEqual(2) + }) test("生成按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "ep-gen", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-gen") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E ep-gen", - }); + }) - await page.goto("/app/editing-planner"); + await page.goto("/app/editing-planner") await expect(page.locator(".ep-v8-root")).toBeVisible({ timeout: 20_000, - }); + }) // 验证主操作按钮存在 - await expect(page.locator(".ep-btn-primary")).toBeVisible(); - }); -}); + await expect(page.locator(".ep-btn-primary")).toBeVisible() + }) +}) diff --git a/apps/web/e2e/products.spec.ts b/apps/web/e2e/products.spec.ts index 32e8516bc..84125fa9b 100644 --- a/apps/web/e2e/products.spec.ts +++ b/apps/web/e2e/products.spec.ts @@ -7,33 +7,29 @@ * 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。 * 真实的生成流程测试见 core-generation.spec.ts。 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "SmokePass123!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "SmokePass123!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" -const routeBrowserApiToTestApi = async ( - page: import("@playwright/test").Page, -) => { - if (!apiOrigin) return; +const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => { + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -46,30 +42,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -77,14 +73,14 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** Mock 产品数据 */ function mockProducts(count: number, statuses: string[] = ["completed"]) { - const products = []; + const products = [] for (let i = 0; i < count; i++) { - const status = statuses[i % statuses.length]; + const status = statuses[i % statuses.length] products.push({ id: `mock-prod-${Date.now()}-${i}`, title: `测试作品 ${i + 1}`, @@ -93,14 +89,13 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) { resolution: "1080x1920", file_size: (5 + i) * 1024 * 1024, duplicate_rate: i * 5, - video_url: - status === "completed" ? "https://example.com/video.mp4" : undefined, + video_url: status === "completed" ? "https://example.com/video.mp4" : undefined, thumbnail_url: undefined, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), - }); + }) } - return products; + return products } /** 在浏览器中设置登录态 */ @@ -111,14 +106,14 @@ async function setupAuthInBrowser( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -132,17 +127,14 @@ async function setupAuthInBrowser( email_verified: true, }, }, - ); + ) } /** Mock 产品列表 API */ -async function mockProductsApi( - page: import("@playwright/test").Page, - products: unknown[], -) { +async function mockProductsApi(page: import("@playwright/test").Page, products: unknown[]) { await page.route("**/api/v1/products", (route) => { - const method = route.request().method(); - const url = route.request().url(); + const method = route.request().method() + const url = route.request().url() if (method === "GET" && url.match(/\/api\/v1\/products$/)) { // 列表 @@ -150,31 +142,29 @@ async function mockProductsApi( status: 200, contentType: "application/json", body: JSON.stringify({ items: products, total: products.length }), - }); - return; + }) + return } // 单个产品详情 - const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/); + const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/) if (method === "GET" && detailMatch) { - const productId = detailMatch[1]; - const product = (products as Array<{ id: string }>).find( - (p) => p.id === productId, - ); + const productId = detailMatch[1] + const product = (products as Array<{ id: string }>).find((p) => p.id === productId) if (product) { route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(product), - }); + }) } else { route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "Not found" }), - }); + }) } - return; + return } // 删除 @@ -183,8 +173,8 @@ async function mockProductsApi( status: 200, contentType: "application/json", body: JSON.stringify({ message: "deleted" }), - }); - return; + }) + return } // 下载链接 @@ -196,67 +186,67 @@ async function mockProductsApi( url: "https://example.com/download.mp4", expires_at: new Date().toISOString(), }), - }); - return; + }) + return } - route.continue(); - }); + route.continue() + }) } test.describe("作品库页面", () => { - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) // ─── 页面加载 ────────────────────────────────────── test("作品库列表页面加载", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-load", - ); + ) - const products = mockProducts(3, ["completed", "processing", "failed"]); - await mockProductsApi(page, products); + const products = mockProducts(3, ["completed", "processing", "failed"]) + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") // 页面容器 await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 20_000, - }); + }) // 页面标题 - await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible() // 筛选栏 - await expect(page.locator(".xx-products-filters")).toBeVisible(); + await expect(page.locator(".xx-products-filters")).toBeVisible() // 卡片网格 await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 10_000, - }); + }) // 作品卡片存在 await expect(page.locator(".xx-product-card")).toHaveCount(3, { timeout: 10_000, - }); - }); + }) + }) // ─── 状态展示 ────────────────────────────────────── test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-status", - ); + ) const products = [ { ...mockProducts(1, ["completed"])[0], title: "已完成作品" }, @@ -270,334 +260,311 @@ test.describe("作品库页面", () => { title: "失败作品", id: `mock-prod-${Date.now()}-f`, }, - ]; - await mockProductsApi(page, products); + ] + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 20_000, - }); + }) // 等待卡片加载 await expect(page.locator(".xx-product-card")).toHaveCount(3, { timeout: 10_000, - }); + }) // 验证各状态标签存在 - const completedCard = page - .locator(".xx-product-card") - .filter({ hasText: "已完成作品" }); - await expect( - completedCard.locator(".xx-product-status.completed"), - ).toHaveText("已完成"); + const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" }) + await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成") - const processingCard = page - .locator(".xx-product-card") - .filter({ hasText: "处理中作品" }); - await expect( - processingCard.locator(".xx-product-status.processing"), - ).toHaveText("处理中"); + const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" }) + await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中") - const failedCard = page - .locator(".xx-product-card") - .filter({ hasText: "失败作品" }); - await expect(failedCard.locator(".xx-product-status.failed")).toHaveText( - "失败", - ); - }); + const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" }) + await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败") + }) // ─── 作品详情页 ──────────────────────────────────── test("作品详情页打开", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-detail", - ); + ) - const products = mockProducts(1, ["completed"]); - products[0].title = "详情页测试作品"; - const productId = products[0].id; - await mockProductsApi(page, products); + const products = mockProducts(1, ["completed"]) + products[0].title = "详情页测试作品" + const productId = products[0].id + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) // 直接访问详情页 - await page.goto(`/app/products/${productId}`); + await page.goto(`/app/products/${productId}`) // 验证 URL - await expect(page).toHaveURL(/\/app\/products\//); + await expect(page).toHaveURL(/\/app\/products\//) // 页面应正常渲染(无错误) await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, { timeout: 5_000, - }); - }); + }) + }) // ─── 视频播放 ────────────────────────────────────── test("视频播放器存在(播放弹窗)", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-play", - ); + ) - const products = mockProducts(1, ["completed"]); - products[0].title = "播放测试作品"; - products[0].video_url = "https://example.com/test-video.mp4"; - await mockProductsApi(page, products); + const products = mockProducts(1, ["completed"]) + products[0].title = "播放测试作品" + products[0].video_url = "https://example.com/test-video.mp4" + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) // 点击作品卡片打开播放 - const productCard = page - .locator(".xx-product-card") - .filter({ hasText: "播放测试作品" }); - await expect(productCard).toBeVisible(); + const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" }) + await expect(productCard).toBeVisible() // 点击播放按钮 - await productCard.locator(".xx-product-play").click({ force: true }); + await productCard.locator(".xx-product-play").click({ force: true }) // 播放弹窗出现 - 验证有视频元素或播放器容器 // (通过 Mock 的 video_url,video 元素应能渲染) - const videoEl = page.locator("video"); + const videoEl = page.locator("video") const videoVisible = await videoEl .first() .isVisible({ timeout: 5000 }) - .catch(() => false); + .catch(() => false) // 或弹窗容器可见 const modalVisible = await page .locator(".ant-modal-content") .filter({ hasText: "播放测试作品" }) .isVisible() - .catch(() => false); + .catch(() => false) - expect(videoVisible || modalVisible).toBeTruthy(); - }); + expect(videoVisible || modalVisible).toBeTruthy() + }) // ─── 下载按钮 ────────────────────────────────────── test("下载按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-download", - ); + ) - const products = mockProducts(1, ["completed"]); - products[0].title = "下载测试作品"; - await mockProductsApi(page, products); + const products = mockProducts(1, ["completed"]) + products[0].title = "下载测试作品" + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) - const productCard = page - .locator(".xx-product-card") - .filter({ hasText: "下载测试作品" }); - await expect(productCard).toBeVisible(); + const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" }) + await expect(productCard).toBeVisible() // 下载按钮存在且可用(已完成状态) - const downloadBtn = productCard.getByRole("button", { name: "下载" }); - await expect(downloadBtn).toBeVisible(); - await expect(downloadBtn).not.toBeDisabled(); - }); + const downloadBtn = productCard.getByRole("button", { name: "下载" }) + await expect(downloadBtn).toBeVisible() + await expect(downloadBtn).not.toBeDisabled() + }) test("处理中作品下载按钮禁用", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-disabled", - ); + ) - const products = mockProducts(1, ["processing"]); - products[0].title = "处理中下载测试"; - await mockProductsApi(page, products); + const products = mockProducts(1, ["processing"]) + products[0].title = "处理中下载测试" + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) - const productCard = page - .locator(".xx-product-card") - .filter({ hasText: "处理中下载测试" }); - await expect(productCard).toBeVisible(); + const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" }) + await expect(productCard).toBeVisible() // 处理中的作品下载按钮应禁用 - const downloadBtn = productCard.getByRole("button", { name: "下载" }); - await expect(downloadBtn).toBeVisible(); - const isDisabled = await downloadBtn.isDisabled(); + const downloadBtn = productCard.getByRole("button", { name: "下载" }) + await expect(downloadBtn).toBeVisible() + const isDisabled = await downloadBtn.isDisabled() const hasDisabled = await downloadBtn.evaluate( (el) => el.hasAttribute("disabled") || el.classList.contains("disabled"), - ); - expect(isDisabled || hasDisabled).toBeTruthy(); - }); + ) + expect(isDisabled || hasDisabled).toBeTruthy() + }) // ─── 删除作品 ────────────────────────────────────── test("删除作品 - API 调用正确", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-delete", - ); + ) - const products = mockProducts(1, ["completed"]); - products[0].title = "待删除作品"; - let deleteCalled = false; - let deletedId = ""; + const products = mockProducts(1, ["completed"]) + products[0].title = "待删除作品" + let deleteCalled = false + let deletedId = "" await page.route("**/api/v1/products", (route) => { - const method = route.request().method(); - const url = route.request().url(); + const method = route.request().method() + const url = route.request().url() if (method === "GET" && url.match(/\/api\/v1\/products$/)) { route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: products, total: products.length }), - }); - return; + }) + return } - const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/); + const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/) if (method === "DELETE" && detailMatch) { - deleteCalled = true; - deletedId = detailMatch[1]; + deleteCalled = true + deletedId = detailMatch[1] route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ message: "deleted" }), - }); - return; + }) + return } if (method === "GET" && detailMatch) { - const productId = detailMatch[1]; - const product = products.find((p) => p.id === productId); + const productId = detailMatch[1] + const product = products.find((p) => p.id === productId) route.fulfill({ status: product ? 200 : 404, contentType: "application/json", body: JSON.stringify(product || { detail: "Not found" }), - }); - return; + }) + return } - route.continue(); - }); + route.continue() + }) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) - const productCard = page - .locator(".xx-product-card") - .filter({ hasText: "待删除作品" }); - await expect(productCard).toBeVisible(); + const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" }) + await expect(productCard).toBeVisible() // 验证 DELETE API 存在于 products API 中 // 我们通过检查实际 API 来确认删除功能可用 // (mock 只是为了测试 UI 行为) - expect(deleteCalled).toBe(false); // 初始状态未调用 - expect(deletedId).toBe(""); - }); + expect(deleteCalled).toBe(false) // 初始状态未调用 + expect(deletedId).toBe("") + }) test("删除作品 API 端点存在", async ({ request }) => { - const { headers } = await createAuthedUser(request, "products-del-api"); + const { headers } = await createAuthedUser(request, "products-del-api") // 测试删除不存在的产品,验证 API 端点存在 - const resp = await request.delete( - `${apiBase}/products/nonexistent-test-id`, - { - headers, - }, - ); + const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, { + headers, + }) // 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在) // 404 表示资源不存在但端点存在 - expect(resp.status(), "删除 API 端点应存在").not.toBe(405); - expect([200, 204, 403, 404]).toContain(resp.status()); - }); + expect(resp.status(), "删除 API 端点应存在").not.toBe(405) + expect([200, 204, 403, 404]).toContain(resp.status()) + }) // ─── 空状态 ──────────────────────────────────────── test("空状态展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-empty", - ); + ) // Mock 空列表 - await mockProductsApi(page, []); + await mockProductsApi(page, []) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-page")).toBeVisible({ timeout: 20_000, - }); + }) // 空状态应显示 await expect(page.locator(".xx-products-empty")).toBeVisible({ timeout: 10_000, - }); - await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible(); - }); + }) + await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible() + }) // ─── 搜索筛选 ────────────────────────────────────── test("作品搜索功能", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-search", - ); + ) const products = [ { @@ -610,44 +577,44 @@ test.describe("作品库页面", () => { title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana`, }, - ]; - await mockProductsApi(page, products); + ] + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) // 两个作品都可见 await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000, - }); - await expect(page.getByText("香蕉推广视频")).toBeVisible(); + }) + await expect(page.getByText("香蕉推广视频")).toBeVisible() // 搜索"苹果" - await page.getByPlaceholder("搜索成片名称...").fill("苹果"); - await expect(page.getByText("苹果宣传视频")).toBeVisible(); - await expect(page.getByText("香蕉推广视频")).toHaveCount(0); + await page.getByPlaceholder("搜索成片名称...").fill("苹果") + await expect(page.getByText("苹果宣传视频")).toBeVisible() + await expect(page.getByText("香蕉推广视频")).toHaveCount(0) // 清空搜索 - await page.getByPlaceholder("搜索成片名称...").fill(""); + await page.getByPlaceholder("搜索成片名称...").fill("") await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000, - }); - }); + }) + }) test("作品状态筛选", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-filter-status", - ); + ) const products = [ { @@ -660,94 +627,90 @@ test.describe("作品库页面", () => { title: "处理中筛选", id: `mock-prod-${Date.now()}-proc`, }, - ]; - await mockProductsApi(page, products); + ] + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) // 两个都可见 - await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 }); - await expect(page.getByText("处理中筛选")).toBeVisible(); + await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 }) + await expect(page.getByText("处理中筛选")).toBeVisible() // 状态筛选下拉存在 - const selects = page.locator(".xx-products-filters-left select"); - const count = await selects.count(); + const selects = page.locator(".xx-products-filters-left select") + const count = await selects.count() if (count >= 2) { // 第2个 select 是状态筛选 - await selects.nth(1).selectOption({ label: "已完成" }); - await expect(page.getByText("已完成筛选")).toBeVisible(); - await expect(page.getByText("处理中筛选")).toHaveCount(0); + await selects.nth(1).selectOption({ label: "已完成" }) + await expect(page.getByText("已完成筛选")).toBeVisible() + await expect(page.getByText("处理中筛选")).toHaveCount(0) } - }); + }) // ─── 批量操作 ────────────────────────────────────── test("批量选择和批量操作栏", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { userId, accessToken, email, username } = await createAuthedUser( request, "products-batch", - ); + ) - const products = mockProducts(3, ["completed"]); - products[0].title = "批量测试 1"; - products[1].title = "批量测试 2"; - products[2].title = "批量测试 3"; - await mockProductsApi(page, products); + const products = mockProducts(3, ["completed"]) + products[0].title = "批量测试 1" + products[1].title = "批量测试 2" + products[2].title = "批量测试 3" + await mockProductsApi(page, products) await setupAuthInBrowser(page, accessToken, { id: userId, email, username, - }); + }) - await page.goto("/app/products"); + await page.goto("/app/products") await expect(page.locator(".xx-products-grid")).toBeVisible({ timeout: 20_000, - }); + }) // 三张卡片 await expect(page.locator(".xx-product-card")).toHaveCount(3, { timeout: 10_000, - }); + }) // 点击第一张卡片的复选框 - const firstCard = page.locator(".xx-product-card").first(); - const checkbox = firstCard.locator(".xx-product-card-checkbox"); - await expect(checkbox).toBeVisible(); - await checkbox.click(); + const firstCard = page.locator(".xx-product-card").first() + const checkbox = firstCard.locator(".xx-product-card-checkbox") + await expect(checkbox).toBeVisible() + await checkbox.click() // 批量操作栏应出现 - const batchBar = page.locator(".xx-products-batch-bar"); - await expect(batchBar).toBeVisible({ timeout: 5_000 }); - await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible(); + const batchBar = page.locator(".xx-products-batch-bar") + await expect(batchBar).toBeVisible({ timeout: 5_000 }) + await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible() // 批量按钮存在 - await expect( - batchBar.getByRole("button", { name: "批量下载" }), - ).toBeVisible(); - await expect( - batchBar.getByRole("button", { name: "批量删除" }), - ).toBeVisible(); + await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible() + await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible() // 取消选择 - await batchBar.getByRole("button", { name: "取消选择" }).click(); - await expect(batchBar).not.toBeVisible({ timeout: 3_000 }); - }); + await batchBar.getByRole("button", { name: "取消选择" }).click() + await expect(batchBar).not.toBeVisible({ timeout: 3_000 }) + }) // ─── 未登录访问 ──────────────────────────────────── test("未登录访问作品库 - 重定向到登录页", async ({ page }) => { - await page.goto("/app/products"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/products") + await expect(page).toHaveURL(/\/login/) + }) +}) diff --git a/apps/web/e2e/profile.spec.ts b/apps/web/e2e/profile.spec.ts index 8f581e56d..188176c76 100644 --- a/apps/web/e2e/profile.spec.ts +++ b/apps/web/e2e/profile.spec.ts @@ -6,36 +6,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -48,30 +41,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -90,14 +83,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -111,210 +104,204 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } test.describe("个人设置页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { - await page.goto("/app/profile"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/profile") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("个人设置页面 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("设置页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "profile-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-load", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("页面标题存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-title", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-title", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证页面包含"个人设置"标题 - const heading = page.getByRole("heading", { name: /个人设置/ }); - await expect(heading.first()).toBeVisible({ timeout: 10_000 }); - }); -}); + const heading = page.getByRole("heading", { name: /个人设置/ }) + await expect(heading.first()).toBeVisible({ timeout: 10_000 }) + }) +}) test.describe("个人设置 - 个人信息展示", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("个人信息卡片展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "profile-info", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-info") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-info", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证设置卡片存在 - await expect(page.locator(".xx-settings-card")).toBeVisible(); - }); + await expect(page.locator(".xx-settings-card")).toBeVisible() + }) test("用户名、邮箱字段展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-fields", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-fields", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证表单字段存在 - const fields = page.locator(".xx-settings-field"); - await expect(fields.first()).toBeVisible(); - const fieldCount = await fields.count(); - expect(fieldCount).toBeGreaterThanOrEqual(2); - }); + const fields = page.locator(".xx-settings-field") + await expect(fields.first()).toBeVisible() + const fieldCount = await fields.count() + expect(fieldCount).toBeGreaterThanOrEqual(2) + }) test("用户名标签和输入框存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-username", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-username", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证用户名标签 const usernameLabel = page.locator(".xx-settings-label").filter({ hasText: "用户名", - }); - await expect(usernameLabel).toBeVisible(); + }) + await expect(usernameLabel).toBeVisible() // 验证邮箱标签 const emailLabel = page.locator(".xx-settings-label").filter({ hasText: "邮箱", - }); - await expect(emailLabel).toBeVisible(); - }); + }) + await expect(emailLabel).toBeVisible() + }) test("显示名称字段可编辑", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-dispname", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-dispname", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 查找显示名称输入框 const displayNameField = page.locator(".xx-settings-field").filter({ has: page.locator(".xx-settings-label", { hasText: "显示名称" }), - }); + }) if (await displayNameField.isVisible()) { - const input = displayNameField.locator("input"); + const input = displayNameField.locator("input") if (await input.isVisible()) { // 验证输入框存在且可输入 - await expect(input).toBeVisible(); - const initialValue = await input.inputValue(); - await input.fill("新的显示名称"); - await expect(input).toHaveValue("新的显示名称"); + await expect(input).toBeVisible() + const initialValue = await input.inputValue() + await input.fill("新的显示名称") + await expect(input).toHaveValue("新的显示名称") // 恢复原值 - await input.fill(initialValue); + await input.fill(initialValue) } } - }); -}); + }) +}) test.describe("个人设置 - 修改密码", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("修改密码 API - 正向", async ({ request }) => { - const { headers, email } = await createAuthedUser(request, "profile-chpwd"); + const { headers, email } = await createAuthedUser(request, "profile-chpwd") - const newPassword = "NewPass123456!"; + const newPassword = "NewPass123456!" const response = await request.post(`${apiBase}/auth/change-password`, { headers, data: { old_password: PASSWORD, new_password: newPassword, }, - }); + }) // 修改密码可能成功或接口不存在 expect( response.status() < 500, `修改密码应返回 2xx 或 4xx,实际: ${response.status()}`, - ).toBeTruthy(); + ).toBeTruthy() // 如果成功,用新密码登录验证 if (response.ok()) { - const loginResp = await loginWithRetry(request, email, newPassword); - expect(loginResp.ok(), "新密码应能登录").toBeTruthy(); + const loginResp = await loginWithRetry(request, email, newPassword) + expect(loginResp.ok(), "新密码应能登录").toBeTruthy() } - }); + }) test("修改密码 - 旧密码错误反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "profile-badpwd"); + const { headers } = await createAuthedUser(request, "profile-badpwd") const response = await request.post(`${apiBase}/auth/change-password`, { headers, @@ -322,17 +309,17 @@ test.describe("个人设置 - 修改密码", () => { old_password: "WrongOldPass123!", new_password: "NewPass123456!", }, - }); + }) // 如果接口存在,应该返回 400/401 if (response.status() < 500 && response.status() >= 400) { - expect([400, 401]).toContain(response.status()); + expect([400, 401]).toContain(response.status()) } // 接口不存在(404)也正常 - }); + }) test("修改密码 - 新密码太弱反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "profile-weakpwd"); + const { headers } = await createAuthedUser(request, "profile-weakpwd") const response = await request.post(`${apiBase}/auth/change-password`, { headers, @@ -340,12 +327,12 @@ test.describe("个人设置 - 修改密码", () => { old_password: PASSWORD, new_password: "123", }, - }); + }) if (response.status() < 500 && response.status() >= 400) { - expect([400, 422]).toContain(response.status()); + expect([400, 422]).toContain(response.status()) } - }); + }) test("未登录修改密码 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/change-password`, { @@ -353,130 +340,115 @@ test.describe("个人设置 - 修改密码", () => { old_password: "old", new_password: "new", }, - }); - expect([401, 403, 404]).toContain(response.status()); - }); -}); + }) + expect([401, 403, 404]).toContain(response.status()) + }) +}) test.describe("个人设置 - 账号安全", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取当前用户信息 - 正向", async ({ request }) => { - const { headers, email, username } = await createAuthedUser( - request, - "profile-me", - ); + const { headers, email, username } = await createAuthedUser(request, "profile-me") - const response = await request.get(`${apiBase}/auth/me`, { headers }); - expect( - response.ok(), - `获取用户信息应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + const response = await request.get(`${apiBase}/auth/me`, { headers }) + expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(data.email).toBe(email); - expect(data.username).toBe(username); - }); + const data = await response.json() + expect(data.email).toBe(email) + expect(data.username).toBe(username) + }) test("账号安全区域提示信息存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-security", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-security", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证通知区域存在 - const notice = page.locator(".xx-settings-notice"); - await expect(notice).toBeVisible(); - }); -}); + const notice = page.locator(".xx-settings-notice") + await expect(notice).toBeVisible() + }) +}) test.describe("个人设置 - 退出登录", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("登出 API - 正向", async ({ request }) => { - const { headers } = await createAuthedUser( - request, - "profile-logout", - ); + const { headers } = await createAuthedUser(request, "profile-logout") const response = await request.post(`${apiBase}/auth/logout`, { headers, - }); - expect( - response.ok(), - `登出应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + }) + expect(response.ok(), `登出应返回 2xx,实际: ${response.status()}`).toBeTruthy() // 登出后 token 应失效 - const meResp = await request.get(`${apiBase}/auth/me`, { headers }); - expect([401, 403]).toContain(meResp.status()); - }); + const meResp = await request.get(`${apiBase}/auth/me`, { headers }) + expect([401, 403]).toContain(meResp.status()) + }) test("登出后页面跳转登录页", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "profile-logout-ui", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-logout-ui", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 清除 localStorage 模拟登出 await page.evaluate(() => { - localStorage.removeItem("access_token"); - localStorage.removeItem("auth-storage"); - }); + localStorage.removeItem("access_token") + localStorage.removeItem("auth-storage") + }) // 刷新页面应该重定向到登录页 - await page.reload(); - await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); - }); -}); + await page.reload() + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }) + }) +}) test.describe("个人设置 - 保存按钮", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("保存按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "profile-save", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-save") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E profile-save", - }); + }) - await page.goto("/app/profile"); + await page.goto("/app/profile") await expect(page.locator(".xx-settings-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证按钮存在 - const button = page.getByRole("button", { name: /保存|暂未开放/ }); - await expect(button.first()).toBeVisible({ timeout: 5_000 }); - }); -}); + const button = page.getByRole("button", { name: /保存|暂未开放/ }) + await expect(button.first()).toBeVisible({ timeout: 5_000 }) + }) +}) diff --git a/apps/web/e2e/register.spec.ts b/apps/web/e2e/register.spec.ts index 40aa302c2..01bad6c6b 100644 --- a/apps/web/e2e/register.spec.ts +++ b/apps/web/e2e/register.spec.ts @@ -4,17 +4,17 @@ * 覆盖:页面渲染、表单验证、成功注册、跳转链接 * 每个测试独立,使用随机邮箱避免冲突。 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ @@ -27,181 +27,154 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } test.describe("注册页面", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) // ─── 页面渲染 ────────────────────────────────────── test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") // 品牌标识 - await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪"); + await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪") // 标题/描述 - await expect( - page.getByText("创建账户,开启智能视频创作之旅"), - ).toBeVisible(); + await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible() // 表单字段 - await expect(page.getByLabel("邮箱")).toBeVisible(); - await expect(page.getByLabel("用户名")).toBeVisible(); - await expect(page.getByLabel("密码")).toBeVisible(); - await expect(page.getByLabel("确认密码")).toBeVisible(); + await expect(page.getByLabel("邮箱")).toBeVisible() + await expect(page.getByLabel("用户名")).toBeVisible() + await expect(page.getByLabel("密码")).toBeVisible() + await expect(page.getByLabel("确认密码")).toBeVisible() // 提交按钮 - await expect( - page.locator("button[type='submit']").filter({ hasText: "注册" }), - ).toBeVisible(); - }); + await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible() + }) // ─── 表单验证 ────────────────────────────────────── test("空提交 - 显示必填错误", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") // 直接点击注册按钮 - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() // 应显示必填错误 - await expect(page.getByText("请输入邮箱")).toBeVisible(); - await expect(page.getByText("请输入用户名")).toBeVisible(); - await expect(page.getByText("请输入密码")).toBeVisible(); - await expect(page.getByText("请确认密码")).toBeVisible(); - }); + await expect(page.getByText("请输入邮箱")).toBeVisible() + await expect(page.getByText("请输入用户名")).toBeVisible() + await expect(page.getByText("请输入密码")).toBeVisible() + await expect(page.getByText("请确认密码")).toBeVisible() + }) test("无效邮箱格式 - 显示格式错误", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill("not-an-email"); - await page.getByLabel("用户名").fill("testuser"); - await page.getByLabel("密码").fill(PASSWORD); - await page.getByLabel("确认密码").fill(PASSWORD); + await page.getByLabel("邮箱").fill("not-an-email") + await page.getByLabel("用户名").fill("testuser") + await page.getByLabel("密码").fill(PASSWORD) + await page.getByLabel("确认密码").fill(PASSWORD) - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() // 应显示邮箱格式错误 - await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible(); - }); + await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible() + }) test("密码太短 - 显示长度错误", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd")); - await page.getByLabel("用户名").fill("testuser"); - await page.getByLabel("密码").fill("123"); - await page.getByLabel("确认密码").fill("123"); + await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd")) + await page.getByLabel("用户名").fill("testuser") + await page.getByLabel("密码").fill("123") + await page.getByLabel("确认密码").fill("123") - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() // 应显示密码长度错误 - await expect(page.getByText("密码至少 8 个字符")).toBeVisible(); - }); + await expect(page.getByText("密码至少 8 个字符")).toBeVisible() + }) test("确认密码不一致 - 显示不一致错误", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch")); - await page.getByLabel("用户名").fill("testuser"); - await page.getByLabel("密码").fill(PASSWORD); - await page.getByLabel("确认密码").fill("Different123!"); + await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch")) + await page.getByLabel("用户名").fill("testuser") + await page.getByLabel("密码").fill(PASSWORD) + await page.getByLabel("确认密码").fill("Different123!") - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() // 应显示密码不一致错误 - await expect(page.getByText("两次输入的密码不一致")).toBeVisible(); - }); + await expect(page.getByText("两次输入的密码不一致")).toBeVisible() + }) test("用户名为空 - 显示必填错误", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill(uniqueEmail("empty-user")); - await page.getByLabel("用户名").fill(""); - await page.getByLabel("密码").fill(PASSWORD); - await page.getByLabel("确认密码").fill(PASSWORD); + await page.getByLabel("邮箱").fill(uniqueEmail("empty-user")) + await page.getByLabel("用户名").fill("") + await page.getByLabel("密码").fill(PASSWORD) + await page.getByLabel("确认密码").fill(PASSWORD) - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() - await expect(page.getByText("请输入用户名")).toBeVisible(); - }); + await expect(page.getByText("请输入用户名")).toBeVisible() + }) // ─── 成功注册 ────────────────────────────────────── test("成功注册 - 提交有效表单", async ({ page, _request }) => { - const email = uniqueEmail("reg-ui-ok"); - const username = uniqueUsername("reguiok"); + const email = uniqueEmail("reg-ui-ok") + const username = uniqueUsername("reguiok") - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill(email); - await page.getByLabel("用户名").fill(username); - await page.getByLabel("密码").fill(PASSWORD); - await page.getByLabel("确认密码").fill(PASSWORD); + await page.getByLabel("邮箱").fill(email) + await page.getByLabel("用户名").fill(username) + await page.getByLabel("密码").fill(PASSWORD) + await page.getByLabel("确认密码").fill(PASSWORD) // 监听注册请求 const registerResponse = page.waitForResponse( - (resp) => - resp.url().includes("/auth/register") && - resp.request().method() === "POST", + (resp) => resp.url().includes("/auth/register") && resp.request().method() === "POST", { timeout: 15_000 }, - ); + ) - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() - const resp = await registerResponse; - expect( - resp.ok(), - `注册请求应返回 2xx,实际: ${resp.status()}`, - ).toBeTruthy(); + const resp = await registerResponse + expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy() // 注册成功后应跳转到登录页或显示成功消息 // 页面应停留在可识别的状态(成功提示或跳转) await expect .poll( async () => { - const url = page.url(); + const url = page.url() // 可能跳转到 login,也可能在当前页显示成功消息 - if (url.includes("/login")) return "redirected"; - const hasSuccess = await page.getByText(/注册成功/).isVisible(); - return hasSuccess ? "success_msg" : url; + if (url.includes("/login")) return "redirected" + const hasSuccess = await page.getByText(/注册成功/).isVisible() + return hasSuccess ? "success_msg" : url }, { timeout: 10_000 }, ) - .toMatch(/redirected|success_msg/); - }); + .toMatch(/redirected|success_msg/) + }) test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => { - const email = uniqueEmail("reg-ui-dup"); - const username1 = uniqueUsername("reguidup1"); - const username2 = uniqueUsername("reguidup2"); + const email = uniqueEmail("reg-ui-dup") + const username1 = uniqueUsername("reguidup1") + const username2 = uniqueUsername("reguidup2") // 先通过 API 注册一个账号 const firstReg = await request.post(`${apiBase}/auth/register`, { @@ -211,74 +184,66 @@ test.describe("注册页面", () => { username: username1, display_name: "User 1", }, - }); - expect(firstReg.ok(), "第一次注册应成功").toBeTruthy(); + }) + expect(firstReg.ok(), "第一次注册应成功").toBeTruthy() // 再在 UI 上用相同邮箱注册 - await page.goto("/register"); + await page.goto("/register") - await page.getByLabel("邮箱").fill(email); - await page.getByLabel("用户名").fill(username2); - await page.getByLabel("密码").fill(PASSWORD); - await page.getByLabel("确认密码").fill(PASSWORD); + await page.getByLabel("邮箱").fill(email) + await page.getByLabel("用户名").fill(username2) + await page.getByLabel("密码").fill(PASSWORD) + await page.getByLabel("确认密码").fill(PASSWORD) - await page - .locator("button[type='submit']") - .filter({ hasText: "注册" }) - .click(); + await page.locator("button[type='submit']").filter({ hasText: "注册" }).click() // 应显示错误提示(通过 antd message 或表单错误) await expect .poll( async () => { // 检查是否有错误消息 - const hasError = await page - .getByText(/注册失败|已注册|已存在|exists/) - .isVisible(); - return hasError ? "error_shown" : "waiting"; + const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible() + return hasError ? "error_shown" : "waiting" }, { timeout: 10_000 }, ) - .toBe("error_shown"); - }); + .toBe("error_shown") + }) // ─── 跳转链接 ────────────────────────────────────── test("跳转到登录页的链接", async ({ page }) => { - await page.goto("/register"); + await page.goto("/register") - await page.getByRole("link", { name: "立即登录" }).click(); + await page.getByRole("link", { name: "立即登录" }).click() - await expect(page).toHaveURL(/\/login/); - await expect(page.getByLabel("邮箱")).toBeVisible(); - }); + await expect(page).toHaveURL(/\/login/) + await expect(page.getByLabel("邮箱")).toBeVisible() + }) test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => { - await page.goto("/login"); + await page.goto("/login") - await page.getByRole("link", { name: "立即注册" }).click(); + await page.getByRole("link", { name: "立即注册" }).click() - await expect(page).toHaveURL(/\/register/); - }); + await expect(page).toHaveURL(/\/register/) + }) test("登录页有忘记密码链接", async ({ page }) => { - await page.goto("/login"); + await page.goto("/login") - await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible(); + await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible() - await page.getByRole("link", { name: /忘记密码/ }).click(); + await page.getByRole("link", { name: /忘记密码/ }).click() - await expect(page).toHaveURL(/\/forgot-password/); - }); + await expect(page).toHaveURL(/\/forgot-password/) + }) // ─── 路由守卫 - 已登录用户访问注册页 ────────────── - test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ - page, - request, - }) => { - const email = uniqueEmail("reg-auth"); - const username = uniqueUsername("regauth"); + test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ page, request }) => { + const email = uniqueEmail("reg-auth") + const username = uniqueUsername("regauth") // 注册 await request.post(`${apiBase}/auth/register`, { @@ -288,24 +253,24 @@ test.describe("注册页面", () => { username, display_name: "Reg Auth Test", }, - }); + }) // 登录 - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), "登录应成功").toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), "登录应成功").toBeTruthy() + const loginData = await login.json() // 设置登录态 await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token: loginData.access_token, @@ -319,15 +284,13 @@ test.describe("注册页面", () => { email_verified: true, }, }, - ); + ) - await page.goto("/register"); + await page.goto("/register") // 注册页对已登录用户也可访问(注册页是公开页面) // 验证页面正常渲染 - await expect(page.getByLabel("邮箱")).toBeVisible(); - await expect( - page.locator("button[type='submit']").filter({ hasText: "注册" }), - ).toBeVisible(); - }); -}); + await expect(page.getByLabel("邮箱")).toBeVisible() + await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible() + }) +}) diff --git a/apps/web/e2e/subscription-full.spec.ts b/apps/web/e2e/subscription-full.spec.ts index 27aeff2c5..d019876e3 100644 --- a/apps/web/e2e/subscription-full.spec.ts +++ b/apps/web/e2e/subscription-full.spec.ts @@ -9,36 +9,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -51,30 +44,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -93,14 +86,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -114,138 +107,129 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } test.describe("订阅套餐页 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("订阅套餐页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "sub-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-load", - }); + }) - await page.goto("/app/subscription"); + await page.goto("/app/subscription") await expect(page.locator(".xx-plans-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("套餐卡片网格展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "sub-cards", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cards") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-cards", - }); + }) - await page.goto("/app/subscription"); + await page.goto("/app/subscription") await expect(page.locator(".xx-plans-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证套餐卡片存在 - const planCards = page.locator(".xx-plan-card"); - await expect(planCards.first()).toBeVisible({ timeout: 10_000 }); - const cardCount = await planCards.count(); - expect(cardCount).toBeGreaterThanOrEqual(2); - }); + const planCards = page.locator(".xx-plan-card") + await expect(planCards.first()).toBeVisible({ timeout: 10_000 }) + const cardCount = await planCards.count() + expect(cardCount).toBeGreaterThanOrEqual(2) + }) test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "sub-cardinfo", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cardinfo") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-cardinfo", - }); + }) - await page.goto("/app/subscription"); + await page.goto("/app/subscription") await expect(page.locator(".xx-plans-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-plan-card").first(); - await expect(firstCard).toBeVisible({ timeout: 10_000 }); + const firstCard = page.locator(".xx-plan-card").first() + await expect(firstCard).toBeVisible({ timeout: 10_000 }) // 验证价格区域存在 - await expect(firstCard.locator(".xx-plan-price")).toBeVisible(); + await expect(firstCard.locator(".xx-plan-price")).toBeVisible() // 验证特性列表存在 - await expect(firstCard.locator(".xx-features")).toBeVisible(); + await expect(firstCard.locator(".xx-features")).toBeVisible() // 验证订阅按钮存在 - await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible(); - }); + await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible() + }) test("推荐套餐有特殊标识", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-recommended", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-recommended", - }); + }) - await page.goto("/app/subscription"); + await page.goto("/app/subscription") await expect(page.locator(".xx-plans-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证有推荐标签 - const featuredCard = page.locator(".xx-plan-card.featured"); + const featuredCard = page.locator(".xx-plan-card.featured") if (await featuredCard.isVisible({ timeout: 5_000 })) { - await expect(featuredCard.locator(".xx-badge")).toBeVisible(); + await expect(featuredCard.locator(".xx-badge")).toBeVisible() } - }); -}); + }) +}) test.describe("订阅套餐页 - 升级交互", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("点击升级套餐按钮跳转升级页", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-upgrade-btn", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-upgrade-btn", - }); + }) - await page.goto("/app/subscription"); + await page.goto("/app/subscription") await expect(page.locator(".xx-plans-page")).toBeVisible({ timeout: 20_000, - }); + }) // 点击一个订阅按钮 - const subscribeBtn = page.locator(".xx-subscribe-btn").first(); + const subscribeBtn = page.locator(".xx-subscribe-btn").first() if (await subscribeBtn.isVisible({ timeout: 10_000 })) { - await subscribeBtn.click(); + await subscribeBtn.click() // 可能跳转到升级页或打开支付弹窗 - const url = page.url(); + const url = page.url() // 验证页面有响应(跳转到支付或保持在订阅页但有弹窗) expect( url.includes("/subscription/upgrade") || @@ -255,201 +239,180 @@ test.describe("订阅套餐页 - 升级交互", () => { .first() .isVisible() .catch(() => false)), - ).toBeTruthy(); + ).toBeTruthy() } - }); + }) test("升级套餐升级页面可访问", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-upgrade-page", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-upgrade-page", - }); + }) - await page.goto("/app/subscription/upgrade"); + await page.goto("/app/subscription/upgrade") // 升级页面应该可访问(可能跳转到订阅页或显示升级内容) - await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 }); - }); -}); + await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 }) + }) +}) test.describe("订阅 - 账单列表页", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("账单页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-billing-load", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-billing-load", - }); + }) - await page.goto("/app/subscription/billing"); + await page.goto("/app/subscription/billing") await expect(page.locator(".xx-billing-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("账单概览区域展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-billing-overview", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-billing-overview", - }); + }) - await page.goto("/app/subscription/billing"); + await page.goto("/app/subscription/billing") await expect(page.locator(".xx-billing-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证概览区域存在 - const overview = page.locator(".xx-billing-overview"); + const overview = page.locator(".xx-billing-overview") if (await overview.isVisible({ timeout: 5_000 })) { - await expect(overview).toBeVisible(); + await expect(overview).toBeVisible() // 验证套餐信息 - await expect(overview.locator(".xx-overview-item").first()).toBeVisible(); + await expect(overview.locator(".xx-overview-item").first()).toBeVisible() } - }); + }) test("自动续费开关存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); + await routeBrowserApiToTestApi(page) const { accessToken, userId, email, username } = await createAuthedUser( request, "sub-autorenew-ui", - ); + ) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E sub-autorenew-ui", - }); + }) - await page.goto("/app/subscription/billing"); + await page.goto("/app/subscription/billing") await expect(page.locator(".xx-billing-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证自动续费区域存在 - const autoRenew = page.locator(".xx-billing-auto-renew"); + const autoRenew = page.locator(".xx-billing-auto-renew") if (await autoRenew.isVisible({ timeout: 5_000 })) { - await expect(autoRenew).toBeVisible(); + await expect(autoRenew).toBeVisible() // 验证开关组件存在 - await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible(); + await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible() } - }); + }) test("账单记录 API 返回数据", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-bills-api"); + const { headers } = await createAuthedUser(request, "sub-bills-api") - const response = await request.get( - `${apiBase}/subscription/billing-records`, - { headers }, - ); + const response = await request.get(`${apiBase}/subscription/billing-records`, { headers }) - expect( - response.ok(), - `获取账单记录应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(Array.isArray(data), "账单记录应为数组").toBeTruthy(); - }); -}); + const data = await response.json() + expect(Array.isArray(data), "账单记录应为数组").toBeTruthy() + }) +}) test.describe("订阅 - 自动续费切换", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("切换自动续费 - 正向 API", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-toggle-api"); + const { headers } = await createAuthedUser(request, "sub-toggle-api") // 关闭自动续费 - const disableResp = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: { enabled: false }, - }, - ); - expect( - disableResp.ok(), - `关闭自动续费应成功: ${await disableResp.text()}`, - ).toBeTruthy(); + const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: { enabled: false }, + }) + expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy() // 重新开启自动续费 - const enableResp = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: { enabled: true }, - }, - ); - expect( - enableResp.ok(), - `开启自动续费应成功: ${await enableResp.text()}`, - ).toBeTruthy(); - }); + const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: { enabled: true }, + }) + expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy() + }) test("切换自动续费 - 无效参数反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-toggle-bad"); + const { headers } = await createAuthedUser(request, "sub-toggle-bad") - const response = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: {}, - }, - ); + const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: {}, + }) - expect([400, 422]).toContain(response.status()); - }); -}); + expect([400, 422]).toContain(response.status()) + }) +}) test.describe("订阅 - 取消订阅", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("取消订阅 API - 免费用户反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-cancel-api"); + const { headers } = await createAuthedUser(request, "sub-cancel-api") const response = await request.post(`${apiBase}/subscription/cancel`, { headers, - }); + }) // 免费用户取消订阅可能返回错误 if (!response.ok()) { - const data = await response.json(); - expect(data.error?.message || data.detail || data.message).toBeTruthy(); + const data = await response.json() + expect(data.error?.message || data.detail || data.message).toBeTruthy() } // 如果成功了也没问题(某些实现可能允许) - expect(response.status() < 500).toBeTruthy(); - }); + expect(response.status() < 500).toBeTruthy() + }) test("未登录取消订阅 - 反向", async ({ request }) => { - const response = await request.post(`${apiBase}/subscription/cancel`); - expect([401, 403]).toContain(response.status()); - }); -}); + const response = await request.post(`${apiBase}/subscription/cancel`) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("订阅 - 套餐变更", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("升级到 Pro 套餐 - 正向 API", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-upgrade-api"); + const { headers } = await createAuthedUser(request, "sub-upgrade-api") const response = await request.post(`${apiBase}/subscription/change-plan`, { headers, @@ -457,19 +420,16 @@ test.describe("订阅 - 套餐变更", () => { target_plan_id: "pro", billing_cycle: "monthly", }, - }); + }) - expect( - response.ok(), - `升级套餐应成功: ${await response.text()}`, - ).toBeTruthy(); + expect(response.ok(), `升级套餐应成功: ${await response.text()}`).toBeTruthy() - const data = await response.json(); - expect(data).toBeTruthy(); - }); + const data = await response.json() + expect(data).toBeTruthy() + }) test("获取当前订阅信息 - 验证升级", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-current-api"); + const { headers } = await createAuthedUser(request, "sub-current-api") // 先升级 await request.post(`${apiBase}/subscription/change-plan`, { @@ -478,25 +438,22 @@ test.describe("订阅 - 套餐变更", () => { target_plan_id: "pro", billing_cycle: "monthly", }, - }); + }) // 获取当前订阅 const response = await request.get(`${apiBase}/subscription/current`, { headers, - }); + }) - expect( - response.ok(), - `获取订阅信息应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(data.plan_id, "应返回 plan_id").toBeTruthy(); - expect(data.status, "应返回 status").toBeTruthy(); - }); + const data = await response.json() + expect(data.plan_id, "应返回 plan_id").toBeTruthy() + expect(data.status, "应返回 status").toBeTruthy() + }) test("降级到 Standard 套餐 - 正向 API", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-downgrade-api"); + const { headers } = await createAuthedUser(request, "sub-downgrade-api") // 先升级到 Pro const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { @@ -505,29 +462,26 @@ test.describe("订阅 - 套餐变更", () => { target_plan_id: "pro", billing_cycle: "monthly", }, - }); - expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy(); + }) + expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy() // 降级到 Standard - const downgrade = await request.post( - `${apiBase}/subscription/change-plan`, - { - headers, - data: { - target_plan_id: "standard", - billing_cycle: "monthly", - }, + const downgrade = await request.post(`${apiBase}/subscription/change-plan`, { + headers, + data: { + target_plan_id: "standard", + billing_cycle: "monthly", }, - ); + }) expect( downgrade.status() < 500, `降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`, - ).toBeTruthy(); - }); + ).toBeTruthy() + }) test("切换到无效套餐 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-badplan-api"); + const { headers } = await createAuthedUser(request, "sub-badplan-api") const response = await request.post(`${apiBase}/subscription/change-plan`, { headers, @@ -535,82 +489,76 @@ test.describe("订阅 - 套餐变更", () => { target_plan_id: "nonexistent_plan", billing_cycle: "monthly", }, - }); + }) - expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - }); -}); + expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400) + expect(response.status()).toBeLessThan(500) + }) +}) test.describe("订阅 - 支付流程", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("创建支付订单 - 正向 API", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-pay-api"); + const { headers } = await createAuthedUser(request, "sub-pay-api") - const response = await request.post( - `${apiBase}/subscription/create-order`, - { - headers, - data: { - plan_id: "pro", - billing_cycle: "monthly", - }, + const response = await request.post(`${apiBase}/subscription/create-order`, { + headers, + data: { + plan_id: "pro", + billing_cycle: "monthly", }, - ); + }) // 创建支付订单可能成功或接口不存在 expect( response.status() < 500, `创建订单应返回 2xx 或 4xx,实际: ${response.status()}`, - ).toBeTruthy(); + ).toBeTruthy() if (response.ok()) { - const data = await response.json(); + const data = await response.json() // 应返回订单 ID 或支付链接 - expect(data.order_id || data.payment_url || data).toBeTruthy(); + expect(data.order_id || data.payment_url || data).toBeTruthy() } - }); + }) test("未登录创建订单 - 反向", async ({ request }) => { - const response = await request.post( - `${apiBase}/subscription/create-order`, - { - data: { - plan_id: "pro", - billing_cycle: "monthly", - }, + const response = await request.post(`${apiBase}/subscription/create-order`, { + data: { + plan_id: "pro", + billing_cycle: "monthly", }, - ); - expect([401, 403, 404]).toContain(response.status()); - }); -}); + }) + expect([401, 403, 404]).toContain(response.status()) + }) +}) test.describe("订阅 - 套餐列表 API", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取套餐列表 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-plans-api"); + const { headers } = await createAuthedUser(request, "sub-plans-api") const response = await request.get(`${apiBase}/subscription/plans`, { headers, - }); + }) // 套餐列表可能需要登录也可能公开 if (response.ok()) { - const data = await response.json(); - const plans = Array.isArray(data) ? data : data.plans || data.items; + const data = await response.json() + const plans = Array.isArray(data) ? data : data.plans || data.items if (Array.isArray(plans)) { - expect(plans.length).toBeGreaterThanOrEqual(2); + expect(plans.length).toBeGreaterThanOrEqual(2) } } // 如果需要登录也正常 - expect(response.status() < 500).toBeTruthy(); - }); + expect(response.status() < 500).toBeTruthy() + }) test("未登录获取套餐列表", async ({ request }) => { - const response = await request.get(`${apiBase}/subscription/plans`); + const response = await request.get(`${apiBase}/subscription/plans`) // 套餐列表可能公开也可能需要登录 - expect(response.status() < 500).toBeTruthy(); - }); -}); + expect(response.status() < 500).toBeTruthy() + }) +}) diff --git a/apps/web/e2e/subscription.spec.ts b/apps/web/e2e/subscription.spec.ts index 705909cbd..3f9cbc3c3 100755 --- a/apps/web/e2e/subscription.spec.ts +++ b/apps/web/e2e/subscription.spec.ts @@ -3,17 +3,17 @@ * * 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -26,74 +26,74 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, - }; + } } test.describe("Subscription route guard", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("redirects anonymous users to login", async ({ page }) => { - await page.goto("/app/subscription"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/subscription") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("订阅信息查看", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取当前订阅信息 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-info"); + const { headers } = await createAuthedUser(request, "sub-info") const response = await request.get(`${apiBase}/subscription/current`, { headers, - }); + }) expect( response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.plan_id, "应返回 plan_id").toBeTruthy(); - expect(data.status, "应返回 status").toBeTruthy(); - }); + const data = await response.json() + expect(data.plan_id, "应返回 plan_id").toBeTruthy() + expect(data.status, "应返回 status").toBeTruthy() + }) test("未登录获取订阅信息 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/subscription/current`); - expect([401, 403]).toContain(response.status()); - }); -}); + const response = await request.get(`${apiBase}/subscription/current`) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("订阅降级", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("Pro 用户降级到 Standard - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-downgrade"); + const { headers } = await createAuthedUser(request, "sub-downgrade") // 先升级到 Pro const upgrade = await request.post(`${apiBase}/subscription/change-plan`, { @@ -102,34 +102,28 @@ test.describe("订阅降级", () => { target_plan_id: "pro", billing_cycle: "monthly", }, - }); - expect( - upgrade.ok(), - `升级到 Pro 应成功: ${await upgrade.text()}`, - ).toBeTruthy(); + }) + expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy() // 降级到 Standard - const downgrade = await request.post( - `${apiBase}/subscription/change-plan`, - { - headers, - data: { - target_plan_id: "standard", - billing_cycle: "monthly", - }, + const downgrade = await request.post(`${apiBase}/subscription/change-plan`, { + headers, + data: { + target_plan_id: "standard", + billing_cycle: "monthly", }, - ); + }) // 降级应成功或返回提示信息(某些业务可能限制降级) - expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500); + expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500) - const data = await downgrade.json(); + const data = await downgrade.json() // 成功或失败都应有明确响应 - expect(data).toBeTruthy(); - }); + expect(data).toBeTruthy() + }) test("降级到相同套餐 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-same"); + const { headers } = await createAuthedUser(request, "sub-same") // 用户默认为 free,再次选择 free const response = await request.post(`${apiBase}/subscription/change-plan`, { @@ -138,19 +132,19 @@ test.describe("订阅降级", () => { target_plan_id: "free", billing_cycle: "monthly", }, - }); + }) // 相同套餐应返回 200 + success=false,或者 400 if (response.ok()) { - const data = await response.json(); - expect(data.success).toBe(false); + const data = await response.json() + expect(data.success).toBe(false) } else { - expect([400, 422]).toContain(response.status()); + expect([400, 422]).toContain(response.status()) } - }); + }) test("降级到无效套餐 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-badplan"); + const { headers } = await createAuthedUser(request, "sub-badplan") const response = await request.post(`${apiBase}/subscription/change-plan`, { headers, @@ -158,107 +152,81 @@ test.describe("订阅降级", () => { target_plan_id: "nonexistent_plan", billing_cycle: "monthly", }, - }); + }) - expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400); - expect(response.status()).toBeLessThan(500); - }); -}); + expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400) + expect(response.status()).toBeLessThan(500) + }) +}) test.describe("订阅过期处理", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("取消订阅 - 反向(免费用户)", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-cancel"); + const { headers } = await createAuthedUser(request, "sub-cancel") // 免费用户取消订阅应返回错误 const response = await request.post(`${apiBase}/subscription/cancel`, { headers, - }); + }) // 免费用户可能不需要取消,返回 400 或类似错误 if (!response.ok()) { - const data = await response.json(); - expect( - data.error?.message || data.detail || data.message, - "应返回错误信息", - ).toBeTruthy(); + const data = await response.json() + expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy() } - }); + }) test("未登录取消订阅 - 反向", async ({ request }) => { - const response = await request.post(`${apiBase}/subscription/cancel`); - expect([401, 403]).toContain(response.status()); - }); + const response = await request.post(`${apiBase}/subscription/cancel`) + expect([401, 403]).toContain(response.status()) + }) test("切换自动续费 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-autorenew"); + const { headers } = await createAuthedUser(request, "sub-autorenew") // 关闭自动续费 - const disableResp = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: { enabled: false }, - }, - ); - expect( - disableResp.ok(), - `关闭自动续费应成功: ${await disableResp.text()}`, - ).toBeTruthy(); + const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: { enabled: false }, + }) + expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy() // 重新开启自动续费 - const enableResp = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: { enabled: true }, - }, - ); - expect( - enableResp.ok(), - `开启自动续费应成功: ${await enableResp.text()}`, - ).toBeTruthy(); - }); + const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: { enabled: true }, + }) + expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy() + }) test("无效参数切换自动续费 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-autoren-bad"); + const { headers } = await createAuthedUser(request, "sub-autoren-bad") // 缺少 enabled 字段 - const response = await request.post( - `${apiBase}/subscription/toggle-auto-renew`, - { - headers, - data: {}, - }, - ); + const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, { + headers, + data: {}, + }) - expect([400, 422]).toContain(response.status()); - }); -}); + expect([400, 422]).toContain(response.status()) + }) +}) test.describe("账单记录", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取账单记录 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "sub-bills"); + const { headers } = await createAuthedUser(request, "sub-bills") - const response = await request.get( - `${apiBase}/subscription/billing-records`, - { headers }, - ); + const response = await request.get(`${apiBase}/subscription/billing-records`, { headers }) - expect( - response.ok(), - `获取账单记录应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(Array.isArray(data), "账单记录应为数组").toBeTruthy(); - }); + const data = await response.json() + expect(Array.isArray(data), "账单记录应为数组").toBeTruthy() + }) test("未登录获取账单记录 - 反向", async ({ request }) => { - const response = await request.get( - `${apiBase}/subscription/billing-records`, - ); - expect([401, 403]).toContain(response.status()); - }); -}); + const response = await request.get(`${apiBase}/subscription/billing-records`) + expect([401, 403]).toContain(response.status()) + }) +}) diff --git a/apps/web/e2e/templates.spec.ts b/apps/web/e2e/templates.spec.ts index 1a4899479..0a2c9343a 100644 --- a/apps/web/e2e/templates.spec.ts +++ b/apps/web/e2e/templates.spec.ts @@ -6,36 +6,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -48,30 +41,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -90,14 +83,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -111,95 +104,88 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } test.describe("模板库页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { - await page.goto("/app/templates"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/templates") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("模板库页面 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("模板库页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "tpl-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-load", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("模板库头部和搜索栏存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "tpl-head", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-head") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-head", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证搜索框 - const searchInput = page.locator(".xx-templates-search-input"); - await expect(searchInput).toBeVisible({ timeout: 10_000 }); - }); + const searchInput = page.locator(".xx-templates-search-input") + await expect(searchInput).toBeVisible({ timeout: 10_000 }) + }) test("分类切换按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "tpl-cat", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-cat") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-cat", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证分类按钮存在 - const categoryBtns = page.locator(".xx-templates-cat-btn"); - await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 }); - const count = await categoryBtns.count(); - expect(count).toBeGreaterThan(0); - }); -}); + const categoryBtns = page.locator(".xx-templates-cat-btn") + await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 }) + const count = await categoryBtns.count() + expect(count).toBeGreaterThan(0) + }) +}) test.describe("模板库 - 模板展示", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("模板卡片展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "tpl-cards"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "tpl-cards", + ) + const suffix = Date.now().toString(36) // 创建一个模板 await request.post(`${apiBase}/templates`, { @@ -220,32 +206,34 @@ test.describe("模板库 - 模板展示", () => { tags: ["e2e", "展示"], category: "种草", }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-cards", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) // 等待模板卡片出现 - const cards = page.locator(".xx-template-card"); - await expect(cards.first()).toBeVisible({ timeout: 15_000 }); - const count = await cards.count(); - expect(count).toBeGreaterThan(0); - }); + const cards = page.locator(".xx-template-card") + await expect(cards.first()).toBeVisible({ timeout: 15_000 }) + const count = await cards.count() + expect(count).toBeGreaterThan(0) + }) test("模板卡片包含名称和类型", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "tpl-info"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "tpl-info", + ) + const suffix = Date.now().toString(36) await request.post(`${apiBase}/templates`, { headers, @@ -263,33 +251,35 @@ test.describe("模板库 - 模板展示", () => { }, ], }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-info", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-template-card").first(); + const firstCard = page.locator(".xx-template-card").first() if (await firstCard.isVisible({ timeout: 15_000 })) { // 验证信息区域存在 - const info = firstCard.locator(".xx-template-info"); - await expect(info).toBeVisible(); + const info = firstCard.locator(".xx-template-info") + await expect(info).toBeVisible() } - }); + }) test("模板预览弹窗功能", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "tpl-preview"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "tpl-preview", + ) + const suffix = Date.now().toString(36) await request.post(`${apiBase}/templates`, { headers, @@ -308,78 +298,75 @@ test.describe("模板库 - 模板展示", () => { }, ], }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-preview", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) // 点击第一个模板卡片打开预览 - const firstCard = page.locator(".xx-template-card").first(); + const firstCard = page.locator(".xx-template-card").first() if (await firstCard.isVisible({ timeout: 15_000 })) { - await firstCard.click(); + await firstCard.click() // 预览弹窗应该出现 - const modal = page.locator(".xx-template-modal"); + const modal = page.locator(".xx-template-modal") if (await modal.isVisible({ timeout: 5_000 })) { - await expect(modal).toBeVisible(); + await expect(modal).toBeVisible() // 验证预览内容存在 - await expect( - modal.locator(".xx-template-modal-title-row"), - ).toBeVisible(); + await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible() } } - }); -}); + }) +}) test.describe("模板库 - 分类切换", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("切换分类筛选", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "tpl-switch", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-switch") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-switch", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) - const categoryBtns = page.locator(".xx-templates-cat-btn"); - const firstBtn = categoryBtns.first(); + const categoryBtns = page.locator(".xx-templates-cat-btn") + const firstBtn = categoryBtns.first() if (await firstBtn.isVisible({ timeout: 10_000 })) { - await firstBtn.click(); + await firstBtn.click() // 验证按钮被选中 - await expect(firstBtn).toHaveClass(/active/); + await expect(firstBtn).toHaveClass(/active/) } - }); -}); + }) +}) test.describe("模板库 - 搜索", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("搜索框可输入并筛选", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "tpl-search"); - const suffix = Date.now().toString(36); - const templateName = `E2E 搜索测试模板 ${suffix}`; + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "tpl-search", + ) + const suffix = Date.now().toString(36) + const templateName = `E2E 搜索测试模板 ${suffix}` await request.post(`${apiBase}/templates`, { headers, @@ -397,35 +384,35 @@ test.describe("模板库 - 搜索", () => { }, ], }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-search", - }); + }) - await page.goto("/app/templates"); + await page.goto("/app/templates") await expect(page.locator(".xx-templates-page")).toBeVisible({ timeout: 20_000, - }); + }) - const searchInput = page.locator(".xx-templates-search-input"); + const searchInput = page.locator(".xx-templates-search-input") if (await searchInput.isVisible({ timeout: 10_000 })) { - await searchInput.fill(suffix); + await searchInput.fill(suffix) // 验证页面正常响应 - await expect(page.locator(".xx-templates-page")).toBeVisible(); + await expect(page.locator(".xx-templates-page")).toBeVisible() } - }); -}); + }) +}) test.describe("模板库 - API 操作", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取模板列表 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "tpl-api-list"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "tpl-api-list") + const suffix = Date.now().toString(36) await request.post(`${apiBase}/templates`, { headers, @@ -442,23 +429,20 @@ test.describe("模板库 - API 操作", () => { }, ], }, - }); + }) - const response = await request.get(`${apiBase}/templates`, { headers }); - expect( - response.ok(), - `获取模板列表应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + const response = await request.get(`${apiBase}/templates`, { headers }) + expect(response.ok(), `获取模板列表应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - const items = data.items || data.templates || []; - expect(Array.isArray(items), "模板列表应为数组").toBeTruthy(); - expect(items.length).toBeGreaterThan(0); - }); + const data = await response.json() + const items = data.items || data.templates || [] + expect(Array.isArray(items), "模板列表应为数组").toBeTruthy() + expect(items.length).toBeGreaterThan(0) + }) test("收藏/取消收藏模板 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "tpl-fav"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "tpl-fav") + const suffix = Date.now().toString(36) // 创建模板 const createResp = await request.post(`${apiBase}/templates`, { @@ -476,33 +460,26 @@ test.describe("模板库 - API 操作", () => { }, ], }, - }); - expect(createResp.ok()).toBeTruthy(); - const created = await createResp.json(); - const templateId = created.id; + }) + expect(createResp.ok()).toBeTruthy() + const created = await createResp.json() + const templateId = created.id // 收藏 - const favResp = await request.post( - `${apiBase}/templates/${templateId}/favorite`, - { headers }, - ); + const favResp = await request.post(`${apiBase}/templates/${templateId}/favorite`, { headers }) // 收藏可能成功或接口不存在 - expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy(); + expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy() // 取消收藏 - const unfavResp = await request.delete( - `${apiBase}/templates/${templateId}/favorite`, - { headers }, - ); - expect( - unfavResp.status() < 500, - "取消收藏请求应返回 2xx 或 4xx", - ).toBeTruthy(); - }); + const unfavResp = await request.delete(`${apiBase}/templates/${templateId}/favorite`, { + headers, + }) + expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy() + }) test("获取模板详情 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "tpl-api-detail"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "tpl-api-detail") + const suffix = Date.now().toString(36) const createResp = await request.post(`${apiBase}/templates`, { headers, @@ -521,22 +498,22 @@ test.describe("模板库 - API 操作", () => { }, ], }, - }); - expect(createResp.ok()).toBeTruthy(); - const created = await createResp.json(); + }) + expect(createResp.ok()).toBeTruthy() + const created = await createResp.json() const detailResp = await request.get(`${apiBase}/templates/${created.id}`, { headers, - }); - expect(detailResp.ok(), "获取详情应成功").toBeTruthy(); - const detail = await detailResp.json(); - expect(detail.id).toBe(created.id); - expect(detail.name).toBe(`详情测试 ${suffix}`); - }); + }) + expect(detailResp.ok(), "获取详情应成功").toBeTruthy() + const detail = await detailResp.json() + expect(detail.id).toBe(created.id) + expect(detail.name).toBe(`详情测试 ${suffix}`) + }) test("使用模板接口 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "tpl-use"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "tpl-use") + const suffix = Date.now().toString(36) const createResp = await request.post(`${apiBase}/templates`, { headers, @@ -553,52 +530,51 @@ test.describe("模板库 - API 操作", () => { }, ], }, - }); - expect(createResp.ok()).toBeTruthy(); - const created = await createResp.json(); + }) + expect(createResp.ok()).toBeTruthy() + const created = await createResp.json() // 使用模板(生成) - const genResp = await request.post( - `${apiBase}/templates/${created.id}/generate`, - { headers, data: {} }, - ); + const genResp = await request.post(`${apiBase}/templates/${created.id}/generate`, { + headers, + data: {}, + }) // 生成可能成功或返回业务错误 - expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy(); - }); + expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy() + }) test("未登录获取模板列表 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/templates`); - expect([401, 403]).toContain(response.status()); - }); -}); + const response = await request.get(`${apiBase}/templates`) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("模板库 - 我的模板 Tab", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("我的模板页面可访问", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "tpl-my", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-my") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-my", - }); + }) - await page.goto("/app/my-templates"); + await page.goto("/app/my-templates") await expect(page.locator(".mt-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("我的模板页面展示已创建的模板", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "tpl-my-data"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "tpl-my-data", + ) + const suffix = Date.now().toString(36) await request.post(`${apiBase}/templates`, { headers, @@ -616,22 +592,22 @@ test.describe("模板库 - 我的模板 Tab", () => { }, ], }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E tpl-my-data", - }); + }) - await page.goto("/app/my-templates"); + await page.goto("/app/my-templates") await expect(page.locator(".mt-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证卡片容器存在 - const cards = page.locator(".mt-card"); - await expect(cards.first()).toBeVisible({ timeout: 15_000 }); - }); -}); + const cards = page.locator(".mt-card") + await expect(cards.first()).toBeVisible({ timeout: 15_000 }) + }) +}) diff --git a/apps/web/e2e/test_asset.spec.ts b/apps/web/e2e/test_asset.spec.ts index c5938bccc..36ef68752 100755 --- a/apps/web/e2e/test_asset.spec.ts +++ b/apps/web/e2e/test_asset.spec.ts @@ -4,17 +4,17 @@ * 覆盖:创建素材库、列出素材库、创建素材记录 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -27,37 +27,37 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, userId: regData.user_id, - }; + } } /** 创建一个项目并返回 project id */ @@ -69,23 +69,19 @@ async function createProject( const resp = await request.post(`${apiBase}/projects`, { headers, data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" }, - }); - expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } test.describe("素材库流程", () => { // 登录限流 10次/60s,测试可能触发限流等待,给足够超时 - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) test("创建素材库", async ({ request }) => { - const { headers } = await createAuthedUser(request, "lib-create"); - const projectId = await createProject( - request, - headers, - Date.now().toString(), - ); + const { headers } = await createAuthedUser(request, "lib-create") + const projectId = await createProject(request, headers, Date.now().toString()) const response = await request.post(`${apiBase}/asset-libraries`, { headers, @@ -94,27 +90,23 @@ test.describe("素材库流程", () => { name: `视频素材库 ${Date.now()}`, kind: "video", }, - }); + }) expect( response.ok(), `创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id, "应返回素材库 ID").toBeTruthy(); - expect(data.name).toContain("视频素材库"); - expect(data.kind).toBe("video"); - expect(data.project_id).toBe(projectId); - }); + const data = await response.json() + expect(data.id, "应返回素材库 ID").toBeTruthy() + expect(data.name).toContain("视频素材库") + expect(data.kind).toBe("video") + expect(data.project_id).toBe(projectId) + }) test("创建素材库 - 无效 kind 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "lib-badkind"); - const projectId = await createProject( - request, - headers, - Date.now().toString(), - ); + const { headers } = await createAuthedUser(request, "lib-badkind") + const projectId = await createProject(request, headers, Date.now().toString()) const response = await request.post(`${apiBase}/asset-libraries`, { headers, @@ -123,14 +115,14 @@ test.describe("素材库流程", () => { name: "Bad Kind Library", kind: "invalid_kind", }, - }); + }) // kind 有 pattern 校验 ^(video|voice|image)$,应返回 422 - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("创建素材库 - 不存在的项目反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "lib-nopj"); + const { headers } = await createAuthedUser(request, "lib-nopj") const response = await request.post(`${apiBase}/asset-libraries`, { headers, @@ -139,18 +131,14 @@ test.describe("素材库流程", () => { name: "Orphan Library", kind: "video", }, - }); + }) - expect(response.status(), "不存在的项目应返回 404").toBe(404); - }); + expect(response.status(), "不存在的项目应返回 404").toBe(404) + }) test("列出素材库", async ({ request }) => { - const { headers } = await createAuthedUser(request, "lib-list"); - const projectId = await createProject( - request, - headers, - Date.now().toString(), - ); + const { headers } = await createAuthedUser(request, "lib-list") + const projectId = await createProject(request, headers, Date.now().toString()) // 创建 2 个不同类型的素材库 await request.post(`${apiBase}/asset-libraries`, { @@ -160,7 +148,7 @@ test.describe("素材库流程", () => { name: `Video Lib ${Date.now()}`, kind: "video", }, - }); + }) await request.post(`${apiBase}/asset-libraries`, { headers, data: { @@ -168,35 +156,31 @@ test.describe("素材库流程", () => { name: `Image Lib ${Date.now()}`, kind: "image", }, - }); + }) // 列出(按 project_id 过滤) const response = await request.get(`${apiBase}/asset-libraries`, { headers, params: { project_id: projectId }, - }); + }) expect( response.ok(), `列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - const items = data.items || []; - expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2); + const data = await response.json() + const items = data.items || [] + expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2) - const kinds = items.map((i: { kind: string }) => i.kind); - expect(kinds).toContain("video"); - expect(kinds).toContain("image"); - }); + const kinds = items.map((i: { kind: string }) => i.kind) + expect(kinds).toContain("video") + expect(kinds).toContain("image") + }) test("创建素材记录", async ({ request }) => { - const { headers, userId } = await createAuthedUser(request, "asset-create"); - const projectId = await createProject( - request, - headers, - Date.now().toString(), - ); + const { headers, userId } = await createAuthedUser(request, "asset-create") + const projectId = await createProject(request, headers, Date.now().toString()) // 创建素材库 const lib = await request.post(`${apiBase}/asset-libraries`, { @@ -206,9 +190,9 @@ test.describe("素材库流程", () => { name: `Asset Lib ${Date.now()}`, kind: "video", }, - }); - expect(lib.ok()).toBeTruthy(); - const libData = await lib.json(); + }) + expect(lib.ok()).toBeTruthy() + const libData = await lib.json() // 创建素材记录 const response = await request.post(`${apiBase}/assets`, { @@ -224,27 +208,23 @@ test.describe("素材库流程", () => { status: "ready", uploaded_by_user_id: userId, }, - }); + }) expect( response.ok(), `创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id, "应返回素材 ID").toBeTruthy(); - expect(data.name).toContain("test_video"); - expect(data.mime_type).toBe("video/mp4"); - expect(data.library_id).toBe(libData.id); - }); + const data = await response.json() + expect(data.id, "应返回素材 ID").toBeTruthy() + expect(data.name).toContain("test_video") + expect(data.mime_type).toBe("video/mp4") + expect(data.library_id).toBe(libData.id) + }) test("列出素材", async ({ request }) => { - const { headers, userId } = await createAuthedUser(request, "asset-list"); - const projectId = await createProject( - request, - headers, - Date.now().toString(), - ); + const { headers, userId } = await createAuthedUser(request, "asset-list") + const projectId = await createProject(request, headers, Date.now().toString()) // 创建素材库 const lib = await request.post(`${apiBase}/asset-libraries`, { @@ -254,9 +234,9 @@ test.describe("素材库流程", () => { name: `List Lib ${Date.now()}`, kind: "video", }, - }); - expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy(); - const libData = await lib.json(); + }) + expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy() + const libData = await lib.json() // 创建 2 个素材 await request.post(`${apiBase}/assets`, { @@ -270,7 +250,7 @@ test.describe("素材库流程", () => { status: "ready", uploaded_by_user_id: userId, }, - }); + }) await request.post(`${apiBase}/assets`, { headers, data: { @@ -282,23 +262,23 @@ test.describe("素材库流程", () => { status: "ready", uploaded_by_user_id: userId, }, - }); + }) // 列出素材 const response = await request.get(`${apiBase}/assets`, { headers, params: { library_id: libData.id }, - }); + }) expect( response.ok(), `列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - const items = data.items || []; - expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2); - }); + const data = await response.json() + const items = data.items || [] + expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2) + }) test("未登录创建素材库 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/asset-libraries`, { @@ -307,8 +287,8 @@ test.describe("素材库流程", () => { name: "Unauthorized Library", kind: "video", }, - }); + }) - expect([401, 403]).toContain(response.status()); - }); -}); + expect([401, 403]).toContain(response.status()) + }) +}) diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index 611c1a108..f1bb928b4 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -4,29 +4,29 @@ * 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息 * 每个测试独立,使用随机邮箱避免冲突。 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 从错误响应中提取错误消息文本,兼容新老格式 */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractErrorMessage(body: any): string { - if (!body) return ""; + if (!body) return "" // 新格式: { error: { code: "...", message: "..." } } if (body.error && typeof body.error === "object" && body.error.message) { - return String(body.error.message); + return String(body.error.message) } // 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." } - return String(body.detail || body.message || body.error || ""); + return String(body.detail || body.message || body.error || "") } /** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */ @@ -39,27 +39,27 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; + }) + if (response.status() !== 429) return response // 被限流了,等窗口重置 - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } // 最后一次直接返回 return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } test.describe("认证流程", () => { // 登录限流 10次/60s,测试可能触发限流等待,给足够超时 - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) // ─── 注册 ──────────────────────────────────────────── test("注册新用户 - 正向", async ({ request }) => { - const email = uniqueEmail("reg-ok"); - const username = uniqueUsername("regok"); + const email = uniqueEmail("reg-ok") + const username = uniqueUsername("regok") const response = await request.post(`${apiBase}/auth/register`, { data: { @@ -68,23 +68,23 @@ test.describe("认证流程", () => { username, display_name: "E2E 注册测试", }, - }); + }) expect( response.ok(), `注册应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.user_id, "应返回 user_id").toBeTruthy(); - expect(data.email).toBe(email); - expect(data.username).toBe(username); - }); + const data = await response.json() + expect(data.user_id, "应返回 user_id").toBeTruthy() + expect(data.email).toBe(email) + expect(data.username).toBe(username) + }) test("注册已存在邮箱 - 反向", async ({ request }) => { - const email = uniqueEmail("reg-dup"); - const username1 = uniqueUsername("regdup1"); - const username2 = uniqueUsername("regdup2"); + const email = uniqueEmail("reg-dup") + const username1 = uniqueUsername("regdup1") + const username2 = uniqueUsername("regdup2") // 第一次注册 const first = await request.post(`${apiBase}/auth/register`, { @@ -94,8 +94,8 @@ test.describe("认证流程", () => { username: username1, display_name: "User 1", }, - }); - expect(first.ok(), "第一次注册应成功").toBeTruthy(); + }) + expect(first.ok(), "第一次注册应成功").toBeTruthy() // 第二次使用相同邮箱 const second = await request.post(`${apiBase}/auth/register`, { @@ -105,24 +105,22 @@ test.describe("认证流程", () => { username: username2, display_name: "User 2", }, - }); + }) - expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual( - 400, - ); - expect(second.status()).toBeLessThan(500); + expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(400) + expect(second.status()).toBeLessThan(500) - const body = await second.json(); + const body = await second.json() // 错误信息应包含"已注册"或"exists"相关提示 - const detail = extractErrorMessage(body).toLowerCase(); + const detail = extractErrorMessage(body).toLowerCase() expect( detail.includes("已") || detail.includes("exist") || detail.includes("registered") || detail.includes("duplicate"), `错误信息应提示邮箱已注册,实际: "${detail}"`, - ).toBeTruthy(); - }); + ).toBeTruthy() + }) test("注册无效邮箱格式 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { @@ -132,11 +130,11 @@ test.describe("认证流程", () => { username: uniqueUsername("bademail"), display_name: "Bad Email", }, - }); + }) // 422 是 FastAPI 参数校验失败的标准状态码 - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("注册弱密码 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { @@ -146,10 +144,10 @@ test.describe("认证流程", () => { username: uniqueUsername("weakpwd"), display_name: "Weak", }, - }); + }) - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("注册用户名为空 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { @@ -159,12 +157,10 @@ test.describe("认证流程", () => { username: "", display_name: "Empty Username", }, - }); + }) - expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain( - response.status(), - ); - }); + expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(response.status()) + }) test("注册缺用户名字段 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/auth/register`, { @@ -173,76 +169,72 @@ test.describe("认证流程", () => { password: PASSWORD, display_name: "No Username Field", }, - }); + }) - expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain( - response.status(), - ); - }); + expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(response.status()) + }) // ─── 登录 ──────────────────────────────────────────── test("登录成功 - 正向", async ({ request }) => { - const email = uniqueEmail("login-ok"); - const username = uniqueUsername("loginok"); + const email = uniqueEmail("login-ok") + const username = uniqueUsername("loginok") // 先注册 const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Login Test" }, - }); - expect(reg.ok(), "注册应成功").toBeTruthy(); + }) + expect(reg.ok(), "注册应成功").toBeTruthy() // 登录(带限流重试) - const response = await loginWithRetry(request, email, PASSWORD); + const response = await loginWithRetry(request, email, PASSWORD) expect( response.ok(), `登录应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.access_token, "应返回 access_token").toBeTruthy(); - expect(data.token_type).toBe("bearer"); - expect(data.email).toBe(email); - }); + const data = await response.json() + expect(data.access_token, "应返回 access_token").toBeTruthy() + expect(data.token_type).toBe("bearer") + expect(data.email).toBe(email) + }) test("登录错误密码 - 反向", async ({ request }) => { - const email = uniqueEmail("login-bad"); - const username = uniqueUsername("loginbad"); + const email = uniqueEmail("login-bad") + const username = uniqueUsername("loginbad") // 先注册 await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Bad Login" }, - }); + }) // 使用错误密码登录(带限流重试) - const response = await loginWithRetry(request, email, "WrongPassword999!"); + const response = await loginWithRetry(request, email, "WrongPassword999!") - expect(response.status(), "错误密码应返回 401").toBe(401); - }); + expect(response.status(), "错误密码应返回 401").toBe(401) + }) test("登录不存在的邮箱 - 反向", async ({ request }) => { // 带限流重试的反向登录测试 - let response; + let response for (let attempt = 0; attempt < 3; attempt++) { response = await request.post(`${apiBase}/auth/login`, { data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, - }); - if (response.status() !== 429) break; - console.log( - `[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`, - ); - await new Promise((r) => setTimeout(r, 65_000)); + }) + if (response.status() !== 429) break + console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`) + await new Promise((r) => setTimeout(r, 65_000)) } - expect(response.status(), "不存在的用户应返回 401").toBe(401); - }); + expect(response.status(), "不存在的用户应返回 401").toBe(401) + }) // ─── 登出 ──────────────────────────────────────────── test("登出成功", async ({ request }) => { - const email = uniqueEmail("logout"); - const username = uniqueUsername("logout"); + const email = uniqueEmail("logout") + const username = uniqueUsername("logout") // 注册 & 登录 await request.post(`${apiBase}/auth/register`, { @@ -252,93 +244,87 @@ test.describe("认证流程", () => { username, display_name: "Logout Test", }, - }); - const login = await loginWithRetry(request, email, PASSWORD); - const { access_token } = await login.json(); - const headers = { Authorization: `Bearer ${access_token}` }; + }) + const login = await loginWithRetry(request, email, PASSWORD) + const { access_token } = await login.json() + const headers = { Authorization: `Bearer ${access_token}` } // 登出 - const logout = await request.post(`${apiBase}/auth/logout`, { headers }); - expect( - logout.ok(), - `登出应返回 2xx,实际: ${logout.status()}`, - ).toBeTruthy(); + const logout = await request.post(`${apiBase}/auth/logout`, { headers }) + expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy() - const body = await logout.json(); - expect(body.message).toBeTruthy(); + const body = await logout.json() + expect(body.message).toBeTruthy() // 登出后 token 应失效,尝试访问 /auth/me - const me = await request.get(`${apiBase}/auth/me`, { headers }); - expect([401, 403]).toContain(me.status()); - }); + const me = await request.get(`${apiBase}/auth/me`, { headers }) + expect([401, 403]).toContain(me.status()) + }) // ─── 获取当前用户信息 ───────────────────────────────── test("获取当前用户信息 - 正向", async ({ request }) => { - const email = uniqueEmail("me-ok"); - const username = uniqueUsername("meok"); + const email = uniqueEmail("me-ok") + const username = uniqueUsername("meok") await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: "Me Test" }, - }); - const login = await loginWithRetry(request, email, PASSWORD); - const { access_token } = await login.json(); + }) + const login = await loginWithRetry(request, email, PASSWORD) + const { access_token } = await login.json() const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${access_token}` }, - }); + }) - expect( - response.ok(), - `获取用户信息应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - expect(data.user_id).toBeTruthy(); - expect(data.email).toBe(email); - expect(data.username).toBe(username); - }); + const data = await response.json() + expect(data.user_id).toBeTruthy() + expect(data.email).toBe(email) + expect(data.username).toBe(username) + }) test("无 token 获取用户信息 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/auth/me`); + const response = await request.get(`${apiBase}/auth/me`) // HTTPBearer 无凭证返回 403 - expect([401, 403]).toContain(response.status()); - }); + expect([401, 403]).toContain(response.status()) + }) test("无效 token 获取用户信息 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer invalid.token.here" }, - }); - expect(response.status()).toBe(401); - }); + }) + expect(response.status()).toBe(401) + }) test("过期 token 获取用户信息 - 反向", async ({ request }) => { // 使用一个伪造的过期 JWT(header.payload.signature) // eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"} // eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期) const expiredToken = - "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"; + "eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature" const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: `Bearer ${expiredToken}` }, - }); + }) - expect([401, 403]).toContain(response.status()); - }); + expect([401, 403]).toContain(response.status()) + }) test("token 格式错误 - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer not-a-jwt" }, - }); + }) - expect([401, 403]).toContain(response.status()); - }); + expect([401, 403]).toContain(response.status()) + }) test("空 Bearer token - 反向", async ({ request }) => { const response = await request.get(`${apiBase}/auth/me`, { headers: { Authorization: "Bearer " }, - }); + }) - expect([401, 403]).toContain(response.status()); - }); -}); + expect([401, 403]).toContain(response.status()) + }) +}) diff --git a/apps/web/e2e/test_project.spec.ts b/apps/web/e2e/test_project.spec.ts index dd74be2e0..d0adee854 100755 --- a/apps/web/e2e/test_project.spec.ts +++ b/apps/web/e2e/test_project.spec.ts @@ -4,17 +4,17 @@ * 覆盖:创建项目、列出项目、获取项目详情 * 每个测试独立,先注册登录获取 auth token。 */ -import { expect, test, type APIRequestContext } from "@playwright/test"; +import { expect, test, type APIRequestContext } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -27,46 +27,46 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, userId: regData.user_id, - }; + } } test.describe("项目流程", () => { // 登录限流 10次/60s,测试可能触发限流等待,给足够超时 - test.describe.configure({ timeout: 180_000 }); + test.describe.configure({ timeout: 180_000 }) test("创建项目", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-create"); - const projectName = `E2E 测试项目 ${Date.now()}`; + const { headers } = await createAuthedUser(request, "proj-create") + const projectName = `E2E 测试项目 ${Date.now()}` const response = await request.post(`${apiBase}/projects`, { headers, @@ -74,156 +74,146 @@ test.describe("项目流程", () => { name: projectName, description: "Playwright E2E 回归测试创建", }, - }); + }) expect( response.ok(), `创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id, "应返回项目 ID").toBeTruthy(); - expect(data.name).toBe(projectName); - expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy(); - }); + const data = await response.json() + expect(data.id, "应返回项目 ID").toBeTruthy() + expect(data.name).toBe(projectName) + expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy() + }) test("创建项目名称为空 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-empty"); + const { headers } = await createAuthedUser(request, "proj-empty") const response = await request.post(`${apiBase}/projects`, { headers, data: { name: "", description: "Should fail" }, - }); + }) // name 有 min_length=1 约束,应返回 422 - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("列出项目", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-list"); + const { headers } = await createAuthedUser(request, "proj-list") // 先创建 2 个项目 await request.post(`${apiBase}/projects`, { headers, data: { name: `List Proj A ${Date.now()}` }, - }); + }) await request.post(`${apiBase}/projects`, { headers, data: { name: `List Proj B ${Date.now()}` }, - }); + }) // 列出 - const response = await request.get(`${apiBase}/projects`, { headers }); + const response = await request.get(`${apiBase}/projects`, { headers }) expect( response.ok(), `列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - const items = data.items || data.projects || data || []; - expect(Array.isArray(items)).toBeTruthy(); - expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2); - }); + const data = await response.json() + const items = data.items || data.projects || data || [] + expect(Array.isArray(items)).toBeTruthy() + expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2) + }) test("获取项目详情", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-detail"); + const { headers } = await createAuthedUser(request, "proj-detail") // 先创建 const created = await request.post(`${apiBase}/projects`, { headers, data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" }, - }); - expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy(); - const { id: projectId } = await created.json(); + }) + expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy() + const { id: projectId } = await created.json() // 获取详情 const response = await request.get(`${apiBase}/projects/${projectId}`, { headers, - }); + }) expect( response.ok(), `获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id).toBe(projectId); - expect(data.name).toBeTruthy(); - expect(data.owner_user_id).toBeTruthy(); - }); + const data = await response.json() + expect(data.id).toBe(projectId) + expect(data.name).toBeTruthy() + expect(data.owner_user_id).toBeTruthy() + }) test("获取不存在的项目 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-404"); + const { headers } = await createAuthedUser(request, "proj-404") - const response = await request.get( - `${apiBase}/projects/nonexistent-project-id-999`, - { headers }, - ); + const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, { + headers, + }) - expect(response.status(), "不存在的项目应返回 404").toBe(404); - }); + expect(response.status(), "不存在的项目应返回 404").toBe(404) + }) test("未登录列出项目 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/projects`); + const response = await request.get(`${apiBase}/projects`) - expect([401, 403]).toContain(response.status()); - }); + expect([401, 403]).toContain(response.status()) + }) test("未授权访问他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 - const { headers: headersA } = await createAuthedUser(request, "proj-owner"); + const { headers: headersA } = await createAuthedUser(request, "proj-owner") const created = await request.post(`${apiBase}/projects`, { headers: headersA, data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" }, - }); - expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); - const { id: projectId } = await created.json(); + }) + expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() + const { id: projectId } = await created.json() // 用户 B 尝试访问用户 A 的项目 - const { headers: headersB } = await createAuthedUser( - request, - "proj-intruder", - ); + const { headers: headersB } = await createAuthedUser(request, "proj-intruder") const response = await request.get(`${apiBase}/projects/${projectId}`, { headers: headersB, - }); + }) // 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性 - expect([403, 404]).toContain(response.status()); - }); + expect([403, 404]).toContain(response.status()) + }) test("未授权删除他人项目 - 反向", async ({ request }) => { // 用户 A 创建项目 - const { headers: headersA } = await createAuthedUser( - request, - "proj-del-owner", - ); + const { headers: headersA } = await createAuthedUser(request, "proj-del-owner") const created = await request.post(`${apiBase}/projects`, { headers: headersA, data: { name: `Delete Test Proj ${Date.now()}` }, - }); - expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy(); - const { id: projectId } = await created.json(); + }) + expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy() + const { id: projectId } = await created.json() // 用户 B 尝试删除用户 A 的项目 - const { headers: headersB } = await createAuthedUser( - request, - "proj-del-attempt", - ); + const { headers: headersB } = await createAuthedUser(request, "proj-del-attempt") const response = await request.delete(`${apiBase}/projects/${projectId}`, { headers: headersB, - }); + }) - expect([403, 404]).toContain(response.status()); - }); + expect([403, 404]).toContain(response.status()) + }) test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "proj-badid"); + const { headers } = await createAuthedUser(request, "proj-badid") - const response = await request.get(`${apiBase}/projects/`, { headers }); + const response = await request.get(`${apiBase}/projects/`, { headers }) // 空 ID 或无效格式应返回 404 或 422 - expect([400, 404, 422]).toContain(response.status()); - }); -}); + expect([400, 404, 422]).toContain(response.status()) + }) +}) diff --git a/apps/web/e2e/titles-full.spec.ts b/apps/web/e2e/titles-full.spec.ts index 92159cddb..ba3955fdd 100644 --- a/apps/web/e2e/titles-full.spec.ts +++ b/apps/web/e2e/titles-full.spec.ts @@ -9,36 +9,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -51,30 +44,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -93,14 +86,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -114,7 +107,7 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } /** 创建一个标题并返回 id */ @@ -133,77 +126,76 @@ async function createTitle( tags: ["e2e", "test"], ...overrides, }, - }); - expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy(); - const data = await resp.json(); - return data.id; + }) + expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy() + const data = await resp.json() + return data.id } test.describe("标题库 - 空状态", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("新用户标题页面显示空状态", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "title-empty", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "title-empty") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E title-empty", - }); + }) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) // 新用户应该能看到页面主体 - await expect(page.locator(".xx-titles-page")).toBeVisible(); - }); -}); + await expect(page.locator(".xx-titles-page")).toBeVisible() + }) +}) test.describe("标题库 - 搜索功能", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("搜索框存在且可输入", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "title-search"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "title-search", + ) + const suffix = Date.now().toString(36) - await createTitle(request, headers, suffix); + await createTitle(request, headers, suffix) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E title-search", - }); + }) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) // 查找搜索框 const searchInput = page.locator( "input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']", - ); + ) if (await searchInput.first().isVisible({ timeout: 10_000 })) { - await searchInput.first().fill("测试搜索"); - await expect(searchInput.first()).toHaveValue("测试搜索"); + await searchInput.first().fill("测试搜索") + await expect(searchInput.first()).toHaveValue("测试搜索") } - }); -}); + }) +}) test.describe("标题库 - API 完整操作", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("创建标题 - 完整参数", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-create-full"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "title-create-full") + const suffix = Date.now().toString(36) const response = await request.post(`${apiBase}/titles`, { headers, @@ -214,29 +206,25 @@ test.describe("标题库 - API 完整操作", () => { tags: ["e2e", "完整测试", "种草"], status: "active", }, - }); + }) expect( response.ok(), `创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.id, "应返回标题 ID").toBeTruthy(); - expect(data.name).toBe(`完整参数测试 ${suffix}`); - expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`); - }); + const data = await response.json() + expect(data.id, "应返回标题 ID").toBeTruthy() + expect(data.name).toBe(`完整参数测试 ${suffix}`) + expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`) + }) test("编辑标题 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-update"); - const titleId = await createTitle( - request, - headers, - Date.now().toString(36), - ); + const { headers } = await createAuthedUser(request, "title-update") + const titleId = await createTitle(request, headers, Date.now().toString(36)) - const newName = `更新后的标题 ${Date.now()}`; - const newText = "这是更新后的标题内容"; + const newName = `更新后的标题 ${Date.now()}` + const newText = "这是更新后的标题内容" const response = await request.patch(`${apiBase}/titles/${titleId}`, { headers, data: { @@ -244,52 +232,48 @@ test.describe("标题库 - API 完整操作", () => { text: newText, category: "知识", }, - }); + }) expect( response.ok(), `更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() - const data = await response.json(); - expect(data.name).toBe(newName); + const data = await response.json() + expect(data.name).toBe(newName) // 验证更新 const verify = await request.get(`${apiBase}/titles/${titleId}`, { headers, - }); - const verifyData = await verify.json(); - expect(verifyData.name).toBe(newName); - expect(verifyData.text).toBe(newText); - }); + }) + const verifyData = await verify.json() + expect(verifyData.name).toBe(newName) + expect(verifyData.text).toBe(newText) + }) test("删除标题 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-delete"); - const titleId = await createTitle( - request, - headers, - Date.now().toString(36), - ); + const { headers } = await createAuthedUser(request, "title-delete") + const titleId = await createTitle(request, headers, Date.now().toString(36)) // 删除 const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, { headers, - }); + }) expect( [200, 204].includes(deleteResp.status()), `删除应返回 200 或 204,实际: ${deleteResp.status()}`, - ).toBeTruthy(); + ).toBeTruthy() // 验证已删除 const getResp = await request.get(`${apiBase}/titles/${titleId}`, { headers, - }); - expect([404, 410]).toContain(getResp.status()); - }); + }) + expect([404, 410]).toContain(getResp.status()) + }) test("批量导入标题 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-batch"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "title-batch") + const suffix = Date.now().toString(36) const titles = [ { @@ -307,29 +291,27 @@ test.describe("标题库 - API 完整操作", () => { text: `内容 3 ${suffix}`, category: "知识", }, - ]; + ] const response = await request.post(`${apiBase}/titles/batch-import`, { headers, data: { titles }, - }); + }) // 批量导入可能成功或接口不存在 expect( response.status() < 500, `批量导入应返回 2xx 或 4xx,实际: ${response.status()}`, - ).toBeTruthy(); + ).toBeTruthy() if (response.ok()) { - const data = await response.json(); - expect( - Array.isArray(data) || data.success_count !== undefined, - ).toBeTruthy(); + const data = await response.json() + expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy() } - }); + }) test("创建标题 - 名称为空反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-empty-name"); + const { headers } = await createAuthedUser(request, "title-empty-name") const response = await request.post(`${apiBase}/titles`, { headers, @@ -338,13 +320,13 @@ test.describe("标题库 - API 完整操作", () => { text: "有内容但名称为空", category: "default", }, - }); + }) - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("创建标题 - 缺少必要字段反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-missing"); + const { headers } = await createAuthedUser(request, "title-missing") const response = await request.post(`${apiBase}/titles`, { headers, @@ -352,46 +334,37 @@ test.describe("标题库 - API 完整操作", () => { name: "缺少 text 字段", // 缺少 text 字段 }, - }); + }) - expect([400, 422]).toContain(response.status()); - }); + expect([400, 422]).toContain(response.status()) + }) test("获取不存在的标题 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-404"); + const { headers } = await createAuthedUser(request, "title-404") - const response = await request.get( - `${apiBase}/titles/nonexistent-title-999`, - { headers }, - ); - expect(response.status(), "不存在的标题应返回 404").toBe(404); - }); + const response = await request.get(`${apiBase}/titles/nonexistent-title-999`, { headers }) + expect(response.status(), "不存在的标题应返回 404").toBe(404) + }) test("更新不存在的标题 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-update-404"); + const { headers } = await createAuthedUser(request, "title-update-404") - const response = await request.patch( - `${apiBase}/titles/nonexistent-title-999`, - { - headers, - data: { name: "不存在的标题", text: "测试" }, - }, - ); - expect(response.status(), "更新不存在的标题应返回 404").toBe(404); - }); + const response = await request.patch(`${apiBase}/titles/nonexistent-title-999`, { + headers, + data: { name: "不存在的标题", text: "测试" }, + }) + expect(response.status(), "更新不存在的标题应返回 404").toBe(404) + }) test("删除不存在的标题 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-del-404"); + const { headers } = await createAuthedUser(request, "title-del-404") - const response = await request.delete( - `${apiBase}/titles/nonexistent-title-999`, - { headers }, - ); + const response = await request.delete(`${apiBase}/titles/nonexistent-title-999`, { headers }) expect( [404, 200, 204].includes(response.status()), "删除不存在的标题应返回 404 或幂等 2xx", - ).toBeTruthy(); - }); + ).toBeTruthy() + }) test("未登录创建标题 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/titles`, { @@ -400,39 +373,39 @@ test.describe("标题库 - API 完整操作", () => { text: "未登录创建标题", category: "default", }, - }); - expect([401, 403]).toContain(response.status()); - }); + }) + expect([401, 403]).toContain(response.status()) + }) test("未登录删除标题 - 反向", async ({ request }) => { - const response = await request.delete(`${apiBase}/titles/some-id`); - expect([401, 403]).toContain(response.status()); - }); -}); + const response = await request.delete(`${apiBase}/titles/some-id`) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("标题库 - 分类/标签筛选", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("标题分类 API 返回数据", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-cat"); + const { headers } = await createAuthedUser(request, "title-cat") // 获取标题列表,检查分类字段 - const response = await request.get(`${apiBase}/titles`, { headers }); - expect(response.ok()).toBeTruthy(); + const response = await request.get(`${apiBase}/titles`, { headers }) + expect(response.ok()).toBeTruthy() - const data = await response.json(); - const items = data.items || data.titles || []; - expect(Array.isArray(items)).toBeTruthy(); + const data = await response.json() + const items = data.items || data.titles || [] + expect(Array.isArray(items)).toBeTruthy() // 如果有标题,验证有分类字段 if (items.length > 0) { - expect(items[0].category !== undefined).toBeTruthy(); + expect(items[0].category !== undefined).toBeTruthy() } - }); + }) test("按分类筛选标题", async ({ request }) => { - const { headers } = await createAuthedUser(request, "title-filter-cat"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "title-filter-cat") + const suffix = Date.now().toString(36) // 创建不同分类的标题 await request.post(`${apiBase}/titles`, { @@ -442,7 +415,7 @@ test.describe("标题库 - 分类/标签筛选", () => { text: "种草内容", category: "种草", }, - }); + }) await request.post(`${apiBase}/titles`, { headers, data: { @@ -450,115 +423,118 @@ test.describe("标题库 - 分类/标签筛选", () => { text: "知识内容", category: "知识", }, - }); + }) // 按分类筛选 const response = await request.get(`${apiBase}/titles`, { headers, params: { category: "种草" }, - }); + }) // 筛选可能支持也可能不支持 - expect( - response.ok(), - `筛选请求应成功,实际: ${response.status()}`, - ).toBeTruthy(); - }); -}); + expect(response.ok(), `筛选请求应成功,实际: ${response.status()}`).toBeTruthy() + }) +}) test.describe("标题库 - 页面交互", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("标题卡片展示完整信息", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "title-card"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "title-card", + ) + const suffix = Date.now().toString(36) - await createTitle(request, headers, suffix); + await createTitle(request, headers, suffix) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E title-card", - }); + }) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-title-card").first(); + const firstCard = page.locator(".xx-title-card").first() if (await firstCard.isVisible({ timeout: 15_000 })) { // 验证标题文本 - const titleText = firstCard.locator(".xx-title-card-text"); + const titleText = firstCard.locator(".xx-title-card-text") if (await titleText.isVisible()) { - await expect(titleText).toBeVisible(); + await expect(titleText).toBeVisible() } // 验证统计信息 - const titleStat = firstCard.locator(".xx-title-card-stat"); + const titleStat = firstCard.locator(".xx-title-card-stat") if (await titleStat.isVisible()) { - await expect(titleStat).toBeVisible(); + await expect(titleStat).toBeVisible() } } - }); + }) test("标题卡片可点击查看详情", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "title-detail"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "title-detail", + ) + const suffix = Date.now().toString(36) - await createTitle(request, headers, suffix); + await createTitle(request, headers, suffix) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E title-detail", - }); + }) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-title-card").first(); + const firstCard = page.locator(".xx-title-card").first() if (await firstCard.isVisible({ timeout: 15_000 })) { - await firstCard.click(); + await firstCard.click() // 点击后页面应该有响应(可能是弹窗或跳转) - await expect(page.locator(".xx-titles-page")).toBeVisible(); + await expect(page.locator(".xx-titles-page")).toBeVisible() } - }); -}); + }) +}) test.describe("标题库 - 批量操作", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("多选复选框存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "title-batch-ui"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "title-batch-ui", + ) + const suffix = Date.now().toString(36) // 创建多个标题 - await createTitle(request, headers, `${suffix}-1`); - await createTitle(request, headers, `${suffix}-2`); - await createTitle(request, headers, `${suffix}-3`); + await createTitle(request, headers, `${suffix}-1`) + await createTitle(request, headers, `${suffix}-2`) + await createTitle(request, headers, `${suffix}-3`) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E title-batch-ui", - }); + }) - await page.goto("/app/titles"); + await page.goto("/app/titles") await expect(page.locator(".xx-titles-page")).toBeVisible({ timeout: 20_000, - }); + }) // 检查是否有批量操作相关 UI // 页面正常加载即可,批量操作是可选功能 - await expect(page.locator(".xx-titles-page")).toBeVisible(); - }); -}); + await expect(page.locator(".xx-titles-page")).toBeVisible() + }) +}) diff --git a/apps/web/e2e/voice-clone.spec.ts b/apps/web/e2e/voice-clone.spec.ts index 144284a85..42e189166 100644 --- a/apps/web/e2e/voice-clone.spec.ts +++ b/apps/web/e2e/voice-clone.spec.ts @@ -6,36 +6,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -48,30 +41,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -90,14 +83,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -111,138 +104,123 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } test.describe("声音克隆页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { - await page.goto("/app/voice-clone"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/voice-clone") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("声音克隆页面 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("声音克隆页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-load", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("页面标题和描述存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-title", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-title", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证页面标题包含"克隆"或"音色"相关文字 // 只要页面正常加载即可,标题可能在 PageHead 组件中 - await expect(page.locator(".vc-page")).toBeVisible(); - }); + await expect(page.locator(".vc-page")).toBeVisible() + }) test("克隆新音色按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-newbtn", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-newbtn", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证克隆新音色按钮存在 // 按钮可能在不同位置,只要页面加载成功即可 - await expect(page.locator(".vc-page")).toBeVisible(); - }); -}); + await expect(page.locator(".vc-page")).toBeVisible() + }) +}) test.describe("声音克隆 - 空状态", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("无克隆音色时显示空状态", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-empty", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-empty", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 新用户应该显示空状态 - const emptyState = page.locator(".vc-empty"); + const emptyState = page.locator(".vc-empty") if (await emptyState.isVisible({ timeout: 10_000 })) { - await expect(emptyState.locator(".vc-empty-title")).toBeVisible(); - await expect(emptyState.locator(".vc-empty-desc")).toBeVisible(); + await expect(emptyState.locator(".vc-empty-title")).toBeVisible() + await expect(emptyState.locator(".vc-empty-desc")).toBeVisible() } - }); -}); + }) +}) test.describe("声音克隆 - API 操作", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("获取克隆列表 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-list"); + const { headers } = await createAuthedUser(request, "vc-list") const response = await request.get(`${apiBase}/voice-clones`, { headers, - }); + }) - expect( - response.ok(), - `获取克隆列表应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - const items = data.items || data.voice_clones || []; - expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy(); - }); + const data = await response.json() + const items = data.items || data.voice_clones || [] + expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy() + }) test("创建音色克隆 - 正向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-create"); - const suffix = Date.now().toString(36); + const { headers } = await createAuthedUser(request, "vc-create") + const suffix = Date.now().toString(36) // 创建一个克隆任务(上传音频文件) const response = await request.post(`${apiBase}/voice-clones`, { @@ -256,52 +234,49 @@ test.describe("声音克隆 - API 操作", () => { buffer: Buffer.from("fake audio data for e2e test"), }, }, - }); + }) // 克隆创建可能成功也可能因为缺少实际音频处理返回错误 // 只要不是 500 错误即可 expect( response.status() < 500, `创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`, - ).toBeTruthy(); + ).toBeTruthy() if (response.ok()) { - const data = await response.json(); - expect(data.id, "应返回克隆 ID").toBeTruthy(); - expect(data.status, "应返回状态").toBeTruthy(); + const data = await response.json() + expect(data.id, "应返回克隆 ID").toBeTruthy() + expect(data.status, "应返回状态").toBeTruthy() } - }); + }) test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-detail"); + const { headers } = await createAuthedUser(request, "vc-detail") // 先获取列表看看有没有数据 const listResp = await request.get(`${apiBase}/voice-clones`, { headers, - }); - expect(listResp.ok()).toBeTruthy(); + }) + expect(listResp.ok()).toBeTruthy() - const listData = await listResp.json(); - const items = listData.items || listData.voice_clones || []; + const listData = await listResp.json() + const items = listData.items || listData.voice_clones || [] if (items.length > 0) { - const cloneId = items[0].id; - const detailResp = await request.get( - `${apiBase}/voice-clones/${cloneId}`, - { headers }, - ); - expect(detailResp.ok(), "获取详情应成功").toBeTruthy(); - const detail = await detailResp.json(); - expect(detail.id).toBe(cloneId); + const cloneId = items[0].id + const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers }) + expect(detailResp.ok(), "获取详情应成功").toBeTruthy() + const detail = await detailResp.json() + expect(detail.id).toBe(cloneId) } // 如果没有数据,测试也通过(新用户正常情况) - }); + }) test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-del"); + const { headers } = await createAuthedUser(request, "vc-del") // 先创建一个克隆 - const suffix = Date.now().toString(36); + const suffix = Date.now().toString(36) const createResp = await request.post(`${apiBase}/voice-clones`, { headers, multipart: { @@ -312,75 +287,63 @@ test.describe("声音克隆 - API 操作", () => { buffer: Buffer.from("delete me"), }, }, - }); + }) if (createResp.ok()) { - const created = await createResp.json(); - const cloneId = created.id; + const created = await createResp.json() + const cloneId = created.id // 删除 - const deleteResp = await request.delete( - `${apiBase}/voice-clones/${cloneId}`, - { headers }, - ); + const deleteResp = await request.delete(`${apiBase}/voice-clones/${cloneId}`, { headers }) expect( [200, 204].includes(deleteResp.status()), `删除应返回 200 或 204,实际: ${deleteResp.status()}`, - ).toBeTruthy(); + ).toBeTruthy() // 验证已删除 const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers, - }); - expect([404, 410]).toContain(getResp.status()); + }) + expect([404, 410]).toContain(getResp.status()) } // 如果创建失败(比如音频格式问题),测试也通过 - }); + }) test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-retry"); + const { headers } = await createAuthedUser(request, "vc-retry") // 先获取列表 const listResp = await request.get(`${apiBase}/voice-clones`, { headers, - }); - expect(listResp.ok()).toBeTruthy(); + }) + expect(listResp.ok()).toBeTruthy() - const listData = await listResp.json(); - const items = listData.items || listData.voice_clones || []; + const listData = await listResp.json() + const items = listData.items || listData.voice_clones || [] // 找一个失败状态的克隆进行重试 - const failedClone = items.find( - (item: { status: string }) => item.status === "failed", - ); + const failedClone = items.find((item: { status: string }) => item.status === "failed") if (failedClone) { - const retryResp = await request.post( - `${apiBase}/voice-clones/${failedClone.id}/retry`, - { headers }, - ); - expect( - retryResp.ok(), - `重试应返回 2xx,实际: ${retryResp.status()}`, - ).toBeTruthy(); + const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, { + headers, + }) + expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy() } // 如果没有失败的克隆,测试通过 - }); + }) test("获取不存在的克隆详情 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "vc-404"); + const { headers } = await createAuthedUser(request, "vc-404") - const response = await request.get( - `${apiBase}/voice-clones/nonexistent-clone-999`, - { headers }, - ); - expect(response.status(), "不存在的克隆应返回 404").toBe(404); - }); + const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers }) + expect(response.status(), "不存在的克隆应返回 404").toBe(404) + }) test("未登录获取克隆列表 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/voice-clones`); - expect([401, 403]).toContain(response.status()); - }); + const response = await request.get(`${apiBase}/voice-clones`) + expect([401, 403]).toContain(response.status()) + }) test("未登录创建克隆 - 反向", async ({ request }) => { const response = await request.post(`${apiBase}/voice-clones`, { @@ -392,47 +355,46 @@ test.describe("声音克隆 - API 操作", () => { buffer: Buffer.from("test"), }, }, - }); - expect([401, 403]).toContain(response.status()); - }); -}); + }) + expect([401, 403]).toContain(response.status()) + }) +}) test.describe("声音克隆 - 克隆列表展示", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("克隆卡片网格布局展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-grid", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-grid") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-grid", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证网格容器或空状态存在 - const grid = page.locator(".vc-grid"); - const empty = page.locator(".vc-empty"); + const grid = page.locator(".vc-grid") + const empty = page.locator(".vc-empty") // 至少一个应该可见 - const gridVisible = await grid.isVisible().catch(() => false); - const emptyVisible = await empty.isVisible().catch(() => false); - expect(gridVisible || emptyVisible).toBeTruthy(); - }); + const gridVisible = await grid.isVisible().catch(() => false) + const emptyVisible = await empty.isVisible().catch(() => false) + expect(gridVisible || emptyVisible).toBeTruthy() + }) test("克隆状态标签展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username, headers } = - await createAuthedUser(request, "vc-status"); - const suffix = Date.now().toString(36); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username, headers } = await createAuthedUser( + request, + "vc-status", + ) + const suffix = Date.now().toString(36) // 创建一个克隆任务 await request.post(`${apiBase}/voice-clones`, { @@ -445,66 +407,61 @@ test.describe("声音克隆 - 克隆列表展示", () => { buffer: Buffer.from("status test data"), }, }, - }); + }) await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-status", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 如果有卡片,验证状态标签存在 - const cards = page.locator(".vc-card"); + const cards = page.locator(".vc-card") if ((await cards.count()) > 0) { - const firstCard = cards.first(); - const statusPill = firstCard.locator(".vc-status-pill"); + const firstCard = cards.first() + const statusPill = firstCard.locator(".vc-status-pill") if (await statusPill.isVisible()) { - await expect(statusPill).toBeVisible(); + await expect(statusPill).toBeVisible() } } - }); -}); + }) +}) test.describe("声音克隆 - 上传区域", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("克隆弹窗上传区域可打开", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "vc-upload", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E vc-upload", - }); + }) - await page.goto("/app/voice-clone"); + await page.goto("/app/voice-clone") await expect(page.locator(".vc-page")).toBeVisible({ timeout: 20_000, - }); + }) // 尝试点击克隆新音色按钮 const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/, - }); + }) if (await cloneBtn.isVisible()) { - await cloneBtn.click(); + await cloneBtn.click() // 弹窗应该出现 - const modal = page.locator( - ".ant-modal, .vc-edit-dialog, [role='dialog']", - ); + const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']") if (await modal.first().isVisible({ timeout: 5_000 })) { - await expect(modal.first()).toBeVisible(); + await expect(modal.first()).toBeVisible() } } - }); -}); + }) +}) diff --git a/apps/web/e2e/voices.spec.ts b/apps/web/e2e/voices.spec.ts index 94a17a4f1..0796ce5d5 100644 --- a/apps/web/e2e/voices.spec.ts +++ b/apps/web/e2e/voices.spec.ts @@ -6,36 +6,29 @@ * * 每个测试独立,先注册登录获取 auth token。 */ -import { - expect, - test, - type APIRequestContext, - type Page, -} from "@playwright/test"; +import { expect, test, type APIRequestContext, type Page } from "@playwright/test" -const PASSWORD = "Test123456!"; -const apiBase = process.env.E2E_API_BASE || "/api/v1"; -const apiOrigin = apiBase.endsWith("/api/v1") - ? apiBase.slice(0, -"/api/v1".length) - : ""; +const PASSWORD = "Test123456!" +const apiBase = process.env.E2E_API_BASE || "/api/v1" +const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : "" const routeBrowserApiToTestApi = async (page: Page) => { - if (!apiOrigin) return; + if (!apiOrigin) return await page.route("**/api/v1/**", async (route) => { - const sourceUrl = new URL(route.request().url()); + const sourceUrl = new URL(route.request().url()) const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, - }); - await route.fulfill({ response }); - }); -}; + }) + await route.fulfill({ response }) + }) +} function uniqueEmail(prefix: string): string { - return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; + return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com` } function uniqueUsername(prefix: string): string { - return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; + return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` } /** 登录操作,遇到 429 限流自动等待重试 */ @@ -48,30 +41,30 @@ async function loginWithRetry( for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); - if (response.status() !== 429) return response; - console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); - await new Promise((r) => setTimeout(r, 65000)); + }) + if (response.status() !== 429) return response + console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`) + await new Promise((r) => setTimeout(r, 65000)) } return request.post(`${apiBase}/auth/login`, { data: { email, password }, - }); + }) } /** 注册并登录,返回 { headers, email, username, userId, accessToken } */ async function createAuthedUser(request: APIRequestContext, label: string) { - const email = uniqueEmail(label); - const username = uniqueUsername(label); + const email = uniqueEmail(label) + const username = uniqueUsername(label) const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, - }); - expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); - const regData = await reg.json(); + }) + expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy() + const regData = await reg.json() - const login = await loginWithRetry(request, email, PASSWORD); - expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); - const loginData = await login.json(); + const login = await loginWithRetry(request, email, PASSWORD) + expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy() + const loginData = await login.json() return { headers: { Authorization: `Bearer ${loginData.access_token}` }, @@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) { username, userId: regData.user_id, accessToken: loginData.access_token, - }; + } } /** 设置页面认证状态(localStorage) */ @@ -90,14 +83,14 @@ async function setupAuth( ) { await page.addInitScript( ({ token, user }) => { - localStorage.setItem("access_token", token); + localStorage.setItem("access_token", token) localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), - ); + ) }, { token, @@ -111,325 +104,289 @@ async function setupAuth( email_verified: true, }, }, - ); + ) } test.describe("音色库页面 - 未登录重定向", () => { test("未登录访问重定向到登录页", async ({ page }) => { - await page.goto("/app/voices"); - await expect(page).toHaveURL(/\/login/); - }); -}); + await page.goto("/app/voices") + await expect(page).toHaveURL(/\/login/) + }) +}) test.describe("音色库页面 - 页面加载", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("音色库页面加载成功", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-load", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-load") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-load", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); - }); + }) + }) test("页面头部和搜索栏存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-head", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-head") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-head", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证搜索框存在 const searchInput = page.locator( "input[type='search'], .xx-voices-search input, input[placeholder*='搜索']", - ); - await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }); - }); -}); + ) + await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }) + }) +}) test.describe("音色库 - 预设音色", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("预设音色列表 API 返回数据", async ({ request }) => { - const { headers } = await createAuthedUser(request, "voice-preset"); + const { headers } = await createAuthedUser(request, "voice-preset") const response = await request.get(`${apiBase}/voices/preset`, { headers, - }); + }) // 预设音色接口可能返回数组或包装对象 - expect( - response.ok(), - `获取预设音色应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取预设音色应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - const items = data.items || data.voices || data; - expect(Array.isArray(items), "预设音色应为数组").toBeTruthy(); - }); + const data = await response.json() + const items = data.items || data.voices || data + expect(Array.isArray(items), "预设音色应为数组").toBeTruthy() + }) test("预设音色卡片在页面中展示", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-cards", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-cards") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-cards", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 等待音色卡片加载(预设音色应该有数据) - const voiceCards = page.locator(".xx-voice-card"); + const voiceCards = page.locator(".xx-voice-card") // 等待至少一张卡片出现 - await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 }); - const count = await voiceCards.count(); - expect(count).toBeGreaterThan(0); - }); + await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 }) + const count = await voiceCards.count() + expect(count).toBeGreaterThan(0) + }) test("音色卡片包含名称和信息", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-info", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-info") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-info", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-voice-card").first(); - await expect(firstCard).toBeVisible({ timeout: 15_000 }); + const firstCard = page.locator(".xx-voice-card").first() + await expect(firstCard).toBeVisible({ timeout: 15_000 }) // 验证音色名称存在 - await expect(firstCard.locator(".xx-voice-name")).toBeVisible(); + await expect(firstCard.locator(".xx-voice-name")).toBeVisible() // 验证头像存在 - await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible(); - }); -}); + await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible() + }) +}) test.describe("音色库 - 我的克隆音色", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("克隆音色列表 API 返回数据", async ({ request }) => { - const { headers } = await createAuthedUser(request, "voice-cln-api"); + const { headers } = await createAuthedUser(request, "voice-cln-api") const response = await request.get(`${apiBase}/voice-clones`, { headers, - }); + }) - expect( - response.ok(), - `获取克隆音色应返回 2xx,实际: ${response.status()}`, - ).toBeTruthy(); + expect(response.ok(), `获取克隆音色应返回 2xx,实际: ${response.status()}`).toBeTruthy() - const data = await response.json(); - const items = data.items || data.voice_clones || []; - expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy(); - }); + const data = await response.json() + const items = data.items || data.voice_clones || [] + expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy() + }) test("空状态展示 - 无克隆音色时", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-empty", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-empty") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-empty", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 切换到"我的克隆"tab(如果有tab的话) - const clonedTab = page.getByText("我的克隆").first(); + const clonedTab = page.getByText("我的克隆").first() if (await clonedTab.isVisible()) { - await clonedTab.click(); + await clonedTab.click() } // 页面至少应该是可访问的 - await expect(page.locator(".xx-voices-page")).toBeVisible(); - }); + await expect(page.locator(".xx-voices-page")).toBeVisible() + }) test("创建克隆音色入口存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-create", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-create") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-create", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮) // 不强制断言一定存在,因为不同页面结构可能不同 // 只验证页面正常加载即可 - await expect(page.locator(".xx-voices-page")).toBeVisible(); - }); -}); + await expect(page.locator(".xx-voices-page")).toBeVisible() + }) +}) test.describe("音色库 - 搜索和筛选", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("搜索框存在且可输入", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-search", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-search") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-search", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 查找搜索输入框 const searchInput = page.locator( "input[placeholder*='搜索'], input[type='search'], .xx-voices-search input", - ); - const firstInput = searchInput.first(); + ) + const firstInput = searchInput.first() if (await firstInput.isVisible({ timeout: 5_000 })) { - await firstInput.fill("测试搜索"); - await expect(firstInput).toHaveValue("测试搜索"); + await firstInput.fill("测试搜索") + await expect(firstInput).toHaveValue("测试搜索") } - }); + }) test("性别/语言筛选选项存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-filter", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-filter") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-filter", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) // 验证筛选相关元素存在(可能是下拉选择器或标签) // 页面正常加载即通过 - await expect(page.locator(".xx-voices-page")).toBeVisible(); - }); -}); + await expect(page.locator(".xx-voices-page")).toBeVisible() + }) +}) test.describe("音色库 - 播放试听", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("音色播放按钮存在", async ({ page, request }) => { - await routeBrowserApiToTestApi(page); - const { accessToken, userId, email, username } = await createAuthedUser( - request, - "voice-play", - ); + await routeBrowserApiToTestApi(page) + const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-play") await setupAuth(page, accessToken, { id: userId, email, username, display_name: "E2E voice-play", - }); + }) - await page.goto("/app/voices"); + await page.goto("/app/voices") await expect(page.locator(".xx-voices-page")).toBeVisible({ timeout: 20_000, - }); + }) - const firstCard = page.locator(".xx-voice-card").first(); + const firstCard = page.locator(".xx-voice-card").first() if (await firstCard.isVisible({ timeout: 15_000 })) { // 验证播放按钮存在 - const playBtn = firstCard.locator(".xx-voice-play-btn"); + const playBtn = firstCard.locator(".xx-voice-play-btn") if (await playBtn.isVisible()) { - await expect(playBtn).toBeVisible(); + await expect(playBtn).toBeVisible() } } - }); -}); + }) +}) test.describe("音色库 - API 边界测试", () => { - test.describe.configure({ timeout: 120_000 }); + test.describe.configure({ timeout: 120_000 }) test("未登录获取预设音色 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/voices/preset`); + const response = await request.get(`${apiBase}/voices/preset`) // 预设音色可能不需要登录,也可能需要,两种情况都接受 // 但如果需要登录,应返回 401/403 if (!response.ok()) { - expect([401, 403]).toContain(response.status()); + expect([401, 403]).toContain(response.status()) } - }); + }) test("未登录获取克隆音色 - 反向", async ({ request }) => { - const response = await request.get(`${apiBase}/voice-clones`); - expect([401, 403]).toContain(response.status()); - }); + const response = await request.get(`${apiBase}/voice-clones`) + expect([401, 403]).toContain(response.status()) + }) test("获取不存在的克隆音色详情 - 反向", async ({ request }) => { - const { headers } = await createAuthedUser(request, "voice-404"); + const { headers } = await createAuthedUser(request, "voice-404") - const response = await request.get( - `${apiBase}/voice-clones/nonexistent-999`, - { headers }, - ); - expect(response.status(), "不存在的克隆应返回 404").toBe(404); - }); -}); + const response = await request.get(`${apiBase}/voice-clones/nonexistent-999`, { headers }) + expect(response.status(), "不存在的克隆应返回 404").toBe(404) + }) +}) diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index 49eda5fe0..3eace6643 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -23,17 +23,18 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^20.14.9", - "@types/react": "^18.3.3", + "@types/react": "^18.3.31", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitejs/plugin-react": "^4.3.1", "@vitest/ui": "^1.6.0", "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.2", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "jsdom": "^24.1.0", - "prettier": "^3.0.0", + "prettier": "^3.9.5", "typescript": "^5.5.3", "vite": "^5.3.1", "vitest": "^1.6.0" @@ -1974,14 +1975,14 @@ }, "node_modules/@types/prop-types": { "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "devOptional": true, "license": "MIT", @@ -3255,6 +3256,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.2", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz", + "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index 313634fa1..70d5a7710 100755 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,16 +4,18 @@ "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite", "build": "tsc && vite build", + "dev": "vite", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "test": "vitest", - "test:ui": "vitest --ui", "test:coverage": "vitest --coverage", "test:e2e": "playwright test", "test:e2e:ci": "npx playwright test --project=chromium --reporter=line", "test:e2e:ui": "playwright test --ui", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "test:ui": "vitest --ui", "type-check": "tsc --noEmit" }, "dependencies": { @@ -32,17 +34,18 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.5.2", "@types/node": "^20.14.9", - "@types/react": "^18.3.3", + "@types/react": "^18.3.31", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vitejs/plugin-react": "^4.3.1", "@vitest/ui": "^1.6.0", "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.2", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "jsdom": "^24.1.0", - "prettier": "^3.0.0", + "prettier": "^3.9.5", "typescript": "^5.5.3", "vite": "^5.3.1", "vitest": "^1.6.0" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index bbf0ec2a4..609707acb 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -1,9 +1,9 @@ /** * Playwright E2E 测试配置 */ -import { defineConfig, devices } from "@playwright/test"; +import { defineConfig, devices } from "@playwright/test" -const externalBaseURL = process.env.E2E_BASE_URL; +const externalBaseURL = process.env.E2E_BASE_URL export default defineConfig({ testDir: "./e2e", @@ -68,4 +68,4 @@ export default defineConfig({ url: "http://localhost:3000", reuseExistingServer: !process.env.CI, }, -}); +}) diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 55dd6706c..370815a9c 100755 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -2,163 +2,157 @@ * 素材相关 API * Phase 1 重构:去掉 project_id,素材直接归属用户 */ -import apiClient from "./client"; -import { getOrCreateDefaultProject } from "./projects"; +import apiClient from "./client" +import { getOrCreateDefaultProject } from "./projects" /** 素材元数据 */ export interface AssetMetadata { /** 时长(秒) */ - duration?: number; + duration?: number /** 宽度(像素) */ - width?: number; + width?: number /** 高度(像素) */ - height?: number; + height?: number /** 比特率(bps) */ - bitrate?: number; + bitrate?: number /** 编码格式 */ - codec?: string; + codec?: string /** 帧率 */ - fps?: number; + fps?: number /** 采样率(Hz) */ - sample_rate?: number; + sample_rate?: number /** 声道数 */ - channels?: number; + channels?: number /** 其他扩展字段 */ - [key: string]: unknown; + [key: string]: unknown } /** 素材分类状态 */ -export type AssetClassificationStatus = - "pending" | "processing" | "completed" | "failed"; +export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed" /** 素材条目 */ export interface AssetItem { - id: string; - library_id: string; - name: string; - storage_key: string; - mime_type: string; - metadata: AssetMetadata; - file_size?: number; - file_url?: string; - thumbnail_url?: string; + id: string + library_id: string + name: string + storage_key: string + mime_type: string + metadata: AssetMetadata + file_size?: number + file_url?: string + thumbnail_url?: string /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ - duration?: number; - status?: string; - classification_status?: AssetClassificationStatus | null; - quality_score?: number | null; - tag_ids?: string[]; - created_at?: string; + duration?: number + status?: string + classification_status?: AssetClassificationStatus | null + quality_score?: number | null + tag_ids?: string[] + created_at?: string } /** 素材库 */ export interface AssetLibraryItem { - id: string; - name: string; - kind: "video" | "voice" | "image"; - asset_count?: number; - total_size?: number; - created_at?: string; + id: string + name: string + kind: "video" | "voice" | "image" + asset_count?: number + total_size?: number + created_at?: string } /** 入库任务 */ export interface IngestJob { - id: string; - library_id: string; - storage_key: string; - status: "pending" | "processing" | "completed" | "failed"; - error_message: string; - result_asset_id: string; + id: string + library_id: string + storage_key: string + status: "pending" | "processing" | "completed" | "failed" + error_message: string + result_asset_id: string } /** 分类任务 */ export interface ClassificationJob { - id: string; - asset_id: string; - status: "pending" | "processing" | "completed" | "failed"; - classification: string; - confidence: number; - error_message: string; + id: string + asset_id: string + status: "pending" | "processing" | "completed" | "failed" + classification: string + confidence: number + error_message: string } /** 素材诊断信息 */ export interface AssetDiagnosis { - readiness_score: number; - readiness_label: string; - total_assets: number; - ready_assets: number; - video_assets: number; - image_assets: number; - voice_assets: number; - total_duration_seconds: number; - estimated_video_count: number; - used_assets: number; - unused_assets: number; - pending_review_assets: number; + readiness_score: number + readiness_label: string + total_assets: number + ready_assets: number + video_assets: number + image_assets: number + voice_assets: number + total_duration_seconds: number + estimated_video_count: number + used_assets: number + unused_assets: number + pending_review_assets: number smart_views: Array<{ - key: string; - label: string; - count: number; - description: string; - }>; + key: string + label: string + count: number + description: string + }> gaps: Array<{ - key: string; - severity: "critical" | "warning" | "info"; - message: string; - recommendation: string; - }>; + key: string + severity: "critical" | "warning" | "info" + message: string + recommendation: string + }> } // ─── 素材诊断 ────────────────────────────────────────────── /** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */ -export const getAssetDiagnosis = async ( - assetId?: string, -): Promise => { - const params: Record = {}; - if (assetId) params.asset_id = assetId; - const response = await apiClient.get("/asset-diagnosis", { params }); - return response.data; -}; +export const getAssetDiagnosis = async (assetId?: string): Promise => { + const params: Record = {} + if (assetId) params.asset_id = assetId + const response = await apiClient.get("/asset-diagnosis", { params }) + return response.data +} // ─── 素材库 ──────────────────────────────────────────────── /** 获取当前用户的所有素材库 */ export const getAssetLibraries = async (): Promise => { - const response = await apiClient.get("/asset-libraries"); - return response.data.items || []; -}; + const response = await apiClient.get("/asset-libraries") + return response.data.items || [] +} /** 创建素材库(自动获取或创建默认项目以提供 project_id) */ export const createAssetLibrary = async (data: { - name: string; - kind: "video" | "voice" | "image"; + name: string + kind: "video" | "voice" | "image" }): Promise => { // 后端要求 project_id,前端自动管理默认项目 - const project = await getOrCreateDefaultProject(); + const project = await getOrCreateDefaultProject() const response = await apiClient.post("/asset-libraries", { project_id: project.id, ...data, - }); - return response.data; -}; + }) + return response.data +} /** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */ export const ensureDefaultLibrary = async (data: { - project_id: string; - kind: "video" | "voice" | "image"; + project_id: string + kind: "video" | "voice" | "image" }): Promise => { - const response = await apiClient.post( - "/asset-libraries/ensure-default", - data, - ); - return response.data; -}; + const response = await apiClient.post("/asset-libraries/ensure-default", data) + return response.data +} /** 删除素材库 */ export const deleteAssetLibrary = async (libraryId: string): Promise => { - await apiClient.delete(`/asset-libraries/${libraryId}`); -}; + await apiClient.delete(`/asset-libraries/${libraryId}`) +} // ─── 素材 ────────────────────────────────────────────────── @@ -166,49 +160,49 @@ export const deleteAssetLibrary = async (libraryId: string): Promise => { export const getAssets = async (libraryId: string): Promise => { const response = await apiClient.get("/assets", { params: { library_id: libraryId }, - }); - return response.data.items || []; -}; + }) + return response.data.items || [] +} /** 按类型获取素材(如 voice/video/image),支持可选筛选 */ export const getAssetsByKind = async ( kind: string, filters?: { - keyword?: string; - gender?: string; - style?: string; - tag_ids?: string[]; + keyword?: string + gender?: string + style?: string + tag_ids?: string[] }, ): Promise => { - const params: Record = { 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(","); - const response = await apiClient.get("/assets", { params }); - return response.data.items || []; -}; + const params: Record = { 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(",") + const response = await apiClient.get("/assets", { params }) + return response.data.items || [] +} /** 创建素材(上传文件后调用,附带 metadata) */ export const createAsset = async (data: { - library_id: string; - name: string; - storage_key: string; - mime_type: string; - metadata?: AssetMetadata; + library_id: string + name: string + storage_key: string + mime_type: string + metadata?: AssetMetadata }): Promise => { - const response = await apiClient.post("/assets", data); - return response.data; -}; + const response = await apiClient.post("/assets", data) + return response.data +} /** 更新素材(名称、metadata 等) */ export const updateAsset = async ( assetId: string, data: { name?: string; metadata?: AssetMetadata }, ): Promise => { - const response = await apiClient.put(`/assets/${assetId}`, data); - return response.data; -}; + const response = await apiClient.put(`/assets/${assetId}`, data) + return response.data +} /** 更新素材审核状态 */ export const updateAssetReviewStatus = async ( @@ -217,14 +211,14 @@ export const updateAssetReviewStatus = async ( ): Promise => { const response = await apiClient.patch(`/assets/${assetId}/review`, { review_status: reviewStatus, - }); - return response.data; -}; + }) + return response.data +} /** 删除素材 */ export const deleteAsset = async (assetId: string): Promise => { - await apiClient.delete(`/assets/${assetId}`); -}; + await apiClient.delete(`/assets/${assetId}`) +} // ─── 上传 ────────────────────────────────────────────────── @@ -235,47 +229,47 @@ export const uploadAsset = async ( const response = await apiClient.post("/upload", formData, { headers: { "Content-Type": "multipart/form-data" }, timeout: 30 * 60 * 1000, - }); - return response.data; -}; + }) + return response.data +} /** 预签名直传准备 */ export const prepareDirectUpload = async (data: { - project_id: string; - library_id: string; - filename: string; - content_type: string; - file_size: number; + project_id: string + library_id: string + filename: string + content_type: string + file_size: number }): Promise<{ - upload_url: string; - method: string; - storage_key: string; - expires_at: string; - fields: Record; - max_size_bytes: number; + upload_url: string + method: string + storage_key: string + expires_at: string + fields: Record + max_size_bytes: number }> => { - const response = await apiClient.post("/upload/direct/prepare", data); - return response.data; -}; + const response = await apiClient.post("/upload/direct/prepare", data) + return response.data +} /** 直传完成确认 */ export const completeDirectUpload = async (data: { - project_id: string; - library_id: string; - storage_key: string; + project_id: string + library_id: string + storage_key: string }): Promise<{ storage_key: string; ingest_job_id: string }> => { - const response = await apiClient.post("/upload/direct/complete", data); - return response.data; -}; + const response = await apiClient.post("/upload/direct/complete", data) + return response.data +} /** 直传上传(大文件推荐),支持可选进度回调 */ export const uploadAssetDirect = async (data: { - file: File; - library_id: string; - onProgress?: (percent: number) => void; + file: File + library_id: string + onProgress?: (percent: number) => void }): Promise<{ storage_key: string; ingest_job_id: string }> => { // 后端要求 project_id,前端自动获取默认项目 - const project = await getOrCreateDefaultProject(); + const project = await getOrCreateDefaultProject() const prepared = await prepareDirectUpload({ project_id: project.id, @@ -283,166 +277,149 @@ export const uploadAssetDirect = async (data: { filename: data.file.name, content_type: data.file.type || "application/octet-stream", file_size: data.file.size, - }); + }) - const directForm = new FormData(); - Object.entries(prepared.fields).forEach(([key, value]) => - directForm.append(key, value), - ); - directForm.append("file", data.file); + const directForm = new FormData() + Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value)) + directForm.append("file", data.file) // 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断 await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open(prepared.method, prepared.upload_url); + const xhr = new XMLHttpRequest() + xhr.open(prepared.method, prepared.upload_url) // 超时 10 分钟 - xhr.timeout = 10 * 60 * 1000; + xhr.timeout = 10 * 60 * 1000 xhr.upload.onprogress = (e) => { if (e.lengthComputable && data.onProgress) { - data.onProgress(Math.round((e.loaded / e.total) * 100)); + data.onProgress(Math.round((e.loaded / e.total) * 100)) } - }; + } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { - resolve(); + resolve() } else { // 解析 OSS 返回的 XML 错误信息 - let ossError = ""; + let ossError = "" try { - const codeMatch = xhr.responseText.match(/([^<]+)<\/Code>/); - const msgMatch = xhr.responseText.match( - /([^<]+)<\/Message>/, - ); + const codeMatch = xhr.responseText.match(/([^<]+)<\/Code>/) + const msgMatch = xhr.responseText.match(/([^<]+)<\/Message>/) if (codeMatch || msgMatch) { - ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`; + ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]` } } catch { // 无法解析响应体 } - const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`; + const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}` console.error("[OSS Upload] 直传失败:", { url: prepared.upload_url, storage_key: prepared.storage_key, status: xhr.status, statusText: xhr.statusText, - }); - reject(new Error(detail)); + }) + reject(new Error(detail)) } - }; + } xhr.onerror = () => { console.error("[OSS Upload] 网络错误:", { url: prepared.upload_url, storage_key: prepared.storage_key, - }); - reject(new Error("OSS 上传网络错误,请检查网络连接")); - }; + }) + reject(new Error("OSS 上传网络错误,请检查网络连接")) + } xhr.ontimeout = () => { console.error("[OSS Upload] 上传超时:", { url: prepared.upload_url, storage_key: prepared.storage_key, - }); - reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")); - }; - xhr.send(directForm); - }); + }) + reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件")) + } + xhr.send(directForm) + }) return completeDirectUpload({ project_id: project.id, library_id: data.library_id, storage_key: prepared.storage_key, - }); -}; + }) +} // ─── 入库 / 分类任务 ─────────────────────────────────────── /** 查询入库任务状态 */ export const getIngestJob = async (jobId: string): Promise => { - const response = await apiClient.get(`/ingest-jobs/${jobId}`); - return response.data; -}; + const response = await apiClient.get(`/ingest-jobs/${jobId}`) + return response.data +} /** 提交素材分类任务 */ export const submitClassificationJob = async (data: { - asset_id: string; + asset_id: string }): Promise => { - const response = await apiClient.post("/classification-jobs", data); - return response.data; -}; + const response = await apiClient.post("/classification-jobs", data) + return response.data +} /** 查询分类任务状态 */ -export const getClassificationJob = async ( - jobId: string, -): Promise => { - const response = await apiClient.get(`/classification-jobs/${jobId}`); - return response.data; -}; +export const getClassificationJob = async (jobId: string): Promise => { + const response = await apiClient.get(`/classification-jobs/${jobId}`) + return response.data +} // ─── 批量操作 ─────────────────────────────────────────────── /** 批量操作结果 */ export interface BatchOperationResult { - succeeded: string[]; - failed: string[]; - total: number; - success_count: number; - failure_count: number; + succeeded: string[] + failed: string[] + total: number + success_count: number + failure_count: number } /** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */ -const normalizeBatchResult = ( - raw: Record, -): BatchOperationResult => { - const succeeded = Array.isArray(raw.succeeded) - ? (raw.succeeded as string[]) - : []; - const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []; - const success_count = - typeof raw.success_count === "number" - ? raw.success_count - : succeeded.length; - const failure_count = - typeof raw.failure_count === "number" ? raw.failure_count : failed.length; - const total = - typeof raw.total === "number" ? raw.total : success_count + failure_count; - return { succeeded, failed, total, success_count, failure_count }; -}; +const normalizeBatchResult = (raw: Record): BatchOperationResult => { + const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : [] + const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : [] + const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length + const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length + const total = typeof raw.total === "number" ? raw.total : success_count + failure_count + return { succeeded, failed, total, success_count, failure_count } +} /** 批量删除素材 */ -export const batchDeleteAssets = async ( - assetIds: string[], -): Promise => { +export const batchDeleteAssets = async (assetIds: string[]): Promise => { const response = await apiClient.post("/assets/batch-delete", { asset_ids: assetIds, - }); - return normalizeBatchResult((response.data || {}) as Record); -}; + }) + return normalizeBatchResult((response.data || {}) as Record) +} /** 批量打标签 */ export const batchTagAssets = async (data: { - asset_ids: string[]; - tags: string[]; - mode: "add" | "replace"; + asset_ids: string[] + tags: string[] + mode: "add" | "replace" }): Promise => { - const response = await apiClient.post("/assets/batch-tag", data); - return normalizeBatchResult((response.data || {}) as Record); -}; + const response = await apiClient.post("/assets/batch-tag", data) + return normalizeBatchResult((response.data || {}) as Record) +} /** 批量改分类 */ export const batchClassifyAssets = async (data: { - asset_ids: string[]; - category: string; + asset_ids: string[] + category: string }): Promise => { - const response = await apiClient.post("/assets/batch-classify", data); - return normalizeBatchResult((response.data || {}) as Record); -}; + const response = await apiClient.post("/assets/batch-classify", data) + return normalizeBatchResult((response.data || {}) as Record) +} /** 批量智能标记 */ export const batchMarkAssets = async (data: { - asset_ids: string[]; - smart_view: "recommended" | "caution" | "high_risk"; + asset_ids: string[] + smart_view: "recommended" | "caution" | "high_risk" }): Promise => { - const response = await apiClient.post("/assets/batch-mark", data); - return normalizeBatchResult((response.data || {}) as Record); -}; + const response = await apiClient.post("/assets/batch-mark", data) + return normalizeBatchResult((response.data || {}) as Record) +} diff --git a/apps/web/src/api/auth.test.ts b/apps/web/src/api/auth.test.ts index 2e79905f4..f51b6ec5c 100644 --- a/apps/web/src/api/auth.test.ts +++ b/apps/web/src/api/auth.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { normalizeUser } from "./auth"; +import { describe, expect, it } from "vitest" +import { normalizeUser } from "./auth" describe("normalizeUser", () => { it("normalizes canonical API current-user fields", () => { @@ -20,8 +20,8 @@ describe("normalizeUser", () => { is_email_verified: true, email_verified: true, created_at: undefined, - }); - }); + }) + }) it("keeps compatibility with legacy UI-shaped user fields", () => { expect( @@ -42,6 +42,6 @@ describe("normalizeUser", () => { is_email_verified: false, email_verified: false, created_at: "2026-06-22T00:00:00Z", - }); - }); -}); + }) + }) +}) diff --git a/apps/web/src/api/auth.ts b/apps/web/src/api/auth.ts index 3ad7d9b0c..4b4f9c891 100644 --- a/apps/web/src/api/auth.ts +++ b/apps/web/src/api/auth.ts @@ -1,58 +1,58 @@ /** * 认证相关 API */ -import axios from "axios"; -import apiClient from "./client"; +import axios from "axios" +import apiClient from "./client" // 类型定义 export interface LoginRequest { - email: string; - password: string; + email: string + password: string } export interface LoginResponse { - access_token: string; - refresh_token?: string | null; - token_type: string; - expires_in: number; - user_id: string; - email: string; - username: string; - display_name: string; + access_token: string + refresh_token?: string | null + token_type: string + expires_in: number + user_id: string + email: string + username: string + display_name: string } export interface RegisterRequest { - email: string; - password: string; - username: string; - display_name?: string; + email: string + password: string + username: string + display_name?: string } export interface User { - id: string; - user_id: string; - email: string; - username: string; - display_name: string; - is_email_verified: boolean; - email_verified: boolean; - created_at?: string; + id: string + user_id: string + email: string + username: string + display_name: string + is_email_verified: boolean + email_verified: boolean + created_at?: string } export interface UserResponse { - id?: string; - user_id?: string; - email: string; - username: string; - display_name: string; - is_email_verified?: boolean; - email_verified?: boolean; - created_at?: string; + id?: string + user_id?: string + email: string + username: string + display_name: string + is_email_verified?: boolean + email_verified?: boolean + created_at?: string } export const normalizeUser = (data: UserResponse): User => { - const userId = data.id ?? data.user_id ?? ""; - const emailVerified = data.is_email_verified ?? data.email_verified ?? false; + const userId = data.id ?? data.user_id ?? "" + const emailVerified = data.is_email_verified ?? data.email_verified ?? false return { id: userId, @@ -63,52 +63,46 @@ export const normalizeUser = (data: UserResponse): User => { is_email_verified: emailVerified, email_verified: emailVerified, created_at: data.created_at, - }; -}; + } +} // 登录 export const login = async (data: LoginRequest): Promise => { - const response = await apiClient.post("/auth/login", data); - return response.data; -}; + const response = await apiClient.post("/auth/login", data) + return response.data +} // 刷新 access_token(使用裸 axios 避免拦截器递归) -export const refreshAccessToken = async ( - refreshToken: string, -): Promise => { - const baseURL = apiClient.defaults.baseURL ?? ""; +export const refreshAccessToken = async (refreshToken: string): Promise => { + const baseURL = apiClient.defaults.baseURL ?? "" const response = await axios.post(`${baseURL}/auth/refresh`, { refresh_token: refreshToken, - }); - return response.data; -}; + }) + return response.data +} // 注册 -export const register = async ( - data: RegisterRequest, -): Promise<{ message: string }> => { - const response = await apiClient.post("/auth/register", data); - return response.data; -}; +export const register = async (data: RegisterRequest): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/register", data) + return response.data +} // 登出 export const logout = async (): Promise => { - await apiClient.post("/auth/logout"); -}; + await apiClient.post("/auth/logout") +} // 获取当前用户 export const getCurrentUser = async (): Promise => { - const response = await apiClient.get("/auth/me"); - return normalizeUser(response.data); -}; + const response = await apiClient.get("/auth/me") + return normalizeUser(response.data) +} // 请求密码重置 -export const requestPasswordReset = async ( - email: string, -): Promise<{ message: string }> => { - const response = await apiClient.post("/auth/forgot-password", { email }); - return response.data; -}; +export const requestPasswordReset = async (email: string): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/forgot-password", { email }) + return response.data +} // 重置密码 export const resetPassword = async ( @@ -118,14 +112,12 @@ export const resetPassword = async ( const response = await apiClient.post("/auth/reset-password", { token, new_password: newPassword, - }); - return response.data; -}; + }) + return response.data +} // 验证邮箱 -export const verifyEmail = async ( - token: string, -): Promise<{ message: string }> => { - const response = await apiClient.post("/auth/verify-email", { token }); - return response.data; -}; +export const verifyEmail = async (token: string): Promise<{ message: string }> => { + const response = await apiClient.post("/auth/verify-email", { token }) + return response.data +} diff --git a/apps/web/src/api/bgm.ts b/apps/web/src/api/bgm.ts index ed4d33737..6debddbaa 100644 --- a/apps/web/src/api/bgm.ts +++ b/apps/web/src/api/bgm.ts @@ -2,48 +2,48 @@ * BGM 预设音乐 API * 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索) */ -import apiClient from "./client"; +import apiClient from "./client" /* ──────────── 类型 ──────────── */ /** BGM 风格分类 */ -export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"; +export type BgmCategory = "轻快" | "治愈" | "科技" | "电商" /** BGM 预设项 */ export interface BgmPreset { - id: string; - name: string; - category: BgmCategory; + id: string + name: string + category: BgmCategory /** 音频文件 URL */ - url: string; + url: string /** 时长(秒) */ - duration: number; + duration: number /** 关键词标签 */ - tags: string[]; + tags: string[] /** 封面图 URL */ - cover_url?: string; + cover_url?: string } /** BGM 预设列表查询参数 */ export interface BgmPresetsQuery { - category?: BgmCategory | string; - keyword?: string; + category?: BgmCategory | string + keyword?: string } /** BGM 混音配置(嵌入剪辑计划) */ export interface BgmMixConfig { /** 是否启用 BGM */ - enabled: boolean; + enabled: boolean /** 选中的 BGM ID */ - music_id: string; + music_id: string /** BGM 音量 0-100 */ - volume: number; + volume: number /** 淡入时长(秒) 0-3 */ - fade_in: number; + fade_in: number /** 淡出时长(秒) 0-3 */ - fade_out: number; + fade_out: number /** 人声闪避(sidechain) */ - voice_dodge: boolean; + voice_dodge: boolean } /** 默认 BGM 混音配置 */ @@ -54,17 +54,15 @@ export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = { fade_in: 0.5, fade_out: 0.5, voice_dodge: true, -}; +} /* ──────────── API ──────────── */ /** 获取 BGM 预设列表 */ -export const getBgmPresets = async ( - params?: BgmPresetsQuery, -): Promise => { - const searchParams: Record = {}; - if (params?.category) searchParams.category = params.category; - if (params?.keyword) searchParams.keyword = params.keyword; - const res = await apiClient.get("/bgm/presets", { params: searchParams }); - return res.data?.data ?? res.data ?? []; -}; +export const getBgmPresets = async (params?: BgmPresetsQuery): Promise => { + const searchParams: Record = {} + if (params?.category) searchParams.category = params.category + if (params?.keyword) searchParams.keyword = params.keyword + const res = await apiClient.get("/bgm/presets", { params: searchParams }) + return res.data?.data ?? res.data ?? [] +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 0fe4a7dea..389a76ec8 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -2,10 +2,10 @@ * API 客户端配置 * 封装 Axios 实例,配置拦截器和 Token 管理 */ -import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; -import { message } from "antd"; -import { useAuthStore } from "@/store/authStore"; -import { refreshAccessToken } from "./auth"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios" +import { message } from "antd" +import { useAuthStore } from "@/store/authStore" +import { refreshAccessToken } from "./auth" // 创建 Axios 实例 const apiClient = axios.create({ @@ -14,161 +14,150 @@ const apiClient = axios.create({ headers: { "Content-Type": "application/json", }, -}); +}) // ── Token 刷新队列 ───────────────────────────────────────── -let isRefreshing = false; +let isRefreshing = false let failedQueue: Array<{ - resolve: (value?: unknown) => void; - reject: (reason?: unknown) => void; -}> = []; + resolve: (value?: unknown) => void + reject: (reason?: unknown) => void +}> = [] const processQueue = (error: unknown | null, token: string | null = null) => { failedQueue.forEach((prom) => { if (error) { - prom.reject(error); + prom.reject(error) } else { - prom.resolve(token); + prom.resolve(token) } - }); - failedQueue = []; -}; + }) + failedQueue = [] +} // 请求拦截器:添加 Token apiClient.interceptors.request.use( (config: InternalAxiosRequestConfig) => { - const token = localStorage.getItem("access_token"); + const token = localStorage.getItem("access_token") if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; + config.headers.Authorization = `Bearer ${token}` } - return config; + return config }, (error: AxiosError) => { - return Promise.reject(error); + return Promise.reject(error) }, -); +) // 响应拦截器:统一错误提示 + 401 自动刷新 Token apiClient.interceptors.response.use( (response) => response, - async ( - error: AxiosError<{ detail?: string; message?: string; msg?: string }>, - ) => { + async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => { const originalRequest = error.config as InternalAxiosRequestConfig & { - _retry?: boolean; - }; + _retry?: boolean + } // 401 → 尝试刷新 Token - if ( - error.response?.status === 401 && - originalRequest && - !originalRequest._retry - ) { - const refreshToken = useAuthStore.getState().refreshToken; + if (error.response?.status === 401 && originalRequest && !originalRequest._retry) { + const refreshToken = useAuthStore.getState().refreshToken // 无 refresh_token → 直接登出 if (!refreshToken) { - useAuthStore.getState().clearAuth(); - window.location.href = "/"; - return Promise.reject(error); + useAuthStore.getState().clearAuth() + window.location.href = "/" + return Promise.reject(error) } // 已在刷新中 → 排队等待 if (isRefreshing) { return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); + failedQueue.push({ resolve, reject }) }).then((token) => { if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${token}`; + originalRequest.headers.Authorization = `Bearer ${token}` } - return apiClient(originalRequest); - }); + return apiClient(originalRequest) + }) } - originalRequest._retry = true; - isRefreshing = true; + originalRequest._retry = true + isRefreshing = true try { - const data = await refreshAccessToken(refreshToken); - const newAccessToken = data.access_token; - const newRefreshToken = data.refresh_token ?? refreshToken; + const data = await refreshAccessToken(refreshToken) + const newAccessToken = data.access_token + const newRefreshToken = data.refresh_token ?? refreshToken // 更新 Zustand + localStorage useAuthStore .getState() - .setAuth( - useAuthStore.getState().user!, - newAccessToken, - newRefreshToken, - ); + .setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken) // 处理排队的请求 - processQueue(null, newAccessToken); + processQueue(null, newAccessToken) // 重试原始请求 if (originalRequest.headers) { - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + originalRequest.headers.Authorization = `Bearer ${newAccessToken}` } - return apiClient(originalRequest); + return apiClient(originalRequest) } catch (refreshError) { // 刷新失败 → 登出 - processQueue(refreshError, null); - useAuthStore.getState().clearAuth(); - window.location.href = "/"; - return Promise.reject(refreshError); + processQueue(refreshError, null) + useAuthStore.getState().clearAuth() + window.location.href = "/" + return Promise.reject(refreshError) } finally { - isRefreshing = false; + isRefreshing = false } } // 提取后端返回的错误信息(detail / message / msg) // 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取 - const data = error.response?.data; - const rawServerMsg = data?.detail || data?.message || data?.msg; + const data = error.response?.data + const rawServerMsg = data?.detail || data?.message || data?.msg // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) const safeExtractString = (val: unknown): string => { - if (typeof val === "string") return val; + if (typeof val === "string") return val if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record; - if (typeof obj.message === "string") return obj.message; - if (typeof obj.msg === "string") return obj.msg; - if (typeof obj.detail === "string") return obj.detail; + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail // 嵌套对象:递归提取 if (typeof obj.message === "object" && obj.message !== null) - return safeExtractString(obj.message); - if (typeof obj.msg === "object" && obj.msg !== null) - return safeExtractString(obj.msg); - return JSON.stringify(val); + return safeExtractString(obj.message) + if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg) + return JSON.stringify(val) } - return ""; - }; - const serverMsg = safeExtractString(rawServerMsg); - let handled = false; + return "" + } + const serverMsg = safeExtractString(rawServerMsg) + let handled = false if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) { - message.error("请求超时,请检查网络后重试"); - handled = true; + message.error("请求超时,请检查网络后重试") + handled = true } else if (!error.response) { - message.error("网络连接异常,请检查网络设置"); - handled = true; + message.error("网络连接异常,请检查网络设置") + handled = true } else if (serverMsg) { - message.error(serverMsg); - handled = true; + message.error(serverMsg) + handled = true } else { - const status = error.response?.status; + const status = error.response?.status if (status === 413) { - message.error("文件过大,请缩小后重试"); - handled = true; + message.error("文件过大,请缩小后重试") + handled = true } else if (status === 415) { - message.error("不支持的文件格式"); - handled = true; + message.error("不支持的文件格式") + handled = true } else if (status === 503) { - message.error("服务暂不可用,请稍后再试"); - handled = true; + message.error("服务暂不可用,请稍后再试") + handled = true } else if (status && status >= 500) { - message.error("服务器繁忙,请稍后再试"); - handled = true; + message.error("服务器繁忙,请稍后再试") + handled = true } // 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理 } @@ -176,11 +165,11 @@ apiClient.interceptors.response.use( // 标记已展示过提示,组件 onError 可据此跳过重复 toast if (handled) { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (error as any).__msgShown = true; + ;(error as any).__msgShown = true } - return Promise.reject(error); + return Promise.reject(error) }, -); +) -export default apiClient; +export default apiClient diff --git a/apps/web/src/api/duplication.ts b/apps/web/src/api/duplication.ts index e88fea881..1135b8163 100644 --- a/apps/web/src/api/duplication.ts +++ b/apps/web/src/api/duplication.ts @@ -2,109 +2,98 @@ * 查重 API 模块 * 提供视频查重相关接口 */ -import apiClient from "./client"; +import apiClient from "./client" /** 查重记录状态 */ -export type DuplicationStatus = - "pending" | "processing" | "completed" | "failed"; +export type DuplicationStatus = "pending" | "processing" | "completed" | "failed" /** 查重记录 */ export interface DuplicationRecord { - id: string; + id: string /** 原始文件名 */ - filename: string; + filename: string /** 文件大小(字节) */ - file_size: number; + file_size: number /** 时长(秒) */ - duration_seconds?: number; + duration_seconds?: number /** 状态 */ - status: DuplicationStatus; + status: DuplicationStatus /** 查重率(0-100) */ - duplicate_rate?: number; + duplicate_rate?: number /** 重复片段数 */ - duplicate_count?: number; + duplicate_count?: number /** 创建时间 */ - created_at: string; + created_at: string /** 更新时间 */ - updated_at: string; + updated_at: string } /** 重复片段详情 */ export interface DuplicateSegment { - id: string; + id: string /** 在原始视频中的起始时间(秒) */ - source_start: number; + source_start: number /** 在原始视频中的结束时间(秒) */ - source_end: number; + source_end: number /** 匹配到的已有视频 ID */ - matched_video_id: string; + matched_video_id: string /** 匹配到的已有视频名称 */ - matched_video_name: string; + matched_video_name: string /** 匹配片段在已有视频中的起始时间 */ - matched_start: number; + matched_start: number /** 匹配片段在已有视频中的结束时间 */ - matched_end: number; + matched_end: number /** 相似度(0-100) */ - similarity: number; + similarity: number } /** 查重详情 */ export interface DuplicationDetail extends DuplicationRecord { /** 重复片段列表 */ - segments: DuplicateSegment[]; + segments: DuplicateSegment[] } /** 上传查重响应 */ export interface DuplicationUploadResponse { /** 查重记录 ID */ - id: string; + id: string /** 状态 */ - status: DuplicationStatus; + status: DuplicationStatus /** 消息 */ - message: string; + message: string } // ============ API 函数 ============ /** 上传视频进行查重 */ -export const uploadForDuplication = async ( - file: File, -): Promise => { - const formData = new FormData(); - formData.append("file", file); +export const uploadForDuplication = async (file: File): Promise => { + const formData = new FormData() + formData.append("file", file) const response = await apiClient.post("/duplication/upload", formData, { headers: { "Content-Type": "multipart/form-data" }, - }); - return response.data; -}; + }) + return response.data +} /** 获取查重记录列表 */ export const getDuplicationRecords = async (): Promise => { - const response = await apiClient.get("/duplication/records"); - return response.data; -}; + const response = await apiClient.get("/duplication/records") + return response.data +} /** 获取查重详情 */ -export const getDuplicationDetail = async ( - recordId: string, -): Promise => { - const response = await apiClient.get(`/duplication/records/${recordId}`); - return response.data; -}; +export const getDuplicationDetail = async (recordId: string): Promise => { + const response = await apiClient.get(`/duplication/records/${recordId}`) + return response.data +} /** 删除查重记录 */ -export const deleteDuplicationRecord = async ( - recordId: string, -): Promise => { - await apiClient.delete(`/duplication/records/${recordId}`); -}; +export const deleteDuplicationRecord = async (recordId: string): Promise => { + await apiClient.delete(`/duplication/records/${recordId}`) +} /** 重新查重 */ -export const retryDuplication = async ( - recordId: string, -): Promise => { - const response = await apiClient.post( - `/duplication/records/${recordId}/retry`, - ); - return response.data; -}; +export const retryDuplication = async (recordId: string): Promise => { + const response = await apiClient.post(`/duplication/records/${recordId}/retry`) + return response.data +} diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 90704264f..274fd3ac1 100755 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -2,8 +2,8 @@ * 剪辑计划 API — 对接后端 Edit Plans Schema * 字段名严格匹配后端 API 响应 */ -import apiClient from "./client"; -import type { AssetItem } from "./assets"; +import apiClient from "./client" +import type { AssetItem } from "./assets" import type { WatermarkConfig, IntroOutroConfig, @@ -12,7 +12,7 @@ import type { ChromaKeyConfig, StickerConfig, CoverConfig, -} from "@/pages/editing-planner/types"; +} from "@/pages/editing-planner/types" /* ============================================================ * 后端 API 类型(严格匹配后端 Schema) @@ -20,203 +20,203 @@ import type { /** 剪辑计划状态枚举 */ export type EditPlanStatus = - "draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"; + "draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled" /** 标题配置(对齐后端 title_config) */ export interface TitleConfig { - ai_auto_select: boolean; - content: string; - font_preset: string; - font_color: string; - font_size: number; - position: string; + ai_auto_select: boolean + content: string + font_preset: string + font_color: string + font_size: number + position: string } /** 字幕配置 */ export interface SubtitleConfig { - enabled: boolean; - position: string; - font: string; - color: string; - size: number; - animation: string; + enabled: boolean + position: string + font: string + color: string + size: number + animation: string } /** BGM 配置 */ export interface BgmConfig { - enabled: boolean; - music_id: string; + enabled: boolean + music_id: string } /** 片段 TTS 配置 */ export interface SegmentTtsConfig { - mode: string; - text: string; - voice_id: string; - speed: number; - pitch: number; - volume: number; - subtitle_sync: boolean; + mode: string + text: string + voice_id: string + speed: number + pitch: number + volume: number + subtitle_sync: boolean } /** 片段裁剪配置 */ export interface SegmentTrimConfig { - start_time: number; - end_time: number; + start_time: number + end_time: number } /** 片段转场配置 */ export interface SegmentTransitionConfig { - type: string; - duration: number; + type: string + duration: number } /** 剪辑计划中的单个片段(config 内部 segments 项) */ export interface EditPlanSegment { - segment_order: number; - duration_min: number; - duration_max: number; - material_type: string; - transition?: SegmentTransitionConfig; - playback_speed?: number; - tts_config?: SegmentTtsConfig; - trim_config?: SegmentTrimConfig; + segment_order: number + duration_min: number + duration_max: number + material_type: string + transition?: SegmentTransitionConfig + playback_speed?: number + tts_config?: SegmentTtsConfig + trim_config?: SegmentTrimConfig } /** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */ export interface EditPlanConfig { - title_config?: TitleConfig; - subtitle_config?: SubtitleConfig; - bgm_config?: BgmConfig; - estimated_duration?: number; - segments?: EditPlanSegment[]; - watermark_config?: WatermarkConfig; - intro_outro_config?: IntroOutroConfig; - pip_config?: PipConfig; - filter_config?: FilterConfig; - green_screen_config?: ChromaKeyConfig; - sticker_config?: StickerConfig; - cover_config?: CoverConfig; + title_config?: TitleConfig + subtitle_config?: SubtitleConfig + bgm_config?: BgmConfig + estimated_duration?: number + segments?: EditPlanSegment[] + watermark_config?: WatermarkConfig + intro_outro_config?: IntroOutroConfig + pip_config?: PipConfig + filter_config?: FilterConfig + green_screen_config?: ChromaKeyConfig + sticker_config?: StickerConfig + cover_config?: CoverConfig /** 前端扩展:关联的素材 ID 列表 */ - asset_ids?: string[]; + asset_ids?: string[] /** 配音 ID */ - voice_id?: string; + voice_id?: string /** 克隆音色档案 ID */ - voice_clone_profile_id?: string; + voice_clone_profile_id?: string /** 自定义配音音频 URL */ - custom_audio_url?: string; + custom_audio_url?: string /** 自定义配音文本 */ - custom_text?: string; + custom_text?: string /** 视频比例 */ - ratio?: string; + ratio?: string /** 视频风格 */ - style?: string; + style?: string /** 目标时长(秒) */ - duration?: number; + duration?: number /** 是否自动生成字幕 */ - auto_subtitles?: boolean; + auto_subtitles?: boolean /** 是否启用 BGM */ - bgm?: boolean; + bgm?: boolean /** 生成数量 */ - generate_count?: number; + generate_count?: number /** 素材模式 */ - material_mode?: string; + material_mode?: string } /** 剪辑计划(后端响应) */ export interface EditPlan { - id: string; - template_id: string; - name: string; - status: EditPlanStatus; - total_duration: number; + id: string + template_id: string + name: string + status: EditPlanStatus + total_duration: number /** 生成视频数量(后端 EditPlanResponse.result_count) */ - result_count: number; - config: EditPlanConfig; - created_at: string; - updated_at: string; + result_count: number + config: EditPlanConfig + created_at: string + updated_at: string } /** 创建剪辑计划请求(后端要求 template_id + name 必填) */ export interface CreateEditPlanRequest { - template_id: string; - name: string; - config?: EditPlanConfig; - total_duration?: number; + template_id: string + name: string + config?: EditPlanConfig + total_duration?: number /** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */ - source_edit_plan_id?: string; + source_edit_plan_id?: string } /** 更新剪辑计划请求 */ export interface UpdateEditPlanRequest { - name?: string; - config?: EditPlanConfig; - total_duration?: number; - status?: EditPlanStatus; + name?: string + config?: EditPlanConfig + total_duration?: number + status?: EditPlanStatus } /** 生成响应 */ export interface GenerateResponse { - plan_id: string; - plan_status: EditPlanStatus; - generation_task_id: string; - clip_count: number; + plan_id: string + plan_status: EditPlanStatus + generation_task_id: string + clip_count: number } /** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */ export interface EditPlanGeneration { - id: string; // 即 generation_task_id - source_edit_plan_id: string; - template_id: string; - asset_ids: string[]; - status: EditPlanStatus; - progress: number; - result_count: number; - error_message: string; - error_info: Record; - logs: Array>; - retry_count: number; - created_at?: string; - updated_at?: string; + id: string // 即 generation_task_id + source_edit_plan_id: string + template_id: string + asset_ids: string[] + status: EditPlanStatus + progress: number + result_count: number + error_message: string + error_info: Record + logs: Array> + retry_count: number + created_at?: string + updated_at?: string } /** 片段生成状态 */ export interface ClipStatusItem { - clip_id: string; - clip_type: string; - order: number; - status: string; - asset_id?: string; - text_content?: string; - duration?: number; + clip_id: string + clip_type: string + order: number + status: string + asset_id?: string + text_content?: string + duration?: number } /** 生成状态轮询响应 */ export interface GenerationStatusResponse { - plan_id: string; - plan_status: EditPlanStatus; - generation_task_id?: string; - clips: ClipStatusItem[]; + plan_id: string + plan_status: EditPlanStatus + generation_task_id?: string + clips: ClipStatusItem[] } /** 生成视频详情(对应后端 GeneratedVideoResponse) */ export interface GeneratedVideo { - id: string; - project_id?: string; - generation_task_id?: string; - name: string; - file_url: string; - file_size?: number; - duration?: number; - thumbnail_url?: string; - width?: number; - height?: number; - fps?: number; - status: string; - review_status?: string; - download_url?: string; - created_at?: string; - updated_at?: string; + id: string + project_id?: string + generation_task_id?: string + name: string + file_url: string + file_size?: number + duration?: number + thumbnail_url?: string + width?: number + height?: number + fps?: number + status: string + review_status?: string + download_url?: string + created_at?: string + updated_at?: string } /* ============================================================ @@ -225,51 +225,51 @@ export interface GeneratedVideo { /** AI 推荐请求 */ export interface AIRecommendRequest { - asset_ids: string[]; - editing_mode?: string; - target_duration?: number; + asset_ids: string[] + editing_mode?: string + target_duration?: number } /** AI 推荐单个片段 */ export interface AIRecommendClipItem { - clip_type: string; - order: number; - text_content: string; - duration: number; - transition_effect: string; - asset_id: string; - start_time: number; - config: EditPlanConfig; + clip_type: string + order: number + text_content: string + duration: number + transition_effect: string + asset_id: string + start_time: number + config: EditPlanConfig } /** AI 推荐响应 */ export interface AIRecommendResponse { - plan_id: string; - clips: AIRecommendClipItem[]; - config: EditPlanConfig; - total_duration: number; - confidence: number; + plan_id: string + clips: AIRecommendClipItem[] + config: EditPlanConfig + total_duration: number + confidence: number } /** AI 封面生成请求 */ export interface GenerateCoverRequest { - asset_ids: string[]; - cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"; - frame_time?: number; + asset_ids: string[] + cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate" + frame_time?: number } /** AI 封面生成响应 */ export interface GenerateCoverResponse { - plan_id: string; - cover: CoverResult; + plan_id: string + cover: CoverResult } /** 封面生成结果 */ export interface CoverResult { - scheme?: string; - asset_id?: string; - frame_time?: number; - thumbnail_url?: string; + scheme?: string + asset_id?: string + frame_time?: number + thumbnail_url?: string } /* ============================================================ @@ -293,30 +293,30 @@ export interface TransitionEffect { | "wipe_up" | "wipe_down" | "circlecrop" - | "rectcrop"; - duration: number; // 转场时长(秒) + | "rectcrop" + duration: number // 转场时长(秒) /** 播放速度倍率 */ - playback_speed?: number; + playback_speed?: number } /** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ export interface MediaAsset { - id: string; - name: string; - type: "video" | "image" | "audio"; + id: string + name: string + type: "video" | "image" | "audio" /** 缩略图 URL */ - thumbnail_url?: string; + thumbnail_url?: string /** 时长(秒),仅 video/audio */ - duration?: number; + duration?: number /** 文件大小(字节) */ - size?: number; + size?: number /** 标签 */ - tags: string[]; - created_at: string; + tags: string[] + created_at: string /** 质量分 0-100 */ - quality_score?: number; + quality_score?: number /** 分类状态 */ - classification_status?: "pending" | "processing" | "completed" | "failed"; + classification_status?: "pending" | "processing" | "completed" | "failed" } /* ============================================================ @@ -325,42 +325,38 @@ export interface MediaAsset { /** 剪辑计划列表查询参数 */ export interface EditPlanListParams { - page?: number; - page_size?: number; - template_id?: string; - status?: string; + page?: number + page_size?: number + template_id?: string + status?: string } /** 剪辑计划列表分页响应 */ export interface EditPlanListResponse { - items: EditPlan[]; - total: number; - page: number; - page_size: number; + items: EditPlan[] + total: number + page: number + page_size: number } /** 获取剪辑计划列表(支持分页和筛选) */ -export async function getEditPlans( - params?: EditPlanListParams, -): Promise { +export async function getEditPlans(params?: EditPlanListParams): Promise { const response = await apiClient.get("/edit-plans", { params, - }); - return response.data; + }) + return response.data } /** 获取单个剪辑计划 */ export async function getEditPlan(planId: string): Promise { - const response = await apiClient.get(`/edit-plans/${planId}`); - return response.data; + const response = await apiClient.get(`/edit-plans/${planId}`) + return response.data } /** 创建剪辑计划 */ -export async function createEditPlan( - data: CreateEditPlanRequest, -): Promise { - const response = await apiClient.post("/edit-plans", data); - return response.data; +export async function createEditPlan(data: CreateEditPlanRequest): Promise { + const response = await apiClient.post("/edit-plans", data) + return response.data } /** 更新剪辑计划 */ @@ -368,31 +364,25 @@ export async function updateEditPlan( planId: string, data: UpdateEditPlanRequest, ): Promise { - const response = await apiClient.put(`/edit-plans/${planId}`, data); - return response.data; + const response = await apiClient.put(`/edit-plans/${planId}`, data) + return response.data } /** 删除剪辑计划 */ export async function deleteEditPlan(planId: string): Promise { - await apiClient.delete(`/edit-plans/${planId}`); + await apiClient.delete(`/edit-plans/${planId}`) } /** 触发剪辑计划生成 */ -export async function generateEditPlan( - planId: string, -): Promise { - const response = await apiClient.post(`/edit-plans/${planId}/generate`); - return response.data; +export async function generateEditPlan(planId: string): Promise { + const response = await apiClient.post(`/edit-plans/${planId}/generate`) + return response.data } /** 获取剪辑计划生成状态(轮询用) */ -export async function getGenerationStatus( - planId: string, -): Promise { - const response = await apiClient.get( - `/edit-plans/${planId}/generation-status`, - ); - return response.data; +export async function getGenerationStatus(planId: string): Promise { + const response = await apiClient.get(`/edit-plans/${planId}/generation-status`) + return response.data } /** AI 推荐片段方案 */ @@ -400,11 +390,8 @@ export async function aiRecommendClips( planId: string, data: AIRecommendRequest, ): Promise { - const response = await apiClient.post( - `/edit-plans/${planId}/ai-recommend`, - data, - ); - return response.data; + const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data) + return response.data } /** AI 生成封面 */ @@ -412,32 +399,25 @@ export async function generateCover( planId: string, data: GenerateCoverRequest, ): Promise { - const response = await apiClient.post( - `/edit-plans/${planId}/generate-cover`, - data, - ); - return response.data; + const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data) + return response.data } /** 获取剪辑计划关联的生成记录 */ -export async function getEditPlanGenerations( - planId: string, -): Promise { - const response = await apiClient.get(`/edit-plans/${planId}/generations`); - return response.data.items || []; +export async function getEditPlanGenerations(planId: string): Promise { + const response = await apiClient.get(`/edit-plans/${planId}/generations`) + return response.data.items || [] } /** 获取生成任务的视频结果列表 */ -export async function getGenerationTaskResults( - taskId: string, -): Promise { - const response = await apiClient.get(`/generation/tasks/${taskId}/results`); - return response.data.items || response.data || []; +export async function getGenerationTaskResults(taskId: string): Promise { + const response = await apiClient.get(`/generation/tasks/${taskId}/results`) + return response.data.items || response.data || [] } /** 取消生成任务 */ export async function cancelGeneration(planId: string): Promise { - await apiClient.post(`/edit-plans/${planId}/cancel`); + await apiClient.post(`/edit-plans/${planId}/cancel`) } /* ============================================================ @@ -445,66 +425,66 @@ export async function cancelGeneration(planId: string): Promise { * ============================================================ */ /** 片段状态 */ -export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"; +export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed" /** 剪辑片段(后端响应) */ export interface EditPlanClip { - id: string; - plan_id: string; - clip_type: string; // main / intro / outro / overlay / background / b_roll 等 - order: number; - asset_id: string; - text_content: string; - start_time: number; - duration: number; - transition_effect: string; - transition_duration: number; - playback_speed: number; - status: EditPlanClipStatus; - config: Record; - created_at?: string; - updated_at?: string; + id: string + plan_id: string + clip_type: string // main / intro / outro / overlay / background / b_roll 等 + order: number + asset_id: string + text_content: string + start_time: number + duration: number + transition_effect: string + transition_duration: number + playback_speed: number + status: EditPlanClipStatus + config: Record + created_at?: string + updated_at?: string } /** 创建片段请求 */ export interface CreateEditPlanClipRequest { - clip_type: string; - order: number; - asset_id?: string; - text_content?: string; - start_time?: number; - duration?: number; - transition_effect?: string; - transition_duration?: number; - playback_speed?: number; - config?: Record; + clip_type: string + order: number + asset_id?: string + text_content?: string + start_time?: number + duration?: number + transition_effect?: string + transition_duration?: number + playback_speed?: number + config?: Record } /** 更新片段请求 */ export interface UpdateEditPlanClipRequest { - clip_type?: string; - order?: number; - asset_id?: string; - text_content?: string; - start_time?: number; - duration?: number; - transition_effect?: string; - transition_duration?: number; - playback_speed?: number; - config?: Record; + clip_type?: string + order?: number + asset_id?: string + text_content?: string + start_time?: number + duration?: number + transition_effect?: string + transition_duration?: number + playback_speed?: number + config?: Record } /** 片段列表响应 */ export interface EditPlanClipListResponse { - items: EditPlanClip[]; - total: number; + items: EditPlanClip[] + total: number } /** 片段列表查询参数 */ export interface EditPlanClipListParams { - status?: string; - skip?: number; - limit?: number; + status?: string + skip?: number + limit?: number } /** 获取片段列表 */ @@ -512,22 +492,16 @@ export async function getEditPlanClips( planId: string, params?: EditPlanClipListParams, ): Promise { - const response = await apiClient.get( - `/edit-plans/${planId}/clips`, - { params }, - ); - return response.data; + const response = await apiClient.get(`/edit-plans/${planId}/clips`, { + params, + }) + return response.data } /** 获取单个片段详情 */ -export async function getEditPlanClip( - planId: string, - clipId: string, -): Promise { - const response = await apiClient.get( - `/edit-plans/${planId}/clips/${clipId}`, - ); - return response.data; +export async function getEditPlanClip(planId: string, clipId: string): Promise { + const response = await apiClient.get(`/edit-plans/${planId}/clips/${clipId}`) + return response.data } /** 创建片段 */ @@ -535,11 +509,8 @@ export async function createEditPlanClip( planId: string, data: CreateEditPlanClipRequest, ): Promise { - const response = await apiClient.post( - `/edit-plans/${planId}/clips`, - data, - ); - return response.data; + const response = await apiClient.post(`/edit-plans/${planId}/clips`, data) + return response.data } /** 更新片段 */ @@ -548,19 +519,13 @@ export async function updateEditPlanClip( clipId: string, data: UpdateEditPlanClipRequest, ): Promise { - const response = await apiClient.put( - `/edit-plans/${planId}/clips/${clipId}`, - data, - ); - return response.data; + const response = await apiClient.put(`/edit-plans/${planId}/clips/${clipId}`, data) + return response.data } /** 删除片段 */ -export async function deleteEditPlanClip( - planId: string, - clipId: string, -): Promise { - await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`); +export async function deleteEditPlanClip(planId: string, clipId: string): Promise { + await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`) } /* ============================================================ @@ -569,30 +534,30 @@ export async function deleteEditPlanClip( /** 重排序条目 */ export interface ClipReorderItem { - clip_id: string; - new_order: number; + clip_id: string + new_order: number } /** 重排序响应 */ export interface ClipReorderResponse { - success: boolean; - updated_count: number; - message: string; + success: boolean + updated_count: number + message: string } /** 批量删除响应 */ export interface ClipBatchDeleteResponse { - success: boolean; - deleted_count: number; - message: string; + success: boolean + deleted_count: number + message: string } /** 从素材批量创建响应 */ export interface ClipsFromAssetsResponse { - success: boolean; - created_count: number; - message: string; - clip_ids: string[]; + success: boolean + created_count: number + message: string + clip_ids: string[] } /** 片段重排序(拖拽排序后一次性提交) */ @@ -603,8 +568,8 @@ export async function reorderEditPlanClips( const response = await apiClient.post( `/edit-plans/${planId}/clips/reorder`, { items }, - ); - return response.data; + ) + return response.data } /** 批量删除片段 */ @@ -615,8 +580,8 @@ export async function batchDeleteEditPlanClips( const response = await apiClient.post( `/edit-plans/${planId}/clips/batch-delete`, { clip_ids: clipIds }, - ); - return response.data; + ) + return response.data } /** 从素材批量创建片段(追加到时间线末尾) */ @@ -628,8 +593,8 @@ export async function createClipsFromAssets( const response = await apiClient.post( `/edit-plans/${planId}/clips/from-assets`, { asset_ids: assetIds, clip_type: clipType }, - ); - return response.data; + ) + return response.data } /* ============================================================ @@ -638,40 +603,32 @@ export async function createClipsFromAssets( /** 复制计划请求 */ export interface CopyEditPlanRequest { - name?: string; - project_id?: string; + name?: string + project_id?: string } /** 复制剪辑计划(含所有片段配置) */ -export async function copyEditPlan( - planId: string, - data?: CopyEditPlanRequest, -): Promise { - const response = await apiClient.post( - `/edit-plans/${planId}/copy`, - data || {}, - ); - return response.data; +export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise { + const response = await apiClient.post(`/edit-plans/${planId}/copy`, data || {}) + return response.data } /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 */ -export async function getMediaAssets( - libraryId?: string, -): Promise { +export async function getMediaAssets(libraryId?: string): Promise { const response = await apiClient.get("/assets", { params: libraryId ? { library_id: libraryId } : undefined, - }); - const items: AssetItem[] = response.data.items || []; - return items.map(mapAssetToMediaAsset); + }) + const items: AssetItem[] = response.data.items || [] + return items.map(mapAssetToMediaAsset) } /** 获取单个素材 */ export async function getMediaAsset(id: string): Promise { - const response = await apiClient.get(`/assets/${id}`); - return mapAssetToMediaAsset(response.data); + const response = await apiClient.get(`/assets/${id}`) + return mapAssetToMediaAsset(response.data) } /* ============================================================ @@ -679,17 +636,15 @@ export async function getMediaAsset(id: string): Promise { * ============================================================ */ function inferMediaType(mimeType: string): "video" | "image" | "audio" { - if (mimeType.startsWith("video/")) return "video"; - if (mimeType.startsWith("image/")) return "image"; - return "audio"; + if (mimeType.startsWith("video/")) return "video" + if (mimeType.startsWith("image/")) return "image" + return "audio" } function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { // 优先取顶层 duration,其次从 metadata 回退 const metaDuration = - typeof asset.metadata?.duration === "number" - ? asset.metadata.duration - : undefined; + typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined return { id: asset.id, name: asset.name, @@ -701,7 +656,7 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { created_at: asset.created_at ?? "", quality_score: asset.quality_score ?? undefined, classification_status: asset.classification_status ?? undefined, - }; + } } /* ============================================================ @@ -710,9 +665,9 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { /** 转场效果选项(14 种预设) */ export const TRANSITION_OPTIONS: { - value: TransitionEffect["type"]; - label: string; - icon: string; + value: TransitionEffect["type"] + label: string + icon: string }[] = [ { value: "none", label: "无转场", icon: "⊘" }, { value: "cut", label: "硬切", icon: "✂" }, @@ -729,7 +684,7 @@ export const TRANSITION_OPTIONS: { { value: "wipe_down", label: "下擦除", icon: "▾̄" }, { value: "circlecrop", label: "圆形裁切", icon: "●" }, { value: "rectcrop", label: "矩形裁切", icon: "■" }, -]; +] /** 素材类型标签 */ export const MATERIAL_TYPE_LABELS: Record = { @@ -737,7 +692,7 @@ export const MATERIAL_TYPE_LABELS: Record = { image: "图片", audio: "音频", voiceover: "配音", -}; +} /** 素材类型图标 */ export const MATERIAL_TYPE_ICONS: Record = { @@ -745,7 +700,7 @@ export const MATERIAL_TYPE_ICONS: Record = { image: "🖼️", audio: "🎵", voiceover: "🎙️", -}; +} /** 计划状态标签 */ export const PLAN_STATUS_LABELS: Record = { @@ -755,17 +710,17 @@ export const PLAN_STATUS_LABELS: Record = { completed: "已完成", failed: "失败", cancelled: "已取消", -}; +} /** 质量分筛选选项 */ export const QUALITY_OPTIONS: { - value: string; - label: string; - min?: number; - max?: number; + value: string + label: string + min?: number + max?: number }[] = [ { value: "all", label: "全部质量" }, { value: "high", label: "高质量 (80-100)", min: 80, max: 100 }, { value: "medium", label: "中质量 (50-79)", min: 50, max: 79 }, { value: "low", label: "低质量 (0-49)", min: 0, max: 49 }, -]; +] diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts index 9b41fff37..c80d97e62 100644 --- a/apps/web/src/api/editingPlanner.ts +++ b/apps/web/src/api/editingPlanner.ts @@ -2,7 +2,7 @@ * 剪辑计划编辑器 API * 对接后端 /api/v1/templates 路由 */ -import apiClient from "./client"; +import apiClient from "./client" import type { WatermarkConfig, IntroOutroConfig, @@ -11,12 +11,12 @@ import type { ChromaKeyConfig, StickerConfig, CoverConfig, -} from "@/pages/editing-planner/types"; +} from "@/pages/editing-planner/types" /* ──────────── 类型定义 ──────────── */ /** 模板模式(后端枚举值) */ -export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"; +export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip" /** 模式显示名称映射 */ export const MODE_LABELS: Record = { @@ -24,7 +24,7 @@ export const MODE_LABELS: Record = { voice_over: "人物口播", one_take: "一镜到底", voice_pip: "口播+混剪", -}; +} /** 模式颜色映射 */ export const MODE_COLORS: Record = { @@ -32,211 +32,203 @@ export const MODE_COLORS: Record = { voice_over: "green", one_take: "orange", voice_pip: "purple", -}; +} /** 标题配置 */ export interface TitleConfig { - ai_auto_select: boolean; - content: string; - font_preset: string; - font_color: string; - font_size: number; - position: string; + ai_auto_select: boolean + content: string + font_preset: string + font_color: string + font_size: number + position: string } /** 字幕配置 */ export interface SubtitleConfig { - enabled: boolean; - position: string; - font: string; - color: string; - size: number; - animation: string; + enabled: boolean + position: string + font: string + color: string + size: number + animation: string } /** BGM 配置 */ export interface BgmConfig { - enabled: boolean; - music_id: string; + enabled: boolean + music_id: string } /** 模板片段 */ export interface TemplateSegment { - id?: string; - segment_order: number; - duration_min: number; - duration_max: number; - material_type: string | null; + id?: string + segment_order: number + duration_min: number + duration_max: number + material_type: string | null } /** 剪辑模板 */ export interface EditingTemplate { - id: string; - name: string; - mode: TemplateMode; - category: string; - tags: string[]; - title_config: TitleConfig; - subtitle_config: SubtitleConfig; - bgm_config: BgmConfig; - estimated_duration: number; - segments: TemplateSegment[]; + id: string + name: string + mode: TemplateMode + category: string + tags: string[] + title_config: TitleConfig + subtitle_config: SubtitleConfig + bgm_config: BgmConfig + estimated_duration: number + segments: TemplateSegment[] /** 水印配置(后端就绪后启用) */ - watermark_config?: WatermarkConfig; + watermark_config?: WatermarkConfig /** 片头片尾配置(后端就绪后启用) */ - intro_outro_config?: IntroOutroConfig; + intro_outro_config?: IntroOutroConfig /** 画中画配置 */ - pip_config?: PipConfig; + pip_config?: PipConfig /** 滤镜调色配置 */ - filter_config?: FilterConfig; + filter_config?: FilterConfig /** 绿幕抠像配置 */ - green_screen_config?: ChromaKeyConfig; + green_screen_config?: ChromaKeyConfig /** 贴纸配置 */ - sticker_config?: StickerConfig; + sticker_config?: StickerConfig /** 封面配置 */ - cover_config?: CoverConfig; - is_active?: boolean; - created_at: string; - updated_at: string; + cover_config?: CoverConfig + is_active?: boolean + created_at: string + updated_at: string } /** 模板分类 */ export interface TemplateCategory { - id: string; - name: string; - created_at?: string; + id: string + name: string + created_at?: string } /** 创建/更新模板请求体 */ export interface SaveTemplatePayload { - name: string; - mode: TemplateMode; - category: string; - tags: string[]; - title_config: TitleConfig; - subtitle_config: SubtitleConfig; - bgm_config: BgmConfig; - estimated_duration: number; - segments: Omit[]; + name: string + mode: TemplateMode + category: string + tags: string[] + title_config: TitleConfig + subtitle_config: SubtitleConfig + bgm_config: BgmConfig + estimated_duration: number + segments: Omit[] /** 水印配置(后端就绪后启用) */ - watermark_config?: WatermarkConfig; + watermark_config?: WatermarkConfig /** 片头片尾配置(后端就绪后启用) */ - intro_outro_config?: IntroOutroConfig; + intro_outro_config?: IntroOutroConfig /** 画中画配置 */ - pip_config?: PipConfig; + pip_config?: PipConfig /** 滤镜调色配置 */ - filter_config?: FilterConfig; + filter_config?: FilterConfig /** 绿幕抠像配置 */ - green_screen_config?: ChromaKeyConfig; + green_screen_config?: ChromaKeyConfig /** 贴纸配置 */ - sticker_config?: StickerConfig; + sticker_config?: StickerConfig /** 封面配置 */ - cover_config?: CoverConfig; + cover_config?: CoverConfig } /** 使用模板生成请求体 */ export interface GenerateFromTemplatePayload { - voiceover_duration: number; + voiceover_duration: number } /** 验证警告详情 */ export interface ValidationWarningDetails { /** 相关字段名 */ - field?: string; + field?: string /** 期望值 */ - expected?: string | number; + expected?: string | number /** 实际值 */ - actual?: string | number; + actual?: string | number /** 建议值 */ - suggested?: string | number; + suggested?: string | number } /** 验证/生成响应 */ export interface ValidateWarning { - code: string; - message: string; - details?: ValidationWarningDetails; + code: string + message: string + details?: ValidationWarningDetails } /** 使用模板生成响应 */ export interface GenerateFromTemplateResponse { - template: EditingTemplate; - warnings: ValidateWarning[]; + template: EditingTemplate + warnings: ValidateWarning[] } /** 列表响应(带分页) */ export interface ListTemplatesResponse { - items: EditingTemplate[]; - total: number; + items: EditingTemplate[] + total: number } /** 分类列表响应 */ export interface ListCategoriesResponse { - items: TemplateCategory[]; + items: TemplateCategory[] } // ============ API 函数 ============ /** 获取模板列表 */ export const getEditingTemplates = async (params?: { - category?: string; - tag?: string; - skip?: number; - limit?: number; + category?: string + tag?: string + skip?: number + limit?: number }): Promise => { const response = await apiClient.get("/templates", { params: { skip: params?.skip ?? 0, limit: params?.limit ?? 50, }, - }); - let list = response.data.items; - if (params?.category) - list = list.filter((t) => t.category === params.category); - if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)); - return list; -}; + }) + let list = response.data.items + if (params?.category) list = list.filter((t) => t.category === params.category) + if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!)) + return list +} /** 获取模板详情 */ -export const getEditingTemplate = async ( - id: string, -): Promise => { - const response = await apiClient.get(`/templates/${id}`); - return response.data; -}; +export const getEditingTemplate = async (id: string): Promise => { + const response = await apiClient.get(`/templates/${id}`) + return response.data +} /** 创建模板 */ export const createEditingTemplate = async ( data: SaveTemplatePayload, ): Promise => { - const response = await apiClient.post("/templates", data); - return response.data; -}; + const response = await apiClient.post("/templates", data) + return response.data +} /** 更新模板 */ export const updateEditingTemplate = async ( id: string, data: SaveTemplatePayload, ): Promise => { - const response = await apiClient.patch( - `/templates/${id}`, - data, - ); - return response.data; -}; + const response = await apiClient.patch(`/templates/${id}`, data) + return response.data +} /** 删除模板 */ export const deleteEditingTemplate = async (id: string): Promise => { - await apiClient.delete(`/templates/${id}`); -}; + await apiClient.delete(`/templates/${id}`) +} /** 获取模板分类列表 */ export const getTemplateCategories = async (): Promise => { - const response = await apiClient.get( - "/templates/categories/list", - ); - return response.data.items; -}; + const response = await apiClient.get("/templates/categories/list") + return response.data.items +} /** 使用模板生成视频(调用 validate 端点) */ export const generateFromTemplate = async ( @@ -246,6 +238,6 @@ export const generateFromTemplate = async ( const response = await apiClient.post( `/templates/${templateId}/validate`, data, - ); - return response.data; -}; + ) + return response.data +} diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts old mode 100755 new mode 100644 index 4188092f2..8d7aaa5ba --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -3,59 +3,59 @@ * 包含:列表查询、复核状态、批量下载 * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 */ -import apiClient from "./client"; -import { getGenerationTaskResults } from "./editPlans"; -import type { GeneratedVideo } from "./editPlans"; +import apiClient from "./client" +import { getGenerationTaskResults } from "./editPlans" +import type { GeneratedVideo } from "./editPlans" /** 复核状态 */ -export type ReviewStatus = "pending_review" | "approved" | "rejected"; +export type ReviewStatus = "pending_review" | "approved" | "rejected" /** 成品条目 */ export interface ProductItem { - id: string; - title: string; - video_url?: string; - thumbnail_url?: string; - duration_seconds?: number; - file_size?: number; - resolution?: string; - status: "processing" | "completed" | "failed"; + id: string + title: string + video_url?: string + thumbnail_url?: string + duration_seconds?: number + file_size?: number + resolution?: string + status: "processing" | "completed" | "failed" /** 复核状态 */ - review_status?: ReviewStatus; + review_status?: ReviewStatus /** 所属项目 ID */ - project_id?: string; + project_id?: string /** 所属项目名称 */ - project_name?: string; + project_name?: string /** 查重率(百分比) */ - duplicate_rate?: number; - created_at?: string; - updated_at?: string; + duplicate_rate?: number + created_at?: string + updated_at?: string } /** 列表查询参数 */ export interface ProductListParams { - page?: number; - page_size?: number; - project_id?: string; - review_status?: ReviewStatus | "all"; + page?: number + page_size?: number + project_id?: string + review_status?: ReviewStatus | "all" } /** 分页响应 */ export interface ProductListResponse { - items: ProductItem[]; - total: number; - page: number; - page_size: number; + items: ProductItem[] + total: number + page: number + page_size: number } /** 批量下载任务状态 */ export interface BatchDownloadStatus { - job_id: string; - status: "processing" | "completed" | "failed"; + job_id: string + status: "processing" | "completed" | "failed" /** 完成后返回的下载 URL */ - download_url?: string; + download_url?: string /** 进度百分比 */ - progress?: number; + progress?: number } /** @@ -69,8 +69,7 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem { thumbnail_url: task.thumbnail_url, duration_seconds: task.duration, file_size: task.file_size, - resolution: - task.width && task.height ? `${task.width}x${task.height}` : undefined, + resolution: task.width && task.height ? `${task.width}x${task.height}` : undefined, status: task.status === "completed" ? "completed" @@ -81,43 +80,36 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem { project_id: task.project_id, created_at: task.created_at, updated_at: task.updated_at, - }; + } } /** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */ -export const getProducts = async ( - params?: ProductListParams, -): Promise => { - const response = await apiClient.get("/generation/tasks", { params }); - const data = response.data; - const tasks = Array.isArray(data?.items) - ? data.items - : Array.isArray(data) - ? data - : []; - return tasks.map(mapTaskToProductItem); -}; +export const getProducts = async (params?: ProductListParams): Promise => { + const response = await apiClient.get("/generation/tasks", { params }) + const tasks = response.data.items || response.data || [] + return tasks.map(mapTaskToProductItem) +} /** 获取单个成品详情 — 通过 task ID 获取结果 */ export const getProduct = async (productId: string): Promise => { - const response = await apiClient.get(`/generation/tasks/${productId}`); - return mapTaskToProductItem(response.data); -}; + const response = await apiClient.get(`/generation/tasks/${productId}`) + return mapTaskToProductItem(response.data) +} /** 删除成品 — 删除 generation task */ export const deleteProduct = async (productId: string): Promise => { - await apiClient.delete(`/generation/tasks/${productId}`); -}; + await apiClient.delete(`/generation/tasks/${productId}`) +} /** 获取成品下载链接 — 从 generation task results 获取 */ export const getProductDownloadUrl = async ( productId: string, ): Promise<{ url: string; expires_at: string }> => { - const videos = await getGenerationTaskResults(productId); - const video = videos[0]; - if (!video?.download_url) throw new Error("下载链接不可用"); - return { url: video.download_url, expires_at: "" }; -}; + const videos = await getGenerationTaskResults(productId) + const video = videos[0] + if (!video?.download_url) throw new Error("下载链接不可用") + return { url: video.download_url, expires_at: "" } +} /** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */ export const updateReviewStatus = async ( @@ -126,26 +118,22 @@ export const updateReviewStatus = async ( ): Promise => { // 后端暂无 /generation/tasks/{id}/review 端点 // 暂时返回当前状态,后续可扩展 - const product = await getProduct(productId); - return { ...product, review_status: status }; -}; + const product = await getProduct(productId) + return { ...product, review_status: status } +} /** 发起批量下载 — TODO: 后端暂无对应端点 */ -export const batchDownload = async ( - videoIds: string[], -): Promise<{ job_id: string }> => { +export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => { // 后端暂无 /generation/tasks/batch-download 端点 // 暂时返回模拟 job_id,后续可扩展 - console.warn("[batchDownload] 后端暂无批量下载端点", videoIds); - return { job_id: `mock-${Date.now()}` }; -}; + console.warn("[batchDownload] 后端暂无批量下载端点", videoIds) + return { job_id: `mock-${Date.now()}` } +} /** 查询批量下载状态 — TODO: 后端暂无对应端点 */ -export const getBatchDownloadStatus = async ( - jobId: string, -): Promise => { +export const getBatchDownloadStatus = async (jobId: string): Promise => { // 后端暂无 /generation/tasks/batch-download/{jobId} 端点 // 暂时返回模拟状态,后续可扩展 - console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId); - return { job_id: jobId, status: "processing", progress: 0 }; -}; + console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId) + return { job_id: jobId, status: "processing", progress: 0 } +} diff --git a/apps/web/src/api/projects.ts b/apps/web/src/api/projects.ts index 5d46a1ddc..45fca2901 100644 --- a/apps/web/src/api/projects.ts +++ b/apps/web/src/api/projects.ts @@ -2,60 +2,59 @@ * 项目相关 API * 素材库需要 project_id,前端自动管理默认项目 */ -import apiClient from "./client"; +import apiClient from "./client" export interface ProjectItem { - id: string; - name: string; - description: string; + id: string + name: string + description: string } /** 后端 ProjectResponse 只返回 id, name, description */ interface BackendProjectResponse { - id: string; - name: string; - description: string; + id: string + name: string + description: string } /** 后端 ListProjectsResponse 返回 { items: [...] } */ interface BackendListProjectsResponse { - items: BackendProjectResponse[]; + items: BackendProjectResponse[] } const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({ id: item.id, name: item.name, description: item.description, -}); +}) /** 获取当前用户的项目列表 */ export const getProjects = async (): Promise => { - const response = - await apiClient.get("/projects"); - return (response.data.items || []).map(toProjectItem); -}; + const response = await apiClient.get("/projects") + return (response.data.items || []).map(toProjectItem) +} /** 创建项目 */ export const createProject = async (data: { - name: string; - description?: string; + name: string + description?: string }): Promise => { const response = await apiClient.post("/projects", { name: data.name, description: data.description || "", - }); - return toProjectItem(response.data); -}; + }) + return toProjectItem(response.data) +} /** 获取或创建默认项目(素材库需要 project_id) */ export const getOrCreateDefaultProject = async (): Promise => { - const projects = await getProjects(); + const projects = await getProjects() if (projects.length > 0) { - return projects[0]; + return projects[0] } // 没有项目时自动创建默认项目 return createProject({ name: "默认项目", description: "系统自动创建的默认项目", - }); -}; + }) +} diff --git a/apps/web/src/api/subscription.ts b/apps/web/src/api/subscription.ts index c4eac3b4b..2d4303801 100644 --- a/apps/web/src/api/subscription.ts +++ b/apps/web/src/api/subscription.ts @@ -2,100 +2,98 @@ * 订阅 API 模块 * 对接后端订阅管理接口 */ -import apiClient from "./client"; +import apiClient from "./client" /** 套餐类型 */ -export type PlanType = "free" | "standard" | "pro" | "enterprise"; +export type PlanType = "free" | "standard" | "pro" | "enterprise" /** 订阅状态 */ -export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"; +export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial" /** 账单状态 */ -export type BillingStatus = "paid" | "pending" | "failed" | "refunded"; +export type BillingStatus = "paid" | "pending" | "failed" | "refunded" /** 计费周期 */ -export type BillingCycle = "monthly" | "yearly"; +export type BillingCycle = "monthly" | "yearly" /** 套餐信息 */ export interface Plan { - id: PlanType; - name: string; - price: number | null; - yearly_price?: number | null; - description: string; - recommended: boolean; - features: string[]; + id: PlanType + name: string + price: number | null + yearly_price?: number | null + description: string + recommended: boolean + features: string[] } /** 当前订阅信息 */ export interface SubscriptionInfo { - id: string; - plan_id: PlanType; - plan_name: string; - status: SubscriptionStatus; - billing_cycle: BillingCycle; - current_period_start: string; - current_period_end: string; - amount: number; - auto_renew: boolean; - created_at: string; + id: string + plan_id: PlanType + plan_name: string + status: SubscriptionStatus + billing_cycle: BillingCycle + current_period_start: string + current_period_end: string + amount: number + auto_renew: boolean + created_at: string } /** 账单记录 */ export interface BillingRecord { - id: string; - plan_name: string; - amount: number; - billing_cycle: BillingCycle; - status: BillingStatus; - payment_method: string; - created_at: string; - invoice_url?: string; + id: string + plan_name: string + amount: number + billing_cycle: BillingCycle + status: BillingStatus + payment_method: string + created_at: string + invoice_url?: string } /** 升级/降级请求 */ export interface ChangePlanRequest { - target_plan_id: PlanType; - billing_cycle: BillingCycle; + target_plan_id: PlanType + billing_cycle: BillingCycle } /** 升级/降级响应 */ export interface ChangePlanResponse { - success: boolean; - message: string; - new_subscription?: SubscriptionInfo; + success: boolean + message: string + new_subscription?: SubscriptionInfo } // ============ API 函数 ============ /** 获取当前订阅信息 */ export const getCurrentSubscription = async (): Promise => { - const response = await apiClient.get("/subscription/current"); - return response.data; -}; + const response = await apiClient.get("/subscription/current") + return response.data +} /** 获取账单记录列表 */ export const getBillingRecords = async (): Promise => { - const response = await apiClient.get("/subscription/billing-records"); - return response.data; -}; + const response = await apiClient.get("/subscription/billing-records") + return response.data +} /** 升级/降级套餐 */ -export const changePlan = async ( - request: ChangePlanRequest, -): Promise => { - const response = await apiClient.post("/subscription/change-plan", request); - return response.data; -}; +export const changePlan = async (request: ChangePlanRequest): Promise => { + const response = await apiClient.post("/subscription/change-plan", request) + return response.data +} /** 取消订阅 */ export const cancelSubscription = async (): Promise<{ - success: boolean; - message: string; + success: boolean + message: string }> => { - const response = await apiClient.post("/subscription/cancel"); - return response.data; -}; + const response = await apiClient.post("/subscription/cancel") + return response.data +} /** 切换自动续费 */ export const toggleAutoRenew = async ( @@ -103,6 +101,6 @@ export const toggleAutoRenew = async ( ): Promise<{ success: boolean; message: string }> => { const response = await apiClient.post("/subscription/toggle-auto-renew", { enabled, - }); - return response.data; -}; + }) + return response.data +} diff --git a/apps/web/src/api/tags.ts b/apps/web/src/api/tags.ts index 7a825ef12..4e6c812d0 100644 --- a/apps/web/src/api/tags.ts +++ b/apps/web/src/api/tags.ts @@ -2,45 +2,39 @@ * 标签 CRUD API * P3 标签体系:对接后端标签表 */ -import apiClient from "./client"; +import apiClient from "./client" export interface TagItem { - id: string; - name: string; - created_at?: string; - usage_count?: number; + id: string + name: string + created_at?: string + usage_count?: number } /** 获取当前用户所有标签 */ export const getTags = async (): Promise => { - const response = await apiClient.get("/tags"); - return response.data.items || []; -}; + const response = await apiClient.get("/tags") + return response.data.items || [] +} /** 创建标签(同名返回 409) */ export const createTag = async (name: string): Promise => { - const response = await apiClient.post("/tags", { name }); - return response.data; -}; + const response = await apiClient.post("/tags", { name }) + return response.data +} /** 删除标签(同时清理素材关联) */ export const deleteTag = async (tagId: string): Promise => { - await apiClient.delete(`/tags/${tagId}`); -}; + await apiClient.delete(`/tags/${tagId}`) +} /** 为素材添加标签(最多 50 个) */ -export const tagAsset = async ( - assetId: string, - tagIds: string[], -): Promise => { - if (tagIds.length === 0) return; - await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds }); -}; +export const tagAsset = async (assetId: string, tagIds: string[]): Promise => { + if (tagIds.length === 0) return + await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds }) +} /** 移除素材的某个标签 */ -export const untagAsset = async ( - assetId: string, - tagId: string, -): Promise => { - await apiClient.delete(`/assets/${assetId}/tags/${tagId}`); -}; +export const untagAsset = async (assetId: string, tagId: string): Promise => { + await apiClient.delete(`/assets/${assetId}/tags/${tagId}`) +} diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 1c4419b03..026c8508c 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -6,85 +6,84 @@ * - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info) * - POST /api/v1/tasks/{task_id}/retry — 重试失败任务 */ -import apiClient from "./client"; +import apiClient from "./client" /* ──────────── 类型定义 ──────────── */ /** 任务状态 */ -export type TaskStatus = - "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"; +export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled" /** 任务类型 */ -export type TaskType = "ingest" | "generation" | string; +export type TaskType = "ingest" | "generation" | string /** 错误详情 */ export interface TaskErrorInfo { - error_type: string; - error_message: string; - failed_step: string; - stack_trace?: string; + error_type: string + error_message: string + failed_step: string + stack_trace?: string } /** 任务条目(对应用户级 UserTaskResponse) */ export interface TaskItem { - id: string; - task_type: TaskType; - project_id: string; - template_id?: string; - status: TaskStatus; - progress: number; - current_step: string; - error_message: string; - user_message: string; - retryable: boolean; - source_id: string; + id: string + task_type: TaskType + project_id: string + template_id?: string + status: TaskStatus + progress: number + current_step: string + error_message: string + user_message: string + retryable: boolean + source_id: string /** 错误详情(失败任务) */ - error_info?: TaskErrorInfo; + error_info?: TaskErrorInfo /** 耗时(秒) */ - duration_seconds?: number; - created_at?: string | null; - updated_at?: string | null; + duration_seconds?: number + created_at?: string | null + updated_at?: string | null } /** 任务列表查询参数 */ export interface TaskListParams { - page?: number; - page_size?: number; - status?: TaskStatus | "all"; - task_type?: TaskType | "all"; + page?: number + page_size?: number + status?: TaskStatus | "all" + task_type?: TaskType | "all" } /** 任务列表分页响应 */ export interface TaskListResponse { - items: TaskItem[]; - total: number; - page: number; - page_size: number; + items: TaskItem[] + total: number + page: number + page_size: number } /** 创建生成任务请求参数 */ export interface CreateGenerationTaskRequest { - template_id: string; - asset_ids: string[]; - title_ids: string[]; - voice_ids: string[]; + template_id: string + asset_ids: string[] + title_ids: string[] + voice_ids: string[] } /** 创建生成任务响应(对齐后端 GenerationTaskResponse) */ export interface CreateGenerationTaskResponse { - id: string; - project_id: string; - asset_library_id: string; - strategy_id: string; - voice_library_id: string; - template_id: string; - asset_ids: string[]; - title_ids: string[]; - voice_ids: string[]; - status: string; - progress: number; - result_count: number; - error_message: string; + id: string + project_id: string + asset_library_id: string + strategy_id: string + voice_library_id: string + template_id: string + asset_ids: string[] + title_ids: string[] + voice_ids: string[] + status: string + progress: number + result_count: number + error_message: string } /* ──────────── API 函数 ──────────── */ @@ -93,37 +92,32 @@ export interface CreateGenerationTaskResponse { export const createGenerationTask = async ( params: CreateGenerationTaskRequest, ): Promise => { - const { data } = await apiClient.post( - "/generation/tasks", - params, - ); - return data; -}; + const { data } = await apiClient.post("/generation/tasks", params) + return data +} /** 获取任务列表(支持分页和筛选) */ -export const getTasks = async ( - params?: TaskListParams, -): Promise => { +export const getTasks = async (params?: TaskListParams): Promise => { const { data } = await apiClient.get("/tasks", { params, - }); - return data; -}; + }) + return data +} /** 获取当前用户的所有任务(兼容旧接口,跨 project) */ export const getUserTasks = async (): Promise => { - const { data } = await apiClient.get("/tasks"); - return data.items || data || []; -}; + const { data } = await apiClient.get("/tasks") + return data.items || data || [] +} /** 获取单个任务详情(含 error_info) */ export const getTask = async (taskId: string): Promise => { - const { data } = await apiClient.get(`/tasks/${taskId}`); - return data; -}; + const { data } = await apiClient.get(`/tasks/${taskId}`) + return data +} /** 重试失败的任务 */ export const retryTask = async (taskId: string): Promise => { - const { data } = await apiClient.post(`/tasks/${taskId}/retry`); - return data; -}; + const { data } = await apiClient.post(`/tasks/${taskId}/retry`) + return data +} diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts index dc132f1dc..c04641077 100644 --- a/apps/web/src/api/templates.ts +++ b/apps/web/src/api/templates.ts @@ -7,142 +7,132 @@ * - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划 * - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏 */ -import apiClient from "./client"; -import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"; -import type { EditPlanConfig } from "./editPlans"; +import apiClient from "./client" +import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner" +import type { EditPlanConfig } from "./editPlans" /* ──────────── 类型定义 ──────────── */ /** 模板条目(后端 TemplateResponse) */ export interface TemplateItem { - id: string; - user_id?: string; - name: string; - description?: string; - mode?: string; - category: string; - tags?: string[]; + id: string + user_id?: string + name: string + description?: string + mode?: string + category: string + tags?: string[] /** 预估时长(后端字段名 estimated_duration) */ - estimated_duration?: number; + estimated_duration?: number /** @deprecated 后端已改名为 estimated_duration,保留兼容 */ - target_duration?: number; - clip_count?: number; + target_duration?: number + clip_count?: number /** 使用次数 */ - usage_count?: number; - thumbnail_url?: string; - preview_url?: string; - is_active?: boolean; - is_favorite?: boolean; + usage_count?: number + thumbnail_url?: string + preview_url?: string + is_active?: boolean + is_favorite?: boolean /** 素材规则(片段配置) */ - segments?: TemplateSegment[]; + segments?: TemplateSegment[] /** 字幕样式 */ - subtitle_config?: SubtitleConfig; + subtitle_config?: SubtitleConfig /** BGM 配置 */ - bgm_config?: BgmConfig; + bgm_config?: BgmConfig /** 标题配置 */ - title_config?: TitleConfig; + title_config?: TitleConfig /** 视频比例 */ - aspect_ratio?: string; - created_at?: string; - updated_at?: string; + aspect_ratio?: string + created_at?: string + updated_at?: string } /** 模板片段(素材规则) */ export interface TemplateSegment { - id?: string; - segment_order: number; - duration_min: number; - duration_max: number; - material_type: string | null; - description?: string; + id?: string + segment_order: number + duration_min: number + duration_max: number + material_type: string | null + description?: string } /** 模板列表查询参数 */ export interface TemplateListParams { - page?: number; - page_size?: number; - category?: string; - tags?: string; - keyword?: string; + page?: number + page_size?: number + category?: string + tags?: string + keyword?: string /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ - duration_range?: "short" | "medium" | "long"; + duration_range?: "short" | "medium" | "long" } /** 模板列表分页响应 */ export interface TemplateListResponse { - items: TemplateItem[]; - total: number; - page: number; - page_size: number; + items: TemplateItem[] + total: number + page: number + page_size: number } /** 从模板生成剪辑计划请求 */ export interface GenerateFromTemplateRequest { - asset_ids?: string[]; - name?: string; - config?: EditPlanConfig; + asset_ids?: string[] + name?: string + config?: EditPlanConfig } /** 从模板生成剪辑计划响应 */ export interface GenerateFromTemplateResponse { - plan_id: string; - template_id: string; - status: string; - name: string; + plan_id: string + template_id: string + status: string + name: string } /** 复制模板响应 */ export interface CopyTemplateResponse { - id: string; - name: string; - source_template_id: string; + id: string + name: string + source_template_id: string } /* ──────────── API 函数 ──────────── */ /** 获取模板列表(支持分页和筛选) */ -export const getTemplates = async ( - params?: TemplateListParams, -): Promise => { +export const getTemplates = async (params?: TemplateListParams): Promise => { const { data } = await apiClient.get("/templates", { params, - }); - return data; -}; + }) + return data +} /** 获取模板列表(兼容旧接口,返回数组) */ export const getTemplatesList = async (): Promise => { - const response = await apiClient.get("/templates"); - return response.data.items || response.data || []; -}; + const response = await apiClient.get("/templates") + return response.data.items || response.data || [] +} /** 获取单个模板详情 */ -export const getTemplate = async ( - templateId: string, -): Promise => { - const response = await apiClient.get(`/templates/${templateId}`); - return response.data; -}; +export const getTemplate = async (templateId: string): Promise => { + const response = await apiClient.get(`/templates/${templateId}`) + return response.data +} /** 收藏 / 取消收藏模板 */ export const toggleFavoriteTemplate = async ( templateId: string, ): Promise<{ is_favorite: boolean }> => { - const response = await apiClient.post( - `/templates/${templateId}/toggle-favorite`, - ); - return response.data; -}; + const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`) + return response.data +} /** 复制模板(创建副本到我的模板) */ -export const copyTemplate = async ( - templateId: string, -): Promise => { - const response = await apiClient.post( - `/templates/${templateId}/copy`, - ); - return response.data; -}; +export const copyTemplate = async (templateId: string): Promise => { + const response = await apiClient.post(`/templates/${templateId}/copy`) + return response.data +} /** 从模板生成剪辑计划 */ export const generateFromTemplate = async ( @@ -152,9 +142,9 @@ export const generateFromTemplate = async ( const response = await apiClient.post( `/templates/${templateId}/generate`, data, - ); - return response.data; -}; + ) + return response.data +} /* ──────────── 常量 ──────────── */ @@ -167,7 +157,7 @@ export const TEMPLATE_CATEGORY_OPTIONS = [ { value: "品牌", label: "品牌" }, { value: "混剪", label: "混剪" }, { value: "Vlog", label: "Vlog" }, -]; +] /** 时长筛选选项 */ export const TEMPLATE_DURATION_OPTIONS = [ @@ -175,4 +165,4 @@ export const TEMPLATE_DURATION_OPTIONS = [ { value: "short", label: "30秒以内" }, { value: "medium", label: "30秒-2分钟" }, { value: "long", label: "2分钟以上" }, -]; +] diff --git a/apps/web/src/api/titles.ts b/apps/web/src/api/titles.ts index 63fd0f2b4..23ba5ea17 100644 --- a/apps/web/src/api/titles.ts +++ b/apps/web/src/api/titles.ts @@ -3,51 +3,51 @@ * Phase 1 新增:全局标题库 * 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示 */ -import apiClient from "./client"; +import apiClient from "./client" /** 标题条目(前端展示用) */ export interface TitleItem { - id: string; - content: string; - category?: string; - source?: string; - word_count?: number; - is_favorite?: boolean; - created_at?: string; - updated_at?: string; + id: string + content: string + category?: string + source?: string + word_count?: number + is_favorite?: boolean + created_at?: string + updated_at?: string } /** 后端标题响应格式 */ interface BackendTitleResponse { - id: string; - user_id: string; - name: string; - text: string; - category: string; - description: string; - tags: string[]; - usage_count: number; - is_active: boolean; - created_at: string; - updated_at: string; + id: string + user_id: string + name: string + text: string + category: string + description: string + tags: string[] + usage_count: number + is_active: boolean + created_at: string + updated_at: string } /** 后端创建标题请求格式 */ interface BackendCreateTitleRequest { - name: string; - text: string; - category: string; - description?: string; - tags?: string[]; + name: string + text: string + category: string + description?: string + tags?: string[] } /** 后端更新标题请求格式 */ interface BackendUpdateTitleRequest { - name?: string; - text?: string; - category?: string; - description?: string; - tags?: string[]; + name?: string + text?: string + category?: string + description?: string + tags?: string[] } /** 将后端响应映射为前端 TitleItem */ @@ -58,73 +58,61 @@ const toTitleItem = (item: BackendTitleResponse): TitleItem => ({ word_count: item.text?.length || 0, created_at: item.created_at, updated_at: item.updated_at, -}); +}) /** 创建标题请求(前端接口,保持向后兼容) */ export interface CreateTitleRequest { - content: string; - category?: string; + content: string + category?: string } /** 获取当前用户的所有标题 */ export const getTitles = async (): Promise => { - const response = await apiClient.get< - { items: BackendTitleResponse[] } | BackendTitleResponse[] - >("/titles"); + const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>( + "/titles", + ) // 兼容两种后端返回格式:{ items: [...] } 或直接 [...] - const items = Array.isArray(response.data) - ? response.data - : response.data.items || []; - return items.map(toTitleItem); -}; + const items = Array.isArray(response.data) ? response.data : response.data.items || [] + return items.map(toTitleItem) +} /** 创建标题 */ -export const createTitle = async ( - data: CreateTitleRequest, -): Promise => { +export const createTitle = async (data: CreateTitleRequest): Promise => { // 后端要求 name(≤255)和 text(≤500),name 从 content 截取 const payload: BackendCreateTitleRequest = { name: data.content.slice(0, 255), text: data.content.slice(0, 500), category: data.category || "default", - }; - const response = await apiClient.post( - "/titles", - payload, - ); - return toTitleItem(response.data); -}; + } + const response = await apiClient.post("/titles", payload) + return toTitleItem(response.data) +} /** 更新标题 */ export const updateTitle = async ( titleId: string, data: Partial, ): Promise => { - const payload: BackendUpdateTitleRequest = {}; + const payload: BackendUpdateTitleRequest = {} if (data.content !== undefined) { - payload.name = data.content.slice(0, 255); - payload.text = data.content.slice(0, 500); + payload.name = data.content.slice(0, 255) + payload.text = data.content.slice(0, 500) } if (data.category !== undefined) { - payload.category = data.category; + payload.category = data.category } // 后端用 PUT,非 PATCH - const response = await apiClient.put( - `/titles/${titleId}`, - payload, - ); - return toTitleItem(response.data); -}; + const response = await apiClient.put(`/titles/${titleId}`, payload) + return toTitleItem(response.data) +} /** 删除标题 */ export const deleteTitle = async (titleId: string): Promise => { - await apiClient.delete(`/titles/${titleId}`); -}; + await apiClient.delete(`/titles/${titleId}`) +} /** 批量导入标题 */ -export const batchImportTitles = async ( - titles: string[], -): Promise<{ imported_count: number }> => { - const response = await apiClient.post("/titles/batch-import", { titles }); - return response.data; -}; +export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => { + const response = await apiClient.post("/titles/batch-import", { titles }) + return response.data +} diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts index e59c7fbc9..7746d867d 100644 --- a/apps/web/src/api/tts.ts +++ b/apps/web/src/api/tts.ts @@ -4,91 +4,91 @@ * * 任务 3.14 新增 */ -import apiClient from "./client"; +import apiClient from "./client" /* ── 类型定义 ──────────────────────────────────── */ /** TTS 元数据(合成时附带的扩展信息) */ export interface TTSMetadata { /** 语音时长(秒) */ - duration?: number; + duration?: number /** 采样率(Hz) */ - sample_rate?: number; + sample_rate?: number /** 语言 */ - language?: string; + language?: string /** 其他扩展字段 */ - [key: string]: unknown; + [key: string]: unknown } /** TTS 合成请求参数 */ export interface TTSSynthesizeRequest { - text: string; - voice_id?: string; - output_name?: string; - language?: string; - speed?: number; - voice_model?: string; - voice_clone_profile_id?: string; - format?: string; - metadata?: TTSMetadata; + text: string + voice_id?: string + output_name?: string + language?: string + speed?: number + voice_model?: string + voice_clone_profile_id?: string + format?: string + metadata?: TTSMetadata } /** TTS 合成创建响应 */ export interface TTSSynthesizeResponse { - job_id: string; - status: string; - message: string; + job_id: string + status: string + message: string } /** TTS 任务详情 */ export interface TTSJob { - id: string; - user_id: string; - project_id: string | null; - text: string; - voice_id: string | null; - voice_model: string | null; - voice_clone_profile_id: string | null; - language: string; - speed: number; - output_name: string | null; - output_audio_url: string | null; - output_format: string; - duration_seconds: number | null; - file_size_bytes: number | null; - sample_rate: number | null; - status: string; - error_message: string | null; - retry_count: number; - max_retries: number; - metadata_: TTSMetadata | null; - created_at: string; - updated_at: string; + id: string + user_id: string + project_id: string | null + text: string + voice_id: string | null + voice_model: string | null + voice_clone_profile_id: string | null + language: string + speed: number + output_name: string | null + output_audio_url: string | null + output_format: string + duration_seconds: number | null + file_size_bytes: number | null + sample_rate: number | null + status: string + error_message: string | null + retry_count: number + max_retries: number + metadata_: TTSMetadata | null + created_at: string + updated_at: string } /** TTS 任务状态(轻量轮询用) */ export interface TTSJobStatus { - id: string; - status: string; - output_audio_url: string | null; - error_message: string | null; - duration_seconds: number | null; - retry_count: number; + id: string + status: string + output_audio_url: string | null + error_message: string | null + duration_seconds: number | null + retry_count: number } /** TTS 任务列表响应 */ export interface TTSJobListResponse { - items: TTSJob[]; - total: number; - skip: number; - limit: number; + items: TTSJob[] + total: number + skip: number + limit: number } /** TTS 任务列表查询参数 */ export interface TTSJobListParams { - status?: string; - skip?: number; - limit?: number; + status?: string + skip?: number + limit?: number } /* ── API 函数 ──────────────────────────────────── */ @@ -97,47 +97,37 @@ export interface TTSJobListParams { export const synthesizeSpeech = async ( data: TTSSynthesizeRequest, ): Promise => { - const response = await apiClient.post( - "/tts/synthesize", - data, - ); - return response.data; -}; + const response = await apiClient.post("/tts/synthesize", data) + return response.data +} /** 获取 TTS 任务详情 */ export const getTTSJob = async (jobId: string): Promise => { - const response = await apiClient.get(`/tts/jobs/${jobId}`); - return response.data; -}; + const response = await apiClient.get(`/tts/jobs/${jobId}`) + return response.data +} /** 获取 TTS 任务状态(轻量轮询) */ export const getTTSJobStatus = async (jobId: string): Promise => { - const response = await apiClient.get( - `/tts/jobs/${jobId}/status`, - ); - return response.data; -}; + const response = await apiClient.get(`/tts/jobs/${jobId}/status`) + return response.data +} /** 获取 TTS 任务列表 */ -export const getTTSJobs = async ( - params?: TTSJobListParams, -): Promise => { - const searchParams = new URLSearchParams(); - if (params?.status) searchParams.set("status", params.status); - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) - searchParams.set("limit", String(params.limit)); - const qs = searchParams.toString(); - const response = await apiClient.get( - `/tts/jobs${qs ? `?${qs}` : ""}`, - ); - return response.data; -}; +export const getTTSJobs = async (params?: TTSJobListParams): Promise => { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/tts/jobs${qs ? `?${qs}` : ""}`) + return response.data +} /** 存为素材请求参数 */ export interface SaveTtsToLibraryRequest { - name?: string; - tag_ids?: string[]; + name?: string + tag_ids?: string[] } /** 将 TTS 合成结果保存到配音素材库 */ @@ -145,59 +135,54 @@ export const saveTtsToLibrary = async ( jobId: string, data?: SaveTtsToLibraryRequest, ): Promise => { - await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}); -}; + await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {}) +} /** 删除 TTS 任务 */ export const deleteTTSJob = async (jobId: string): Promise => { - await apiClient.delete(`/tts/jobs/${jobId}`); -}; + await apiClient.delete(`/tts/jobs/${jobId}`) +} /* ── 音色列表 ──────────────────────────────────── */ /** TTS 音色 */ export interface TTSVoice { - id: string; - name: string; + id: string + name: string /** 音色分类标签:male/female/young/service/news/emotion */ - category?: string; + category?: string /** 语言 */ - language?: string; + language?: string /** 试听 URL */ - preview_url?: string; + preview_url?: string /** 描述 */ - description?: string; + description?: string } /** 获取 TTS 音色列表 */ export const getTtsVoices = async (): Promise => { - const response = await apiClient.get("/tts/voices"); - return response.data; -}; + const response = await apiClient.get("/tts/voices") + return response.data +} /* ── TTS 试听 ──────────────────────────────────── */ /** TTS 试听请求参数 */ export interface TTSPreviewRequest { - text: string; - voice_id: string; - speed?: number; - pitch?: number; + text: string + voice_id: string + speed?: number + pitch?: number } /** TTS 试听响应 */ export interface TTSPreviewResponse { - audio_url: string; - duration?: number; + audio_url: string + duration?: number } /** TTS 试听 */ -export const previewTts = async ( - data: TTSPreviewRequest, -): Promise => { - const response = await apiClient.post( - "/tts/preview", - data, - ); - return response.data; -}; +export const previewTts = async (data: TTSPreviewRequest): Promise => { + const response = await apiClient.post("/tts/preview", data) + return response.data +} diff --git a/apps/web/src/api/voiceClone.ts b/apps/web/src/api/voiceClone.ts index c0d5690f1..7054ab164 100644 --- a/apps/web/src/api/voiceClone.ts +++ b/apps/web/src/api/voiceClone.ts @@ -3,35 +3,35 @@ * 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05) * 任务 3.15:新增 progress 字段用于进度展示 */ -import apiClient from "./client"; +import apiClient from "./client" /* ── 前端兼容类型 ─────────────────────────────────────── */ /** 克隆音色状态(前端展示用) */ -export type VoiceCloneStatus = "ready" | "processing" | "failed"; +export type VoiceCloneStatus = "ready" | "processing" | "failed" /** 克隆音色条目(前端展示用) */ export interface VoiceClone { - id: string; - name: string; - description: string; - duration_seconds: number; - status: VoiceCloneStatus; + id: string + name: string + description: string + duration_seconds: number + status: VoiceCloneStatus /** 克隆进度 0-100,仅 processing 状态时有值 */ - progress: number; - sample_url?: string; - language: string; - gender: string; - error_message: string | null; - created_at: string; - updated_at: string; + progress: number + sample_url?: string + language: string + gender: string + error_message: string | null + created_at: string + updated_at: string } /** 创建克隆请求(前端简化版) */ export interface CreateVoiceCloneRequest { - name: string; - audio_url: string; - description?: string; + name: string + audio_url: string + description?: string } /* ── 后端 API 类型 ────────────────────────────────────── */ @@ -39,60 +39,60 @@ export interface CreateVoiceCloneRequest { /** 音色克隆元数据(克隆时附带的扩展信息) */ export interface VoiceCloneMetadata { /** 语音时长(秒) */ - duration?: number; + duration?: number /** 采样率(Hz) */ - sample_rate?: number; + sample_rate?: number /** 音色 ID(克隆完成后分配) */ - voice_id?: string; + voice_id?: string /** 其他扩展字段 */ - [key: string]: unknown; + [key: string]: unknown } /** 后端克隆档案响应 */ export interface VoiceCloneProfile { - id: string; - user_id: string; - name: string; - description: string; - source_audio_url: string; - voice_id: string | null; - voice_model: string; - language: string; - gender: string; - status: "pending" | "processing" | "ready" | "failed"; - error_message: string | null; - retry_count: number; - max_retries: number; - metadata_: VoiceCloneMetadata | null; - created_at: string; - updated_at: string; + id: string + user_id: string + name: string + description: string + source_audio_url: string + voice_id: string | null + voice_model: string + language: string + gender: string + status: "pending" | "processing" | "ready" | "failed" + error_message: string | null + retry_count: number + max_retries: number + metadata_: VoiceCloneMetadata | null + created_at: string + updated_at: string } /** 后端克隆列表响应 */ export interface ListVoiceCloneResponse { - items: VoiceCloneProfile[]; - total: number; + items: VoiceCloneProfile[] + total: number } /** 后端克隆状态响应 */ export interface VoiceCloneStatusResponse { - id: string; - status: "pending" | "processing" | "ready" | "failed"; - error_message: string | null; - voice_id: string | null; - retry_count: number; + id: string + status: "pending" | "processing" | "ready" | "failed" + error_message: string | null + voice_id: string | null + retry_count: number } /** 后端创建克隆请求(完整版) */ export interface CreateVoiceCloneRequestFull { - name: string; - description?: string; - source_audio_url: string; - voice_model?: string; - language?: string; - gender?: string; - max_retries?: number; - metadata_?: VoiceCloneMetadata; + name: string + description?: string + source_audio_url: string + voice_model?: string + language?: string + gender?: string + max_retries?: number + metadata_?: VoiceCloneMetadata } /* ── 辅助函数 ─────────────────────────────────────────── */ @@ -114,66 +114,54 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({ error_message: profile.error_message || null, created_at: profile.created_at, updated_at: profile.updated_at, -}); +}) /** 格式化时长 */ export const formatDuration = (seconds: number): string => { - const m = Math.floor(seconds / 60); - const s = seconds % 60; - return `${m}:${String(s).padStart(2, "0")}`; -}; + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${String(s).padStart(2, "0")}` +} /* ── 查询参数 ─────────────────────────────────────────── */ export interface VoiceCloneListParams { - status?: string; - skip?: number; - limit?: number; + status?: string + skip?: number + limit?: number } /* ── API 函数 ─────────────────────────────────────────── */ /** 获取克隆音色列表(返回前端兼容数组) */ -export const getVoiceClones = async ( - params?: VoiceCloneListParams, -): Promise => { - const searchParams = new URLSearchParams(); - if (params?.status) searchParams.set("status", params.status); - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) - searchParams.set("limit", String(params.limit)); - const qs = searchParams.toString(); - const response = await apiClient.get( - `/voice-clones${qs ? `?${qs}` : ""}`, - ); - return response.data.items.map(toVoiceClone); -}; +export const getVoiceClones = async (params?: VoiceCloneListParams): Promise => { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) + return response.data.items.map(toVoiceClone) +} /** 获取克隆音色列表(返回完整响应含 total) */ export const getVoiceClonesWithTotal = async ( params?: VoiceCloneListParams, ): Promise => { - const searchParams = new URLSearchParams(); - if (params?.status) searchParams.set("status", params.status); - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) - searchParams.set("limit", String(params.limit)); - const qs = searchParams.toString(); - const response = await apiClient.get( - `/voice-clones${qs ? `?${qs}` : ""}`, - ); - return response.data; -}; + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/voice-clones${qs ? `?${qs}` : ""}`) + return response.data +} /** 获取单个克隆音色详情 */ -export const getVoiceCloneDetail = async ( - id: string, -): Promise => { - const response = await apiClient.get( - `/voice-clones/${id}`, - ); - return response.data; -}; +export const getVoiceCloneDetail = async (id: string): Promise => { + const response = await apiClient.get(`/voice-clones/${id}`) + return response.data +} /** 创建克隆音色 */ export const createVoiceClone = async ( @@ -183,18 +171,15 @@ export const createVoiceClone = async ( name: data.name, description: data.description, source_audio_url: data.audio_url, - }; - const response = await apiClient.post( - "/voice-clones", - payload, - ); - return response.data; -}; + } + const response = await apiClient.post("/voice-clones", payload) + return response.data +} /** 删除克隆音色 */ export const deleteVoiceClone = async (id: string): Promise => { - await apiClient.delete(`/voice-clones/${id}`); -}; + await apiClient.delete(`/voice-clones/${id}`) +} /** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */ export const updateVoiceClone = async ( @@ -202,32 +187,22 @@ export const updateVoiceClone = async ( data: Partial>, ): Promise => { // 后端暂未提供更新端点,暂用详情接口模拟 - const response = await apiClient.get( - `/voice-clones/${id}`, - ); + const response = await apiClient.get(`/voice-clones/${id}`) return toVoiceClone({ ...response.data, ...data, updated_at: new Date().toISOString(), - }); -}; + }) +} /** 获取克隆状态 */ -export const getVoiceCloneStatus = async ( - id: string, -): Promise => { - const response = await apiClient.get( - `/voice-clones/${id}/status`, - ); - return response.data; -}; +export const getVoiceCloneStatus = async (id: string): Promise => { + const response = await apiClient.get(`/voice-clones/${id}/status`) + return response.data +} /** 重试克隆 */ -export const retryVoiceClone = async ( - id: string, -): Promise => { - const response = await apiClient.post( - `/voice-clones/${id}/retry`, - ); - return response.data; -}; +export const retryVoiceClone = async (id: string): Promise => { + const response = await apiClient.post(`/voice-clones/${id}/retry`) + return response.data +} diff --git a/apps/web/src/api/voices.ts b/apps/web/src/api/voices.ts index f9defedf4..7d777606b 100644 --- a/apps/web/src/api/voices.ts +++ b/apps/web/src/api/voices.ts @@ -4,148 +4,142 @@ * * 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容 */ -import apiClient from "./client"; +import apiClient from "./client" /* ── 统一音色 API(后端 3.04) ─────────────────────────── */ /** 统一音色条目(preset + clone 混合) */ export interface UnifiedVoiceItem { - id: string; - type: "preset" | "clone"; - name: string; - description: string; - gender: string; - language: string; - voice_id: string; - voice_provider: string; - audio_url: string | null; - preview_url: string | null; - duration: number | null; - file_size: number | null; - status: string; - tags: string[]; - user_id: string | null; - project_id: string | null; - voice_clone_profile_id: string | null; - created_at: string | null; - updated_at: string | null; + id: string + type: "preset" | "clone" + name: string + description: string + gender: string + language: string + voice_id: string + voice_provider: string + audio_url: string | null + preview_url: string | null + duration: number | null + file_size: number | null + status: string + tags: string[] + user_id: string | null + project_id: string | null + voice_clone_profile_id: string | null + created_at: string | null + updated_at: string | null } /** 统一音色列表响应 */ export interface UnifiedVoiceListResponse { - items: UnifiedVoiceItem[]; - total: number; - preset_count: number; - clone_count: number; + items: UnifiedVoiceItem[] + total: number + preset_count: number + clone_count: number } /** 预设音色条目 */ export interface PresetVoiceItem { - voice_id: string; - name: string; - description: string; - gender: string; - language: string; - preview_url: string | null; - tags: string[]; + voice_id: string + name: string + description: string + gender: string + language: string + preview_url: string | null + tags: string[] } /** 预设音色列表响应 */ export interface PresetVoiceListResponse { - items: PresetVoiceItem[]; - total: number; + items: PresetVoiceItem[] + total: number } /** 统一列表查询参数 */ export interface UnifiedVoiceListParams { - type?: "preset" | "clone"; - status?: string; - skip?: number; - limit?: number; + type?: "preset" | "clone" + status?: string + skip?: number + limit?: number } /** 获取统一音色列表(推荐) */ export const fetchVoices = async ( params?: UnifiedVoiceListParams, ): Promise => { - const searchParams = new URLSearchParams(); - if (params?.type) searchParams.set("type", params.type); - if (params?.status) searchParams.set("status", params.status); - if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)); - if (params?.limit !== undefined) - searchParams.set("limit", String(params.limit)); - const qs = searchParams.toString(); - const response = await apiClient.get( - `/voices${qs ? `?${qs}` : ""}`, - ); - return response.data; -}; + const searchParams = new URLSearchParams() + if (params?.type) searchParams.set("type", params.type) + if (params?.status) searchParams.set("status", params.status) + if (params?.skip !== undefined) searchParams.set("skip", String(params.skip)) + if (params?.limit !== undefined) searchParams.set("limit", String(params.limit)) + const qs = searchParams.toString() + const response = await apiClient.get(`/voices${qs ? `?${qs}` : ""}`) + return response.data +} /** 获取预设音色列表(无需鉴权) */ export const fetchPresetVoices = async (): Promise => { - const response = - await apiClient.get("/voices/presets"); - return response.data; -}; + const response = await apiClient.get("/voices/presets") + return response.data +} /* ── 向后兼容(旧接口) ────────────────────────────────── */ /** 配音条目(旧) */ export interface VoiceItem { - id: string; - name: string; - text: string; - voice_type?: string; - duration_seconds?: number; - storage_key?: string; - audio_url?: string; - status?: string; - is_favorite?: boolean; - created_at?: string; - updated_at?: string; + id: string + name: string + text: string + voice_type?: string + duration_seconds?: number + storage_key?: string + audio_url?: string + status?: string + is_favorite?: boolean + created_at?: string + updated_at?: string } /** 创建配音请求(旧) */ export interface CreateVoiceRequest { - name: string; - text: string; - voice_type?: string; + name: string + text: string + voice_type?: string } /** 获取当前用户的所有配音(旧 → /voices/legacy) */ export const getVoices = async (): Promise => { - const response = await apiClient.get("/voices/legacy"); - return response.data.items || response.data || []; -}; + const response = await apiClient.get("/voices/legacy") + return response.data.items || response.data || [] +} /** 创建配音 */ -export const createVoice = async ( - data: CreateVoiceRequest, -): Promise => { - const response = await apiClient.post("/voices", data); - return response.data; -}; +export const createVoice = async (data: CreateVoiceRequest): Promise => { + const response = await apiClient.post("/voices", data) + return response.data +} /** 更新配音 */ export const updateVoice = async ( voiceId: string, data: Partial, ): Promise => { - const response = await apiClient.put(`/voices/${voiceId}`, data); - return response.data; -}; + const response = await apiClient.put(`/voices/${voiceId}`, data) + return response.data +} /** 删除配音 */ export const deleteVoice = async (voiceId: string): Promise => { - await apiClient.delete(`/voices/${voiceId}`); -}; + await apiClient.delete(`/voices/${voiceId}`) +} /** AI 生成配音 */ export const generateAIVoice = async (data: { - text: string; - voice_type?: string; - speed?: number; + text: string + voice_type?: string + speed?: number }): Promise => { - const response = await apiClient.post("/voices/generate", data); - return response.data; -}; + const response = await apiClient.post("/voices/generate", data) + return response.data +} diff --git a/apps/web/src/components/AssetSelector/AssetSelector.tsx b/apps/web/src/components/AssetSelector/AssetSelector.tsx index d5e7b338b..e83afc998 100644 --- a/apps/web/src/components/AssetSelector/AssetSelector.tsx +++ b/apps/web/src/components/AssetSelector/AssetSelector.tsx @@ -9,72 +9,62 @@ * - 筛选增强:类型筛选 + 质量分筛选 * - 视图切换:网格视图 / 列表视图 */ -import React, { - useState, - useMemo, - useCallback, - useRef, - useEffect, -} from "react"; -import "./AssetSelector.css"; -import { Input, Select, Button } from "@/components/ui"; -import type { MediaAsset } from "@/api/editPlans"; -import { - MATERIAL_TYPE_LABELS, - MATERIAL_TYPE_ICONS, - QUALITY_OPTIONS, -} from "@/api/editPlans"; +import React, { useState, useMemo, useCallback, useRef, useEffect } from "react" +import "./AssetSelector.css" +import { Input, Select, Button } from "@/components/ui" +import type { MediaAsset } from "@/api/editPlans" +import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans" /* ──────────── 类型 ──────────── */ export interface AssetSelectorProps { - assets: MediaAsset[]; - selectedIds?: string[]; - onSelectionChange?: (ids: string[]) => void; - onAssetDragStart?: (asset: MediaAsset) => void; - onReorder?: (fromIdx: number, toIdx: number) => void; - showQualityFilter?: boolean; - showBatchSelect?: boolean; - compact?: boolean; + assets: MediaAsset[] + selectedIds?: string[] + onSelectionChange?: (ids: string[]) => void + onAssetDragStart?: (asset: MediaAsset) => void + onReorder?: (fromIdx: number, toIdx: number) => void + showQualityFilter?: boolean + showBatchSelect?: boolean + compact?: boolean } -type ViewMode = "grid" | "list"; +type ViewMode = "grid" | "list" /* ──────────── 工具函数 ──────────── */ /** 格式化文件大小 */ const formatSize = (bytes?: number): string => { - if (!bytes) return ""; - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -}; + if (!bytes) return "" + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} /** 格式化时长 */ const formatDuration = (seconds?: number): string => { - if (!seconds) return ""; - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`; -}; + if (!seconds) return "" + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s` +} /** 获取质量分等级 */ const getQualityLevel = (score?: number): string => { - if (score == null) return "none"; - if (score >= 90) return "excellent"; - if (score >= 70) return "good"; - if (score >= 50) return "fair"; - return "poor"; -}; + if (score == null) return "none" + if (score >= 90) return "excellent" + if (score >= 70) return "good" + if (score >= 50) return "fair" + return "poor" +} /** 质量分颜色 */ const getQualityColor = (score?: number): string => { - if (score == null) return "var(--text-secondary)"; - if (score >= 90) return "var(--success-color, #10b981)"; - if (score >= 70) return "var(--primary-color, #6366f1)"; - if (score >= 50) return "var(--warning-color, #f59e0b)"; - return "var(--error-color, #ef4444)"; -}; + if (score == null) return "var(--text-secondary)" + if (score >= 90) return "var(--success-color, #10b981)" + if (score >= 70) return "var(--primary-color, #6366f1)" + if (score >= 50) return "var(--warning-color, #f59e0b)" + return "var(--error-color, #ef4444)" +} /* ──────────── 类型筛选选项 ──────────── */ @@ -83,7 +73,7 @@ const TYPE_OPTIONS = [ { value: "video", label: "🎬 视频" }, { value: "image", label: "🖼️ 图片" }, { value: "audio", label: "🎵 音频" }, -]; +] /* ──────────── 组件 ──────────── */ @@ -98,189 +88,171 @@ const AssetSelector: React.FC = ({ compact = false, }) => { /* ── 搜索 & 筛选 ── */ - const [searchText, setSearchText] = useState(""); - const [filterType, setFilterType] = useState(""); - const [filterQuality, setFilterQuality] = useState(""); - const [viewMode, setViewMode] = useState("grid"); + const [searchText, setSearchText] = useState("") + const [filterType, setFilterType] = useState("") + const [filterQuality, setFilterQuality] = useState("") + const [viewMode, setViewMode] = useState("grid") /* ── 拖拽状态 ── */ - const [dragIdx, setDragIdx] = useState(null); - const [dragOverIdx, setDragOverIdx] = useState(null); + const [dragIdx, setDragIdx] = useState(null) + const [dragOverIdx, setDragOverIdx] = useState(null) /* ── 悬浮预览 ── */ - const [previewAsset, setPreviewAsset] = useState(null); - const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }); - const previewTimer = useRef | null>(null); + const [previewAsset, setPreviewAsset] = useState(null) + const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 }) + const previewTimer = useRef | null>(null) /* ── Shift 连选 ── */ - const lastClickedIdx = useRef(null); + const lastClickedIdx = useRef(null) /* ── 过滤后的素材列表 ── */ const filteredAssets = useMemo(() => { - let list = assets; + let list = assets if (searchText) { - const q = searchText.toLowerCase(); + const q = searchText.toLowerCase() list = list.filter( - (a) => - a.name.toLowerCase().includes(q) || - a.tags.some((t) => t.toLowerCase().includes(q)), - ); + (a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)), + ) } if (filterType) { - list = list.filter((a) => a.type === filterType); + list = list.filter((a) => a.type === filterType) } if (filterQuality) { - const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality); + const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality) if (opt?.min != null && opt?.max != null) { list = list.filter( (a) => - a.quality_score != null && - a.quality_score >= opt.min! && - a.quality_score <= opt.max!, - ); + a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!, + ) } } - return list; - }, [assets, searchText, filterType, filterQuality]); + return list + }, [assets, searchText, filterType, filterQuality]) /* ── 选中状态 ── */ - const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]) /* ── 选择操作 ── */ const toggleSelect = useCallback( (asset: MediaAsset, idx: number, shiftKey: boolean) => { - if (!onSelectionChange) return; + if (!onSelectionChange) return if (shiftKey && lastClickedIdx.current !== null) { // Shift 连选 - const start = Math.min(lastClickedIdx.current, idx); - const end = Math.max(lastClickedIdx.current, idx); - const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id); - const newSet = new Set(selectedIds); - rangeIds.forEach((id) => newSet.add(id)); - onSelectionChange(Array.from(newSet)); + const start = Math.min(lastClickedIdx.current, idx) + const end = Math.max(lastClickedIdx.current, idx) + const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id) + const newSet = new Set(selectedIds) + rangeIds.forEach((id) => newSet.add(id)) + onSelectionChange(Array.from(newSet)) } else { - const newSet = new Set(selectedIds); + const newSet = new Set(selectedIds) if (newSet.has(asset.id)) { - newSet.delete(asset.id); + newSet.delete(asset.id) } else { - newSet.add(asset.id); + newSet.add(asset.id) } - onSelectionChange(Array.from(newSet)); + onSelectionChange(Array.from(newSet)) } - lastClickedIdx.current = idx; + lastClickedIdx.current = idx }, [onSelectionChange, selectedIds, filteredAssets], - ); + ) const clearSelection = useCallback(() => { - onSelectionChange?.([]); - }, [onSelectionChange]); + onSelectionChange?.([]) + }, [onSelectionChange]) /* ── 拖拽排序 ── */ const handleDragStart = useCallback( (e: React.DragEvent, idx: number) => { - setDragIdx(idx); - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(idx)); + setDragIdx(idx) + e.dataTransfer.effectAllowed = "move" + e.dataTransfer.setData("text/plain", String(idx)) // 设置素材数据,供 TimelinePanel 接收(P1-2 修复) - e.dataTransfer.setData( - "application/x-media-asset", - JSON.stringify(filteredAssets[idx]), - ); + e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx])) // 批量拖拽:如果有多个选中素材,一起携带 - if ( - selectedIds.length > 1 && - selectedIds.includes(filteredAssets[idx].id) - ) { - const batchAssets = filteredAssets.filter((a) => - selectedIds.includes(a.id), - ); - e.dataTransfer.setData( - "application/x-media-assets", - JSON.stringify(batchAssets), - ); + if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) { + const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id)) + e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets)) } // 通知父组件素材拖拽开始 if (onAssetDragStart) { - onAssetDragStart(filteredAssets[idx]); + onAssetDragStart(filteredAssets[idx]) } }, [filteredAssets, selectedIds, onAssetDragStart], - ); + ) const handleDragOver = useCallback( (e: React.DragEvent, idx: number) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - if (dragIdx === null || dragIdx === idx) return; - setDragOverIdx(idx); + e.preventDefault() + e.dataTransfer.dropEffect = "move" + if (dragIdx === null || dragIdx === idx) return + setDragOverIdx(idx) }, [dragIdx], - ); + ) const handleDrop = useCallback( (e: React.DragEvent, toIdx: number) => { - e.preventDefault(); + e.preventDefault() if (dragIdx !== null && dragIdx !== toIdx && onReorder) { - onReorder(dragIdx, toIdx); + onReorder(dragIdx, toIdx) } - setDragIdx(null); - setDragOverIdx(null); + setDragIdx(null) + setDragOverIdx(null) }, [dragIdx, onReorder], - ); + ) const handleDragEnd = useCallback(() => { - setDragIdx(null); - setDragOverIdx(null); - }, []); + setDragIdx(null) + setDragOverIdx(null) + }, []) /* ── 悬浮预览 ── */ - const handleMouseEnter = useCallback( - (asset: MediaAsset, e: React.MouseEvent) => { - if (previewTimer.current) clearTimeout(previewTimer.current); - previewTimer.current = setTimeout(() => { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - setPreviewAsset(asset); - setPreviewPos({ - x: rect.right + 12, - y: Math.max(8, rect.top - 20), - }); - }, 400); - }, - [], - ); + const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => { + if (previewTimer.current) clearTimeout(previewTimer.current) + previewTimer.current = setTimeout(() => { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + setPreviewAsset(asset) + setPreviewPos({ + x: rect.right + 12, + y: Math.max(8, rect.top - 20), + }) + }, 400) + }, []) const handleMouseLeave = useCallback(() => { if (previewTimer.current) { - clearTimeout(previewTimer.current); - previewTimer.current = null; + clearTimeout(previewTimer.current) + previewTimer.current = null } - setPreviewAsset(null); - }, []); + setPreviewAsset(null) + }, []) // 清理定时器 useEffect(() => { return () => { - if (previewTimer.current) clearTimeout(previewTimer.current); - }; - }, []); + if (previewTimer.current) clearTimeout(previewTimer.current) + } + }, []) /* ── 点击卡片 ── */ const handleCardClick = useCallback( (asset: MediaAsset, idx: number, e: React.MouseEvent) => { // 如果点击的是 checkbox 区域,不触发卡片点击 - const target = e.target as HTMLElement; - if (target.closest("[data-checkbox]")) return; - toggleSelect(asset, idx, e.shiftKey); + const target = e.target as HTMLElement + if (target.closest("[data-checkbox]")) return + toggleSelect(asset, idx, e.shiftKey) }, [toggleSelect], - ); + ) /* ──────────── 渲染 ──────────── */ - const hasSelection = selectedIds.length > 0; + const hasSelection = selectedIds.length > 0 return (
@@ -329,9 +301,7 @@ const AssetSelector: React.FC = ({ {/* ═══ 批量操作栏 ═══ */} {showBatchSelect && hasSelection && (
- - 已选 {selectedIds.length} 项 - + 已选 {selectedIds.length} 项
- ); -}; + ) +} -export default AppLayout; +export default AppLayout diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index dacfec862..a90377c4f 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -2,27 +2,22 @@ * Phase 1 Header 重构 * 扁平化导航菜单 + 手机端汉堡菜单 */ -import React, { useState } from "react"; -import { Avatar, Dropdown, Drawer, Space } from "antd"; -import { - LogoutOutlined, - SettingOutlined, - UserOutlined, - MenuOutlined, -} from "@ant-design/icons"; -import { useLocation, useNavigate } from "react-router-dom"; -import { useAuthStore } from "@/store/authStore"; -import { useLogout } from "@/hooks/useAuth"; -import type { MenuProps } from "antd"; -import { NAV_ITEMS } from "@/config/navigation"; -import "./Header.css"; +import React, { useState } from "react" +import { Avatar, Dropdown, Drawer, Space } from "antd" +import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons" +import { useLocation, useNavigate } from "react-router-dom" +import { useAuthStore } from "@/store/authStore" +import { useLogout } from "@/hooks/useAuth" +import type { MenuProps } from "antd" +import { NAV_ITEMS } from "@/config/navigation" +import "./Header.css" const Header: React.FC = () => { - const navigate = useNavigate(); - const location = useLocation(); - const user = useAuthStore((state) => state.user); - const logoutMutation = useLogout(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const navigate = useNavigate() + const location = useLocation() + const user = useAuthStore((state) => state.user) + const logoutMutation = useLogout() + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) /** 用户下拉菜单 */ const menuItems: MenuProps["items"] = [ @@ -45,7 +40,7 @@ const Header: React.FC = () => { label: "退出登录", onClick: () => logoutMutation.mutateAsync(), }, - ]; + ] /** 判断导航项是否激活 */ const isActive = (path: string) => { @@ -55,19 +50,15 @@ const Header: React.FC = () => { location.pathname === "/" || location.pathname === "/app" || location.pathname === "/app/dashboard" - ); + ) } - return location.pathname.startsWith(path); - }; + return location.pathname.startsWith(path) + } return (
- @@ -88,11 +79,7 @@ const Header: React.FC = () => {
{/* 手机端汉堡菜单按钮 */} - @@ -123,8 +110,8 @@ const Header: React.FC = () => { className={`xx-mobile-nav-item ${isActive(item.path) ? "active" : ""}`} type="button" onClick={() => { - navigate(item.path); - setMobileMenuOpen(false); + navigate(item.path) + setMobileMenuOpen(false) }} > {item.icon} @@ -134,7 +121,7 @@ const Header: React.FC = () => {
- ); -}; + ) +} -export default Header; +export default Header diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index 32d775cb2..848da13bd 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -9,39 +9,32 @@ * * 侧边栏/导航具体内容 deferred to Tasks 1.3, 1.4 */ -import React, { useState } from "react"; -import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; -import AppLayout from "./AppLayout"; -import Sidebar from "./Sidebar"; -import "./MainLayout.css"; +import React, { useState } from "react" +import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons" +import AppLayout from "./AppLayout" +import Sidebar from "./Sidebar" +import "./MainLayout.css" /** 侧边栏上下文 —— 子组件可读取折叠状态 */ export interface SidebarContextValue { - collapsed: boolean; + collapsed: boolean } // eslint-disable-next-line react-refresh/only-export-components export const SidebarContext = React.createContext({ collapsed: false, -}); +}) const MainLayout: React.FC = () => { // 移动端默认折叠,桌面端默认展开 - const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768); + const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768) const sidebar = ( <> {/* 移动端遮罩层 —— 仅在侧边栏展开时由 CSS 显示 */} -
setCollapsed(true)} - aria-hidden="true" - /> +
setCollapsed(true)} aria-hidden="true" /> -
{/* 错误提示 */} @@ -532,9 +501,7 @@ const CloneModal: React.FC = ({ {/* 提示 */}
💡 - - 建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳 - + 建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
{/* 底部按钮 */} @@ -542,11 +509,7 @@ const CloneModal: React.FC = ({ -
@@ -559,15 +522,15 @@ const CloneModal: React.FC = ({ {/* 步骤指示器 */}
{PROGRESS_STEPS.map((step, idx) => { - const isActive = idx === progressIndex; - const isDone = idx < progressIndex; + const isActive = idx === progressIndex + const isDone = idx < progressIndex const stepClass = [ "xx-clonemodal-step-progress", isActive ? "xx-clonemodal-step-progress--active" : "", isDone ? "xx-clonemodal-step-progress--done" : "", ] .filter(Boolean) - .join(" "); + .join(" ") return ( @@ -577,15 +540,11 @@ const CloneModal: React.FC = ({ /> )}
-
- {isDone ? "✓" : step.icon} -
- - {step.label} - +
{isDone ? "✓" : step.icon}
+ {step.label}
- ); + ) })}
@@ -595,20 +554,14 @@ const CloneModal: React.FC = ({ <>

正在上传音频文件…

-

- 请稍候,正在将音频上传至服务器 -

+

请稍候,正在将音频上传至服务器

)} {phase === "cloning" && ( <>
-

- AI 正在克隆你的声音… -

-

- 正在分析声音特征,生成专属音色模型 -

+

AI 正在克隆你的声音…

+

正在分析声音特征,生成专属音色模型

)}
@@ -643,7 +596,7 @@ const CloneModal: React.FC = ({
)} - ); -}; + ) +} -export default CloneModal; +export default CloneModal diff --git a/apps/web/src/components/voice/clone-modal.css b/apps/web/src/components/voice/clone-modal.css index cbdb8ee08..35952976d 100644 --- a/apps/web/src/components/voice/clone-modal.css +++ b/apps/web/src/components/voice/clone-modal.css @@ -49,8 +49,7 @@ flex-shrink: 0; } -.xx-clonemodal-step:not(.xx-clonemodal-step--active) - .xx-clonemodal-step-number { +.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number { background: var(--xx-color-border, #e5e7eb); color: var(--xx-color-text-secondary, #6b7280); } diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index 729aa52bf..2d614c4fe 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -2,7 +2,7 @@ * 统一导航配置 * Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复 */ -import React from "react"; +import React from "react" import { DashboardOutlined, FileOutlined, @@ -18,20 +18,20 @@ import { ControlOutlined, CrownOutlined, UnorderedListOutlined, -} from "@ant-design/icons"; +} from "@ant-design/icons" /** 导航项类型 */ export interface NavItem { - key: string; - label: string; - path: string; - icon: React.ReactNode; + key: string + label: string + path: string + icon: React.ReactNode } /** 导航分组类型 */ export interface NavGroup { - title: string; - items: NavItem[]; + title: string + items: NavItem[] } /** @@ -117,7 +117,7 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/tasks", icon: React.createElement(UnorderedListOutlined), }, -]; +] /** 侧边栏导航分组(Sidebar 分组列表使用) */ export const NAV_GROUPS: NavGroup[] = [ @@ -226,4 +226,4 @@ export const NAV_GROUPS: NavGroup[] = [ }, ], }, -]; +] diff --git a/apps/web/src/hooks/useAuth.ts b/apps/web/src/hooks/useAuth.ts index 224e10c2d..1d3f0fec5 100644 --- a/apps/web/src/hooks/useAuth.ts +++ b/apps/web/src/hooks/useAuth.ts @@ -1,94 +1,94 @@ /** * 认证相关 Hooks */ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import * as authApi from "@/api/auth"; -import { useAuthStore } from "@/store/authStore"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "react-router-dom" +import * as authApi from "@/api/auth" +import { useAuthStore } from "@/store/authStore" // 登录 Hook export const useLogin = () => { - const navigate = useNavigate(); - const setAuth = useAuthStore((state) => state.setAuth); + const navigate = useNavigate() + const setAuth = useAuthStore((state) => state.setAuth) const mutation = useMutation({ mutationFn: authApi.login, - }); + }) // 手动处理成功后的逻辑 const login = async (credentials: Parameters[0]) => { - const data = await mutation.mutateAsync(credentials); - const refreshToken = data.refresh_token ?? null; + const data = await mutation.mutateAsync(credentials) + const refreshToken = data.refresh_token ?? null // 先保存 token - localStorage.setItem("access_token", data.access_token); + localStorage.setItem("access_token", data.access_token) if (refreshToken) { - localStorage.setItem("refresh_token", refreshToken); + localStorage.setItem("refresh_token", refreshToken) } else { - localStorage.removeItem("refresh_token"); + localStorage.removeItem("refresh_token") } // 获取用户信息 - const user = await authApi.getCurrentUser(); - setAuth(user, data.access_token, refreshToken); - navigate("/"); - return data; - }; + const user = await authApi.getCurrentUser() + setAuth(user, data.access_token, refreshToken) + navigate("/") + return data + } - return { ...mutation, mutateAsync: login }; -}; + return { ...mutation, mutateAsync: login } +} // 注册 Hook export const useRegister = () => { - const navigate = useNavigate(); + const navigate = useNavigate() const mutation = useMutation({ mutationFn: authApi.register, - }); + }) const register = async (data: Parameters[0]) => { - const result = await mutation.mutateAsync(data); + const result = await mutation.mutateAsync(data) navigate("/login", { state: { message: "注册成功!请查收验证邮件。" }, - }); - return result; - }; + }) + return result + } - return { ...mutation, mutateAsync: register }; -}; + return { ...mutation, mutateAsync: register } +} // 登出 Hook export const useLogout = () => { - const navigate = useNavigate(); - const clearAuth = useAuthStore((state) => state.clearAuth); - const queryClient = useQueryClient(); + const navigate = useNavigate() + const clearAuth = useAuthStore((state) => state.clearAuth) + const queryClient = useQueryClient() const mutation = useMutation({ mutationFn: authApi.logout, - }); + }) const logout = async () => { try { - await mutation.mutateAsync(); + await mutation.mutateAsync() } catch (error) { // 即使登出失败也清除本地状态 } finally { - clearAuth(); - queryClient.clear(); - navigate("/"); + clearAuth() + queryClient.clear() + navigate("/") } - }; + } - return { ...mutation, mutateAsync: logout }; -}; + return { ...mutation, mutateAsync: logout } +} // 获取当前用户 Hook export const useCurrentUser = () => { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) return useQuery({ queryKey: ["currentUser"], queryFn: authApi.getCurrentUser, enabled: isAuthenticated, - }); -}; + }) +} diff --git a/apps/web/src/hooks/useCloneProgress.ts b/apps/web/src/hooks/useCloneProgress.ts index 55ee0d080..b33e7d5b1 100644 --- a/apps/web/src/hooks/useCloneProgress.ts +++ b/apps/web/src/hooks/useCloneProgress.ts @@ -4,76 +4,76 @@ * 当存在 processing 状态的克隆条目时,每 3 秒自动拉取最新列表; * 全部完成(ready / failed)后停止轮询。 */ -import { useState, useEffect, useCallback, useRef } from "react"; -import { getVoiceClones } from "@/api/voiceClone"; -import type { VoiceClone } from "@/api/voiceClone"; +import { useState, useEffect, useCallback, useRef } from "react" +import { getVoiceClones } from "@/api/voiceClone" +import type { VoiceClone } from "@/api/voiceClone" -const POLL_INTERVAL = 3000; // 3 秒 +const POLL_INTERVAL = 3000 // 3 秒 export const useCloneProgress = () => { - const [clones, setClones] = useState([]); - const [loading, setLoading] = useState(false); - const timerRef = useRef>(undefined); + const [clones, setClones] = useState([]) + const [loading, setLoading] = useState(false) + const timerRef = useRef>(undefined) const fetchClones = useCallback(async (silent = false) => { - if (!silent) setLoading(true); + if (!silent) setLoading(true) try { - const data = await getVoiceClones(); - setClones(data); + const data = await getVoiceClones() + setClones(data) } catch { // 静默失败,下次轮询重试 } finally { - if (!silent) setLoading(false); + if (!silent) setLoading(false) } - }, []); + }, []) /* 初始加载 */ useEffect(() => { - fetchClones(); - }, [fetchClones]); + fetchClones() + }, [fetchClones]) /* 轮询:有 processing 条目时启动,全部结束时停止 */ useEffect(() => { - const hasProcessing = clones.some((c) => c.status === "processing"); + const hasProcessing = clones.some((c) => c.status === "processing") if (hasProcessing) { if (!timerRef.current) { timerRef.current = setInterval(() => { - fetchClones(true); - }, POLL_INTERVAL); + fetchClones(true) + }, POLL_INTERVAL) } } else { if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = undefined; + clearInterval(timerRef.current) + timerRef.current = undefined } } return () => { if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = undefined; + clearInterval(timerRef.current) + timerRef.current = undefined } - }; - }, [clones, fetchClones]); + } + }, [clones, fetchClones]) /** 手动刷新 */ - const refresh = useCallback(() => fetchClones(), [fetchClones]); + const refresh = useCallback(() => fetchClones(), [fetchClones]) /** 克隆成功后追加到列表 */ const addClone = useCallback((voice: VoiceClone) => { - setClones((prev) => [voice, ...prev]); - }, []); + setClones((prev) => [voice, ...prev]) + }, []) /** 删除后从列表移除 */ const removeClone = useCallback((id: string) => { - setClones((prev) => prev.filter((c) => c.id !== id)); - }, []); + setClones((prev) => prev.filter((c) => c.id !== id)) + }, []) /** 更新某条克隆(如改名) */ const updateClone = useCallback((updated: VoiceClone) => { - setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); - }, []); + setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) + }, []) return { clones, @@ -84,5 +84,5 @@ export const useCloneProgress = () => { updateClone, /** 是否有正在处理中的克隆 */ hasProcessing: clones.some((c) => c.status === "processing"), - }; -}; + } +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c7e20e665..da4f455a5 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -16,9 +16,8 @@ body, } body { - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", - "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", + "Microsoft YaHei", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; color: var(--text-primary); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 856a9fb16..084f245bc 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -2,15 +2,15 @@ * 更新主入口,引入全局样式 * V21 设计系统配置 */ -import React from "react"; -import ReactDOM from "react-dom/client"; -import { RouterProvider } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ConfigProvider, App as AntApp } from "antd"; -import zhCN from "antd/locale/zh_CN"; -import router from "./router"; -import "./index.css"; -import "./styles/global.css"; +import React from "react" +import ReactDOM from "react-dom/client" +import { RouterProvider } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { ConfigProvider, App as AntApp } from "antd" +import zhCN from "antd/locale/zh_CN" +import router from "./router" +import "./index.css" +import "./styles/global.css" // 创建 React Query 客户端 const queryClient = new QueryClient({ @@ -22,7 +22,7 @@ const queryClient = new QueryClient({ gcTime: 10 * 60 * 1000, // 10 分钟 }, }, -}); +}) // V21 Ant Design 主题配置 - Indigo 主色 / 大圆角 const theme = { @@ -85,7 +85,7 @@ const theme = { borderRadius: 14, }, }, -}; +} ReactDOM.createRoot(document.getElementById("root")!).render( @@ -97,4 +97,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render( , -); +) diff --git a/apps/web/src/pages/accounts/Accounts.tsx b/apps/web/src/pages/accounts/Accounts.tsx index 8252a30bb..eadb97653 100644 --- a/apps/web/src/pages/accounts/Accounts.tsx +++ b/apps/web/src/pages/accounts/Accounts.tsx @@ -6,21 +6,21 @@ * * 零 antd 直接导入,全部使用 CSS 变量 */ -import React from "react"; -import { Button } from "@/components/ui"; -import PageHead from "@/components/layout/PageHead"; -import "./accounts.css"; +import React from "react" +import { Button } from "@/components/ui" +import PageHead from "@/components/layout/PageHead" +import "./accounts.css" /* ── 类型定义 ───────────────────────────────────────────── */ -export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat"; +export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat" export interface Platform { - id: PlatformId; - name: string; - subName: string; - icon: string; - gradient: string; + id: PlatformId + name: string + subName: string + icon: string + gradient: string } /** 支持的平台列表 */ @@ -53,27 +53,21 @@ const PLATFORMS: Platform[] = [ icon: "💬", gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)", }, -]; +] /* ── 主页面 ─────────────────────────────────────────────── */ const Accounts: React.FC = () => { return (
- + {/* 平台卡片网格 — 占位状态 */}
{PLATFORMS.map((platform) => (
-
+
{platform.icon}
@@ -100,12 +94,11 @@ const Accounts: React.FC = () => {
📊 - 支持 {PLATFORMS.length}{" "} - 个平台,功能即将上线 + 支持 {PLATFORMS.length} 个平台,功能即将上线
- ); -}; + ) +} -export default Accounts; +export default Accounts diff --git a/apps/web/src/pages/admin/AdminComingSoon.tsx b/apps/web/src/pages/admin/AdminComingSoon.tsx index 7be809566..0f4c0448c 100644 --- a/apps/web/src/pages/admin/AdminComingSoon.tsx +++ b/apps/web/src/pages/admin/AdminComingSoon.tsx @@ -1,10 +1,10 @@ -import React from "react"; -import { Button, Card, Result } from "antd"; -import { useNavigate } from "react-router-dom"; -import "./Admin.css"; +import React from "react" +import { Button, Card, Result } from "antd" +import { useNavigate } from "react-router-dom" +import "./Admin.css" const AdminComingSoon: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() return (
@@ -27,9 +27,9 @@ const AdminComingSoon: React.FC = () => {
- ); -}; + ) +} -export default AdminComingSoon; +export default AdminComingSoon -export const Component = AdminComingSoon; +export const Component = AdminComingSoon diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 23ecd166c..b8a555e47 100755 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -3,7 +3,7 @@ * 两栏布局:左侧素材库列表(260px)+ 右侧素材网格 * 使用 useQuery 对接后端真实 API(api/assets.ts) */ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState } from "react" import { Upload, Modal as AntModal, @@ -14,7 +14,7 @@ import { Input as AntInput, Radio, Select as AntSelect, -} from "antd"; +} from "antd" import { PlusOutlined, SearchOutlined, @@ -32,8 +32,8 @@ import { ThunderboltOutlined, CheckCircleOutlined, CloseCircleOutlined, -} from "@ant-design/icons"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +} from "@ant-design/icons" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { getAssetLibraries, createAssetLibrary, @@ -49,34 +49,34 @@ import { type AssetLibraryItem, type AssetItem as ApiAssetItem, type BatchOperationResult, -} from "@/api/assets"; -import { Button, Input, Select } from "@/components/ui"; -import "./assets.css"; +} from "@/api/assets" +import { Button, Input, Select } from "@/components/ui" +import "./assets.css" /* ============================================================ * 类型 * ============================================================ */ -type AssetKind = "video" | "image"; -type StatusType = "ok" | "warn" | "bad" | "info"; +type AssetKind = "video" | "image" +type StatusType = "ok" | "warn" | "bad" | "info" interface LibraryItem { - id: string; - name: string; - kind: AssetKind; - count: number; + id: string + name: string + kind: AssetKind + count: number } interface AssetItem { - id: string; - name: string; - kind: AssetKind; - thumbUrl?: string; - fileUrl?: string; - status: StatusType; - statusLabel: string; - duration?: string; - size: number; - createdAt: string; + id: string + name: string + kind: AssetKind + thumbUrl?: string + fileUrl?: string + status: StatusType + statusLabel: string + duration?: string + size: number + createdAt: string } /* ============================================================ @@ -85,9 +85,9 @@ interface AssetItem { /** 根据 mime_type 推断前端 AssetKind */ const inferKind = (mimeType: string): AssetKind => { - if (mimeType.startsWith("video/")) return "video"; - return "image"; -}; + if (mimeType.startsWith("video/")) return "video" + return "image" +} /** 根据 quality_score / classification_status / asset status 推断前端状态 */ const inferStatus = ( @@ -97,30 +97,27 @@ const inferStatus = ( ): { status: StatusType; label: string } => { // 素材已就绪(status=ready)时,不应因 classification 未执行而显示"处理中" if (assetStatus === "ready") { - if (score == null) return { status: "info", label: "待诊断" }; - if (score >= 70) return { status: "ok", label: "合格" }; - if (score >= 40) return { status: "warn", label: "待优化" }; - return { status: "bad", label: "不合格" }; + if (score == null) return { status: "info", label: "待诊断" } + if (score >= 70) return { status: "ok", label: "合格" } + if (score >= 40) return { status: "warn", label: "待优化" } + return { status: "bad", label: "不合格" } } // 素材未就绪:classification 正在处理中 - if ( - classificationStatus === "processing" || - classificationStatus === "pending" - ) { - return { status: "info", label: "处理中" }; + if (classificationStatus === "processing" || classificationStatus === "pending") { + return { status: "info", label: "处理中" } } - if (score == null) return { status: "info", label: "待诊断" }; - if (score >= 70) return { status: "ok", label: "合格" }; - if (score >= 40) return { status: "warn", label: "待优化" }; - return { status: "bad", label: "不合格" }; -}; + if (score == null) return { status: "info", label: "待诊断" } + if (score >= 70) return { status: "ok", label: "合格" } + if (score >= 40) return { status: "warn", label: "待优化" } + return { status: "bad", label: "不合格" } +} /** 格式化时长(秒 → mm:ss) */ const formatDuration = (seconds: number): string => { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; -}; + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` +} /** 将后端 AssetLibraryItem 映射为前端 LibraryItem */ const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({ @@ -128,7 +125,7 @@ const mapLibrary = (item: AssetLibraryItem): LibraryItem => ({ name: item.name, kind: (item.kind === "voice" ? "video" : item.kind) || inferKind("video"), count: item.asset_count ?? 0, -}); +}) /** 将后端 ApiAssetItem 映射为前端 AssetItem */ const mapAsset = (item: ApiAssetItem): AssetItem => { @@ -136,9 +133,9 @@ const mapAsset = (item: ApiAssetItem): AssetItem => { item.quality_score ?? undefined, item.classification_status ?? undefined, item.status ?? undefined, - ); - const metadata = item.metadata || {}; - const kind = inferKind(item.mime_type || ""); + ) + const metadata = item.metadata || {} + const kind = inferKind(item.mime_type || "") return { id: item.id, name: item.name, @@ -148,27 +145,20 @@ const mapAsset = (item: ApiAssetItem): AssetItem => { (item.thumbnail_url as string | undefined) || (metadata.thumbnail_url as string | undefined) || (kind !== "video" ? (item.file_url as string | undefined) : undefined), - fileUrl: - (item.file_url as string | undefined) || - (metadata.file_url as string | undefined), + fileUrl: (item.file_url as string | undefined) || (metadata.file_url as string | undefined), status, statusLabel: label, - duration: - metadata.duration != null - ? formatDuration(metadata.duration as number) - : undefined, + duration: metadata.duration != null ? formatDuration(metadata.duration as number) : undefined, size: item.file_size ? +(item.file_size / (1024 * 1024)).toFixed(1) : 0, - createdAt: item.created_at - ? new Date(item.created_at).toISOString().slice(0, 10) - : "—", - }; -}; + createdAt: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—", + } +} /* ============================================================ * 常量 * ============================================================ */ -const MAX_FILE_SIZE = 2048 * 1024 * 1024; -const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; +const MAX_FILE_SIZE = 2048 * 1024 * 1024 +const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024 /* ============================================================ * 工具函数 @@ -176,40 +166,37 @@ const LARGE_FILE_THRESHOLD = 100 * 1024 * 1024; const kindIcon = (kind: AssetKind) => { switch (kind) { case "video": - return ; + return case "image": - return ; + return } -}; +} const kindLabel = (kind: AssetKind) => { switch (kind) { case "video": - return "视频"; + return "视频" case "image": - return "图片"; + return "图片" } -}; +} /** 根据素材类型返回渐变背景 */ const thumbGradient = (kind: AssetKind): string => { switch (kind) { case "video": - return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)"; + return "linear-gradient(135deg, #312e81 0%, #4f46e5 50%, #6366f1 100%)" case "image": - return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)"; + return "linear-gradient(135deg, #78350f 0%, #d97706 50%, #f59e0b 100%)" } -}; +} /* ============================================================ * StatusPill 组件 * ============================================================ */ -const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ - status, - label, -}) => ( +const StatusPill: React.FC<{ status: StatusType; label: string }> = ({ status, label }) => ( {label} -); +) /* ============================================================ * SkeletonCard — 骨架屏卡片(素材列表加载时占位) @@ -218,14 +205,8 @@ const SkeletonCard: React.FC = () => (
-
-
+
+
( />
-); +) /* ============================================================ * AssetCard 组件 * ============================================================ */ const AssetCard: React.FC<{ - asset: AssetItem; - selected: boolean; - diagnosing?: boolean; - onToggle: () => void; - onDiagnose: () => void; - onPlay: () => void; - onDelete: () => void; -}> = ({ - asset, - selected, - diagnosing, - onToggle, - onDiagnose, - onPlay, - onDelete, -}) => ( -
+ asset: AssetItem + selected: boolean + diagnosing?: boolean + onToggle: () => void + onDiagnose: () => void + onPlay: () => void + onDelete: () => void +}> = ({ asset, selected, diagnosing, onToggle, onDiagnose, onPlay, onDelete }) => ( +
{/* 缩略图区 */} -
+
{asset.thumbUrl ? ( {asset.name} ) : ( - - {kindIcon(asset.kind)} - + {kindIcon(asset.kind)} )} {/* 视频/配音类显示播放按钮 */} @@ -281,8 +246,8 @@ const AssetCard: React.FC<{ { - e.stopPropagation(); - onPlay(); + e.stopPropagation() + onPlay() }} > @@ -294,8 +259,8 @@ const AssetCard: React.FC<{ title="确认删除" description="删除后不可恢复,确定要删除这个素材吗?" onConfirm={(e) => { - e?.stopPropagation(); - onDelete(); + e?.stopPropagation() + onDelete() }} onCancel={(e) => e?.stopPropagation()} okText="删除" @@ -328,8 +293,8 @@ const AssetCard: React.FC<{ className={`xx-asset-diagnose-btn${diagnosing ? " xx-asset-diagnose-btn-loading" : ""}`} disabled={diagnosing} onClick={(e) => { - e.stopPropagation(); - onDiagnose(); + e.stopPropagation() + onDiagnose() }} > {diagnosing ? : } @@ -337,34 +302,31 @@ const AssetCard: React.FC<{
-); +) /* ============================================================ * 主组件 * ============================================================ */ const AssetLibrary: React.FC = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() /* ── 获取素材库列表 ── */ - const { data: apiLibraries = [], isLoading: libLoading } = useQuery< - AssetLibraryItem[], - Error - >({ + const { data: apiLibraries = [], isLoading: libLoading } = useQuery({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, staleTime: 60_000, - }); + }) const libraries = useMemo( () => (Array.isArray(apiLibraries) ? apiLibraries : []).map(mapLibrary), [apiLibraries], - ); + ) /* ── 当前选中的素材库 ── */ - const [activeLibId, setActiveLibId] = useState(""); + const [activeLibId, setActiveLibId] = useState("") // 当库列表加载完成后,自动选中第一个 - const effectiveLibId = activeLibId || libraries[0]?.id || ""; + const effectiveLibId = activeLibId || libraries[0]?.id || "" /* ── 获取当前库的素材列表 ── */ const { @@ -378,391 +340,388 @@ const AssetLibrary: React.FC = () => { queryFn: () => getAssets(effectiveLibId), enabled: !!effectiveLibId, staleTime: 30_000, - }); + }) const assets = useMemo( () => (Array.isArray(apiAssets) ? apiAssets : []).map(mapAsset), [apiAssets], - ); + ) /* ── Mutations ── */ const createLibMutation = useMutation({ mutationFn: createAssetLibrary, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - message.success("素材库创建成功"); + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + message.success("素材库创建成功") }, onError: () => { - message.error("创建素材库失败"); + message.error("创建素材库失败") }, - }); + }) const deleteLibMutation = useMutation({ mutationFn: deleteAssetLibrary, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - message.success("素材库已删除"); + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + message.success("素材库已删除") }, onError: () => { - message.error("删除素材库失败"); + message.error("删除素材库失败") }, - }); + }) /* 状态 */ - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()) // 大文件直传由 handleUpload 直接调用 uploadAssetDirect 处理 /* 筛选 */ - const [searchText, setSearchText] = useState(""); - const [filterType, setFilterType] = useState("all"); - const [filterTime, setFilterTime] = useState("all"); + const [searchText, setSearchText] = useState("") + const [filterType, setFilterType] = useState("all") + const [filterTime, setFilterTime] = useState("all") /* 上传 */ - const [uploading, setUploading] = useState(false); - const [uploadProgress, setUploadProgress] = useState(0); + const [uploading, setUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) /* 新建素材库 */ - const [createModalOpen, setCreateModalOpen] = useState(false); - const [newLibName, setNewLibName] = useState(""); - const [newLibKind, setNewLibKind] = useState("video"); + const [createModalOpen, setCreateModalOpen] = useState(false) + const [newLibName, setNewLibName] = useState("") + const [newLibKind, setNewLibKind] = useState("video") /* 视频播放 */ - const [playingAsset, setPlayingAsset] = useState(null); + const [playingAsset, setPlayingAsset] = useState(null) /* 诊断中状态 — 记录正在诊断的素材 ID */ - const [diagnosingId, setDiagnosingId] = useState(null); + const [diagnosingId, setDiagnosingId] = useState(null) /* ── 批量操作弹窗状态 ── */ - const [tagModalOpen, setTagModalOpen] = useState(false); - const [classifyModalOpen, setClassifyModalOpen] = useState(false); - const [markModalOpen, setMarkModalOpen] = useState(false); - const [resultDrawerOpen, setResultDrawerOpen] = useState(false); + const [tagModalOpen, setTagModalOpen] = useState(false) + const [classifyModalOpen, setClassifyModalOpen] = useState(false) + const [markModalOpen, setMarkModalOpen] = useState(false) + const [resultDrawerOpen, setResultDrawerOpen] = useState(false) /* 批量打标签 */ - const [batchTagInput, setBatchTagInput] = useState(""); - const [batchTags, setBatchTags] = useState([]); - const [tagMode, setTagMode] = useState<"add" | "replace">("add"); + const [batchTagInput, setBatchTagInput] = useState("") + const [batchTags, setBatchTags] = useState([]) + const [tagMode, setTagMode] = useState<"add" | "replace">("add") /* 批量改分类 */ - const [batchCategory, setBatchCategory] = useState(""); + const [batchCategory, setBatchCategory] = useState("") /* 批量智能标记 */ - const [batchSmartView, setBatchSmartView] = useState< - "recommended" | "caution" | "high_risk" - >("recommended"); + const [batchSmartView, setBatchSmartView] = useState<"recommended" | "caution" | "high_risk">( + "recommended", + ) /* 操作结果 */ - const [operationResult, setOperationResult] = - useState(null); - const [operationTitle, setOperationTitle] = useState(""); + const [operationResult, setOperationResult] = useState(null) + const [operationTitle, setOperationTitle] = useState("") /* 批量操作 loading */ - const [batchLoading, setBatchLoading] = useState(false); + const [batchLoading, setBatchLoading] = useState(false) /* 派生数据 */ const filteredAssets = useMemo(() => { - let list = assets; + let list = assets /* 按素材库类型过滤(如果筛选类型不是 all) */ if (filterType !== "all") { - list = list.filter((a) => a.kind === filterType); + list = list.filter((a) => a.kind === filterType) } /* 按时间筛选 */ if (filterTime !== "all") { - const now = new Date(); + const now = new Date() list = list.filter((a) => { - const d = new Date(a.createdAt); - const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); - if (filterTime === "today") return diffDays < 1; - if (filterTime === "week") return diffDays < 7; - if (filterTime === "month") return diffDays < 30; - return true; - }); + const d = new Date(a.createdAt) + const diffDays = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24) + if (filterTime === "today") return diffDays < 1 + if (filterTime === "week") return diffDays < 7 + if (filterTime === "month") return diffDays < 30 + return true + }) } /* 搜索 */ if (searchText.trim()) { - const q = searchText.trim().toLowerCase(); - list = list.filter((a) => a.name.toLowerCase().includes(q)); + const q = searchText.trim().toLowerCase() + list = list.filter((a) => a.name.toLowerCase().includes(q)) } - return list; - }, [assets, filterType, filterTime, searchText]); + return list + }, [assets, filterType, filterTime, searchText]) /* 选择操作 */ const toggleSelect = (id: string) => { setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } const selectAll = () => { - setSelectedIds(new Set(filteredAssets.map((a) => a.id))); - }; + setSelectedIds(new Set(filteredAssets.map((a) => a.id))) + } const deselectAll = () => { - setSelectedIds(new Set()); - }; + setSelectedIds(new Set()) + } /* 上传 — 调用真实 API */ const handleUpload = async (file: File) => { if (file.size > MAX_FILE_SIZE) { - message.error(`文件 "${file.name}" 超过 2GB 限制`); - return; + message.error(`文件 "${file.name}" 超过 2GB 限制`) + return } if (!effectiveLibId) { - message.warning("请先选择或创建一个素材库"); - return; + message.warning("请先选择或创建一个素材库") + return } - setUploading(true); - setUploadProgress(0); + setUploading(true) + setUploadProgress(0) try { if (file.size > LARGE_FILE_THRESHOLD) { - message.info(`大文件 "${file.name}" 将使用直传上传`); + message.info(`大文件 "${file.name}" 将使用直传上传`) } await uploadAssetDirect({ file, library_id: effectiveLibId, onProgress: (pct) => setUploadProgress(pct), - }); - message.success(`"${file.name}" 上传成功`); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + }) + message.success(`"${file.name}" 上传成功`) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) } catch (err: unknown) { - const detail = err instanceof Error ? err.message : ""; - console.error("[handleUpload] 上传失败:", err); - message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`); + const detail = err instanceof Error ? err.message : "" + console.error("[handleUpload] 上传失败:", err) + message.error(`"${file.name}" 上传失败${detail ? `:${detail}` : ""}`) // 错误时延迟关闭弹窗,让用户能看到错误提示 - await new Promise((r) => setTimeout(r, 1500)); + await new Promise((r) => setTimeout(r, 1500)) } finally { - setUploading(false); - setUploadProgress(0); + setUploading(false) + setUploadProgress(0) } - }; + } /* 新建素材库 */ const handleCreateLibrary = async () => { if (!newLibName.trim()) { - message.warning("请输入素材库名称"); - return; + message.warning("请输入素材库名称") + return } try { const newLib = await createLibMutation.mutateAsync({ name: newLibName.trim(), kind: newLibKind, - }); - setActiveLibId(newLib.id); - setCreateModalOpen(false); - setNewLibName(""); - setNewLibKind("video"); + }) + setActiveLibId(newLib.id) + setCreateModalOpen(false) + setNewLibName("") + setNewLibKind("video") } catch { // error handled in mutation } - }; + } /* 删除素材库 */ const handleDeleteLibrary = async (id: string) => { try { - await deleteLibMutation.mutateAsync(id); + await deleteLibMutation.mutateAsync(id) if (effectiveLibId === id) { - const remaining = libraries.filter((l) => l.id !== id); - if (remaining.length > 0) setActiveLibId(remaining[0].id); - else setActiveLibId(""); + const remaining = libraries.filter((l) => l.id !== id) + if (remaining.length > 0) setActiveLibId(remaining[0].id) + else setActiveLibId("") } } catch { // error handled in mutation } - }; + } /* 诊断 — 调用真实 API,带 loading 状态 */ const handleDiagnose = async (asset: AssetItem) => { - setDiagnosingId(asset.id); + setDiagnosingId(asset.id) try { - const result = await getAssetDiagnosis(asset.id); - const score = result.readiness_score ?? "-"; - message.success(`"${asset.name}" 诊断完成,就绪分:${score}`); - queryClient.invalidateQueries({ queryKey: ["assets"] }); + const result = await getAssetDiagnosis(asset.id) + const score = result.readiness_score ?? "-" + message.success(`"${asset.name}" 诊断完成,就绪分:${score}`) + queryClient.invalidateQueries({ queryKey: ["assets"] }) } catch { - message.error(`"${asset.name}" 诊断失败`); + message.error(`"${asset.name}" 诊断失败`) } finally { - setDiagnosingId(null); + setDiagnosingId(null) } - }; + } /* 批量删除 */ /* 单个素材删除 */ const handleSingleDelete = async (assetId: string) => { try { - await deleteAsset(assetId); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + await deleteAsset(assetId) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) // 从选中集合中移除 setSelectedIds((prev) => { - const next = new Set(prev); - next.delete(assetId); - return next; - }); - message.success("素材已删除"); + const next = new Set(prev) + next.delete(assetId) + return next + }) + message.success("素材已删除") } catch { - message.error("删除失败,请重试"); + message.error("删除失败,请重试") } - }; + } const handleBatchDelete = async () => { - const ids = Array.from(selectedIds); - setBatchLoading(true); + const ids = Array.from(selectedIds) + setBatchLoading(true) try { - const result = await batchDeleteAssets(ids); - setOperationResult(result); - setOperationTitle("批量删除"); - setResultDrawerOpen(true); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - setSelectedIds(new Set()); + const result = await batchDeleteAssets(ids) + setOperationResult(result) + setOperationTitle("批量删除") + setResultDrawerOpen(true) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) + setSelectedIds(new Set()) if (result.failure_count === 0) { - message.success(`成功删除 ${result.success_count} 个素材`); + message.success(`成功删除 ${result.success_count} 个素材`) } else { message.warning( `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ); + ) } } catch { - message.error("批量删除失败,请重试"); + message.error("批量删除失败,请重试") } finally { - setBatchLoading(false); + setBatchLoading(false) } - }; + } /* 批量打标签 */ const handleBatchTag = async () => { if (batchTags.length === 0) { - message.warning("请至少输入一个标签"); - return; + message.warning("请至少输入一个标签") + return } - const ids = Array.from(selectedIds); - setBatchLoading(true); + const ids = Array.from(selectedIds) + setBatchLoading(true) try { const result = await batchTagAssets({ asset_ids: ids, tags: batchTags, mode: tagMode, - }); - setOperationResult(result); - setOperationTitle("批量打标签"); - setResultDrawerOpen(true); - setTagModalOpen(false); - setBatchTags([]); - setBatchTagInput(""); - setTagMode("add"); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - setSelectedIds(new Set()); + }) + setOperationResult(result) + setOperationTitle("批量打标签") + setResultDrawerOpen(true) + setTagModalOpen(false) + setBatchTags([]) + setBatchTagInput("") + setTagMode("add") + queryClient.invalidateQueries({ queryKey: ["assets"] }) + setSelectedIds(new Set()) if (result.failure_count === 0) { - message.success(`成功为 ${result.success_count} 个素材打标签`); + message.success(`成功为 ${result.success_count} 个素材打标签`) } else { message.warning( `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ); + ) } } catch { - message.error("批量打标签失败,请重试"); + message.error("批量打标签失败,请重试") } finally { - setBatchLoading(false); + setBatchLoading(false) } - }; + } /* 批量改分类 */ const handleBatchClassify = async () => { if (!batchCategory) { - message.warning("请选择分类"); - return; + message.warning("请选择分类") + return } - const ids = Array.from(selectedIds); - setBatchLoading(true); + const ids = Array.from(selectedIds) + setBatchLoading(true) try { const result = await batchClassifyAssets({ asset_ids: ids, category: batchCategory, - }); - setOperationResult(result); - setOperationTitle("批量改分类"); - setResultDrawerOpen(true); - setClassifyModalOpen(false); - setBatchCategory(""); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - setSelectedIds(new Set()); + }) + setOperationResult(result) + setOperationTitle("批量改分类") + setResultDrawerOpen(true) + setClassifyModalOpen(false) + setBatchCategory("") + queryClient.invalidateQueries({ queryKey: ["assets"] }) + setSelectedIds(new Set()) if (result.failure_count === 0) { - message.success( - `成功将 ${result.success_count} 个素材改为「${batchCategory}」`, - ); + message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}」`) } else { message.warning( `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ); + ) } } catch { - message.error("批量改分类失败,请重试"); + message.error("批量改分类失败,请重试") } finally { - setBatchLoading(false); + setBatchLoading(false) } - }; + } /* 批量智能标记 */ const handleBatchMark = async () => { - const ids = Array.from(selectedIds); - setBatchLoading(true); + const ids = Array.from(selectedIds) + setBatchLoading(true) try { const result = await batchMarkAssets({ asset_ids: ids, smart_view: batchSmartView, - }); - setOperationResult(result); - setOperationTitle("批量智能标记"); - setResultDrawerOpen(true); - setMarkModalOpen(false); - queryClient.invalidateQueries({ queryKey: ["assets"] }); - setSelectedIds(new Set()); + }) + setOperationResult(result) + setOperationTitle("批量智能标记") + setResultDrawerOpen(true) + setMarkModalOpen(false) + queryClient.invalidateQueries({ queryKey: ["assets"] }) + setSelectedIds(new Set()) const labelMap = { recommended: "推荐", caution: "慎用", high_risk: "高风险", - }; + } if (result.failure_count === 0) { message.success( `成功将 ${result.success_count} 个素材标记为「${labelMap[batchSmartView]}」`, - ); + ) } else { message.warning( `智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, - ); + ) } } catch { - message.error("批量智能标记失败,请重试"); + message.error("批量智能标记失败,请重试") } finally { - setBatchLoading(false); + setBatchLoading(false) } - }; + } /* 标签输入处理 */ const handleTagInputKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && batchTagInput.trim()) { - e.preventDefault(); - const tag = batchTagInput.trim(); + e.preventDefault() + const tag = batchTagInput.trim() if (!batchTags.includes(tag)) { - setBatchTags([...batchTags, tag]); + setBatchTags([...batchTags, tag]) } - setBatchTagInput(""); + setBatchTagInput("") } - }; + } const removeBatchTag = (tag: string) => { - setBatchTags(batchTags.filter((t) => t !== tag)); - }; + setBatchTags(batchTags.filter((t) => t !== tag)) + } // ── Loading 状态 ── if (libLoading) { @@ -774,7 +733,7 @@ const AssetLibrary: React.FC = () => { ))}
- ); + ) } return ( @@ -790,12 +749,7 @@ const AssetLibrary: React.FC = () => { className="xx-upload-progress-modal" >
- + {/* 背景圆环 */} { { - e?.stopPropagation(); - handleDeleteLibrary(lib.id); + e?.stopPropagation() + handleDeleteLibrary(lib.id) }} onCancel={(e) => e?.stopPropagation()} okText="删除" @@ -867,10 +821,7 @@ const AssetLibrary: React.FC = () => { ))} {/* 新建素材库 */} -
setCreateModalOpen(true)} - > +
setCreateModalOpen(true)}> 新建素材库
@@ -883,8 +834,8 @@ const AssetLibrary: React.FC = () => { beforeUpload={(file) => { // 同步返回 false 阻止 antd 默认上传行为 // 异步上传由 handleUpload 处理 - handleUpload(file as File); - return false; + handleUpload(file as File) + return false }} showUploadList={false} multiple @@ -897,9 +848,7 @@ const AssetLibrary: React.FC = () => {

{uploading ? "上传中..." : "点击或拖拽文件到此区域上传"}

-

- 支持视频、图片,单文件不超过 2GB -

+

支持视频、图片,单文件不超过 2GB

@@ -940,18 +889,14 @@ const AssetLibrary: React.FC = () => { - - 共 {filteredAssets.length} 个素材 - + 共 {filteredAssets.length} 个素材
{/* 批量操作栏 */} {selectedIds.size > 0 && (
- - 已选 {selectedIds.size} 项 - + 已选 {selectedIds.size} 项 @@ -985,11 +930,7 @@ const AssetLibrary: React.FC = () => { okText="删除" cancelText="取消" > - @@ -1008,14 +949,8 @@ const AssetLibrary: React.FC = () => {
-

- {assetsErrorObj?.message || "加载失败"} -

-
@@ -1039,9 +974,7 @@ const AssetLibrary: React.FC = () => {
-

- 暂无素材,请上传或切换素材库 -

+

暂无素材,请上传或切换素材库

)}
@@ -1093,18 +1026,11 @@ const AssetLibrary: React.FC = () => { destroyOnClose > {playingAsset?.fileUrl ? ( -
- ); -}; + ) +} -export default Login; +export default Login diff --git a/apps/web/src/pages/auth/Register.css b/apps/web/src/pages/auth/Register.css index c3ec61896..094e2bb29 100644 --- a/apps/web/src/pages/auth/Register.css +++ b/apps/web/src/pages/auth/Register.css @@ -31,19 +31,13 @@ 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); + box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent); transition: all 0.2s; } .xx-auth-card .xx-btn-primary:hover { transform: translateY(-1px); - box-shadow: 0 6px 20px - color-mix(in srgb, var(--primary-color) 40%, transparent); + box-shadow: 0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent); } diff --git a/apps/web/src/pages/auth/Register.tsx b/apps/web/src/pages/auth/Register.tsx index d8a34b1da..230e91c7a 100755 --- a/apps/web/src/pages/auth/Register.tsx +++ b/apps/web/src/pages/auth/Register.tsx @@ -1,24 +1,24 @@ /** * 注册页面 - V21 完全对标 */ -import React from "react"; -import { Form, Input, message } from "antd"; -import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons"; -import { Link } from "react-router-dom"; -import { useRegister } from "@/hooks/useAuth"; -import Button from "@/components/ui/Button"; -import "./Register.css"; +import React from "react" +import { Form, Input, message } from "antd" +import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons" +import { Link } from "react-router-dom" +import { useRegister } from "@/hooks/useAuth" +import Button from "@/components/ui/Button" +import "./Register.css" interface RegisterFormValues { - email: string; - username: string; - password: string; - confirmPassword: string; + email: string + username: string + password: string + confirmPassword: string } const Register: React.FC = () => { - const registerMutation = useRegister(); - const [form] = Form.useForm(); + const registerMutation = useRegister() + const [form] = Form.useForm() const onFinish = async (values: RegisterFormValues) => { try { @@ -27,13 +27,12 @@ const Register: React.FC = () => { username: values.username, password: values.password, display_name: values.username, - }); - message.success("注册成功!请查收验证邮件。"); + }) + message.success("注册成功!请查收验证邮件。") } catch (error: unknown) { - if (!(error as { __msgShown?: boolean })?.__msgShown) - message.error("注册失败,请重试"); + if (!(error as { __msgShown?: boolean })?.__msgShown) message.error("注册失败,请重试") } - }; + } return (
@@ -46,13 +45,7 @@ const Register: React.FC = () => {

创建账户,开启智能视频创作之旅

- + { { type: "email", message: "请输入有效的邮箱地址" }, ]} > - } - placeholder="name@example.com" - size="large" - /> + } placeholder="name@example.com" size="large" /> { }, ]} > - } - placeholder="用户名" - size="large" - /> + } placeholder="用户名" size="large" /> { }, ]} > - } - placeholder="•••••••••" - size="large" - /> + } placeholder="•••••••••" size="large" /> { ({ getFieldValue }) => ({ validator(_, value) { if (!value || getFieldValue("password") === value) { - return Promise.resolve(); + return Promise.resolve() } - return Promise.reject(new Error("两次输入的密码不一致")); + return Promise.reject(new Error("两次输入的密码不一致")) }, }), ]} > - } - placeholder="•••••••••" - size="large" - /> + } placeholder="•••••••••" size="large" /> @@ -148,7 +125,7 @@ const Register: React.FC = () => {
- ); -}; + ) +} -export default Register; +export default Register diff --git a/apps/web/src/pages/auth/ResetPassword.css b/apps/web/src/pages/auth/ResetPassword.css index a6a7dd7b7..44d16d496 100644 --- a/apps/web/src/pages/auth/ResetPassword.css +++ b/apps/web/src/pages/auth/ResetPassword.css @@ -3,11 +3,7 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient( - 135deg, - rgba(79, 70, 229, 0.08), - rgba(16, 185, 129, 0.06) - ); + background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); padding: 24px; } @@ -86,11 +82,7 @@ display: grid; place-items: center; min-height: 100vh; - background: linear-gradient( - 135deg, - rgba(79, 70, 229, 0.08), - rgba(16, 185, 129, 0.06) - ); + background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06)); padding: 24px; } diff --git a/apps/web/src/pages/auth/ResetPassword.tsx b/apps/web/src/pages/auth/ResetPassword.tsx index 01caafd80..87e856974 100644 --- a/apps/web/src/pages/auth/ResetPassword.tsx +++ b/apps/web/src/pages/auth/ResetPassword.tsx @@ -1,35 +1,34 @@ /** * 重置密码页面 - V21 完全对标 */ -import React from "react"; -import { Form, Input, Button, Result, message } from "antd"; -import { LockOutlined } from "@ant-design/icons"; -import { Link, useSearchParams, useNavigate } from "react-router-dom"; -import { useMutation } from "@tanstack/react-query"; -import { resetPassword } from "@/api/auth"; -import "./ResetPassword.css"; +import React from "react" +import { Form, Input, Button, Result, message } from "antd" +import { LockOutlined } from "@ant-design/icons" +import { Link, useSearchParams, useNavigate } from "react-router-dom" +import { useMutation } from "@tanstack/react-query" +import { resetPassword } from "@/api/auth" +import "./ResetPassword.css" const ResetPassword: React.FC = () => { - const [form] = Form.useForm(); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const token = searchParams.get("token"); + const [form] = Form.useForm() + const [searchParams] = useSearchParams() + const navigate = useNavigate() + const token = searchParams.get("token") const resetMutation = useMutation({ mutationFn: (password: string) => resetPassword(token!, password), onSuccess: () => { - message.success("密码重置成功!"); - setTimeout(() => navigate("/login"), 2000); + message.success("密码重置成功!") + setTimeout(() => navigate("/login"), 2000) }, onError: (error: unknown) => { - if (!(error as { __msgShown?: boolean })?.__msgShown) - message.error("重置失败,请重试"); + if (!(error as { __msgShown?: boolean })?.__msgShown) message.error("重置失败,请重试") }, - }); + }) const onFinish = (values: { password: string }) => { - resetMutation.mutate(values.password); - }; + resetMutation.mutate(values.password) + } if (!token) { return ( @@ -47,7 +46,7 @@ const ResetPassword: React.FC = () => { />
- ); + ) } if (resetMutation.isSuccess) { @@ -66,7 +65,7 @@ const ResetPassword: React.FC = () => { />
- ); + ) } return ( @@ -78,12 +77,7 @@ const ResetPassword: React.FC = () => {

设置新密码

-
+ { }, ]} > - } - placeholder="•••••••••" - size="large" - /> + } placeholder="•••••••••" size="large" /> { ({ getFieldValue }) => ({ validator(_, value) { if (!value || getFieldValue("password") === value) { - return Promise.resolve(); + return Promise.resolve() } - return Promise.reject(new Error("两次输入的密码不一致")); + return Promise.reject(new Error("两次输入的密码不一致")) }, }), ]} > - } - placeholder="•••••••••" - size="large" - /> + } placeholder="•••••••••" size="large" /> @@ -143,7 +129,7 @@ const ResetPassword: React.FC = () => { - ); -}; + ) +} -export default ResetPassword; +export default ResetPassword diff --git a/apps/web/src/pages/dashboard/Dashboard.tsx b/apps/web/src/pages/dashboard/Dashboard.tsx index 95239e755..416149512 100644 --- a/apps/web/src/pages/dashboard/Dashboard.tsx +++ b/apps/web/src/pages/dashboard/Dashboard.tsx @@ -3,16 +3,16 @@ * KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告 * CSS 变量,V21 组件 */ -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/components/ui"; -import { DatabaseOutlined } from "@ant-design/icons"; -import "./dashboard.css"; +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" +import { DatabaseOutlined } from "@ant-design/icons" +import "./dashboard.css" /* ── 主组件 ─────────────────────────────────────────────── */ const Dashboard: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() return (
@@ -39,11 +39,7 @@ const Dashboard: React.FC = () => {

最近任务

-
@@ -55,10 +51,7 @@ const Dashboard: React.FC = () => {
{/* 使用统计 */} -
+

使用统计

@@ -73,18 +66,13 @@ const Dashboard: React.FC = () => {
{/* 公告 */} -
+

公告

- - 官方 - + 官方

欢迎使用小应 SaaS 平台

@@ -93,7 +81,7 @@ const Dashboard: React.FC = () => {
- ); -}; + ) +} -export default Dashboard; +export default Dashboard diff --git a/apps/web/src/pages/duplication/DuplicationDetail.tsx b/apps/web/src/pages/duplication/DuplicationDetail.tsx index ada59f3a9..2cfe869a8 100644 --- a/apps/web/src/pages/duplication/DuplicationDetail.tsx +++ b/apps/web/src/pages/duplication/DuplicationDetail.tsx @@ -3,75 +3,70 @@ * 风险评估 + 基本信息 + 检测项列表 + 匹配片段 * 零 antd 依赖 */ -import React, { useState, useCallback } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button, Tag, Tooltip } from "@/components/ui"; -import { useParams, useNavigate } from "react-router-dom"; -import { - getDuplicationDetail, - retryDuplication, - type DuplicateSegment, -} from "@/api/duplication"; -import "./duplication.css"; -import PageHead from "@/components/layout/PageHead"; +import React, { useState, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Button, Tag, Tooltip } from "@/components/ui" +import { useParams, useNavigate } from "react-router-dom" +import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication" +import "./duplication.css" +import PageHead from "@/components/layout/PageHead" /** 格式化时间(秒 → mm:ss) */ const formatTime = (seconds: number) => { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`; -}; + const m = Math.floor(seconds / 60) + const s = Math.floor(seconds % 60) + return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}` +} /** 格式化文件大小 */ const formatSize = (bytes: number) => { - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; -}; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` +} /** 格式化时长 */ const formatDuration = (seconds?: number) => { - if (!seconds) return "-"; - const totalSec = Math.round(seconds); - const m = Math.floor(totalSec / 60); - const s = totalSec % 60; - return m > 0 ? `${m}分${s}秒` : `${s}秒`; -}; + if (!seconds) return "-" + const totalSec = Math.round(seconds) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + return m > 0 ? `${m}分${s}秒` : `${s}秒` +} /** 根据查重率获取风险等级 */ const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { - if (rate === undefined) return "low"; - if (rate <= 10) return "low"; - if (rate <= 30) return "medium"; - return "high"; -}; + if (rate === undefined) return "low" + if (rate <= 10) return "low" + if (rate <= 30) return "medium" + return "high" +} /** 风险等级描述 */ const RISK_DESC: Record = { low: "查重率较低,内容原创度高", medium: "存在一定重复,建议修改部分片段", high: "重复率较高,建议大幅修改或替换", -}; +} /** 风险等级标签变体 */ const RISK_TAG_VARIANT: Record = { low: "success", medium: "warning", high: "error", -}; +} /** 风险等级文字 */ const RISK_LABEL: Record = { low: "低风险", medium: "中风险", high: "高风险", -}; +} /** 简易 toast */ interface ToastState { - message: string; - type: "success" | "error" | "warning"; + message: string + type: "success" | "error" | "warning" } /** 单个重复片段卡片 */ @@ -79,14 +74,9 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ segment, index, }) => { - const sourceDuration = segment.source_end - segment.source_start; - const matchedDuration = segment.matched_end - segment.matched_start; - const riskLevel = - segment.similarity >= 90 - ? "high" - : segment.similarity >= 70 - ? "medium" - : "low"; + const sourceDuration = segment.source_end - segment.source_start + const matchedDuration = segment.matched_end - segment.matched_start + const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low" return (
@@ -96,9 +86,8 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ 片段 {index + 1}:{segment.matched_video_name}

- 原始 {formatTime(segment.source_start)} -{" "} - {formatTime(segment.source_end)}({sourceDuration.toFixed(0)}s)→ 匹配{" "} - {formatTime(segment.matched_start)} -{" "} + 原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}( + {sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "} {formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)

@@ -108,26 +97,21 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({ style={{ width: `${Math.min(segment.similarity, 100)}%` }} /> - - {segment.similarity.toFixed(1)}% - + {segment.similarity.toFixed(1)}% - ); -}; + ) +} const DuplicationDetail: React.FC = () => { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [toast, setToast] = useState(null); + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() + const [toast, setToast] = useState(null) - const showToast = useCallback( - (message: string, type: "success" | "error" | "warning") => { - setToast({ message, type }); - setTimeout(() => setToast(null), 3000); - }, - [], - ); + const showToast = useCallback((message: string, type: "success" | "error" | "warning") => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + }, []) const { data: detail, @@ -137,19 +121,19 @@ const DuplicationDetail: React.FC = () => { queryKey: ["duplication-detail", id], queryFn: () => getDuplicationDetail(id!), enabled: !!id, - }); + }) // 重新查重 const retryMutation = useMutation({ mutationFn: retryDuplication, onSuccess: () => { - showToast("已重新提交查重", "success"); - queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] }); + showToast("已重新提交查重", "success") + queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] }) }, onError: () => { - showToast("重新查重失败", "error"); + showToast("重新查重失败", "error") }, - }); + }) if (isLoading) { return ( @@ -159,7 +143,7 @@ const DuplicationDetail: React.FC = () => {

加载中...

- ); + ) } if (isError || !detail) { @@ -178,23 +162,17 @@ const DuplicationDetail: React.FC = () => { - ); + ) } - const riskLevel = getRiskLevel(detail.duplicate_rate); + const riskLevel = getRiskLevel(detail.duplicate_rate) const similarityPercent = - detail.duplicate_rate !== undefined - ? detail.duplicate_rate.toFixed(1) - : "—"; + detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—" return (
{/* Toast */} - {toast && ( -
- {toast.message} -
- )} + {toast &&
{toast.message}
} { marginTop: 8, }} > - - {RISK_LABEL[riskLevel]} - + {RISK_LABEL[riskLevel]} {formatSize(detail.file_size)} {formatDuration(detail.duration_seconds)} - - 提交于 {new Date(detail.created_at).toLocaleString("zh-CN")} - + 提交于 {new Date(detail.created_at).toLocaleString("zh-CN")}
} actions={ -
+
文件大小 - - {formatSize(detail.file_size)} - + {formatSize(detail.file_size)}
视频时长 - - {formatDuration(detail.duration_seconds)} - + {formatDuration(detail.duration_seconds)}
查重状态 @@ -315,9 +282,7 @@ const DuplicationDetail: React.FC = () => {
重复片段数 - - {detail.duplicate_count ?? 0} 个 - + {detail.duplicate_count ?? 0} 个
提交时间 @@ -352,7 +317,7 @@ const DuplicationDetail: React.FC = () => {
- ); -}; + ) +} -export default DuplicationDetail; +export default DuplicationDetail diff --git a/apps/web/src/pages/duplication/DuplicationResults.tsx b/apps/web/src/pages/duplication/DuplicationResults.tsx index e150b215c..e311c9a89 100644 --- a/apps/web/src/pages/duplication/DuplicationResults.tsx +++ b/apps/web/src/pages/duplication/DuplicationResults.tsx @@ -2,73 +2,72 @@ * 查重结果列表页面 — V21 设计系统 * 胶囊筛选 + 卡片列表,零 antd 依赖 */ -import React, { useState, useMemo } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button, Tag, Tooltip } from "@/components/ui"; -import { useNavigate } from "react-router-dom"; +import React, { useState, useMemo } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Button, Tag, Tooltip } from "@/components/ui" +import { useNavigate } from "react-router-dom" import { getDuplicationRecords, deleteDuplicationRecord, retryDuplication, type DuplicationStatus, -} from "@/api/duplication"; -import "./duplication.css"; -import PageHead from "@/components/layout/PageHead"; +} from "@/api/duplication" +import "./duplication.css" +import PageHead from "@/components/layout/PageHead" /** 风险等级分类 */ -type RiskFilter = "all" | "high" | "medium" | "low"; +type RiskFilter = "all" | "high" | "medium" | "low" /** 状态配置 */ const STATUS_CONFIG: Record< DuplicationStatus, { - variant: "primary" | "warning" | "success" | "error"; - text: string; - icon: string; + variant: "primary" | "warning" | "success" | "error" + text: string + icon: string } > = { pending: { variant: "primary", text: "等待中", icon: "⏳" }, processing: { variant: "warning", text: "查重中", icon: "🔄" }, completed: { variant: "success", text: "已完成", icon: "✅" }, failed: { variant: "error", text: "失败", icon: "❌" }, -}; +} /** 根据查重率获取风险等级 */ const getRiskLevel = (rate?: number): "low" | "medium" | "high" => { - if (rate === undefined) return "low"; - if (rate <= 10) return "low"; - if (rate <= 30) return "medium"; - return "high"; -}; + if (rate === undefined) return "low" + if (rate <= 10) return "low" + if (rate <= 30) return "medium" + return "high" +} /** 风险等级标签 */ const RISK_LABELS: Record = { low: "低风险", medium: "中风险", high: "高风险", -}; +} /** 格式化文件大小 */ const formatSize = (bytes: number) => { - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; - if (bytes < 1024 * 1024 * 1024) - return `${(bytes / 1024 / 1024).toFixed(1)} MB`; - return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; -}; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB` +} /** 格式化时长 */ const formatDuration = (seconds?: number) => { - if (!seconds) return "-"; - const totalSec = Math.round(seconds); - const m = Math.floor(totalSec / 60); - const s = totalSec % 60; - return m > 0 ? `${m}分${s}秒` : `${s}秒`; -}; + if (!seconds) return "-" + const totalSec = Math.round(seconds) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + return m > 0 ? `${m}分${s}秒` : `${s}秒` +} /** 简易 toast */ interface ToastState { - message: string; - type: "success" | "error" | "warning"; + message: string + type: "success" | "error" | "warning" } const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [ @@ -76,69 +75,62 @@ const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [ { key: "low", label: "低风险" }, { key: "medium", label: "中风险" }, { key: "high", label: "高风险" }, -]; +] const DuplicationResults: React.FC = () => { - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [riskFilter, setRiskFilter] = useState("all"); - const [toast, setToast] = useState(null); + const navigate = useNavigate() + const queryClient = useQueryClient() + const [riskFilter, setRiskFilter] = useState("all") + const [toast, setToast] = useState(null) - const showToast = ( - message: string, - type: "success" | "error" | "warning", - ) => { - setToast({ message, type }); - setTimeout(() => setToast(null), 3000); - }; + const showToast = (message: string, type: "success" | "error" | "warning") => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + } // 获取查重记录 const { data: records = [], isLoading } = useQuery({ queryKey: ["duplication-records"], queryFn: getDuplicationRecords, - }); + }) // 删除 const deleteMutation = useMutation({ mutationFn: deleteDuplicationRecord, onSuccess: () => { - showToast("已删除", "success"); - queryClient.invalidateQueries({ queryKey: ["duplication-records"] }); + showToast("已删除", "success") + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) }, onError: () => { - showToast("删除失败", "error"); + showToast("删除失败", "error") }, - }); + }) // 重新查重 const retryMutation = useMutation({ mutationFn: retryDuplication, onSuccess: () => { - showToast("已重新提交查重", "success"); - queryClient.invalidateQueries({ queryKey: ["duplication-records"] }); + showToast("已重新提交查重", "success") + queryClient.invalidateQueries({ queryKey: ["duplication-records"] }) }, onError: () => { - showToast("重新查重失败", "error"); + showToast("重新查重失败", "error") }, - }); + }) /** 按风险等级筛选 */ const filteredRecords = useMemo(() => { - if (riskFilter === "all") return records; + if (riskFilter === "all") return records return records.filter((r) => { - if (r.status !== "completed") return riskFilter === "low"; - return getRiskLevel(r.duplicate_rate) === riskFilter; - }); - }, [records, riskFilter]); + if (r.status !== "completed") return riskFilter === "low" + return getRiskLevel(r.duplicate_rate) === riskFilter + }) + }, [records, riskFilter]) return (
{/* Toast */} - {toast && ( -
- {toast.message} -
- )} + {toast &&
{toast.message}
} { {!isLoading && filteredRecords.length > 0 && (
{filteredRecords.map((record) => { - const statusCfg = STATUS_CONFIG[record.status]; - const riskLevel = getRiskLevel(record.duplicate_rate); - const rateValue = record.duplicate_rate; + const statusCfg = STATUS_CONFIG[record.status] + const riskLevel = getRiskLevel(record.duplicate_rate) + const rateValue = record.duplicate_rate return (
{ className="dup-result-card" onClick={() => { if (record.status === "completed") { - navigate(`/duplication/${record.id}`); + navigate(`/duplication/${record.id}`) } }} > @@ -217,13 +209,10 @@ const DuplicationResults: React.FC = () => { {formatSize(record.file_size)} {formatDuration(record.duration_seconds)} - - {new Date(record.created_at).toLocaleDateString("zh-CN")} - - {record.status === "completed" && - record.duplicate_count !== undefined && ( - {record.duplicate_count} 个重复片段 - )} + {new Date(record.created_at).toLocaleDateString("zh-CN")} + {record.status === "completed" && record.duplicate_count !== undefined && ( + {record.duplicate_count} 个重复片段 + )}
@@ -247,17 +236,15 @@ const DuplicationResults: React.FC = () => { buttonType="ghost" buttonSize="sm" onClick={(e) => { - e.stopPropagation(); - retryMutation.mutate(record.id); + e.stopPropagation() + retryMutation.mutate(record.id) }} > 🔄 重试 ) : ( - + {record.status === "processing" ? "分析中..." : "—"} )} @@ -268,21 +255,21 @@ const DuplicationResults: React.FC = () => { buttonType="ghost" buttonSize="sm" onClick={(e) => { - e.stopPropagation(); + e.stopPropagation() if (window.confirm("确定删除此记录?")) { - deleteMutation.mutate(record.id); + deleteMutation.mutate(record.id) } }} > 🗑️
- ); + ) })} )} - ); -}; + ) +} -export default DuplicationResults; +export default DuplicationResults diff --git a/apps/web/src/pages/duplication/DuplicationUpload.tsx b/apps/web/src/pages/duplication/DuplicationUpload.tsx index 377a950e9..4dba20a0d 100755 --- a/apps/web/src/pages/duplication/DuplicationUpload.tsx +++ b/apps/web/src/pages/duplication/DuplicationUpload.tsx @@ -3,126 +3,119 @@ * 左右分栏:拖拽上传区 + 格式说明 * 零 antd 依赖 */ -import React, { useState, useRef, useCallback } from "react"; -import { useMutation } from "@tanstack/react-query"; -import { Button, Card, Tag } from "@/components/ui"; -import { uploadForDuplication } from "@/api/duplication"; -import { useNavigate } from "react-router-dom"; -import "./duplication.css"; -import PageHead from "@/components/layout/PageHead"; +import React, { useState, useRef, useCallback } from "react" +import { useMutation } from "@tanstack/react-query" +import { Button, Card, Tag } from "@/components/ui" +import { uploadForDuplication } from "@/api/duplication" +import { useNavigate } from "react-router-dom" +import "./duplication.css" +import PageHead from "@/components/layout/PageHead" /** 支持的视频格式 */ -const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"; -const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]; +const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm" +const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"] /** 最大文件大小:2GB */ -const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; +const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 /** 简易 toast */ interface ToastState { - message: string; - type: "success" | "error" | "warning"; + message: string + type: "success" | "error" | "warning" } const DuplicationUpload: React.FC = () => { - const navigate = useNavigate(); - const fileInputRef = useRef(null); - const [dragging, setDragging] = useState(false); - const [uploading, setUploading] = useState(false); + const navigate = useNavigate() + const fileInputRef = useRef(null) + const [dragging, setDragging] = useState(false) + const [uploading, setUploading] = useState(false) const [uploadResult, setUploadResult] = useState<{ - id: string; - message: string; - } | null>(null); - const [toast, setToast] = useState(null); + id: string + message: string + } | null>(null) + const [toast, setToast] = useState(null) /** 显示 toast */ - const showToast = useCallback( - (message: string, type: "success" | "error" | "warning") => { - setToast({ message, type }); - setTimeout(() => setToast(null), 3000); - }, - [], - ); + const showToast = useCallback((message: string, type: "success" | "error" | "warning") => { + setToast({ message, type }) + setTimeout(() => setToast(null), 3000) + }, []) // 上传查重 mutation const uploadMutation = useMutation({ mutationFn: (file: File) => uploadForDuplication(file), onSuccess: (data) => { - setUploading(false); - setUploadResult({ id: data.id, message: data.message }); - showToast("查重任务已提交", "success"); + setUploading(false) + setUploadResult({ id: data.id, message: data.message }) + showToast("查重任务已提交", "success") }, onError: () => { - setUploading(false); - showToast("上传失败,请重试", "error"); + setUploading(false) + showToast("上传失败,请重试", "error") }, - }); + }) /** 校验并上传文件 */ const handleFile = useCallback( (file: File) => { if (file.size > MAX_FILE_SIZE) { - showToast("文件大小不能超过 2GB", "error"); - return; + showToast("文件大小不能超过 2GB", "error") + return } - const ext = file.name.toLowerCase().split(".").pop(); - const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(","); + const ext = file.name.toLowerCase().split(".").pop() + const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",") if (!allowedExts.includes(ext || "")) { - showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error"); - return; + showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error") + return } - setUploading(true); - setUploadResult(null); - uploadMutation.mutate(file); + setUploading(true) + setUploadResult(null) + uploadMutation.mutate(file) }, [uploadMutation, showToast], - ); + ) /** 拖拽事件 */ const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setDragging(true); - }, []); + e.preventDefault() + setDragging(true) + }, []) const handleDragLeave = useCallback(() => { - setDragging(false); - }, []); + setDragging(false) + }, []) const handleDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault(); - setDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFile(file); + e.preventDefault() + setDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFile(file) }, [handleFile], - ); + ) /** 点击选择文件 */ const handleSelectFile = () => { - fileInputRef.current?.click(); - }; + fileInputRef.current?.click() + } const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) handleFile(file); + const file = e.target.files?.[0] + if (file) handleFile(file) // 重置 input 以便重复选择同一文件 - e.target.value = ""; - }; + e.target.value = "" + } /** 重置状态 */ const handleReset = () => { - setUploadResult(null); - setUploading(false); - }; + setUploadResult(null) + setUploading(false) + } return (
{/* Toast */} - {toast && ( -
- {toast.message} -
- )} + {toast &&
{toast.message}
} { onClick={uploading ? undefined : handleSelectFile} >
{uploading ? "⏳" : "📁"}
-

- {uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"} -

+

{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}

支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB

{FORMAT_LIST.map((fmt) => ( @@ -200,11 +191,7 @@ const DuplicationUpload: React.FC = () => { > 查看结果 -
@@ -240,7 +227,7 @@ const DuplicationUpload: React.FC = () => {
- ); -}; + ) +} -export default DuplicationUpload; +export default DuplicationUpload diff --git a/apps/web/src/pages/edit-plans/EditPlans.tsx b/apps/web/src/pages/edit-plans/EditPlans.tsx index ce68bc71e..1ef3eb13e 100755 --- a/apps/web/src/pages/edit-plans/EditPlans.tsx +++ b/apps/web/src/pages/edit-plans/EditPlans.tsx @@ -2,19 +2,10 @@ * 剪辑计划管理页面 * 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成 */ -import { useState, useCallback } from "react"; -import { useNavigate } from "react-router-dom"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { - Table, - Tabs, - Select, - Tag, - Button, - message, - Popconfirm, - Tooltip, -} from "antd"; +import { useState, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd" import { CheckCircleOutlined, ClockCircleOutlined, @@ -27,8 +18,8 @@ import { CopyOutlined, UnorderedListOutlined, StopOutlined, -} from "@ant-design/icons"; -import type { ColumnsType } from "antd/es/table"; +} from "@ant-design/icons" +import type { ColumnsType } from "antd/es/table" import { getEditPlans, deleteEditPlan, @@ -38,9 +29,9 @@ import { type EditPlan, type EditPlanStatus, type EditPlanListParams, -} from "@/api/editPlans"; -import { getTemplatesList, type TemplateItem } from "@/api/templates"; -import "./edit-plans.css"; +} from "@/api/editPlans" +import { getTemplatesList, type TemplateItem } from "@/api/templates" +import "./edit-plans.css" /* ──────────── 常量 ──────────── */ @@ -53,7 +44,7 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [ { key: "completed", label: "已完成" }, { key: "failed", label: "失败" }, { key: "cancelled", label: "已取消" }, -]; +] /** 状态标签配置 */ const STATUS_CONFIG: Record< @@ -90,45 +81,43 @@ const STATUS_CONFIG: Record< color: "default", icon: , }, -}; +} /* ──────────── 工具函数 ──────────── */ /** 格式化时长 */ const formatDuration = (seconds: number): string => { - if (seconds <= 0) return "-"; - const totalSec = Math.round(seconds); - const m = Math.floor(totalSec / 60); - const s = totalSec % 60; - if (m === 0) return `${s}秒`; - return `${m}分${s > 0 ? `${s}秒` : ""}`; -}; + if (seconds <= 0) return "-" + const totalSec = Math.round(seconds) + const m = Math.floor(totalSec / 60) + const s = totalSec % 60 + if (m === 0) return `${s}秒` + return `${m}分${s > 0 ? `${s}秒` : ""}` +} /** 格式化时间 */ const formatTime = (dateStr?: string | null): string => { - if (!dateStr) return "-"; - const date = new Date(dateStr); + if (!dateStr) return "-" + const date = new Date(dateStr) return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", - }); -}; + }) +} /* ──────────── 主组件 ──────────── */ export default function EditPlans() { - const navigate = useNavigate(); - const queryClient = useQueryClient(); + const navigate = useNavigate() + const queryClient = useQueryClient() // 筛选状态 - const [statusFilter, setStatusFilter] = useState( - "all", - ); - const [templateFilter, setTemplateFilter] = useState("all"); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const [statusFilter, setStatusFilter] = useState("all") + const [templateFilter, setTemplateFilter] = useState("all") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) // 查询参数 const queryParams: EditPlanListParams = { @@ -136,7 +125,7 @@ export default function EditPlans() { page_size: pageSize, ...(statusFilter !== "all" && { status: statusFilter }), ...(templateFilter !== "all" && { template_id: templateFilter }), - }; + } // 获取剪辑计划列表 const { @@ -148,87 +137,85 @@ export default function EditPlans() { queryFn: () => getEditPlans(queryParams), refetchInterval: (query) => { // 有进行中的计划时自动刷新 - const plans = query.state.data?.items ?? []; - const hasRunning = plans.some( - (p) => p.status === "rendering" || p.status === "editing", - ); - return hasRunning ? 5000 : false; + const plans = query.state.data?.items ?? [] + const hasRunning = plans.some((p) => p.status === "rendering" || p.status === "editing") + return hasRunning ? 5000 : false }, - }); + }) // 获取模板列表(用于筛选下拉) const { data: templates } = useQuery({ queryKey: ["templates-list-simple"], queryFn: getTemplatesList, - }); + }) - const plans = planData?.items ?? []; - const total = planData?.total ?? 0; + const plans = planData?.items ?? [] + const total = planData?.total ?? 0 // 模板名称映射 - const templateNameMap = new Map(); - (templates ?? []).forEach((t: TemplateItem) => { - templateNameMap.set(t.id, t.name); - }); + const templateNameMap = new Map() + ;(templates ?? []).forEach((t: TemplateItem) => { + templateNameMap.set(t.id, t.name) + }) // 删除计划 const deleteMutation = useMutation({ mutationFn: deleteEditPlan, onSuccess: () => { - message.success("剪辑计划已删除"); - queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + message.success("剪辑计划已删除") + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }) }, onError: () => { - message.error("删除失败,请稍后重试"); + message.error("删除失败,请稍后重试") }, - }); + }) // 重新生成 const regenerateMutation = useMutation({ mutationFn: generateEditPlan, onSuccess: () => { - message.success("已重新提交生成"); - queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + message.success("已重新提交生成") + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }) }, onError: () => { - message.error("重新生成失败,请稍后重试"); + message.error("重新生成失败,请稍后重试") }, - }); + }) // 取消生成 const cancelMutation = useMutation({ mutationFn: cancelGeneration, onSuccess: () => { - message.success("已提交取消请求"); - queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + message.success("已提交取消请求") + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }) }, onError: () => { - message.error("取消失败,请稍后重试"); + message.error("取消失败,请稍后重试") }, - }); + }) // 复制计划 const copyMutation = useMutation({ mutationFn: ({ planId, name }: { planId: string; name?: string }) => copyEditPlan(planId, name ? { name } : undefined), onSuccess: (newPlan) => { - message.success("计划已复制"); - queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + message.success("计划已复制") + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }) // 自动跳转到新计划的编辑器 - navigate(`/app/editing-planner?planId=${newPlan.id}`); + navigate(`/app/editing-planner?planId=${newPlan.id}`) }, onError: () => { - message.error("复制失败,请稍后重试"); + message.error("复制失败,请稍后重试") }, - }); + }) // 跳转到剪辑编辑器 const handleEdit = useCallback( (plan: EditPlan) => { - navigate(`/app/editing-planner?planId=${plan.id}`); + navigate(`/app/editing-planner?planId=${plan.id}`) }, [navigate], - ); + ) // 表格列定义 const columns: ColumnsType = [ @@ -253,12 +240,12 @@ export default function EditPlans() { width: 140, ellipsis: true, render: (templateId: string) => { - const name = templateNameMap.get(templateId); + const name = templateNameMap.get(templateId) return ( {name || templateId.slice(0, 8)} - ); + ) }, }, { @@ -271,16 +258,12 @@ export default function EditPlans() { label: status, color: "default", icon: null, - }; + } return ( - + {config.label} - ); + ) }, }, { @@ -288,9 +271,7 @@ export default function EditPlans() { dataIndex: "total_duration", key: "total_duration", width: 100, - render: (seconds: number) => ( - {formatDuration(seconds)} - ), + render: (seconds: number) => {formatDuration(seconds)}, }, { title: "视频数", @@ -307,18 +288,14 @@ export default function EditPlans() { dataIndex: "created_at", key: "created_at", width: 130, - render: (time: string) => ( - {formatTime(time)} - ), + render: (time: string) => {formatTime(time)}, }, { title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 130, - render: (time: string) => ( - {formatTime(time)} - ), + render: (time: string) => {formatTime(time)}, }, { title: "操作", @@ -433,7 +410,7 @@ export default function EditPlans() { ), }, - ]; + ] // 错误处理 if (error) { @@ -445,7 +422,7 @@ export default function EditPlans() { - ); + ) } return ( @@ -467,8 +444,8 @@ export default function EditPlans() { { - setStatusFilter(key as EditPlanStatus | "all"); - setPage(1); + setStatusFilter(key as EditPlanStatus | "all") + setPage(1) }} items={STATUS_TABS.map((tab) => ({ key: tab.key, @@ -481,8 +458,8 @@ export default function EditPlans() {
- + - + @@ -607,8 +571,7 @@ const PlanClipsManager: React.FC = () => { loading={importLoading} disabled={selectedAssetIds.length === 0} > - 导入{" "} - {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""} + 导入 {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""} } > @@ -625,7 +588,7 @@ const PlanClipsManager: React.FC = () => { prev.includes(asset.id) ? prev.filter((id) => id !== asset.id) : [...prev, asset.id], - ); + ) }} >
@@ -652,7 +615,7 @@ const PlanClipsManager: React.FC = () => { )}
- ); -}; + ) +} -export default PlanClipsManager; +export default PlanClipsManager diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.css b/apps/web/src/pages/editing-planner/EditingPlanner.css index 790d355d0..9efaaf4c9 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.css +++ b/apps/web/src/pages/editing-planner/EditingPlanner.css @@ -651,11 +651,7 @@ .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; @@ -903,11 +899,7 @@ .ep-clip-thumbnail { height: 34px; - 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; @@ -6144,8 +6136,13 @@ } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } } /* ═══ 右侧栏 Tab ═══ */ diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index 6c46ff45b..15a5f4968 100755 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -2,16 +2,16 @@ * 剪辑计划编辑器 — V8 原型 1:1 还原 * 四行布局:顶栏(42px) → 模式栏(48px) → 三栏主体 → 底栏(40px) */ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { useSearchParams } from "react-router-dom"; -import { message, Modal, Progress, Button } from "antd"; -import { useQuery } from "@tanstack/react-query"; +import React, { useState, useCallback, useEffect, useRef } from "react" +import { useSearchParams } from "react-router-dom" +import { message, Modal, Progress, Button } from "antd" +import { useQuery } from "@tanstack/react-query" import type { EditingTemplate, TemplateCategory, TemplateMode, SaveTemplatePayload, -} from "@/api/editingPlanner"; +} from "@/api/editingPlanner" import { getEditingTemplates, getEditingTemplate, @@ -19,14 +19,14 @@ import { updateEditingTemplate, getTemplateCategories, MODE_LABELS, -} from "@/api/editingPlanner"; +} from "@/api/editingPlanner" import type { EditPlanGeneration, EditPlanConfig, GeneratedVideo, MediaAsset, TransitionEffect, -} from "@/api/editPlans"; +} from "@/api/editPlans" import { getMediaAssets, getEditPlanGenerations, @@ -44,8 +44,8 @@ import { type EditPlanClip, type CreateEditPlanClipRequest, type ClipStatusItem, -} from "@/api/editPlans"; -import { useUndoRedo } from "./hooks/useUndoRedo"; +} from "@/api/editPlans" +import { useUndoRedo } from "./hooks/useUndoRedo" import type { ClipData, ClipType, @@ -62,7 +62,7 @@ import type { StickerConfig, CoverConfig, TitleSettings, -} from "./types"; +} from "./types" import { DEFAULT_TRANSITION, DEFAULT_SPEED, @@ -74,37 +74,33 @@ import { DEFAULT_CHROMA_KEY_CONFIG, DEFAULT_STICKER_CONFIG, DEFAULT_COVER_CONFIG, -} from "./types"; -import { - ensureDefaultLibrary, - getAssetsByKind, - type AssetItem, -} from "@/api/assets"; -import { getOrCreateDefaultProject } from "@/api/projects"; +} from "./types" +import { ensureDefaultLibrary, getAssetsByKind, type AssetItem } from "@/api/assets" +import { getOrCreateDefaultProject } from "@/api/projects" -import MediaPanel from "./components/MediaPanel"; -import PreviewPlayer from "./components/PreviewPlayer"; -import TimelinePanel from "./components/TimelinePanel"; -import ClipPropertiesPanel from "./components/ClipPropertiesPanel"; -import EditorClipList from "./components/EditorClipList"; -import BgmSelector from "./components/BgmSelector"; -import SubtitleStylePanel from "./components/SubtitleStylePanel"; -import type { SubtitleStyleConfig } from "./types/subtitle"; -import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle"; -import TransitionSelector from "./components/TransitionSelector"; -import SpeedPanel from "./components/SpeedPanel"; -import TtsPanel from "./components/TtsPanel"; -import WatermarkPanel from "./components/WatermarkPanel"; -import IntroOutroPanel from "./components/IntroOutroPanel"; -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"; -import "./EditingPlanner.css"; +import MediaPanel from "./components/MediaPanel" +import PreviewPlayer from "./components/PreviewPlayer" +import TimelinePanel from "./components/TimelinePanel" +import ClipPropertiesPanel from "./components/ClipPropertiesPanel" +import EditorClipList from "./components/EditorClipList" +import BgmSelector from "./components/BgmSelector" +import SubtitleStylePanel from "./components/SubtitleStylePanel" +import type { SubtitleStyleConfig } from "./types/subtitle" +import { DEFAULT_SUBTITLE_STYLE } from "./types/subtitle" +import TransitionSelector from "./components/TransitionSelector" +import SpeedPanel from "./components/SpeedPanel" +import TtsPanel from "./components/TtsPanel" +import WatermarkPanel from "./components/WatermarkPanel" +import IntroOutroPanel from "./components/IntroOutroPanel" +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" +import "./EditingPlanner.css" /* ──────────── 常量 ──────────── */ @@ -113,32 +109,30 @@ const MODE_LIST: { key: TemplateMode; label: string; icon: string }[] = [ { key: "voice_over", label: "人物口播", icon: "🎙️" }, { key: "one_take", 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 = ["全部", "种草", "知识", "日常", "推荐"]; +const FILTER_CATEGORIES = ["全部", "种草", "知识", "日常", "推荐"] /* ──────────── 组件 ──────────── */ const EditingPlanner: React.FC = () => { - const [searchParams] = useSearchParams(); - const urlTemplateId = searchParams.get("templateId") || ""; - const urlPlanId = searchParams.get("planId") || ""; + const [searchParams] = useSearchParams() + const urlTemplateId = searchParams.get("templateId") || "" + const urlPlanId = searchParams.get("planId") || "" /* ── 模板列表 ── */ - const [templates, setTemplates] = useState([]); - const [categories, setCategories] = useState([]); - const [loadingTemplates, setLoadingTemplates] = useState(false); - const [loadedTemplateId, setLoadedTemplateId] = useState( - urlTemplateId || null, - ); - const [currentMode, setCurrentMode] = useState("pip"); + const [templates, setTemplates] = useState([]) + const [categories, setCategories] = useState([]) + const [loadingTemplates, setLoadingTemplates] = useState(false) + const [loadedTemplateId, setLoadedTemplateId] = useState(urlTemplateId || null) + const [currentMode, setCurrentMode] = useState("pip") /* ── 片段(撤销/重做) ── */ const { @@ -149,20 +143,19 @@ const EditingPlanner: React.FC = () => { canUndo, canRedo, reset: resetClips, - } = useUndoRedo([]); - const [selectedClipId, setSelectedClipId] = useState(null); + } = useUndoRedo([]) + const [selectedClipId, setSelectedClipId] = useState(null) /* ── AI 操作状态 ── */ - const [aiCoverLoading, setAiCoverLoading] = useState(false); + const [aiCoverLoading, setAiCoverLoading] = useState(false) /* ── 封面方案 ── */ - const [currentCoverScheme, setCurrentCoverScheme] = - useState("ai_frame"); + const [currentCoverScheme, setCurrentCoverScheme] = useState("ai_frame") /* ── 左栏筛选 ── */ - const [currentFilter, setCurrentFilter] = useState("全部"); - const [searchQuery, setSearchQuery] = useState(""); + const [currentFilter, setCurrentFilter] = useState("全部") + const [searchQuery, setSearchQuery] = useState("") /* ── 标题/字幕/BGM 设置 ── */ const [titleSettings, setTitleSettings] = useState({ @@ -176,145 +169,136 @@ const EditingPlanner: React.FC = () => { stroke: true, shadow: true, color: "#ffffff", - }); + }) - const [subtitleSettings, setSubtitleSettings] = useState( - { - ...DEFAULT_SUBTITLE_STYLE, - }, - ); + const [subtitleSettings, setSubtitleSettings] = useState({ + ...DEFAULT_SUBTITLE_STYLE, + }) const [bgmSettings, setBgmSettings] = useState({ ...DEFAULT_BGM_MIX_CONFIG, - }); + }) /* ── Drawer 开关 ── */ - const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false); - const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false); - const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false); - const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false); + const [bgmDrawerOpen, setBgmDrawerOpen] = useState(false) + const [subtitleDrawerOpen, setSubtitleDrawerOpen] = useState(false) + const [transitionDrawerOpen, setTransitionDrawerOpen] = useState(false) + const [speedDrawerOpen, setSpeedDrawerOpen] = useState(false) /** 当前正在编辑转场的片段 ID(null = 全局默认转场) */ - const [transitionTargetClipId, setTransitionTargetClipId] = useState< - string | null - >(null); + const [transitionTargetClipId, setTransitionTargetClipId] = useState(null) /** 当前正在调速的片段 ID */ - const [speedTargetClipId, setSpeedTargetClipId] = useState( - null, - ); + const [speedTargetClipId, setSpeedTargetClipId] = useState(null) /** TTS 配音面板是否打开 */ - const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false); + const [ttsDrawerOpen, setTtsDrawerOpen] = useState(false) /** 当前正在配置 TTS 的片段 ID */ - const [ttsTargetClipId, setTtsTargetClipId] = useState(null); + const [ttsTargetClipId, setTtsTargetClipId] = useState(null) /* ── 水印 / 片头片尾 ── */ const [watermarkSettings, setWatermarkSettings] = useState({ ...DEFAULT_WATERMARK, - }); - const [introOutroSettings, setIntroOutroSettings] = - useState({ ...DEFAULT_INTRO_OUTRO }); - const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false); - const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false); + }) + const [introOutroSettings, setIntroOutroSettings] = useState({ + ...DEFAULT_INTRO_OUTRO, + }) + const [watermarkDrawerOpen, setWatermarkDrawerOpen] = useState(false) + const [introOutroDrawerOpen, setIntroOutroDrawerOpen] = useState(false) /* ── 画中画 ── */ const [pipSettings, setPipSettings] = useState({ ...DEFAULT_PIP_CONFIG, - }); - const [pipDrawerOpen, setPipDrawerOpen] = useState(false); + }) + const [pipDrawerOpen, setPipDrawerOpen] = useState(false) /* ── 滤镜调色 ── */ const [filterSettings, setFilterSettings] = useState({ ...DEFAULT_FILTER_CONFIG, - }); - const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); + }) + const [filterDrawerOpen, setFilterDrawerOpen] = useState(false) /* ── 绿幕抠像 ── */ const [chromaKeySettings, setChromaKeySettings] = useState({ ...DEFAULT_CHROMA_KEY_CONFIG, - }); - const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false); + }) + const [chromaKeyDrawerOpen, setChromaKeyDrawerOpen] = useState(false) /* ── 贴纸 ── */ const [stickerSettings, setStickerSettings] = useState({ ...DEFAULT_STICKER_CONFIG, - }); - const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false); + }) + const [stickerDrawerOpen, setStickerDrawerOpen] = useState(false) /* ── 封面 ── */ const [coverSettings, setCoverSettings] = useState({ ...DEFAULT_COVER_CONFIG, - }); - const [coverDrawerOpen, setCoverDrawerOpen] = useState(false); + }) + const [coverDrawerOpen, setCoverDrawerOpen] = useState(false) /* ── 右侧栏 Tab ── */ - const [rightTab, setRightTab] = useState<"properties" | "clips">( - "properties", - ); + const [rightTab, setRightTab] = useState<"properties" | "clips">("properties") /* ── 保存弹窗 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false); - const [draftName, setDraftName] = useState(""); - const [draftCategory, setDraftCategory] = useState(""); - const [draftTags, setDraftTags] = useState(""); - const [saveLoading, setSaveLoading] = useState(false); + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [draftName, setDraftName] = useState("") + const [draftCategory, setDraftCategory] = useState("") + const [draftTags, setDraftTags] = useState("") + const [saveLoading, setSaveLoading] = useState(false) /* ── 素材库 ── */ - const [mediaAssets, setMediaAssets] = useState([]); - const [selectedAssetIds, setSelectedAssetIds] = useState([]); + const [mediaAssets, setMediaAssets] = useState([]) + const [selectedAssetIds, setSelectedAssetIds] = useState([]) const handleAssetSelect = (ids: string[]) => { - setSelectedAssetIds(ids); - }; + setSelectedAssetIds(ids) + } /* ── 生成历史 ── */ - const [genHistoryOpen, setGenHistoryOpen] = useState(false); - const [genHistory, setGenHistory] = useState([]); - const [genHistoryLoading, setGenHistoryLoading] = useState(false); + const [genHistoryOpen, setGenHistoryOpen] = useState(false) + const [genHistory, setGenHistory] = useState([]) + const [genHistoryLoading, setGenHistoryLoading] = useState(false) /* ── 剪辑计划(从列表页编辑进入时) ── */ - const [loadedPlanId, setLoadedPlanId] = useState( - urlPlanId || null, - ); + const [loadedPlanId, setLoadedPlanId] = useState(urlPlanId || null) /* ── 生成进度 ── */ - const [generating, setGenerating] = useState(false); - const [genProgress, setGenProgress] = useState(0); - const [genTotalClips, setGenTotalClips] = useState(0); - const [genDoneClips, setGenDoneClips] = useState(0); - const [generated, setGenerated] = useState(false); - const [generatedVideos, setGeneratedVideos] = useState([]); - const [genError, setGenError] = useState(null); - const [cancelling, setCancelling] = useState(false); - const [genCancelled, setGenCancelled] = useState(false); - const [genClipStatuses, setGenClipStatuses] = useState([]); - const genTimerRef = useRef | null>(null); + const [generating, setGenerating] = useState(false) + const [genProgress, setGenProgress] = useState(0) + const [genTotalClips, setGenTotalClips] = useState(0) + const [genDoneClips, setGenDoneClips] = useState(0) + const [generated, setGenerated] = useState(false) + const [generatedVideos, setGeneratedVideos] = useState([]) + const [genError, setGenError] = useState(null) + const [cancelling, setCancelling] = useState(false) + const [genCancelled, setGenCancelled] = useState(false) + const [genClipStatuses, setGenClipStatuses] = useState([]) + const genTimerRef = useRef | null>(null) /* ── 播放 ── */ - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [pixelsPerSecond, setPixelsPerSecond] = useState(40); - const prevFrameTimeRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [pixelsPerSecond, setPixelsPerSecond] = useState(40) + const prevFrameTimeRef = useRef(null) /** 播放头跳转 */ const handleSeek = useCallback((time: number) => { - setCurrentTime(Math.max(0, time)); - }, []); + setCurrentTime(Math.max(0, time)) + }, []) /** 轨道缩放 */ const handleZoomChange = useCallback((pps: number) => { - setPixelsPerSecond(pps); - }, []); + setPixelsPerSecond(pps) + }, []) /* ── 配音素材(queryKey 与 VoiceMaterialLibrary 共享缓存) ── */ const voiceMaterialsQuery = useQuery({ queryKey: ["assets", "voice"], queryFn: async () => { - const project = await getOrCreateDefaultProject(); - await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }); - const assets = await getAssetsByKind("voice"); - return assets; + const project = await getOrCreateDefaultProject() + await ensureDefaultLibrary({ project_id: project.id, kind: "voice" }) + const assets = await getAssetsByKind("voice") + return assets }, staleTime: 30_000, - }); - const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? []; + }) + const voiceMaterials: AssetItem[] = voiceMaterialsQuery.data ?? [] /** 为片段选择配音素材 */ const handleClipVoiceSelect = useCallback( @@ -329,10 +313,10 @@ const EditingPlanner: React.FC = () => { } : c, ), - ); + ) }, [setClips], - ); + ) /* ──────────── 加载 ──────────── */ @@ -341,26 +325,26 @@ const EditingPlanner: React.FC = () => { * 首次挂载时调用,三个接口无依赖关系,用 Promise.all 并发 */ const loadTemplates = useCallback(async () => { - setLoadingTemplates(true); + setLoadingTemplates(true) try { const [tpls, cats, assets] = await Promise.all([ getEditingTemplates(), getTemplateCategories(), getMediaAssets(), - ]); - setTemplates(tpls); - setCategories(cats); - setMediaAssets(assets); + ]) + setTemplates(tpls) + setCategories(cats) + setMediaAssets(assets) } catch { - message.error("加载模板失败"); + message.error("加载模板失败") } finally { - setLoadingTemplates(false); + setLoadingTemplates(false) } - }, []); + }, []) useEffect(() => { - loadTemplates(); - }, [loadTemplates]); + loadTemplates() + }, [loadTemplates]) /** * 加载模板详情并初始化片段列表 @@ -368,23 +352,21 @@ const EditingPlanner: React.FC = () => { * 同时还原标题/字幕/BGM 配置 */ useEffect(() => { - if (!loadedTemplateId) return; + if (!loadedTemplateId) return getEditingTemplate(loadedTemplateId) .then((tpl) => { - if (!tpl) return; - setCurrentMode(tpl.mode); + if (!tpl) return + setCurrentMode(tpl.mode) const mapped: ClipData[] = tpl.segments.map((seg, idx) => ({ id: seg.id || `seg-${idx}`, template_segment_id: seg.id || `seg-${idx}`, - type: (seg.material_type === "voiceover" - ? "voice" - : "pip") as ClipType, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, duration: (seg.duration_min + seg.duration_max) / 2, startOffset: 0, script_text: "", order: seg.segment_order, - })); - resetClips(mapped); + })) + resetClips(mapped) setTitleSettings((prev) => ({ ...prev, @@ -394,35 +376,34 @@ const EditingPlanner: React.FC = () => { 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, - position: (tpl.subtitle_config.position || - "bottom") as SubtitleStyleConfig["position"], + position: (tpl.subtitle_config.position || "bottom") as SubtitleStyleConfig["position"], font: tpl.subtitle_config.font, fontSize: tpl.subtitle_config.size, fontColor: tpl.subtitle_config.color || "#ffffff", animation: tpl.subtitle_config.animation, - })); + })) setBgmSettings((prev) => ({ ...prev, enabled: tpl.bgm_config.enabled, music_id: tpl.bgm_config.music_id || "", - })); - setDraftName(tpl.name); - setDraftCategory(tpl.category); - setDraftTags(tpl.tags.join(", ")); + })) + setDraftName(tpl.name) + setDraftCategory(tpl.category) + setDraftTags(tpl.tags.join(", ")) }) - .catch(() => message.error("加载模板详情失败")); - }, [loadedTemplateId, resetClips]); + .catch(() => message.error("加载模板详情失败")) + }, [loadedTemplateId, resetClips]) /** * 加载已有剪辑计划数据到编辑器 * 从列表页"编辑"按钮进入时,URL 带 planId,需要还原计划配置 */ useEffect(() => { - if (!loadedPlanId) return; + if (!loadedPlanId) return // 并行加载计划基本信息 + 片段列表 Promise.all([ @@ -434,13 +415,13 @@ const EditingPlanner: React.FC = () => { ]) .then(([plan, clipsRes]) => { // 设置关联的模板(触发模板加载 effect) - setLoadedTemplateId(plan.template_id); + setLoadedTemplateId(plan.template_id) // 还原基本信息 - setDraftName(plan.name); + setDraftName(plan.name) // 还原 config 中的编辑器状态 - const cfg = plan.config; + const cfg = plan.config if (cfg.title_config) { setTitleSettings((prev) => ({ ...prev, @@ -450,7 +431,7 @@ const EditingPlanner: React.FC = () => { 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) => ({ @@ -462,14 +443,14 @@ const EditingPlanner: React.FC = () => { fontSize: cfg.subtitle_config!.size, fontColor: cfg.subtitle_config!.color || "#ffffff", animation: cfg.subtitle_config!.animation, - })); + })) } if (cfg.bgm_config) { setBgmSettings((prev) => ({ ...prev, enabled: cfg.bgm_config!.enabled, music_id: cfg.bgm_config!.music_id || "", - })); + })) } // 还原封面配置 if (cfg.cover_config) { @@ -479,25 +460,20 @@ const EditingPlanner: React.FC = () => { mode: (cfg.cover_config!.mode as CoverConfig["mode"]) || prev.mode, frame_time: cfg.cover_config!.frame_time ?? prev.frame_time, upload_url: cfg.cover_config!.upload_url || prev.upload_url, - thumbnail_url: - cfg.cover_config!.thumbnail_url || prev.thumbnail_url, - ai_suggested_time: - cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, - })); + thumbnail_url: cfg.cover_config!.thumbnail_url || prev.thumbnail_url, + ai_suggested_time: cfg.cover_config!.ai_suggested_time ?? prev.ai_suggested_time, + })) } // 还原片段:优先从后端 clips 表,其次从 config.segments 兜底 - const backendClips = clipsRes?.items || []; + const backendClips = clipsRes?.items || [] if (backendClips.length > 0) { // 从后端 clips 表还原 - const sorted = [...backendClips].sort((a, b) => a.order - b.order); + const sorted = [...backendClips].sort((a, b) => a.order - b.order) const mapped: ClipData[] = sorted.map((clip) => ({ id: clip.id, - template_segment_id: - (clip.config?.template_segment_id as string) || "", - type: (clip.clip_type === "voiceover" - ? "voice" - : "pip") as ClipType, + template_segment_id: (clip.config?.template_segment_id as string) || "", + type: (clip.clip_type === "voiceover" ? "voice" : "pip") as ClipType, duration: clip.duration || 3, startOffset: 0, script_text: clip.text_content || "", @@ -515,16 +491,14 @@ const EditingPlanner: React.FC = () => { : undefined, tts_config: (clip.config?.tts_config as TtsConfig) || undefined, trim_config: (clip.config?.trim_config as TrimConfig) || undefined, - })); - setTimeout(() => resetClips(mapped), 100); + })) + setTimeout(() => resetClips(mapped), 100) } else if (cfg.segments && cfg.segments.length > 0) { // 兜底:从 config.segments 还原(老数据兼容) const mapped: ClipData[] = cfg.segments.map((seg, idx) => ({ id: `seg-${idx}`, template_segment_id: `seg-${idx}`, - type: (seg.material_type === "voiceover" - ? "voice" - : "pip") as ClipType, + type: (seg.material_type === "voiceover" ? "voice" : "pip") as ClipType, duration: (seg.duration_min + seg.duration_max) / 2, startOffset: 0, script_text: "", @@ -542,84 +516,80 @@ const EditingPlanner: React.FC = () => { ? { ...seg.tts_config, mode: seg.tts_config.mode as TtsMode } : undefined, trim_config: seg.trim_config || undefined, - })); - setTimeout(() => resetClips(mapped), 100); + })) + setTimeout(() => resetClips(mapped), 100) } }) - .catch(() => message.error("加载剪辑计划失败")); - }, [loadedPlanId, resetClips]); + .catch(() => message.error("加载剪辑计划失败")) + }, [loadedPlanId, resetClips]) /* ──────────── 计算 ──────────── */ - const currentTemplate = templates.find((t) => t.id === loadedTemplateId); - const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0); - const selectedClip = clips.find((c) => c.id === selectedClipId) || null; + const currentTemplate = templates.find((t) => t.id === loadedTemplateId) + const totalDuration = clips.reduce((sum, c) => sum + c.duration, 0) + const selectedClip = clips.find((c) => c.id === selectedClipId) || null /* rAF 帧推进 — 播放时平滑更新播放头位置 */ useEffect(() => { if (!isPlaying) { - prevFrameTimeRef.current = null; - return; + prevFrameTimeRef.current = null + return } - let rafId: number; + let rafId: number const tick = (timestamp: number) => { if (prevFrameTimeRef.current !== null) { - const delta = (timestamp - prevFrameTimeRef.current) / 1000; + const delta = (timestamp - prevFrameTimeRef.current) / 1000 setCurrentTime((prev) => { - const next = prev + delta; - return next >= totalDuration ? totalDuration : next; - }); + const next = prev + delta + return next >= totalDuration ? totalDuration : next + }) } - prevFrameTimeRef.current = timestamp; - rafId = requestAnimationFrame(tick); - }; - rafId = requestAnimationFrame(tick); + prevFrameTimeRef.current = timestamp + rafId = requestAnimationFrame(tick) + } + rafId = requestAnimationFrame(tick) return () => { - cancelAnimationFrame(rafId); - prevFrameTimeRef.current = null; - }; - }, [isPlaying, totalDuration]); + cancelAnimationFrame(rafId) + prevFrameTimeRef.current = null + } + }, [isPlaying, totalDuration]) const filteredTemplates = templates.filter((t) => { - if (currentFilter !== "全部" && t.category !== currentFilter) return false; - if ( - searchQuery && - !t.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) - return false; - return true; - }); + if (currentFilter !== "全部" && t.category !== currentFilter) return false + if (searchQuery && !t.name.toLowerCase().includes(searchQuery.toLowerCase())) return false + return true + }) /* ──────────── 事件 ──────────── */ const handleLoadTemplate = (templateId: string) => { - setLoadedTemplateId(templateId); - setSelectedClipId(null); - }; + setLoadedTemplateId(templateId) + setSelectedClipId(null) + } const handleModeChange = (mode: TemplateMode) => { - setCurrentMode(mode); + setCurrentMode(mode) // 切换纯单类型模式时,自动转换所有已有片段的类型 if (mode === "voice_over") { - setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))); + setClips((prev) => prev.map((c) => ({ ...c, type: "voice" as const }))) } else if (mode === "pip") { - setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))); + setClips((prev) => prev.map((c) => ({ ...c, type: "pip" as const }))) } // 混合模式(voice_pip)和一镜到底(one_take)不自动转换,保留原有类型 - }; + } const handleClipSelect = (clipId: string) => { - setSelectedClipId(clipId); - }; + setSelectedClipId(clipId) + } const handleClipReorder = (fromIdx: number, toIdx: number) => { setClips((prev) => { - const updated = [...prev]; - const [moved] = updated.splice(fromIdx, 1); - updated.splice(toIdx, 0, moved); - return updated.map((c, i) => ({ ...c, order: i })); - }); - }; + const updated = [...prev] + const [moved] = updated.splice(fromIdx, 1) + updated.splice(toIdx, 0, moved) + return updated.map((c, i) => ({ ...c, order: i })) + }) + } const handleClipRemove = (clipId: string) => { Modal.confirm({ @@ -629,20 +599,18 @@ const EditingPlanner: React.FC = () => { okType: "danger", cancelText: "取消", onOk: () => { - setClips((prev) => prev.filter((c) => c.id !== clipId)); - if (selectedClipId === clipId) setSelectedClipId(null); + setClips((prev) => prev.filter((c) => c.id !== clipId)) + if (selectedClipId === clipId) setSelectedClipId(null) }, - }); - }; + }) + } const handleClipUpdate = useCallback( (clipId: string, data: Partial) => { - setClips((prev) => - prev.map((c) => (c.id === clipId ? { ...c, ...data } : c)), - ); + setClips((prev) => prev.map((c) => (c.id === clipId ? { ...c, ...data } : c))) }, [setClips], - ); + ) /** * 添加片段(不绑定任何素材) @@ -656,35 +624,33 @@ const EditingPlanner: React.FC = () => { duration, startOffset: 0, order: clips.length, - }; - setClips((prev) => [...prev, newClip]); + } + setClips((prev) => [...prev, newClip]) }, [clips.length, setClips], - ); + ) /* ── 裁剪更新:调整片段的 trim_config 和 duration ── */ const handleClipTrim = useCallback( (clipId: string, trimConfig: TrimConfig, newDuration: number) => { setClips((prev) => prev.map((c) => - c.id === clipId - ? { ...c, trim_config: trimConfig, duration: newDuration } - : c, + c.id === clipId ? { ...c, trim_config: trimConfig, duration: newDuration } : c, ), - ); + ) }, [setClips], - ); + ) /* ── 片段分割:在指定比例位置将片段一分为二 ── */ const handleClipSplit = useCallback( (clipId: string, splitRatio: number) => { setClips((prev) => { - const idx = prev.findIndex((c) => c.id === clipId); - if (idx === -1) return prev; - const clip = prev[idx]; - const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10; - if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev; + const idx = prev.findIndex((c) => c.id === clipId) + if (idx === -1) return prev + const clip = prev[idx] + const splitPoint = Math.round(clip.duration * splitRatio * 10) / 10 + if (splitPoint < 0.5 || splitPoint >= clip.duration - 0.5) return prev // 前半段 const firstHalf: ClipData = { @@ -696,7 +662,7 @@ const EditingPlanner: React.FC = () => { end_time: clip.trim_config.start_time + splitPoint, } : undefined, - }; + } // 后半段 const secondHalf: ClipData = { @@ -711,155 +677,150 @@ const EditingPlanner: React.FC = () => { } : undefined, order: (clip.order ?? idx) + 1, - }; + } - const updated = [...prev]; - updated[idx] = firstHalf; - updated.splice(idx + 1, 0, secondHalf); - return updated.map((c, i) => ({ ...c, order: i })); - }); + const updated = [...prev] + updated[idx] = firstHalf + updated.splice(idx + 1, 0, secondHalf) + return updated.map((c, i) => ({ ...c, order: i })) + }) }, [setClips], - ); + ) /* ── 恢复片段原始长度 ── */ const handleClipResetTrim = useCallback( (clipId: string) => { setClips((prev) => prev.map((c) => { - if (c.id !== clipId || !c.trim_config) return c; - const originalDuration = - c.trim_config.original_duration ?? c.duration; + if (c.id !== clipId || !c.trim_config) return c + const originalDuration = c.trim_config.original_duration ?? c.duration return { ...c, duration: originalDuration, trim_config: undefined, - }; + } }), - ); + ) }, [setClips], - ); + ) /* ── 转场特效变更 ── */ const handleTransitionChange = useCallback( (config: TransitionConfig) => { if (transitionTargetClipId) { // 更新指定片段的转场 - handleClipUpdate(transitionTargetClipId, { transition: config }); + handleClipUpdate(transitionTargetClipId, { transition: config }) } // 同时更新全局默认转场(供新片段使用) }, [transitionTargetClipId, handleClipUpdate], - ); + ) /* ── 打开转场选择器 ── */ const handleOpenTransitionDrawer = useCallback((clipId?: string) => { - setTransitionTargetClipId(clipId ?? null); - setTransitionDrawerOpen(true); - }, []); + setTransitionTargetClipId(clipId ?? null) + setTransitionDrawerOpen(true) + }, []) /* ── 调速变更 ── */ const handleSpeedChange = useCallback( (config: SpeedConfig) => { if (speedTargetClipId) { - handleClipUpdate(speedTargetClipId, { speed: config }); + handleClipUpdate(speedTargetClipId, { speed: config }) } }, [speedTargetClipId, handleClipUpdate], - ); + ) /* ── 打开调速面板 ── */ const handleOpenSpeedDrawer = useCallback((clipId: string) => { - setSpeedTargetClipId(clipId); - setSpeedDrawerOpen(true); - }, []); + setSpeedTargetClipId(clipId) + setSpeedDrawerOpen(true) + }, []) /* ── TTS 配音变更 ── */ const handleTtsChange = useCallback( (ttsConfig: TtsConfig) => { - if (!ttsTargetClipId) return; - handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig }); + if (!ttsTargetClipId) return + handleClipUpdate(ttsTargetClipId, { tts_config: ttsConfig }) }, [ttsTargetClipId, handleClipUpdate], - ); + ) /* ── 打开 TTS 配音面板 ── */ const handleOpenTtsDrawer = useCallback((clipId: string) => { - setTtsTargetClipId(clipId); - setTtsDrawerOpen(true); - }, []); + setTtsTargetClipId(clipId) + setTtsDrawerOpen(true) + }, []) /* ── 调速应用到所有片段 ── */ const handleApplySpeedAll = useCallback( (config: SpeedConfig) => { - setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } }))); - message.success("已应用到所有片段"); + setClips((prev) => prev.map((c) => ({ ...c, speed: { ...config } }))) + message.success("已应用到所有片段") }, [setClips], - ); + ) /* ── 水印配置变更 ── */ const handleWatermarkChange = useCallback((config: WatermarkConfig) => { - setWatermarkSettings(config); - }, []); + setWatermarkSettings(config) + }, []) /* ── 片头片尾配置变更 ── */ const handleIntroOutroChange = useCallback((config: IntroOutroConfig) => { - setIntroOutroSettings(config); - }, []); + setIntroOutroSettings(config) + }, []) /* ── 画中画配置变更 ── */ const handlePipChange = useCallback((config: PipConfig) => { - setPipSettings(config); - }, []); + setPipSettings(config) + }, []) /* ── 滤镜调色配置变更 ── */ const handleFilterChange = useCallback((config: FilterConfig) => { - setFilterSettings(config); - }, []); + setFilterSettings(config) + }, []) /* ── 绿幕抠像配置变更 ── */ const handleChromaKeyChange = useCallback((config: ChromaKeyConfig) => { - setChromaKeySettings(config); - }, []); + setChromaKeySettings(config) + }, []) /* ── 贴纸配置变更 ── */ const handleStickerChange = useCallback((config: StickerConfig) => { - setStickerSettings(config); - }, []); + setStickerSettings(config) + }, []) /* ── 封面配置变更 ── */ const handleCoverChange = useCallback((config: CoverConfig) => { - setCoverSettings(config); - }, []); + setCoverSettings(config) + }, []) /* AI 封面生成 */ - const handleAiGenerateCover = async ( - coverType: "ai_frame" | "ai_regenerate", - ) => { - if (!loadedTemplateId) return; - const assetIds = selectedAssetIds; + const handleAiGenerateCover = async (coverType: "ai_frame" | "ai_regenerate") => { + if (!loadedTemplateId) return + const assetIds = selectedAssetIds if (assetIds.length === 0) { - message.warning("请先在素材库中选择素材"); - return; + message.warning("请先在素材库中选择素材") + return } - setAiCoverLoading(true); + setAiCoverLoading(true) try { await generateCover(loadedTemplateId, { asset_ids: assetIds, cover_type: coverType, - }); - setCurrentCoverScheme( - coverType === "ai_frame" ? "ai_frame" : "ai_reselect", - ); - message.success("AI 封面生成成功"); + }) + setCurrentCoverScheme(coverType === "ai_frame" ? "ai_frame" : "ai_reselect") + message.success("AI 封面生成成功") } catch { - message.error("AI 封面生成失败"); + message.error("AI 封面生成失败") } finally { - setAiCoverLoading(false); + setAiCoverLoading(false) } - }; + } /** 构建剪辑计划 config(编辑器状态 → API config) */ const buildPlanConfig = (): EditPlanConfig => ({ @@ -918,19 +879,19 @@ const EditingPlanner: React.FC = () => { green_screen_config: { ...chromaKeySettings }, sticker_config: { ...stickerSettings }, cover_config: { ...coverSettings }, - }); + }) /* 保存 — 无论是否已加载模板,都打开保存弹窗;未加载时创建新模板 */ const handleOpenSaveModal = () => { - setSaveModalOpen(true); - }; + setSaveModalOpen(true) + } const handleSave = async () => { if (!draftName.trim()) { - message.warning("请输入模板名称"); - return; + message.warning("请输入模板名称") + return } - setSaveLoading(true); + setSaveLoading(true) try { const payload: SaveTemplatePayload = { name: draftName, @@ -995,36 +956,36 @@ const EditingPlanner: React.FC = () => { green_screen_config: { ...chromaKeySettings }, sticker_config: { ...stickerSettings }, cover_config: { ...coverSettings }, - }; - if (loadedTemplateId) { - await updateEditingTemplate(loadedTemplateId, payload); - } else { - await createEditingTemplate(payload); } - message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功"); - setSaveModalOpen(false); - loadTemplates(); + if (loadedTemplateId) { + await updateEditingTemplate(loadedTemplateId, payload) + } else { + await createEditingTemplate(payload) + } + message.success(loadedTemplateId ? "模板保存成功" : "模板创建成功") + setSaveModalOpen(false) + loadTemplates() } catch { - message.error("保存失败"); + message.error("保存失败") } finally { - setSaveLoading(false); + setSaveLoading(false) } - }; + } /** * 将本地编辑的片段同步到后端 clips 表 * 策略:先删除后端所有片段,再批量创建(简单可靠,生成前使用) */ const syncClipsToBackend = async (planId: string): Promise => { - if (clips.length === 0) return; + if (clips.length === 0) return // 1. 获取并删除后端现有片段 - const existing = await getEditPlanClips(planId, { limit: 500 }); + const existing = await getEditPlanClips(planId, { limit: 500 }) if (existing.items.length > 0) { await batchDeleteEditPlanClips( planId, existing.items.map((c) => c.id), - ); + ) } // 2. 批量创建新片段(并发 3 个) @@ -1043,21 +1004,19 @@ const EditingPlanner: React.FC = () => { trim_config: c.trim_config || null, template_segment_id: c.template_segment_id || null, }, - })); + })) // 并发控制:最多同时 3 个请求 - const results: EditPlanClip[] = []; - const concurrency = 3; + const results: EditPlanClip[] = [] + const concurrency = 3 for (let i = 0; i < clipDataList.length; i += concurrency) { - const batch = clipDataList.slice(i, i + concurrency); - const batchResults = await Promise.all( - batch.map((data) => createEditPlanClip(planId, data)), - ); - results.push(...batchResults); + const batch = clipDataList.slice(i, i + concurrency) + const batchResults = await Promise.all(batch.map((data) => createEditPlanClip(planId, data))) + results.push(...batchResults) } - console.log(`[片段同步] 创建了 ${results.length} 个片段`); - }; + console.log(`[片段同步] 创建了 ${results.length} 个片段`) + } /** * 剪辑计划生成 @@ -1067,40 +1026,40 @@ const EditingPlanner: React.FC = () => { */ const handleGoToGenerate = async () => { if (!loadedTemplateId) { - message.warning("请先选择一个模板"); - return; + message.warning("请先选择一个模板") + return } if (clips.length === 0) { - message.warning("请先添加片段"); - return; + message.warning("请先添加片段") + return } - setGenerating(true); - setGenerated(false); - setGeneratedVideos([]); - setGenError(null); - setGenProgress(0); - setGenCancelled(false); + setGenerating(true) + setGenerated(false) + setGeneratedVideos([]) + setGenError(null) + setGenProgress(0) + setGenCancelled(false) try { - const config = buildPlanConfig(); - let planId = loadedPlanId; + const config = buildPlanConfig() + let planId = loadedPlanId if (planId) { // 已有计划 → 先重置状态为 draft(failed/editing 等非 draft 状态会被后端拒绝更新和生成) try { - await updateEditPlan(planId, { status: "draft" }); + await updateEditPlan(planId, { status: "draft" }) } catch (resetErr) { - console.warn("[状态重置跳过]", resetErr); + console.warn("[状态重置跳过]", resetErr) } // 再更新配置 try { await updateEditPlan(planId, { config, total_duration: totalDuration, - }); + }) } catch (updateErr) { - console.warn("[计划更新跳过]", updateErr); + console.warn("[计划更新跳过]", updateErr) } } else { // 无计划 → 创建新计划 @@ -1109,111 +1068,109 @@ const EditingPlanner: React.FC = () => { name: draftName || "未命名计划", config, total_duration: totalDuration, - }); - planId = plan.id; - setLoadedPlanId(planId); + }) + planId = plan.id + setLoadedPlanId(planId) // 更新 URL 参数(不刷新页面) - const params = new URLSearchParams(window.location.search); - params.set("planId", planId); - window.history.replaceState(null, "", `?${params.toString()}`); + const params = new URLSearchParams(window.location.search) + params.set("planId", planId) + window.history.replaceState(null, "", `?${params.toString()}`) } // 同步片段到后端 clips 表(生成前必须同步,后端生成从 clips 表读) try { - await syncClipsToBackend(planId); + await syncClipsToBackend(planId) } catch (syncErr) { - console.warn("[片段同步失败]", syncErr); - message.warning("片段同步失败,将使用模板默认配置生成"); + console.warn("[片段同步失败]", syncErr) + message.warning("片段同步失败,将使用模板默认配置生成") // 同步失败不阻塞生成,后端有模板兜底 } // 触发生成 - const genRes = await generateEditPlan(planId); - setGenTotalClips(genRes.clip_count); - message.info("已提交生成,等待处理..."); + const genRes = await generateEditPlan(planId) + setGenTotalClips(genRes.clip_count) + message.info("已提交生成,等待处理...") // 开始轮询 - startPolling(planId); + startPolling(planId) } catch (err) { - console.error("[生成失败]", err); - setGenError("生成提交失败,请重试"); - setGenerating(false); + console.error("[生成失败]", err) + setGenError("生成提交失败,请重试") + setGenerating(false) } - }; + } /** 轮询生成状态,每 2 秒一次 */ const startPolling = (planId: string) => { const poll = async () => { try { - const status = await getGenerationStatus(planId); + const status = await getGenerationStatus(planId) // 计算进度 - const total = status.clips.length || genTotalClips; + const total = status.clips.length || genTotalClips const done = status.clips.filter( (c) => c.status === "completed" || c.status === "failed", - ).length; - setGenDoneClips(done); - setGenTotalClips(total); - setGenClipStatuses(status.clips || []); - setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5); + ).length + setGenDoneClips(done) + setGenTotalClips(total) + setGenClipStatuses(status.clips || []) + setGenProgress(total > 0 ? Math.round((done / total) * 100) : 5) if (status.plan_status === "completed") { - setGenProgress(100); - setGenerating(false); - setGenerated(true); + setGenProgress(100) + setGenerating(false) + setGenerated(true) // 获取视频结果 if (status.generation_task_id) { try { - const videos = await getGenerationTaskResults( - status.generation_task_id, - ); - setGeneratedVideos(videos); + const videos = await getGenerationTaskResults(status.generation_task_id) + setGeneratedVideos(videos) } catch (e) { - console.error("[获取视频结果失败]", e); + console.error("[获取视频结果失败]", e) } } - message.success("视频生成完成!"); - return; // 停止轮询 + message.success("视频生成完成!") + return // 停止轮询 } if (status.plan_status === "failed") { - setGenerating(false); - setGenError("生成失败,请重试"); - return; // 停止轮询 + setGenerating(false) + setGenError("生成失败,请重试") + return // 停止轮询 } if (status.plan_status === "cancelled") { - setGenerating(false); - setGenError("生成已取消"); - setGenCancelled(true); - message.info("生成任务已取消"); - return; // 停止轮询 + setGenerating(false) + setGenError("生成已取消") + setGenCancelled(true) + message.info("生成任务已取消") + return // 停止轮询 } // 继续轮询 - genTimerRef.current = setTimeout(poll, 2000); + genTimerRef.current = setTimeout(poll, 2000) } catch (err) { - console.error("[轮询状态失败]", err); - genTimerRef.current = setTimeout(poll, 5000); // 出错后 5 秒重试 + console.error("[轮询状态失败]", err) + genTimerRef.current = setTimeout(poll, 5000) // 出错后 5 秒重试 } - }; + } // 首次延迟 2 秒后开始 - genTimerRef.current = setTimeout(poll, 2000); - }; + genTimerRef.current = setTimeout(poll, 2000) + } /** 清理轮询定时器 */ useEffect(() => { return () => { - if (genTimerRef.current) clearTimeout(genTimerRef.current); - }; - }, []); + if (genTimerRef.current) clearTimeout(genTimerRef.current) + } + }, []) /** 取消生成任务 */ const handleCancelGeneration = async () => { - const targetId = loadedPlanId; - if (!targetId) return; + const targetId = loadedPlanId + if (!targetId) return Modal.confirm({ title: "确认取消生成", @@ -1223,38 +1180,38 @@ const EditingPlanner: React.FC = () => { okButtonProps: { danger: true }, onOk: async () => { try { - setCancelling(true); - await cancelGeneration(targetId); - message.success("已提交取消请求"); + setCancelling(true) + await cancelGeneration(targetId) + message.success("已提交取消请求") // 轮询会继续运行直到检测到 cancelled 状态 } catch (err) { - console.error("[取消失败]", err); - message.error("取消失败,请稍后重试"); + console.error("[取消失败]", err) + message.error("取消失败,请稍后重试") } finally { - setCancelling(false); + setCancelling(false) } }, - }); - }; + }) + } /* 查看生成历史 */ const handleViewGenHistory = async () => { - const targetId = loadedPlanId || loadedTemplateId; + const targetId = loadedPlanId || loadedTemplateId if (!targetId) { - message.warning("请先加载一个模板或计划"); - return; + message.warning("请先加载一个模板或计划") + return } - setGenHistoryOpen(true); - setGenHistoryLoading(true); + setGenHistoryOpen(true) + setGenHistoryLoading(true) try { - const items = await getEditPlanGenerations(targetId); - setGenHistory(items); + const items = await getEditPlanGenerations(targetId) + setGenHistory(items) } catch { - message.error("加载生成历史失败"); + message.error("加载生成历史失败") } finally { - setGenHistoryLoading(false); + setGenHistoryLoading(false) } - }; + } /* ──────────── 渲染 ──────────── */ @@ -1266,9 +1223,7 @@ const EditingPlanner: React.FC = () => { ✂️ 小虾剪辑编排器 | - - {currentTemplate?.name || "未选择模板"} - + {currentTemplate?.name || "未选择模板"}
- , ] @@ -1593,8 +1538,8 @@ const EditingPlanner: React.FC = () => { key="close" type="primary" onClick={() => { - setGenCancelled(false); - setGenerating(false); + setGenCancelled(false) + setGenerating(false) }} > 关闭 @@ -1605,8 +1550,8 @@ const EditingPlanner: React.FC = () => {
- ); -}; + ) +} -export default EditingPlanner; +export default EditingPlanner diff --git a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx index 41df587fd..785524e9e 100644 --- a/apps/web/src/pages/editing-planner/components/BgmSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/BgmSelector.tsx @@ -2,92 +2,85 @@ * BGM 选择器 — Drawer 形式 * 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置 */ -import React, { useState, useRef, useCallback, useEffect } from "react"; -import { Drawer, Slider, Input, Tag, message } from "antd"; +import React, { useState, useRef, useCallback, useEffect } from "react" +import { Drawer, Slider, Input, Tag, message } from "antd" import { getBgmPresets, type BgmPreset, type BgmCategory, type BgmMixConfig, DEFAULT_BGM_MIX_CONFIG, -} from "@/api/bgm"; +} from "@/api/bgm" -const { Search } = Input; +const { Search } = Input /* ──────────── 分类标签 ──────────── */ const CATEGORY_LIST: { - key: BgmCategory | "all"; - label: string; - icon: string; + key: BgmCategory | "all" + label: string + icon: string }[] = [ { key: "all", label: "全部", icon: "🎶" }, { key: "轻快", label: "轻快", icon: "🎉" }, { key: "治愈", label: "治愈", icon: "🌿" }, { key: "科技", label: "科技", icon: "🔬" }, { key: "电商", label: "电商", icon: "🛒" }, -]; +] /* ──────────── Props ──────────── */ interface BgmSelectorProps { - open: boolean; - onClose: () => void; - config: BgmMixConfig; - onChange: (config: BgmMixConfig) => void; + open: boolean + onClose: () => void + config: BgmMixConfig + onChange: (config: BgmMixConfig) => void } -const BgmSelector: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { - const [presets, setPresets] = useState([]); - const [loading, setLoading] = useState(false); - const [activeCategory, setActiveCategory] = useState( - "all", - ); - const [keyword, setKeyword] = useState(""); - const [previewingId, setPreviewingId] = useState(null); +const BgmSelector: React.FC = ({ open, onClose, config, onChange }) => { + const [presets, setPresets] = useState([]) + const [loading, setLoading] = useState(false) + const [activeCategory, setActiveCategory] = useState("all") + const [keyword, setKeyword] = useState("") + const [previewingId, setPreviewingId] = useState(null) - const audioRef = useRef(null); + const audioRef = useRef(null) /* ── 加载 BGM 列表 ── */ const loadPresets = useCallback(async () => { - setLoading(true); + setLoading(true) try { - const params: { category?: string; keyword?: string } = {}; - if (activeCategory !== "all") params.category = activeCategory; - if (keyword.trim()) params.keyword = keyword.trim(); - const data = await getBgmPresets(params); - setPresets(data); + const params: { category?: string; keyword?: string } = {} + if (activeCategory !== "all") params.category = activeCategory + if (keyword.trim()) params.keyword = keyword.trim() + const data = await getBgmPresets(params) + setPresets(data) } catch { - message.error("加载 BGM 列表失败"); + message.error("加载 BGM 列表失败") } finally { - setLoading(false); + setLoading(false) } - }, [activeCategory, keyword]); + }, [activeCategory, keyword]) useEffect(() => { - if (open) loadPresets(); - }, [open, loadPresets]); + if (open) loadPresets() + }, [open, loadPresets]) /* ── 试听 ── */ const handlePreview = useCallback( (bgm: BgmPreset) => { if (previewingId === bgm.id) { - audioRef.current?.pause(); - setPreviewingId(null); - return; + audioRef.current?.pause() + setPreviewingId(null) + return } - audioRef.current?.pause(); - const audio = new Audio(bgm.url); - audioRef.current = audio; - audio.play().catch(() => {}); - audio.onended = () => setPreviewingId(null); - setPreviewingId(bgm.id); + audioRef.current?.pause() + const audio = new Audio(bgm.url) + audioRef.current = audio + audio.play().catch(() => {}) + audio.onended = () => setPreviewingId(null) + setPreviewingId(bgm.id) }, [previewingId], - ); + ) /* ── 选中 BGM ── */ const handleSelect = useCallback( @@ -96,27 +89,27 @@ const BgmSelector: React.FC = ({ ...config, enabled: true, music_id: bgm.id, - }); + }) }, [config, onChange], - ); + ) /* ── 关闭时停止播放 ── */ const handleClose = useCallback(() => { - audioRef.current?.pause(); - setPreviewingId(null); - onClose(); - }, [onClose]); + audioRef.current?.pause() + setPreviewingId(null) + onClose() + }, [onClose]) /* ── 移除 BGM ── */ const handleClear = useCallback(() => { - audioRef.current?.pause(); - setPreviewingId(null); - onChange({ ...DEFAULT_BGM_MIX_CONFIG }); - }, [onChange]); + audioRef.current?.pause() + setPreviewingId(null) + onChange({ ...DEFAULT_BGM_MIX_CONFIG }) + }, [onChange]) /* ── 当前选中的 BGM ── */ - const selectedBgm = presets.find((p) => p.id === config.music_id); + const selectedBgm = presets.find((p) => p.id === config.music_id) return ( = ({ {/* ── BGM 列表 ── */}
{loading &&
加载中...
} - {!loading && presets.length === 0 && ( -
暂无 BGM 数据
- )} + {!loading && presets.length === 0 &&
暂无 BGM 数据
} {presets.map((bgm) => { - const isSelected = config.music_id === bgm.id; - const isPlaying = previewingId === bgm.id; + const isSelected = config.music_id === bgm.id + const isPlaying = previewingId === bgm.id return (
= ({ {isSelected && }
- ); + ) })}
@@ -219,9 +210,7 @@ const BgmSelector: React.FC = ({
- {selectedBgm - ? `当前:${selectedBgm.name}` - : `当前:${config.music_id}`} + {selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
{/* 音量 */} @@ -240,10 +229,7 @@ const BgmSelector: React.FC = ({ {/* 淡入 */}
= ({ {/* 淡出 */}
= ({
- onChange({ ...config, voice_dodge: !config.voice_dodge }) - } + onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })} >
@@ -286,7 +267,7 @@ const BgmSelector: React.FC = ({
)} - ); -}; + ) +} -export default BgmSelector; +export default BgmSelector diff --git a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx index 5fbb4b4ff..ed1286729 100644 --- a/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/ClipPropertiesPanel.tsx @@ -2,103 +2,95 @@ * 右栏设置面板 — V8 原型 1:1 还原 * 标题设置(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, TitleSettings } from "../types"; -import { TRANSITION_OPTIONS } from "@/api/editPlans"; -import type { AssetItem } from "@/api/assets"; +import React, { useRef, useState, useCallback } from "react" +import { useNavigate } from "react-router-dom" +import type { TemplateMode } from "@/api/editingPlanner" +import type { ClipData, ClipType, TitleSettings } from "../types" +import { TRANSITION_OPTIONS } from "@/api/editPlans" +import type { AssetItem } from "@/api/assets" interface SubtitleSettings { - enabled: boolean; - position: string; - font: string; - fontSize: number; - fontColor: string; - animation: string; - mode?: string; - stroke?: boolean; - shadow?: boolean; - asrLanguage?: string; + enabled: boolean + position: string + font: string + fontSize: number + fontColor: string + animation: string + mode?: string + stroke?: boolean + shadow?: boolean + asrLanguage?: string } interface BgmSettings { - enabled: boolean; - music_id: string; - volume?: number; - fade_in?: number; - fade_out?: number; - voice_dodge?: boolean; + enabled: boolean + music_id: string + volume?: number + fade_in?: number + fade_out?: number + voice_dodge?: boolean } interface ClipPropertiesPanelProps { - selectedClip: ClipData | null; - titleSettings: TitleSettings; - subtitleSettings: SubtitleSettings; - bgmSettings: BgmSettings; - clipsCount: number; - totalDuration: number; - currentMode: TemplateMode; - onTitleSettingsChange: (partial: Partial) => void; - onSubtitleSettingsChange: (partial: Partial) => void; - onBgmSettingsChange: (partial: Partial) => void; - onClipUpdate: (clipId: string, data: Partial) => void; + selectedClip: ClipData | null + titleSettings: TitleSettings + subtitleSettings: SubtitleSettings + bgmSettings: BgmSettings + clipsCount: number + totalDuration: number + currentMode: TemplateMode + onTitleSettingsChange: (partial: Partial) => void + onSubtitleSettingsChange: (partial: Partial) => void + onBgmSettingsChange: (partial: Partial) => void + onClipUpdate: (clipId: string, data: Partial) => void /** 打开 BGM 选择器 Drawer */ - onOpenBgmDrawer?: () => void; + onOpenBgmDrawer?: () => void /** 打开字幕样式配置 Drawer */ - onOpenSubtitleDrawer?: () => void; + onOpenSubtitleDrawer?: () => void /** 配音素材列表(从配音素材库 API 获取) */ - voiceMaterials?: AssetItem[]; + voiceMaterials?: AssetItem[] /** 配音素材加载中 */ - voiceMaterialsLoading?: boolean; + voiceMaterialsLoading?: boolean /** 刷新配音素材列表 */ - onRefreshVoiceMaterials?: () => void; + onRefreshVoiceMaterials?: () => void /** 为片段选择配音素材 */ - onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void; + onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void /** 打开转场特效选择器 Drawer */ - onOpenTransitionDrawer?: (clipId: string) => void; + onOpenTransitionDrawer?: (clipId: string) => void /** 打开片段调速面板 Drawer */ - onOpenSpeedDrawer?: (clipId: string) => void; + onOpenSpeedDrawer?: (clipId: string) => void /** 打开 TTS 配音面板 Drawer */ - onOpenTtsDrawer?: (clipId: string) => void; + onOpenTtsDrawer?: (clipId: string) => void /** 打开水印设置面板 Drawer */ - onOpenWatermarkDrawer?: () => void; + onOpenWatermarkDrawer?: () => void /** 打开片头片尾设置面板 Drawer */ - onOpenIntroOutroDrawer?: () => void; + onOpenIntroOutroDrawer?: () => void /** 打开画中画设置面板 Drawer */ - onOpenPipDrawer?: () => void; + onOpenPipDrawer?: () => void /** 打开滤镜调色面板 Drawer */ - onOpenFilterDrawer?: () => void; + onOpenFilterDrawer?: () => void /** 打开绿幕抠像面板 Drawer */ - onOpenGreenScreenDrawer?: () => void; + onOpenGreenScreenDrawer?: () => void /** 打开贴纸面板 Drawer */ - onOpenStickerDrawer?: () => void; + onOpenStickerDrawer?: () => void /** 打开封面选择器 Drawer */ - onOpenCoverDrawer?: () => void; + onOpenCoverDrawer?: () => void } const POSITION_OPTIONS = [ { value: "top", label: "顶部" }, { value: "center", label: "居中" }, { value: "bottom", label: "底部" }, -]; +] -const FONT_OPTIONS = [ - "思源黑体", - "思源宋体", - "苹方", - "PingFang", - "微软雅黑", - "楷体", - "华康俪金黑", -]; +const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"] const ANIMATION_OPTIONS = [ { value: "none", label: "无" }, { value: "fade", label: "淡入淡出" }, { value: "slide", label: "滑动" }, { value: "typewriter", label: "打字机" }, -]; +] /** * 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号) @@ -227,8 +219,7 @@ const TITLE_PRESETS = [ previewStyle: { fontWeight: 700, color: "#00e5ff", - textShadow: - "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", + textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)", fontSize: "20px", }, }, @@ -250,7 +241,7 @@ const TITLE_PRESETS = [ fontSize: "20px", }, }, -]; +] /** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */ function getActivePreset(settings: TitleSettings): string | null { @@ -263,18 +254,18 @@ function getActivePreset(settings: TitleSettings): string | null { settings.stroke === p.style.stroke && settings.shadow === p.style.shadow ) { - return p.key; + return p.key } } - return null; + return null } /** 片段类型图标/标签 */ -const CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️" }; +const CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️" } const CLIP_TYPE_LABELS: Record = { voice: "口播", pip: "画中画", -}; +} const ClipPropertiesPanel: React.FC = ({ selectedClip, @@ -305,41 +296,41 @@ const ClipPropertiesPanel: React.FC = ({ onOpenStickerDrawer, onOpenCoverDrawer, }) => { - const navigate = useNavigate(); + const navigate = useNavigate() /* ── 配音试听 ── */ - const audioRef = useRef(null); - const [previewingId, setPreviewingId] = useState(null); + const audioRef = useRef(null) + const [previewingId, setPreviewingId] = useState(null) /** 试听配音素材 */ const handlePreviewVoice = useCallback( (asset: AssetItem) => { // 点击同一个 → 暂停 if (previewingId === asset.id) { - audioRef.current?.pause(); - setPreviewingId(null); - return; + audioRef.current?.pause() + setPreviewingId(null) + return } // 停止上一个 - audioRef.current?.pause(); - const url = asset.file_url || (asset.metadata?.preview_url as string); - if (!url) return; - const audio = new Audio(url); - audioRef.current = audio; - audio.play().catch(() => {}); - audio.onended = () => setPreviewingId(null); - setPreviewingId(asset.id); + audioRef.current?.pause() + const url = asset.file_url || (asset.metadata?.preview_url as string) + if (!url) return + const audio = new Audio(url) + audioRef.current = audio + audio.play().catch(() => {}) + audio.onended = () => setPreviewingId(null) + setPreviewingId(asset.id) }, [previewingId], - ); + ) /** 从 metadata 取性别标签 */ const getGenderLabel = (m: AssetItem): string => { - const g = (m.metadata?.gender as string) || ""; - if (g === "male") return "男"; - if (g === "female") return "女"; - return ""; - }; + const g = (m.metadata?.gender as string) || "" + if (g === "male") return "男" + if (g === "female") return "女" + return "" + } return (
{/* ═══ 标题设置 ═══ */} @@ -370,9 +361,7 @@ const ClipPropertiesPanel: React.FC = ({ - onTitleSettingsChange({ font: e.target.value }) - } + onChange={(e) => onTitleSettingsChange({ font: e.target.value })} > {FONT_OPTIONS.map((f) => (
@@ -420,7 +405,7 @@ const ClipPropertiesPanel: React.FC = ({
{TITLE_PRESETS.map((p) => { - const isActive = getActivePreset(titleSettings) === p.key; + const isActive = getActivePreset(titleSettings) === p.key return ( - ); + ) })}
@@ -457,36 +437,28 @@ const ClipPropertiesPanel: React.FC = ({
@@ -580,9 +546,7 @@ const ClipPropertiesPanel: React.FC = ({ {/* 试听按钮 */} @@ -949,28 +891,20 @@ const ClipPropertiesPanel: React.FC = ({ )} {voiceMaterials.length === 0 && ( -
- 暂无配音素材,请先上传 -
+
暂无配音素材,请先上传
)} )} @@ -1001,9 +935,7 @@ const ClipPropertiesPanel: React.FC = ({
总时长
-
- {totalDuration.toFixed(1)}s -
+
{totalDuration.toFixed(1)}s
当前模式
@@ -1021,7 +953,7 @@ const ClipPropertiesPanel: React.FC = ({
)} - ); -}; + ) +} -export default ClipPropertiesPanel; +export default ClipPropertiesPanel diff --git a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx index 88778b988..7c1784175 100644 --- a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx @@ -2,17 +2,17 @@ * 封面选择器 * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 */ -import React, { useCallback, useRef, useState } from "react"; -import { Drawer } from "antd"; -import type { CoverConfig, CoverMode } from "../types"; -import { DEFAULT_COVER_CONFIG } from "../types"; +import React, { useCallback, useRef, useState } from "react" +import { Drawer } from "antd" +import type { CoverConfig, CoverMode } from "../types" +import { DEFAULT_COVER_CONFIG } from "../types" interface CoverSelectorProps { - open: boolean; - onClose: () => void; - config: CoverConfig; - onChange: (config: CoverConfig) => void; - totalDuration: number; + open: boolean + onClose: () => void + config: CoverConfig + onChange: (config: CoverConfig) => void + totalDuration: number } /** 封面模式标签 */ @@ -20,14 +20,14 @@ const MODE_LABELS: Record = { auto: "智能封面", frame: "抽帧选封面", upload: "上传封面", -}; +} /** 封面模式图标 */ const MODE_ICONS: Record = { auto: "🤖", frame: "🎞️", upload: "📤", -}; +} const CoverSelector: React.FC = ({ open, @@ -36,67 +36,67 @@ const CoverSelector: React.FC = ({ onChange, totalDuration, }) => { - const fileInputRef = useRef(null); - const [isDragging, setIsDragging] = useState(false); + const fileInputRef = useRef(null) + const [isDragging, setIsDragging] = useState(false) const update = useCallback( (partial: Partial) => { - onChange({ ...config, ...partial }); + onChange({ ...config, ...partial }) }, [config, onChange], - ); + ) const handleReset = useCallback(() => { - onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }); - }, [config.enabled, onChange]); + onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }) + }, [config.enabled, onChange]) /** 切换模式 */ const handleModeChange = useCallback( (mode: CoverMode) => { - update({ mode }); + update({ mode }) }, [update], - ); + ) /** 处理文件上传 */ const handleFileUpload = useCallback( (file: File) => { - if (!file.type.startsWith("image/")) return; - const reader = new FileReader(); + if (!file.type.startsWith("image/")) return + const reader = new FileReader() reader.onload = (e) => { - const url = e.target?.result as string; - update({ upload_url: url, thumbnail_url: url, mode: "upload" }); - }; - reader.readAsDataURL(file); + const url = e.target?.result as string + update({ upload_url: url, thumbnail_url: url, mode: "upload" }) + } + reader.readAsDataURL(file) }, [update], - ); + ) /** 拖拽上传 */ const handleDrop = useCallback( (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - const file = e.dataTransfer.files[0]; - if (file) handleFileUpload(file); + e.preventDefault() + setIsDragging(false) + const file = e.dataTransfer.files[0] + if (file) handleFileUpload(file) }, [handleFileUpload], - ); + ) /** 使用 AI 推荐时间 */ const handleUseAiSuggestion = useCallback(() => { if (config.ai_suggested_time !== null) { - update({ frame_time: config.ai_suggested_time, mode: "frame" }); + update({ frame_time: config.ai_suggested_time, mode: "frame" }) } - }, [config.ai_suggested_time, update]); + }, [config.ai_suggested_time, update]) /** 格式化时间 */ 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 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}` + } return ( = ({
推荐时间点:{formatTime(config.ai_suggested_time)}
- @@ -173,17 +170,13 @@ const CoverSelector: React.FC = ({
🎞️ - - {formatTime(config.frame_time)} - + {formatTime(config.frame_time)}
拖动选择封面帧 - - {formatTime(config.frame_time)} - + {formatTime(config.frame_time)}
= ({
快捷选帧: {[0, 0.25, 0.5, 0.75].map((ratio) => { - const t = totalDuration * ratio; + const t = totalDuration * ratio return ( - ); + ) })}
@@ -224,8 +217,8 @@ const CoverSelector: React.FC = ({
{ - e.preventDefault(); - setIsDragging(true); + e.preventDefault() + setIsDragging(true) }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} @@ -239,12 +232,8 @@ const CoverSelector: React.FC = ({ ) : (
📤 - - 点击或拖拽上传封面图片 - - - 支持 JPG / PNG,建议 16:9 比例 - + 点击或拖拽上传封面图片 + 支持 JPG / PNG,建议 16:9 比例
)} = ({ accept="image/*" style={{ display: "none" }} onChange={(e) => { - const file = e.target.files?.[0]; - if (file) handleFileUpload(file); + const file = e.target.files?.[0] + if (file) handleFileUpload(file) }} />
@@ -267,11 +256,7 @@ const CoverSelector: React.FC = ({
封面预览
{config.upload_url ? ( - 封面预览 + 封面预览 ) : (
🖼️ @@ -295,7 +280,7 @@ const CoverSelector: React.FC = ({
- ); -}; + ) +} -export default CoverSelector; +export default CoverSelector diff --git a/apps/web/src/pages/editing-planner/components/EditorClipList.tsx b/apps/web/src/pages/editing-planner/components/EditorClipList.tsx index 015391fa2..751c21a44 100755 --- a/apps/web/src/pages/editing-planner/components/EditorClipList.tsx +++ b/apps/web/src/pages/editing-planner/components/EditorClipList.tsx @@ -2,8 +2,8 @@ * 编辑器右侧栏 — 片段列表 Tab * 紧凑版片段管理:选中、上下移动、删除、添加 */ -import React from "react"; -import { Button, Tooltip, Empty } from "antd"; +import React from "react" +import { Button, Tooltip, Empty } from "antd" import { UpOutlined, DownOutlined, @@ -13,17 +13,17 @@ import { SoundOutlined, PictureOutlined, VideoCameraOutlined, -} from "@ant-design/icons"; -import type { ClipData, ClipType } from "../types"; +} from "@ant-design/icons" +import type { ClipData, ClipType } from "../types" interface EditorClipListProps { - clips: ClipData[]; - selectedClipId: string | null; - onSelect: (clipId: string) => void; - onMoveUp: (clipId: string) => void; - onMoveDown: (clipId: string) => void; - onRemove: (clipId: string) => void; - onAdd: () => void; + clips: ClipData[] + selectedClipId: string | null + onSelect: (clipId: string) => void + onMoveUp: (clipId: string) => void + onMoveDown: (clipId: string) => void + onRemove: (clipId: string) => void + onAdd: () => void } const clipTypeIcon: Record = { @@ -31,21 +31,21 @@ const clipTypeIcon: Record = { image: , voice: , pip: , -}; +} const clipTypeLabel: Record = { video: "视频", image: "图片", voice: "配音", pip: "画中画", -}; +} const formatDuration = (sec: number) => { - if (sec < 60) return `${sec.toFixed(1)}s`; - const m = Math.floor(sec / 60); - const s = (sec % 60).toFixed(0); - return `${m}m${s.padStart(2, "0")}s`; -}; + if (sec < 60) return `${sec.toFixed(1)}s` + const m = Math.floor(sec / 60) + const s = (sec % 60).toFixed(0) + return `${m}m${s.padStart(2, "0")}s` +} const EditorClipList: React.FC = ({ clips, @@ -68,7 +68,7 @@ const EditorClipList: React.FC = ({ 添加片段
- ); + ) } return ( @@ -79,12 +79,7 @@ const EditorClipList: React.FC = ({ 共 {clips.length} 个片段 - @@ -95,9 +90,7 @@ const EditorClipList: React.FC = ({ {clips.map((clip, index) => (
onSelect(clip.id)} > {/* 序号 + 类型图标 */} @@ -109,9 +102,7 @@ const EditorClipList: React.FC = ({ {clipTypeLabel[clip.type] || "片段"} - - {formatDuration(clip.duration)} - + {formatDuration(clip.duration)}
{/* 文案预览 */} @@ -123,10 +114,7 @@ const EditorClipList: React.FC = ({ )} {/* 操作按钮 */} -
e.stopPropagation()} - > +
e.stopPropagation()}>
- ); -}; + ) +} -export default EditorClipList; +export default EditorClipList diff --git a/apps/web/src/pages/editing-planner/components/FilterPanel.tsx b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx index 617112b34..d1453bd54 100644 --- a/apps/web/src/pages/editing-planner/components/FilterPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx @@ -2,16 +2,16 @@ * 滤镜调色配置面板 * 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度) */ -import React, { useCallback } from "react"; -import { Drawer, Switch } from "antd"; -import type { FilterConfig, FilterPreset } from "../types"; -import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"; +import React, { useCallback } from "react" +import { Drawer, Switch } from "antd" +import type { FilterConfig, FilterPreset } from "../types" +import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types" interface FilterPanelProps { - open: boolean; - onClose: () => void; - config: FilterConfig; - onChange: (config: FilterConfig) => void; + open: boolean + onClose: () => void + config: FilterConfig + onChange: (config: FilterConfig) => void } /** 所有预设列表 */ @@ -26,7 +26,7 @@ const PRESET_LIST: FilterPreset[] = [ "bw", "sunshine", "film", -]; +] /** 预设对应的示例渐变色(用于视觉预览) */ const PRESET_GRADIENTS: Record = { @@ -40,40 +40,35 @@ const PRESET_GRADIENTS: Record = { bw: "linear-gradient(135deg, #434343 0%, #000000 100%)", sunshine: "linear-gradient(135deg, #f6d365 0%, #fda085 100%)", film: "linear-gradient(135deg, #8e9eab 0%, #eef2f3 100%)", -}; +} -const FilterPanel: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { +const FilterPanel: React.FC = ({ open, onClose, config, onChange }) => { const update = useCallback( (partial: Partial) => { - onChange({ ...config, ...partial }); + onChange({ ...config, ...partial }) }, [config, onChange], - ); + ) const handleReset = useCallback(() => { - onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }); - }, [config.enabled, onChange]); + onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }) + }, [config.enabled, onChange]) /** 选择预设时重置手动参数 */ const handlePresetSelect = useCallback( (preset: FilterPreset) => { if (preset === "none") { - onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }); + onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled }) } else { onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled, preset, - }); + }) } }, [config.enabled, onChange], - ); + ) return ( = ({ className={`filter-preset-item${config.preset === p ? " active" : ""}`} onClick={() => handlePresetSelect(p)} > -
- - {FILTER_PRESET_LABELS[p]} - +
+ {FILTER_PRESET_LABELS[p]} ))}
@@ -228,7 +218,7 @@ const FilterPanel: React.FC = ({
- ); -}; + ) +} -export default FilterPanel; +export default FilterPanel diff --git a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx index b9019decf..33ba2e147 100755 --- a/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx +++ b/apps/web/src/pages/editing-planner/components/GenerationHistoryModal.tsx @@ -2,18 +2,18 @@ * 生成历史弹窗 — 展示当前剪辑计划的生成任务记录 * 从 EditingPlanner 拆分,避免主文件过大 */ -import React from "react"; -import { CloseOutlined, InboxOutlined } from "@ant-design/icons"; -import type { EditPlanGeneration } from "@/api/editPlans"; -import { PLAN_STATUS_LABELS } from "@/api/editPlans"; +import React from "react" +import { CloseOutlined, InboxOutlined } from "@ant-design/icons" +import type { EditPlanGeneration } from "@/api/editPlans" +import { PLAN_STATUS_LABELS } from "@/api/editPlans" interface GenerationHistoryModalProps { - open: boolean; - loading: boolean; - history: EditPlanGeneration[]; - onClose: () => void; - onCancel?: (taskId: string) => void; - cancelLoading?: boolean; + open: boolean + loading: boolean + history: EditPlanGeneration[] + onClose: () => void + onCancel?: (taskId: string) => void + cancelLoading?: boolean } const GenerationHistoryModal: React.FC = ({ @@ -24,14 +24,11 @@ const GenerationHistoryModal: React.FC = ({ onCancel, cancelLoading, }) => { - if (!open) return null; + if (!open) return null return (
-
e.stopPropagation()} - > +
e.stopPropagation()}>

生成历史

- ) : (history?.length ?? 0) === 0 ? ( + ) : history.length === 0 ? (
暂无生成记录 @@ -65,10 +62,9 @@ const GenerationHistoryModal: React.FC = ({ - {(history ?? []).map((gen) => { - const statusClass = `ep-gh-status-tag--${gen.status}`; - const canCancel = - gen.status === "rendering" || gen.status === "editing"; + {history.map((gen) => { + const statusClass = `ep-gh-status-tag--${gen.status}` + const canCancel = gen.status === "rendering" || gen.status === "editing" return ( @@ -80,14 +76,10 @@ const GenerationHistoryModal: React.FC = ({ - {gen.created_at - ? new Date(gen.created_at).toLocaleString("zh-CN") - : "—"} + {gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"} - {gen.updated_at - ? new Date(gen.updated_at).toLocaleString("zh-CN") - : "—"} + {gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"} {onCancel && ( @@ -105,7 +97,7 @@ const GenerationHistoryModal: React.FC = ({ )} - ); + ) })} @@ -118,7 +110,7 @@ const GenerationHistoryModal: React.FC = ({
- ); -}; + ) +} -export default GenerationHistoryModal; +export default GenerationHistoryModal diff --git a/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx b/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx index 36747a050..df7605e93 100644 --- a/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx +++ b/apps/web/src/pages/editing-planner/components/GenerationProgressModal.tsx @@ -3,32 +3,32 @@ * 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果) * V21 设计系统,CSS 类名前缀 ep-gen- */ -import React, { useEffect, useRef } from "react"; -import { Modal, Button } from "@/components/ui"; -import type { TaskItem } from "@/api/tasks"; +import React, { useEffect, useRef } from "react" +import { Modal, Button } from "@/components/ui" +import type { TaskItem } from "@/api/tasks" /* ──────────── 类型 ──────────── */ -export type GenPhase = "setup" | "progress" | "completed" | "failed"; +export type GenPhase = "setup" | "progress" | "completed" | "failed" export interface GenerationProgressModalProps { - open: boolean; - phase: GenPhase; + open: boolean + phase: GenPhase /* setup 阶段 */ - voiceoverDuration: number | null; - estimatedDuration: number; - onDurationChange: (v: number | null) => void; - onGenerate: () => void; + voiceoverDuration: number | null + estimatedDuration: number + onDurationChange: (v: number | null) => void + onGenerate: () => void /* progress / 结果阶段 */ - task: TaskItem | null; + task: TaskItem | null /* 通用 */ - submitting: boolean; - onCancel: () => void; - onRetry?: () => void; - onClose?: () => void; + submitting: boolean + onCancel: () => void + onRetry?: () => void + onClose?: () => void } /* ──────────── 步骤文案映射 ──────────── */ @@ -42,10 +42,9 @@ const STEP_LABELS: Record = { encoding: "编码输出中…", completed: "生成完成!", failed: "生成失败", -}; +} -const getStepLabel = (step: string) => - STEP_LABELS[step] || step.replace(/_/g, " "); +const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ") /* ──────────── 状态徽标颜色 ──────────── */ @@ -59,7 +58,7 @@ const STATUS_COLOR: Record = { encoding: "#0891b2", completed: "#10b981", failed: "#ef4444", -}; +} /* ──────────── 组件 ──────────── */ @@ -77,20 +76,20 @@ const GenerationProgressModal: React.FC = ({ onClose, }) => { /* 关闭弹窗时重置(避免下次打开残留旧状态) */ - const prevOpen = useRef(false); + const prevOpen = useRef(false) useEffect(() => { if (prevOpen.current && !open) { /* modal just closed — parent handles reset */ } - prevOpen.current = open; - }, [open]); + prevOpen.current = open + }, [open]) - const progress = task?.progress ?? 0; - const status = task?.status ?? ""; - const currentStep = task?.current_step ?? ""; - const userMessage = task?.user_message ?? ""; - const errorMessage = task?.error_message ?? ""; - const retryable = task?.retryable ?? false; + const progress = task?.progress ?? 0 + const status = task?.status ?? "" + const currentStep = task?.current_step ?? "" + const userMessage = task?.user_message ?? "" + const errorMessage = task?.error_message ?? "" + const retryable = task?.retryable ?? false /* ── setup 阶段 ── */ if (phase === "setup") { @@ -113,8 +112,8 @@ const GenerationProgressModal: React.FC = ({ placeholder="请输入配音时长" value={voiceoverDuration ?? ""} onChange={(e) => { - const v = e.target.value ? Number(e.target.value) : null; - onDurationChange(v); + const v = e.target.value ? Number(e.target.value) : null + onDurationChange(v) }} min={1} max={600} @@ -124,33 +123,20 @@ const GenerationProgressModal: React.FC = ({
- ); + ) } /* ── progress 阶段 ── */ if (phase === "progress") { - const stepColor = - STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"; + const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5" return ( - +
{/* 进度环 */}
- + = ({ {task?.id &&
任务 ID: {task.id}
}
- ); + ) } /* ── completed 阶段 ── */ @@ -205,9 +191,7 @@ const GenerationProgressModal: React.FC = ({
🎉
视频生成完成!
- {userMessage && ( -
{userMessage}
- )} + {userMessage &&
{userMessage}
}
- ); + ) } /* ── failed 阶段 ── */ @@ -248,7 +232,7 @@ const GenerationProgressModal: React.FC = ({
- ); -}; + ) +} -export default GenerationProgressModal; +export default GenerationProgressModal diff --git a/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx index 36cd45f9e..ffab70927 100644 --- a/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx @@ -2,47 +2,36 @@ * 绿幕抠像配置面板 * 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制 */ -import React, { useCallback } from "react"; -import { Drawer, Switch } from "antd"; -import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"; +import React, { useCallback } from "react" +import { Drawer, Switch } from "antd" +import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types" import { DEFAULT_CHROMA_KEY_CONFIG, CHROMA_KEY_PRESET_LABELS, CHROMA_KEY_PRESET_COLORS, -} from "../types"; +} from "../types" interface GreenScreenPanelProps { - open: boolean; - onClose: () => void; - config: ChromaKeyConfig; - onChange: (config: ChromaKeyConfig) => void; + open: boolean + onClose: () => void + config: ChromaKeyConfig + onChange: (config: ChromaKeyConfig) => void } /** 预设列表 */ -const PRESET_LIST: ChromaKeyColorPreset[] = [ - "green", - "blue", - "red", - "pure_green", - "soft_green", -]; +const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"] -const GreenScreenPanel: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { +const GreenScreenPanel: React.FC = ({ open, onClose, config, onChange }) => { const update = useCallback( (partial: Partial) => { - onChange({ ...config, ...partial }); + onChange({ ...config, ...partial }) }, [config, onChange], - ); + ) const handleReset = useCallback(() => { - onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled }); - }, [config.enabled, onChange]); + onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled }) + }, [config.enabled, onChange]) /** 选择颜色预设时同步更新 color 字段 */ const handlePresetSelect = useCallback( @@ -50,18 +39,18 @@ const GreenScreenPanel: React.FC = ({ update({ color_preset: preset, color: CHROMA_KEY_PRESET_COLORS[preset], - }); + }) }, [update], - ); + ) /** 自定义颜色变化时清除预设标记 */ const handleColorChange = useCallback( (e: React.ChangeEvent) => { - update({ color: e.target.value }); + update({ color: e.target.value }) }, [update], - ); + ) return ( = ({ className="green-preset-dot" style={{ background: CHROMA_KEY_PRESET_COLORS[p] }} /> - - {CHROMA_KEY_PRESET_LABELS[p]} - + {CHROMA_KEY_PRESET_LABELS[p]} ))} @@ -121,10 +108,7 @@ const GreenScreenPanel: React.FC = ({ onChange={handleColorChange} placeholder="#00FF00" /> -
+
@@ -188,10 +172,7 @@ const GreenScreenPanel: React.FC = ({
效果预览
-
+
主体
@@ -213,7 +194,7 @@ const GreenScreenPanel: React.FC = ({
- ); -}; + ) +} -export default GreenScreenPanel; +export default GreenScreenPanel diff --git a/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx index abb12d237..84fc8968f 100644 --- a/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx @@ -3,16 +3,11 @@ * 两个区块:片头(Intro)/ 片尾(Outro) * 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画 */ -import React, { useCallback } from "react"; -import { Drawer } from "antd"; -import type { - IntroOutroConfig, - IntroOutroItem, - IntroOutroKind, - TransitionType, -} from "../types"; -import { DEFAULT_INTRO_OUTRO } from "../types"; -import { TRANSITION_OPTIONS } from "@/api/editPlans"; +import React, { useCallback } from "react" +import { Drawer } from "antd" +import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types" +import { DEFAULT_INTRO_OUTRO } from "../types" +import { TRANSITION_OPTIONS } from "@/api/editPlans" /* ──────────── 常量 ──────────── */ @@ -20,58 +15,53 @@ const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [ { value: "none", label: "无", icon: "🚫" }, { value: "video", label: "视频", icon: "🎬" }, { value: "image", label: "图片", icon: "🖼️" }, -]; +] /* ──────────── Props ──────────── */ interface IntroOutroPanelProps { - open: boolean; - onClose: () => void; - config: IntroOutroConfig; - onChange: (config: IntroOutroConfig) => void; + open: boolean + onClose: () => void + config: IntroOutroConfig + onChange: (config: IntroOutroConfig) => void } -const IntroOutroPanel: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { +const IntroOutroPanel: React.FC = ({ open, onClose, config, onChange }) => { /* ── 更新片头 ── */ const handleIntroChange = useCallback( (partial: Partial) => { - onChange({ ...config, intro: { ...config.intro, ...partial } }); + onChange({ ...config, intro: { ...config.intro, ...partial } }) }, [config, onChange], - ); + ) /* ── 更新片尾 ── */ const handleOutroChange = useCallback( (partial: Partial) => { - onChange({ ...config, outro: { ...config.outro, ...partial } }); + onChange({ ...config, outro: { ...config.outro, ...partial } }) }, [config, onChange], - ); + ) /* ── 切换片头类型 ── */ const handleIntroKindChange = useCallback( (kind: IntroOutroKind) => { - handleIntroChange({ kind, url: kind === "none" ? undefined : "" }); + handleIntroChange({ kind, url: kind === "none" ? undefined : "" }) }, [handleIntroChange], - ); + ) /* ── 切换片尾类型 ── */ const handleOutroKindChange = useCallback( (kind: IntroOutroKind) => { - handleOutroChange({ kind, url: kind === "none" ? undefined : "" }); + handleOutroChange({ kind, url: kind === "none" ? undefined : "" }) }, [handleOutroChange], - ); + ) /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_INTRO_OUTRO }); - }, [onChange]); + onChange({ ...DEFAULT_INTRO_OUTRO }) + }, [onChange]) return ( = ({ max={15} step={0.5} value={config.intro.duration} - onChange={(e) => - handleIntroChange({ duration: Number(e.target.value) }) - } + onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })} /> - - {config.intro.duration}s - + {config.intro.duration}s
@@ -240,13 +226,9 @@ const IntroOutroPanel: React.FC = ({ max={15} step={0.5} value={config.outro.duration} - onChange={(e) => - handleOutroChange({ duration: Number(e.target.value) }) - } + onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })} /> - - {config.outro.duration}s - + {config.outro.duration}s
@@ -303,7 +285,7 @@ const IntroOutroPanel: React.FC = ({
- ); -}; + ) +} -export default IntroOutroPanel; +export default IntroOutroPanel diff --git a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx index 33da4806a..ed4172647 100755 --- a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx @@ -2,26 +2,26 @@ * 左侧面板 — V8 原型 1:1 还原 * Tab 切换:模板列表 + 素材库 */ -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"; +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[]; - loading: boolean; - searchQuery: string; - currentFilter: string; - filterCategories: string[]; - loadedTemplateId: string | null; - onLoadTemplate: (id: string) => void; - onSearchChange: (q: string) => void; - onFilterChange: (f: string) => void; + templates: EditingTemplate[] + loading: boolean + searchQuery: string + currentFilter: string + filterCategories: string[] + loadedTemplateId: string | null + onLoadTemplate: (id: string) => void + onSearchChange: (q: string) => void + onFilterChange: (f: string) => void // 素材相关 - mediaAssets?: MediaAsset[]; - onAssetSelect?: (ids: string[]) => void; - selectedAssetIds?: string[]; + mediaAssets?: MediaAsset[] + onAssetSelect?: (ids: string[]) => void + selectedAssetIds?: string[] } const MediaPanel: React.FC = ({ @@ -38,9 +38,7 @@ const MediaPanel: React.FC = ({ onAssetSelect, selectedAssetIds = [], }) => { - const [activeTab, setActiveTab] = useState<"templates" | "assets">( - "templates", - ); + const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates") return (
@@ -108,9 +106,7 @@ const MediaPanel: React.FC = ({ >
{tpl.name} - - {MODE_LABELS[tpl.mode]} - + {MODE_LABELS[tpl.mode]}
⏱️ {tpl.estimated_duration}s @@ -146,7 +142,7 @@ const MediaPanel: React.FC = ({
)}
- ); -}; + ) +} -export default MediaPanel; +export default MediaPanel diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx index c1fbfbee0..8781e4e6c 100644 --- a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx @@ -2,16 +2,10 @@ * 画中画配置面板 — Drawer 形式 * 左侧图层列表 + 右侧单图层配置 + 迷你预览区 */ -import React, { useCallback, useMemo } from "react"; -import { Drawer, Switch } from "antd"; -import type { - PipConfig, - PipLayer, - PipGridPosition, - PipAnimType, - PipSlideDirection, -} from "../types"; -import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"; +import React, { useCallback, useMemo } from "react" +import { Drawer, Switch } from "antd" +import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types" +import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types" /* ──────────── 常量 ──────────── */ @@ -26,7 +20,7 @@ const GRID_POSITION_MAP: Record = { bottom_left: { x: 5, y: 70 }, bottom_center: { x: 37.5, y: 70 }, bottom_right: { x: 70, y: 70 }, -}; +} /** 九宫格位置选项 */ const GRID_POSITIONS: PipGridPosition[] = [ @@ -39,14 +33,14 @@ const GRID_POSITIONS: PipGridPosition[] = [ "bottom_left", "bottom_center", "bottom_right", -]; +] /** 入场动画选项 */ const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [ { value: "none", label: "无" }, { value: "fade_in", label: "淡入" }, { value: "slide_in", label: "滑入" }, -]; +] /** 滑入方向选项 */ const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ @@ -54,7 +48,7 @@ const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [ { value: "right", label: "→ 右" }, { value: "up", label: "↑ 上" }, { value: "down", label: "↓ 下" }, -]; +] /** 预览图层颜色池 */ const LAYER_COLORS = [ @@ -64,22 +58,22 @@ const LAYER_COLORS = [ "rgba(255,77,79,0.5)", "rgba(114,46,209,0.5)", "rgba(19,194,194,0.5)", -]; +] /* ──────────── Props ──────────── */ interface PipConfigPanelProps { - open: boolean; - onClose: () => void; - config: PipConfig; - onChange: (config: PipConfig) => void; - totalDuration: number; + open: boolean + onClose: () => void + config: PipConfig + onChange: (config: PipConfig) => void + totalDuration: number } /* ──────────── 辅助函数 ──────────── */ -let layerIdCounter = 0; -const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`; +let layerIdCounter = 0 +const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}` /* ──────────── 组件 ──────────── */ @@ -91,13 +85,13 @@ const PipConfigPanel: React.FC = ({ totalDuration, }) => { /** 当前选中图层 ID */ - const [selectedId, setSelectedId] = React.useState(""); + const [selectedId, setSelectedId] = React.useState("") /** 当前选中图层 */ const selectedLayer = useMemo( () => config.layers.find((l) => l.id === selectedId) ?? null, [config.layers, selectedId], - ); + ) /* ── 添加图层 ── */ const handleAddLayer = useCallback(() => { @@ -106,92 +100,90 @@ const PipConfigPanel: React.FC = ({ id: genLayerId(), name: `图层 ${config.layers.length + 1}`, z_index: config.layers.length + 1, - }; + } onChange({ ...config, layers: [...config.layers, newLayer], - }); - setSelectedId(newLayer.id); - }, [config, onChange]); + }) + setSelectedId(newLayer.id) + }, [config, onChange]) /* ── 删除图层 ── */ const handleDeleteLayer = useCallback( (id: string) => { - const newLayers = config.layers.filter((l) => l.id !== id); - onChange({ ...config, layers: newLayers }); + const newLayers = config.layers.filter((l) => l.id !== id) + onChange({ ...config, layers: newLayers }) if (selectedId === id) { - setSelectedId(newLayers.length > 0 ? newLayers[0].id : ""); + setSelectedId(newLayers.length > 0 ? newLayers[0].id : "") } }, [config, onChange, selectedId], - ); + ) /* ── 更新图层 ── */ const updateLayer = useCallback( (id: string, partial: Partial) => { onChange({ ...config, - layers: config.layers.map((l) => - l.id === id ? { ...l, ...partial } : l, - ), - }); + layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)), + }) }, [config, onChange], - ); + ) /* ── 切换启用 ── */ const handleEnableToggle = useCallback( (checked: boolean) => { - onChange({ ...config, enabled: checked }); + onChange({ ...config, enabled: checked }) }, [config, onChange], - ); + ) /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_PIP_CONFIG }); - setSelectedId(""); - }, [onChange]); + onChange({ ...DEFAULT_PIP_CONFIG }) + setSelectedId("") + }, [onChange]) /* ── 九宫格点击 ── */ const handleGridClick = useCallback( (pos: PipGridPosition) => { - if (!selectedLayer) return; - const coords = GRID_POSITION_MAP[pos]; + if (!selectedLayer) return + const coords = GRID_POSITION_MAP[pos] updateLayer(selectedLayer.id, { grid_position: pos, x: coords.x, y: coords.y, - }); + }) }, [selectedLayer, updateLayer], - ); + ) /* ── 宽高比锁定 ── */ const handleWidthChange = useCallback( (val: number) => { - if (!selectedLayer) return; - const partial: Partial = { width: val }; + if (!selectedLayer) return + const partial: Partial = { width: val } if (selectedLayer.aspect_lock) { // 保持宽高比 1:1(百分比相同) - partial.height = val; + partial.height = val } - updateLayer(selectedLayer.id, partial); + updateLayer(selectedLayer.id, partial) }, [selectedLayer, updateLayer], - ); + ) const handleHeightChange = useCallback( (val: number) => { - if (!selectedLayer) return; - const partial: Partial = { height: val }; + if (!selectedLayer) return + const partial: Partial = { height: val } if (selectedLayer.aspect_lock) { - partial.width = val; + partial.width = val } - updateLayer(selectedLayer.id, partial); + updateLayer(selectedLayer.id, partial) }, [selectedLayer, updateLayer], - ); + ) return ( = ({
启用 - +
@@ -250,8 +238,8 @@ const PipConfigPanel: React.FC = ({ @@ -315,8 +299,7 @@ const PipConfigPanel: React.FC = ({ {/* ── 素材 URL ── */}
= ({ {/* ── 位置:九宫格 + 坐标 ── */}
-
+
{GRID_POSITIONS.map((pos) => (
@@ -478,9 +445,7 @@ const PipConfigPanel: React.FC = ({ }) } /> - - {selectedLayer.opacity}% - + {selectedLayer.opacity}%
@@ -576,7 +541,7 @@ const PipConfigPanel: React.FC = ({
- ); -}; + ) +} -export default PipConfigPanel; +export default PipConfigPanel diff --git a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx index e33175f49..9b694d09d 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -3,46 +3,46 @@ * 手机模型预览(150x267) + 封面预览(150x267) 并排 * 封面右侧竖排4个方案按钮 */ -import React from "react"; -import type { ClipData, ClipType, TitleSettings } from "../types"; +import React from "react" +import type { ClipData, ClipType, TitleSettings } from "../types" interface CoverScheme { - key: string; - label: string; + key: string + label: string } interface SubtitleSettings { - enabled: boolean; - position: string; - font: string; - size: number; - animation: string; + enabled: boolean + position: string + font: string + size: number + animation: string } interface PreviewPlayerProps { - clips: ClipData[]; - selectedClipId: string | null; - isPlaying: boolean; - 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; + clips: ClipData[] + selectedClipId: string | null + isPlaying: boolean + 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 = { voice: "🎙️", pip: "🖼️", -}; +} const CLIP_TYPE_LABELS: Record = { voice: "口播", pip: "画中画", -}; +} const PreviewPlayer: React.FC = ({ clips, @@ -57,8 +57,8 @@ const PreviewPlayer: React.FC = ({ onPlayPause, onAiGenerateCover, }) => { - const selectedClip = clips.find((c) => c.id === selectedClipId); - const displayClip = selectedClip || clips[0]; + const selectedClip = clips.find((c) => c.id === selectedClipId) + const displayClip = selectedClip || clips[0] return (
@@ -90,38 +90,29 @@ const PreviewPlayer: React.FC = ({ )} {/* 标题实时预览 */} - {titleSettings && - !titleSettings.aiAutoSelect && - titleSettings.title && ( -
- {titleSettings.title} -
- )} + {titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && ( +
+ {titleSettings.title} +
+ )} {/* 字幕实时预览 */} {subtitleSettings?.enabled && ( @@ -137,10 +128,7 @@ const PreviewPlayer: React.FC = ({ ? "50%" : "auto", bottom: subtitleSettings.position === "bottom" ? "8px" : "auto", - transform: - subtitleSettings.position === "center" - ? "translateY(-50%)" - : "none", + transform: subtitleSettings.position === "center" ? "translateY(-50%)" : "none", }} > 字幕预览文字 @@ -153,16 +141,13 @@ const PreviewPlayer: React.FC = ({
{displayClip ? ( - - {CLIP_TYPE_ICONS[displayClip.type] || "🎬"} - + {CLIP_TYPE_ICONS[displayClip.type] || "🎬"} ) : ( 暂无封面 )}
- {coverSchemes.find((s) => s.key === currentCoverScheme)?.label || - "封面预览"} + {coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"}
{/* AI 封面操作按钮 */}
@@ -198,7 +183,7 @@ const PreviewPlayer: React.FC = ({ ))}
- ); -}; + ) +} -export default PreviewPlayer; +export default PreviewPlayer diff --git a/apps/web/src/pages/editing-planner/components/SaveModal.tsx b/apps/web/src/pages/editing-planner/components/SaveModal.tsx index 364bdc205..c9910c038 100644 --- a/apps/web/src/pages/editing-planner/components/SaveModal.tsx +++ b/apps/web/src/pages/editing-planner/components/SaveModal.tsx @@ -2,24 +2,24 @@ * 保存/更新模板弹窗 — V21 设计系统 * 分类使用 Select 关联后端分类 API(P1-5) */ -import React from "react"; -import { Modal, Input, Select } from "@/components/ui"; -import type { TemplateCategory } from "@/api/editingPlanner"; +import React from "react" +import { Modal, Input, Select } from "@/components/ui" +import type { TemplateCategory } from "@/api/editingPlanner" interface SaveModalProps { - open: boolean; - loading: boolean; - isUpdate: boolean; - draftName: string; - draftCategory: string; - draftTags: string; - categories: TemplateCategory[]; - estimatedDuration: number; - onNameChange: (v: string) => void; - onCategoryChange: (v: string) => void; - onTagsChange: (v: string) => void; - onSave: () => void; - onCancel: () => void; + open: boolean + loading: boolean + isUpdate: boolean + draftName: string + draftCategory: string + draftTags: string + categories: TemplateCategory[] + estimatedDuration: number + onNameChange: (v: string) => void + onCategoryChange: (v: string) => void + onTagsChange: (v: string) => void + onSave: () => void + onCancel: () => void } const SaveModal: React.FC = ({ @@ -80,7 +80,7 @@ const SaveModal: React.FC = ({ ⏱️ 预估时长:~{estimatedDuration}s
- ); -}; + ) +} -export default SaveModal; +export default SaveModal diff --git a/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx index d6265d895..ea81bb136 100644 --- a/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx @@ -3,10 +3,10 @@ * 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关 * 支持应用到当前片段 / 所有片段 */ -import React, { useCallback } from "react"; -import { Drawer, Slider } from "antd"; -import type { SpeedConfig } from "../types"; -import { DEFAULT_SPEED } from "../types"; +import React, { useCallback } from "react" +import { Drawer, Slider } from "antd" +import type { SpeedConfig } from "../types" +import { DEFAULT_SPEED } from "../types" /* ──────────── 预设速度 ──────────── */ const SPEED_PRESETS: { rate: number; label: string }[] = [ @@ -14,56 +14,50 @@ const SPEED_PRESETS: { rate: number; label: string }[] = [ { rate: 1.0, label: "1x" }, { rate: 1.5, label: "1.5x" }, { rate: 2.0, label: "2x" }, -]; +] /* ──────────── Props ──────────── */ interface SpeedPanelProps { - open: boolean; - onClose: () => void; + open: boolean + onClose: () => void /** 当前片段调速配置 */ - config: SpeedConfig; - onChange: (config: SpeedConfig) => void; + config: SpeedConfig + onChange: (config: SpeedConfig) => void /** 应用到所有片段 */ - onApplyAll?: (config: SpeedConfig) => void; + onApplyAll?: (config: SpeedConfig) => void } -const SpeedPanel: React.FC = ({ - open, - onClose, - config, - onChange, - onApplyAll, -}) => { +const SpeedPanel: React.FC = ({ open, onClose, config, onChange, onApplyAll }) => { /* ── 修改速度 ── */ const handleChangeRate = useCallback( (rate: number) => { - onChange({ ...config, rate }); + onChange({ ...config, rate }) }, [config, onChange], - ); + ) /* ── 切换音调修正 ── */ const handleTogglePitch = useCallback(() => { - onChange({ ...config, pitchCorrection: !config.pitchCorrection }); - }, [config, onChange]); + onChange({ ...config, pitchCorrection: !config.pitchCorrection }) + }, [config, onChange]) /* ── 选择预设 ── */ const handlePreset = useCallback( (rate: number) => { - onChange({ ...config, rate }); + onChange({ ...config, rate }) }, [config, onChange], - ); + ) /* ── 应用到所有片段 ── */ const handleApplyAll = useCallback(() => { - onApplyAll?.(config); - }, [config, onApplyAll]); + onApplyAll?.(config) + }, [config, onApplyAll]) /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_SPEED }); - }, [onChange]); + onChange({ ...DEFAULT_SPEED }) + }, [onChange]) /* ── 速度描述文字 ── */ const speedLabel = @@ -73,7 +67,7 @@ const SpeedPanel: React.FC = ({ ? "原速" : config.rate < 2 ? "快速" - : "极速"; + : "极速" return ( = ({ )} - ); -}; + ) +} -export default SpeedPanel; +export default SpeedPanel diff --git a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx index 883c044d0..12074bd92 100644 --- a/apps/web/src/pages/editing-planner/components/StickerPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx @@ -2,26 +2,17 @@ * 贴纸配置面板 * 贴纸素材库(emoji / 图片)+ 文字花字 + 位置大小调整 */ -import React, { useCallback, useState } from "react"; -import { Drawer, Switch } from "antd"; -import type { - StickerConfig, - StickerItem, - StickerType, - TextStickerPreset, -} from "../types"; -import { - DEFAULT_STICKER_CONFIG, - DEFAULT_STICKER_ITEM, - TEXT_STICKER_PRESET_LABELS, -} from "../types"; +import React, { useCallback, useState } from "react" +import { Drawer, Switch } from "antd" +import type { StickerConfig, StickerItem, StickerType, TextStickerPreset } from "../types" +import { DEFAULT_STICKER_CONFIG, DEFAULT_STICKER_ITEM, TEXT_STICKER_PRESET_LABELS } from "../types" interface StickerPanelProps { - open: boolean; - onClose: () => void; - config: StickerConfig; - onChange: (config: StickerConfig) => void; - totalDuration: number; + open: boolean + onClose: () => void + config: StickerConfig + onChange: (config: StickerConfig) => void + totalDuration: number } /** 常用 emoji 素材 */ @@ -56,7 +47,7 @@ const EMOJI_LIST = [ "➡️", "⭕", "🔔", -]; +] /** 文字花字预设对应的 CSS 样式预览 */ const TEXT_PRESET_STYLES: Record = { @@ -72,11 +63,10 @@ const TEXT_PRESET_STYLES: Record = { WebkitBackgroundClip: "text", }, handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" }, -}; +} /** 生成唯一 ID */ -const genId = () => - `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +const genId = () => `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` const StickerPanel: React.FC = ({ open, @@ -85,23 +75,21 @@ const StickerPanel: React.FC = ({ onChange, totalDuration, }) => { - const [selectedId, setSelectedId] = useState(null); - const [activeTab, setActiveTab] = useState("emoji"); + const [selectedId, setSelectedId] = useState(null) + const [activeTab, setActiveTab] = useState("emoji") - const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null; + const selectedSticker = config.items.find((s) => s.id === selectedId) ?? null /** 更新单个贴纸 */ const updateItem = useCallback( (id: string, partial: Partial) => { onChange({ ...config, - items: config.items.map((s) => - s.id === id ? { ...s, ...partial } : s, - ), - }); + items: config.items.map((s) => (s.id === id ? { ...s, ...partial } : s)), + }) }, [config, onChange], - ); + ) /** 添加贴纸 */ const addSticker = useCallback( @@ -113,16 +101,16 @@ const StickerPanel: React.FC = ({ content, duration: totalDuration > 0 ? totalDuration : 5, z_index: config.items.length + 1, - }; + } onChange({ ...config, enabled: true, items: [...config.items, newItem], - }); - setSelectedId(newItem.id); + }) + setSelectedId(newItem.id) }, [config, onChange, totalDuration], - ); + ) /** 删除贴纸 */ const removeSticker = useCallback( @@ -130,20 +118,20 @@ const StickerPanel: React.FC = ({ onChange({ ...config, items: config.items.filter((s) => s.id !== id), - }); - if (selectedId === id) setSelectedId(null); + }) + if (selectedId === id) setSelectedId(null) }, [config, onChange, selectedId], - ); + ) /** 重置所有 */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }); - setSelectedId(null); - }, [config.enabled, onChange]); + onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }) + setSelectedId(null) + }, [config.enabled, onChange]) /** 文字花字输入 */ - const [textInput, setTextInput] = useState(""); + const [textInput, setTextInput] = useState("") return ( = ({ className={`sticker-tab${activeTab === t ? " active" : ""}`} onClick={() => setActiveTab(t)} > - {t === "emoji" - ? "表情贴纸" - : t === "image" - ? "图片贴纸" - : "文字花字"} + {t === "emoji" ? "表情贴纸" : t === "image" ? "图片贴纸" : "文字花字"} ))} @@ -207,21 +191,18 @@ const StickerPanel: React.FC = ({ placeholder="输入图片 URL 添加贴纸..." onKeyDown={(e) => { if (e.key === "Enter" && e.currentTarget.value.trim()) { - addSticker("image", e.currentTarget.value.trim()); - e.currentTarget.value = ""; + addSticker("image", e.currentTarget.value.trim()) + e.currentTarget.value = "" } }} /> - ); -}; + ) +} -export default StickerPanel; +export default StickerPanel diff --git a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx index 27f252da0..2f7913aae 100755 --- a/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx @@ -2,10 +2,10 @@ * 字幕样式配置面板 — Drawer 形式 * 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言 */ -import React from "react"; -import { Drawer, Slider, ColorPicker, Select } from "antd"; -import type { Color } from "antd/es/color-picker"; -import type { SubtitleStyleConfig } from "../types/subtitle"; +import React from "react" +import { Drawer, Slider, ColorPicker, Select } from "antd" +import type { Color } from "antd/es/color-picker" +import type { SubtitleStyleConfig } from "../types/subtitle" /* ──────────── 选项常量 ──────────── */ @@ -13,37 +13,29 @@ const POSITION_OPTIONS = [ { value: "top", label: "顶部" }, { value: "center", label: "居中" }, { value: "bottom", label: "底部" }, -]; +] -const FONT_OPTIONS = [ - "思源黑体", - "思源宋体", - "苹方", - "PingFang", - "微软雅黑", - "楷体", - "华康俪金黑", -]; +const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"] const ANIMATION_OPTIONS = [ { value: "none", label: "无" }, { value: "fade", label: "淡入淡出" }, { value: "slide", label: "滑动" }, { value: "typewriter", label: "打字机" }, -]; +] const ASR_LANGUAGE_OPTIONS = [ { value: "zh", label: "中文" }, { value: "en", label: "English" }, -]; +] /* ──────────── Props ──────────── */ interface SubtitleStylePanelProps { - open: boolean; - onClose: () => void; - config: SubtitleStyleConfig; - onChange: (config: SubtitleStyleConfig) => void; + open: boolean + onClose: () => void + config: SubtitleStyleConfig + onChange: (config: SubtitleStyleConfig) => void } const SubtitleStylePanel: React.FC = ({ @@ -53,8 +45,8 @@ const SubtitleStylePanel: React.FC = ({ onChange, }) => { const update = (partial: Partial) => { - onChange({ ...config, ...partial }); - }; + onChange({ ...config, ...partial }) + } return ( = ({
- update({ fontColor: hex }) - } + onChange={(_color: Color, hex: string) => update({ fontColor: hex })} showText />
@@ -217,9 +207,7 @@ const SubtitleStylePanel: React.FC = ({ color: config.fontColor, fontFamily: config.font, WebkitTextStroke: config.stroke ? "1px #000" : undefined, - textShadow: config.shadow - ? "2px 2px 4px rgba(0,0,0,0.8)" - : undefined, + textShadow: config.shadow ? "2px 2px 4px rgba(0,0,0,0.8)" : undefined, }} > 这是一段字幕预览 @@ -229,7 +217,7 @@ const SubtitleStylePanel: React.FC = ({ )}
- ); -}; + ) +} -export default SubtitleStylePanel; +export default SubtitleStylePanel diff --git a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx index 9e30a868f..18162ce65 100755 --- a/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TimelinePanel.tsx @@ -8,76 +8,65 @@ * - 拖动时实时显示裁剪预览(入点/出点/时长) * - 右键片段弹出菜单:分割 / 恢复原始长度 / 删除 */ -import React, { - useState, - useRef, - useCallback, - useEffect, - useLayoutEffect, - useMemo, -} from "react"; -import type { ClipData, ClipType, TrimConfig } from "../types"; -import { TRANSITION_OPTIONS } from "@/api/editPlans"; +import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMemo } from "react" +import type { ClipData, ClipType, TrimConfig } from "../types" +import { TRANSITION_OPTIONS } from "@/api/editPlans" interface TimelinePanelProps { - clips: ClipData[]; - selectedClipId: string | null; - currentMode: string; - onClipSelect: (clipId: string) => void; - onClipReorder: (fromIdx: number, toIdx: number) => void; - onClipRemove: (clipId: string) => void; - onAddClip: (type: ClipType, duration: number) => void; + clips: ClipData[] + selectedClipId: string | null + currentMode: string + onClipSelect: (clipId: string) => void + onClipReorder: (fromIdx: number, toIdx: number) => void + onClipRemove: (clipId: string) => void + onAddClip: (type: ClipType, duration: number) => void /** 裁剪更新:调整片段的 trim_config 和 duration */ - onClipTrim?: ( - clipId: string, - trimConfig: TrimConfig, - newDuration: number, - ) => void; + onClipTrim?: (clipId: string, trimConfig: TrimConfig, newDuration: number) => void /** 在指定位置分割片段 */ - onClipSplit?: (clipId: string, splitRatio: number) => void; + onClipSplit?: (clipId: string, splitRatio: number) => void /** 恢复片段原始长度 */ - onClipResetTrim?: (clipId: string) => void; + onClipResetTrim?: (clipId: string) => void /** 当前播放时间(秒) */ - currentTime?: number; + currentTime?: number /** 缩放:每秒像素数 */ - pixelsPerSecond?: number; + pixelsPerSecond?: number /** 缩放变更回调 */ - onZoomChange?: (pps: number) => void; + onZoomChange?: (pps: number) => void /** 播放头跳转回调 */ - onSeek?: (time: number) => void; + onSeek?: (time: number) => void /** 总时长(秒),可选(默认由 clips 计算) */ - totalDuration?: number; + totalDuration?: number } /** 片段类型图标 */ const CLIP_TYPE_ICONS: Record = { voice: "🎙️", pip: "🖼️", -}; +} /** 片段类型标签 */ const CLIP_TYPE_LABELS: Record = { voice: "口播", pip: "画中画", -}; +} /** 裁剪拖拽方向 */ -type TrimDirection = "left" | "right"; +type TrimDirection = "left" | "right" /** 裁剪拖拽状态 */ interface TrimDragState { - clipId: string; - direction: TrimDirection; - startX: number; - originalTrim: TrimConfig; - originalDuration: number; + clipId: string + direction: TrimDirection + startX: number + originalTrim: TrimConfig + originalDuration: number } /** 右键菜单状态 */ interface ContextMenuState { - x: number; - y: number; - clipId: string; + x: number + y: number + clipId: string } const TimelinePanel: React.FC = ({ @@ -97,260 +86,248 @@ const TimelinePanel: React.FC = ({ onSeek, totalDuration: totalDurationProp, }) => { - const [dragIdx, setDragIdx] = useState(null); - const [dragOverIdx, setDragOverIdx] = useState(null); - const dragRef = useRef(null); - const [showAddPicker, setShowAddPicker] = useState(false); - const pickerRef = useRef(null); - const addCardRef = useRef(null); + const [dragIdx, setDragIdx] = useState(null) + const [dragOverIdx, setDragOverIdx] = useState(null) + const dragRef = useRef(null) + const [showAddPicker, setShowAddPicker] = useState(false) + const pickerRef = useRef(null) + const addCardRef = useRef(null) const [pickerPos, setPickerPos] = useState<{ top: number; right: number }>({ top: 0, right: 0, - }); + }) /* ── 裁剪拖拽状态 ── */ - const [trimDrag, setTrimDrag] = useState(null); + const [trimDrag, setTrimDrag] = useState(null) const [trimPreview, setTrimPreview] = useState<{ - clipId: string; - startTime: number; - endTime: number; - duration: number; - x: number; - y: number; - } | null>(null); + clipId: string + startTime: number + endTime: number + duration: number + x: number + y: number + } | null>(null) /* ── 右键菜单 ── */ - const [contextMenu, setContextMenu] = useState(null); - const contextMenuRef = useRef(null); + const [contextMenu, setContextMenu] = useState(null) + const contextMenuRef = useRef(null) /* ── 悬停的片段 ID(显示裁剪手柄) ── */ - const [hoveredClipId, setHoveredClipId] = useState(null); + const [hoveredClipId, setHoveredClipId] = useState(null) /* ── 播放头拖拽状态 ── */ - const [playheadDragging, setPlayheadDragging] = useState(false); - const trackRef = useRef(null); + const [playheadDragging, setPlayheadDragging] = useState(false) + const trackRef = useRef(null) /* ── 根据模式决定可选类型 ── */ const availableTypes: ClipType[] = useMemo( () => - currentMode === "voice_over" - ? ["voice"] - : currentMode === "pip" - ? ["pip"] - : ["voice", "pip"], // voice_pip / one_take / 默认 + currentMode === "voice_over" ? ["voice"] : currentMode === "pip" ? ["pip"] : ["voice", "pip"], // voice_pip / one_take / 默认 [currentMode], - ); + ) /* ── 默认添加类型:跟随模式 ── */ const defaultAddType: ClipType = useMemo(() => { - if (currentMode === "voice_over") return "voice"; - if (currentMode === "pip") return "pip"; - return "voice"; - }, [currentMode]); + if (currentMode === "voice_over") return "voice" + if (currentMode === "pip") return "pip" + return "voice" + }, [currentMode]) /* ── "+" 卡片:类型+时长选择状态 ── */ - const [addType, setAddType] = useState(defaultAddType); - const [addDuration, setAddDuration] = useState(5); + const [addType, setAddType] = useState(defaultAddType) + const [addDuration, setAddDuration] = useState(5) /* ── 模式切换时自动同步默认添加类型 ── */ useEffect(() => { if (!availableTypes.includes(addType)) { - setAddType(defaultAddType); + setAddType(defaultAddType) } - }, [currentMode, addType, availableTypes, defaultAddType]); + }, [currentMode, addType, availableTypes, defaultAddType]) /* ── 面板尺寸 ── */ - const PICKER_W = 240; - const GAP = 6; + const PICKER_W = 240 + const GAP = 6 /* ── 计算 picker 初始位置 ── */ const updatePickerPosition = useCallback(() => { - if (!addCardRef.current) return; - const rect = addCardRef.current.getBoundingClientRect(); - const vw = window.innerWidth; - const roughHeight = 180; - let top = rect.top - GAP - roughHeight; - if (top < 8) top = 8; - let right = vw - rect.right; + if (!addCardRef.current) return + const rect = addCardRef.current.getBoundingClientRect() + const vw = window.innerWidth + const roughHeight = 180 + let top = rect.top - GAP - roughHeight + if (top < 8) top = 8 + let right = vw - rect.right if (rect.right - PICKER_W < 8) { - right = vw - PICKER_W - 8; + right = vw - PICKER_W - 8 } - setPickerPos({ top, right }); - }, []); + setPickerPos({ top, right }) + }, []) const handleTogglePicker = () => { if (!showAddPicker) { const defaultType = - currentMode === "pip" - ? "pip" - : currentMode === "voice_over" - ? "voice" - : "voice"; - setAddType(defaultType); - updatePickerPosition(); + currentMode === "pip" ? "pip" : currentMode === "voice_over" ? "voice" : "voice" + setAddType(defaultType) + updatePickerPosition() } - setShowAddPicker((v) => !v); - }; + setShowAddPicker((v) => !v) + } /* ── 渲染后精确边界校正 ── */ useLayoutEffect(() => { - if (!showAddPicker || !pickerRef.current || !addCardRef.current) return; - const pickerEl = pickerRef.current; - const addRect = addCardRef.current.getBoundingClientRect(); - const pickerH = pickerEl.offsetHeight; - const vh = window.innerHeight; - const vw = window.innerWidth; - let top = addRect.top - GAP - pickerH; + if (!showAddPicker || !pickerRef.current || !addCardRef.current) return + const pickerEl = pickerRef.current + const addRect = addCardRef.current.getBoundingClientRect() + const pickerH = pickerEl.offsetHeight + const vh = window.innerHeight + const vw = window.innerWidth + let top = addRect.top - GAP - pickerH if (top < 8) { - top = addRect.bottom + GAP; + top = addRect.bottom + GAP if (top + pickerH > vh - 8) { - top = vh - 8 - pickerH; - if (top < 8) top = 8; + top = vh - 8 - pickerH + if (top < 8) top = 8 } } - let right = vw - addRect.right; - const pickerRect = pickerEl.getBoundingClientRect(); + let right = vw - addRect.right + const pickerRect = pickerEl.getBoundingClientRect() if (pickerRect.left < 8) { - right = vw - PICKER_W - 8; + right = vw - PICKER_W - 8 } - setPickerPos({ top, right }); - }, [showAddPicker]); + setPickerPos({ top, right }) + }, [showAddPicker]) /* ── 点击外部关闭添加面板 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) { - setShowAddPicker(false); + setShowAddPicker(false) } - }; - if (showAddPicker) { - document.addEventListener("mousedown", handleClickOutside); } - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [showAddPicker]); + if (showAddPicker) { + document.addEventListener("mousedown", handleClickOutside) + } + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [showAddPicker]) /* ── 点击外部关闭右键菜单 ── */ useEffect(() => { const handleClickOutside = (e: MouseEvent) => { - if ( - contextMenuRef.current && - !contextMenuRef.current.contains(e.target as Node) - ) { - setContextMenu(null); + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenu(null) } - }; - if (contextMenu) { - document.addEventListener("mousedown", handleClickOutside); } - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [contextMenu]); + if (contextMenu) { + document.addEventListener("mousedown", handleClickOutside) + } + return () => document.removeEventListener("mousedown", handleClickOutside) + }, [contextMenu]) /* ── 缩放 & 时长 ── */ - const pps = pixelsPerSecond ?? 40; - const totalDuration = - totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0); + const pps = pixelsPerSecond ?? 40 + const totalDuration = totalDurationProp ?? clips.reduce((s, c) => s + c.duration, 0) /* ── 播放头拖拽全局 mousemove/mouseup ── */ useEffect(() => { - if (!playheadDragging) return; + if (!playheadDragging) return const handleMouseMove = (e: MouseEvent) => { - const trackEl = trackRef.current; - if (!trackEl) return; - const rect = trackEl.getBoundingClientRect(); - const x = e.clientX - rect.left + trackEl.scrollLeft; - const time = Math.max(0, Math.min(x / pps, totalDuration)); - onSeek?.(Math.round(time * 10) / 10); - }; + const trackEl = trackRef.current + if (!trackEl) return + const rect = trackEl.getBoundingClientRect() + const x = e.clientX - rect.left + trackEl.scrollLeft + const time = Math.max(0, Math.min(x / pps, totalDuration)) + onSeek?.(Math.round(time * 10) / 10) + } const handleMouseUp = () => { - setPlayheadDragging(false); - }; + setPlayheadDragging(false) + } - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [playheadDragging, pps, totalDuration, onSeek]); + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + }, [playheadDragging, pps, totalDuration, onSeek]) /* ── 标尺点击跳转播放头 ── */ const handleRulerClick = useCallback( (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const x = e.clientX - rect.left; - const time = Math.max(0, Math.min(x / pps, totalDuration)); - onSeek?.(Math.round(time * 10) / 10); + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const time = Math.max(0, Math.min(x / pps, totalDuration)) + onSeek?.(Math.round(time * 10) / 10) }, [pps, totalDuration, onSeek], - ); + ) /* ── 播放头拖拽开始 ── */ const handlePlayheadMouseDown = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setPlayheadDragging(true); - }, []); + e.preventDefault() + e.stopPropagation() + setPlayheadDragging(true) + }, []) /* ── 确认添加片段 ── */ const handleConfirmAdd = () => { - onAddClip(addType, addDuration); - setShowAddPicker(false); - }; + onAddClip(addType, addDuration) + setShowAddPicker(false) + } /* ── 片段拖拽排序 ── */ const handleDragStart = (e: React.DragEvent, idx: number) => { // 如果正在裁剪拖拽,不允许排序拖拽 - if (trimDrag) return; - dragRef.current = idx; - setDragIdx(idx); - e.dataTransfer.setData("application/x-clip-drag", String(idx)); - e.dataTransfer.effectAllowed = "move"; - }; + if (trimDrag) return + dragRef.current = idx + setDragIdx(idx) + e.dataTransfer.setData("application/x-clip-drag", String(idx)) + e.dataTransfer.effectAllowed = "move" + } const handleDragOver = (e: React.DragEvent, idx: number) => { - e.preventDefault(); - e.dataTransfer.dropEffect = "move"; - setDragOverIdx(idx); - }; + e.preventDefault() + e.dataTransfer.dropEffect = "move" + setDragOverIdx(idx) + } const handleDragEnd = () => { - dragRef.current = null; - setDragIdx(null); - setDragOverIdx(null); - }; + dragRef.current = null + setDragIdx(null) + setDragOverIdx(null) + } const handleDrop = (e: React.DragEvent, toIdx: number) => { - e.preventDefault(); - setDragOverIdx(null); - const fromStr = e.dataTransfer.getData("application/x-clip-drag"); + e.preventDefault() + setDragOverIdx(null) + const fromStr = e.dataTransfer.getData("application/x-clip-drag") if (fromStr !== "") { - const fromIdx = Number(fromStr); + const fromIdx = Number(fromStr) if (fromIdx !== toIdx) { - onClipReorder(fromIdx, toIdx); + onClipReorder(fromIdx, toIdx) } } - }; + } /* ── 空轨道区域不接受素材拖入 ── */ const handleEmptyDragOver = (e: React.DragEvent) => { - e.preventDefault(); - }; + e.preventDefault() + } /* ── 裁剪手柄拖拽 ── */ const handleTrimHandleMouseDown = useCallback( (e: React.MouseEvent, clipId: string, direction: TrimDirection) => { - e.preventDefault(); - e.stopPropagation(); - const clip = clips.find((c) => c.id === clipId); - if (!clip) return; + e.preventDefault() + e.stopPropagation() + const clip = clips.find((c) => c.id === clipId) + if (!clip) return const trim: TrimConfig = clip.trim_config ?? { start_time: 0, end_time: clip.duration, original_duration: clip.duration, - }; + } setTrimDrag({ clipId, @@ -358,43 +335,37 @@ const TimelinePanel: React.FC = ({ startX: e.clientX, originalTrim: { ...trim }, originalDuration: clip.duration, - }); + }) }, [clips], - ); + ) /* ── 裁剪拖拽全局 mousemove/mouseup ── */ useEffect(() => { - if (!trimDrag) return; + if (!trimDrag) return - const PX_PER_SECOND = pixelsPerSecond ?? 40; // 与缩放级别同步 + const PX_PER_SECOND = pixelsPerSecond ?? 40 // 与缩放级别同步 const handleMouseMove = (e: MouseEvent) => { - const dx = e.clientX - trimDrag.startX; - const dtSec = dx / PX_PER_SECOND; - const clip = clips.find((c) => c.id === trimDrag.clipId); - if (!clip) return; + const dx = e.clientX - trimDrag.startX + const dtSec = dx / PX_PER_SECOND + const clip = clips.find((c) => c.id === trimDrag.clipId) + if (!clip) return - const origTrim = trimDrag.originalTrim; - const origDur = origTrim.original_duration ?? trimDrag.originalDuration; - let newStart = origTrim.start_time; - let newEnd = origTrim.end_time; + const origTrim = trimDrag.originalTrim + const origDur = origTrim.original_duration ?? trimDrag.originalDuration + let newStart = origTrim.start_time + let newEnd = origTrim.end_time if (trimDrag.direction === "left") { // 左手柄:调整入点 - newStart = Math.max( - 0, - Math.min(origTrim.start_time + dtSec, newEnd - 1), - ); + newStart = Math.max(0, Math.min(origTrim.start_time + dtSec, newEnd - 1)) } else { // 右手柄:调整出点 - newEnd = Math.max( - origTrim.start_time + 1, - Math.min(origTrim.end_time + dtSec, origDur), - ); + newEnd = Math.max(origTrim.start_time + 1, Math.min(origTrim.end_time + dtSec, origDur)) } - const newDuration = Math.round((newEnd - newStart) * 10) / 10; + const newDuration = Math.round((newEnd - newStart) * 10) / 10 setTrimPreview({ clipId: trimDrag.clipId, @@ -403,83 +374,78 @@ const TimelinePanel: React.FC = ({ duration: newDuration, x: e.clientX, y: e.clientY, - }); - }; + }) + } const handleMouseUp = () => { if (trimPreview && trimPreview.clipId === trimDrag.clipId && onClipTrim) { const newTrim: TrimConfig = { start_time: trimPreview.startTime, end_time: trimPreview.endTime, - original_duration: - trimDrag.originalTrim.original_duration ?? - trimDrag.originalDuration, - }; - onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration); + original_duration: trimDrag.originalTrim.original_duration ?? trimDrag.originalDuration, + } + onClipTrim(trimDrag.clipId, newTrim, trimPreview.duration) } - setTrimDrag(null); - setTrimPreview(null); - }; + setTrimDrag(null) + setTrimPreview(null) + } - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("mousemove", handleMouseMove) + document.addEventListener("mouseup", handleMouseUp) return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]); + document.removeEventListener("mousemove", handleMouseMove) + document.removeEventListener("mouseup", handleMouseUp) + } + }, [trimDrag, trimPreview, clips, onClipTrim, pixelsPerSecond]) /* ── 右键菜单 ── */ - const handleContextMenu = useCallback( - (e: React.MouseEvent, clipId: string) => { - e.preventDefault(); - e.stopPropagation(); - setContextMenu({ x: e.clientX, y: e.clientY, clipId }); - }, - [], - ); + const handleContextMenu = useCallback((e: React.MouseEvent, clipId: string) => { + e.preventDefault() + e.stopPropagation() + setContextMenu({ x: e.clientX, y: e.clientY, clipId }) + }, []) /* ── 右键菜单操作 ── */ const handleContextSplit = useCallback(() => { - if (!contextMenu) return; + if (!contextMenu) return if (onClipSplit) { - onClipSplit(contextMenu.clipId, 0.5); // 在中间分割 + onClipSplit(contextMenu.clipId, 0.5) // 在中间分割 } - setContextMenu(null); - }, [contextMenu, onClipSplit]); + setContextMenu(null) + }, [contextMenu, onClipSplit]) const handleContextResetTrim = useCallback(() => { - if (!contextMenu) return; + if (!contextMenu) return if (onClipResetTrim) { - onClipResetTrim(contextMenu.clipId); + onClipResetTrim(contextMenu.clipId) } - setContextMenu(null); - }, [contextMenu, onClipResetTrim]); + setContextMenu(null) + }, [contextMenu, onClipResetTrim]) const handleContextDelete = useCallback(() => { - if (!contextMenu) return; - onClipRemove(contextMenu.clipId); - setContextMenu(null); - }, [contextMenu, onClipRemove]); + if (!contextMenu) return + onClipRemove(contextMenu.clipId) + setContextMenu(null) + }, [contextMenu, onClipRemove]) /* ── 时间标尺 ── */ - const trackWidth = Math.max(totalDuration * pps, 300); - const rulerMarks: number[] = []; - const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15; + const trackWidth = Math.max(totalDuration * pps, 300) + const rulerMarks: number[] = [] + const step = totalDuration <= 30 ? 5 : totalDuration <= 60 ? 10 : 15 for (let t = 0; t <= totalDuration + step; t += step) { - rulerMarks.push(t); + rulerMarks.push(t) } const formatTime = (sec: number) => { - const m = Math.floor(sec / 60); - const s = Math.floor(sec % 60); - return `${m}:${s.toString().padStart(2, "0")}`; - }; + const m = Math.floor(sec / 60) + const s = Math.floor(sec % 60) + return `${m}:${s.toString().padStart(2, "0")}` + } /** 格式化裁剪时间(精确到0.1秒) */ const formatTrimTime = (sec: number) => { - return `${sec.toFixed(1)}s`; - }; + return `${sec.toFixed(1)}s` + } return (
@@ -487,9 +453,7 @@ const TimelinePanel: React.FC = ({
🎬 时间线 - - 总时长: {formatTime(totalDuration)} - + 总时长: {formatTime(totalDuration)}
{/* 缩放控件 */} @@ -523,8 +487,8 @@ const TimelinePanel: React.FC = ({ className="ep-timeline-action-btn" onClick={() => { if (clips.length > 1) { - const last = clips[clips.length - 1]; - onClipRemove(last.id); + const last = clips[clips.length - 1] + onClipRemove(last.id) } }} title="删除最后一个片段" @@ -538,20 +502,14 @@ const TimelinePanel: React.FC = ({
{/* 一镜到底提示 */} - {currentMode === "one_take" && ( -
🎥 一镜到底模式无片段
- )} + {currentMode === "one_take" &&
🎥 一镜到底模式无片段
} {/* 时间标尺 */} {currentMode !== "one_take" && (
{rulerMarks.map((t) => ( - + {t}s ))} @@ -561,11 +519,7 @@ const TimelinePanel: React.FC = ({ {/* 水平片段轨道 */} {currentMode !== "one_take" && ( -
+
{/* 播放头 */} {currentMode !== "one_take" && totalDuration > 0 && (
= ({ ) : ( clips.map((clip, idx) => { /* 转场指示器 */ - const trans = clip.transition; - const showTransition = idx > 0 && trans && trans.type !== "none"; + const trans = clip.transition + const showTransition = idx > 0 && trans && trans.type !== "none" const transOpt = showTransition ? TRANSITION_OPTIONS.find((o) => o.value === trans!.type) - : undefined; + : undefined /* 速度徽章 */ - const speed = clip.speed; - const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01; + const speed = clip.speed + const showSpeed = speed && Math.abs(speed.rate - 1.0) > 0.01 /* 裁剪状态 */ - const hasTrim = !!clip.trim_config; - const isHovered = hoveredClipId === clip.id; + const hasTrim = !!clip.trim_config + const isHovered = hoveredClipId === clip.id return ( @@ -607,9 +561,7 @@ const TimelinePanel: React.FC = ({ title={`${transOpt.label} · ${trans!.duration.toFixed(1)}s`} > {transOpt.icon} - - {trans!.duration.toFixed(1)}s - + {trans!.duration.toFixed(1)}s
)} @@ -630,9 +582,7 @@ const TimelinePanel: React.FC = ({ {isHovered && onClipTrim && (
- handleTrimHandleMouseDown(e, clip.id, "left") - } + onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "left")} title="拖动调整入点" >
@@ -640,9 +590,7 @@ const TimelinePanel: React.FC = ({ )} {/* 类型图标 */} -
- {CLIP_TYPE_ICONS[clip.type] || "🎬"} -
+
{CLIP_TYPE_ICONS[clip.type] || "🎬"}
{/* 片段信息 */}
@@ -660,11 +608,7 @@ const TimelinePanel: React.FC = ({
{/* 速度徽章 */} - {showSpeed && ( - - {speed!.rate.toFixed(1)}x - - )} + {showSpeed && {speed!.rate.toFixed(1)}x} {/* 裁剪徽章 */} {hasTrim && ( @@ -680,9 +624,7 @@ const TimelinePanel: React.FC = ({ {isHovered && onClipTrim && (
- handleTrimHandleMouseDown(e, clip.id, "right") - } + onMouseDown={(e) => handleTrimHandleMouseDown(e, clip.id, "right")} title="拖动调整出点" >
@@ -693,15 +635,15 @@ const TimelinePanel: React.FC = ({
- ); + ) }) )} @@ -731,21 +673,15 @@ const TimelinePanel: React.FC = ({ >
入点 - - {formatTrimTime(trimPreview.startTime)} - + {formatTrimTime(trimPreview.startTime)}
出点 - - {formatTrimTime(trimPreview.endTime)} - + {formatTrimTime(trimPreview.endTime)}
时长 - - {formatTrimTime(trimPreview.duration)} - + {formatTrimTime(trimPreview.duration)}
)} @@ -766,10 +702,7 @@ const TimelinePanel: React.FC = ({ 分割片段
{clips.find((c) => c.id === contextMenu.clipId)?.trim_config && ( -
+
↩️ 恢复原始长度
@@ -822,25 +755,20 @@ const TimelinePanel: React.FC = ({ max={120} value={addDuration} onChange={(e) => - setAddDuration( - Math.max(1, Math.min(120, Number(e.target.value) || 1)), - ) + setAddDuration(Math.max(1, Math.min(120, Number(e.target.value) || 1))) } />
{/* 确认按钮 */} -
)}
- ); -}; + ) +} -export default TimelinePanel; +export default TimelinePanel diff --git a/apps/web/src/pages/editing-planner/components/TransitionSelector.tsx b/apps/web/src/pages/editing-planner/components/TransitionSelector.tsx index d22c71e9b..5a423f468 100644 --- a/apps/web/src/pages/editing-planner/components/TransitionSelector.tsx +++ b/apps/web/src/pages/editing-planner/components/TransitionSelector.tsx @@ -3,21 +3,21 @@ * 14 种转场预设卡片网格 + 转场时长滑块 * 支持全局默认转场 + 单个片段间独立设置 */ -import React, { useCallback } from "react"; -import { Drawer, Slider } from "antd"; -import { TRANSITION_OPTIONS } from "@/api/editPlans"; -import type { TransitionConfig, TransitionType } from "../types"; -import { DEFAULT_TRANSITION } from "../types"; +import React, { useCallback } from "react" +import { Drawer, Slider } from "antd" +import { TRANSITION_OPTIONS } from "@/api/editPlans" +import type { TransitionConfig, TransitionType } from "../types" +import { DEFAULT_TRANSITION } from "../types" /* ──────────── Props ──────────── */ interface TransitionSelectorProps { - open: boolean; - onClose: () => void; + open: boolean + onClose: () => void /** 当前转场配置 */ - config: TransitionConfig; - onChange: (config: TransitionConfig) => void; + config: TransitionConfig + onChange: (config: TransitionConfig) => void /** 标题提示(区分全局 / 片段间) */ - title?: string; + title?: string } const TransitionSelector: React.FC = ({ @@ -30,23 +30,23 @@ const TransitionSelector: React.FC = ({ /* ── 选择转场类型 ── */ const handleSelectType = useCallback( (type: TransitionType) => { - onChange({ ...config, type }); + onChange({ ...config, type }) }, [config, onChange], - ); + ) /* ── 修改时长 ── */ const handleChangeDuration = useCallback( (duration: number) => { - onChange({ ...config, duration }); + onChange({ ...config, duration }) }, [config, onChange], - ); + ) /* ── 重置为无转场 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TRANSITION }); - }, [onChange]); + onChange({ ...DEFAULT_TRANSITION }) + }, [onChange]) return ( = ({
转场时长 - - {config.duration.toFixed(1)}s - + {config.duration.toFixed(1)}s
= ({ {/* ── 转场类型卡片网格 ── */}
{TRANSITION_OPTIONS.map((opt) => { - const isActive = config.type === opt.value; + const isActive = config.type === opt.value return (
= ({
{opt.label}
{isActive && }
- ); + ) })}
@@ -105,14 +103,13 @@ const TransitionSelector: React.FC = ({
当前: - {TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ?? - "无转场"} + {TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ?? "无转场"} {" · "} {config.duration.toFixed(1)}s
- ); -}; + ) +} -export default TransitionSelector; +export default TransitionSelector diff --git a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx index 1fbe04bf3..832927ca9 100644 --- a/apps/web/src/pages/editing-planner/components/TtsPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx @@ -2,11 +2,11 @@ * TTS 配音面板 — Drawer 形式 * 配音模式切换 + 文本输入 + 音色选择 + 语速/语调/音量 + 试听 + 字幕联动 */ -import React, { useState, useCallback, useEffect, useRef } from "react"; -import { Drawer, Slider, message } from "antd"; -import type { TtsConfig, TtsMode } from "../types"; -import { DEFAULT_TTS_CONFIG } from "../types"; -import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts"; +import React, { useState, useCallback, useEffect, useRef } from "react" +import { Drawer, Slider, message } from "antd" +import type { TtsConfig, TtsMode } from "../types" +import { DEFAULT_TTS_CONFIG } from "../types" +import { getTtsVoices, previewTts, type TTSVoice } from "@/api/tts" /* ──────────── 音色卡片分类图标 ──────────── */ const VOICE_CATEGORY_MAP: Record = { @@ -16,143 +16,138 @@ const VOICE_CATEGORY_MAP: Record = { service: { icon: "🎧", label: "客服" }, news: { icon: "📰", label: "新闻" }, emotion: { icon: "🎭", label: "情感" }, -}; +} /* ──────────── Props ──────────── */ interface TtsPanelProps { - open: boolean; - onClose: () => void; + open: boolean + onClose: () => void /** 当前片段 TTS 配置 */ - config: TtsConfig; - onChange: (config: TtsConfig) => void; + config: TtsConfig + onChange: (config: TtsConfig) => void } -const TtsPanel: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { +const TtsPanel: React.FC = ({ open, onClose, config, onChange }) => { /* ── 音色列表 ── */ - const [voices, setVoices] = useState([]); - const [voicesLoading, setVoicesLoading] = useState(false); + const [voices, setVoices] = useState([]) + const [voicesLoading, setVoicesLoading] = useState(false) /* ── 试听状态 ── */ - const [previewLoading, setPreviewLoading] = useState(false); - const audioRef = useRef(null); + const [previewLoading, setPreviewLoading] = useState(false) + const audioRef = useRef(null) /* ── 加载音色列表 ── */ useEffect(() => { - if (!open) return; - setVoicesLoading(true); + if (!open) return + setVoicesLoading(true) getTtsVoices() .then((v) => setVoices(v)) .catch(() => message.error("加载音色列表失败")) - .finally(() => setVoicesLoading(false)); - }, [open]); + .finally(() => setVoicesLoading(false)) + }, [open]) /* ── 切换配音模式 ── */ const handleModeChange = useCallback( (mode: TtsMode) => { - onChange({ ...config, mode }); + onChange({ ...config, mode }) }, [config, onChange], - ); + ) /* ── 文本输入 ── */ const handleTextChange = useCallback( (e: React.ChangeEvent) => { - const text = e.target.value.slice(0, 5000); - onChange({ ...config, text }); + const text = e.target.value.slice(0, 5000) + onChange({ ...config, text }) }, [config, onChange], - ); + ) /* ── 选择音色 ── */ const handleVoiceSelect = useCallback( (voiceId: string) => { - onChange({ ...config, voice_id: voiceId }); + onChange({ ...config, voice_id: voiceId }) }, [config, onChange], - ); + ) /* ── 语速 ── */ const handleSpeedChange = useCallback( (speed: number) => { - onChange({ ...config, speed }); + onChange({ ...config, speed }) }, [config, onChange], - ); + ) /* ── 语调 ── */ const handlePitchChange = useCallback( (pitch: number) => { - onChange({ ...config, pitch }); + onChange({ ...config, pitch }) }, [config, onChange], - ); + ) /* ── 音量 ── */ const handleVolumeChange = useCallback( (volume: number) => { - onChange({ ...config, volume }); + onChange({ ...config, volume }) }, [config, onChange], - ); + ) /* ── 字幕联动 ── */ const handleSubtitleSyncToggle = useCallback(() => { - onChange({ ...config, subtitle_sync: !config.subtitle_sync }); - }, [config, onChange]); + onChange({ ...config, subtitle_sync: !config.subtitle_sync }) + }, [config, onChange]) /* ── 试听 ── */ const handlePreview = useCallback(async () => { if (!config.text.trim()) { - message.warning("请先输入合成文本"); - return; + message.warning("请先输入合成文本") + return } if (!config.voice_id) { - message.warning("请先选择音色"); - return; + message.warning("请先选择音色") + return } - setPreviewLoading(true); + setPreviewLoading(true) try { const res = await previewTts({ text: config.text.slice(0, 200), // 试听截取前200字 voice_id: config.voice_id, speed: config.speed, pitch: config.pitch, - }); + }) // 停止上一个 - audioRef.current?.pause(); - const audio = new Audio(res.audio_url); - audioRef.current = audio; - audio.play().catch(() => message.error("播放失败")); + audioRef.current?.pause() + const audio = new Audio(res.audio_url) + audioRef.current = audio + audio.play().catch(() => message.error("播放失败")) audio.onended = () => { - audioRef.current = null; - }; - message.success("试听播放中"); + audioRef.current = null + } + message.success("试听播放中") } catch { - message.error("试听生成失败"); + message.error("试听生成失败") } finally { - setPreviewLoading(false); + setPreviewLoading(false) } - }, [config]); + }, [config]) /* ── 重置 ── */ const handleReset = useCallback(() => { - onChange({ ...DEFAULT_TTS_CONFIG }); - }, [onChange]); + onChange({ ...DEFAULT_TTS_CONFIG }) + }, [onChange]) /* ── 关闭时停止音频 ── */ const handleClose = useCallback(() => { - audioRef.current?.pause(); - audioRef.current = null; - onClose(); - }, [onClose]); + audioRef.current?.pause() + audioRef.current = null + onClose() + }, [onClose]) /* ── 音色分类分组 ── */ - const voiceCategories = Object.entries(VOICE_CATEGORY_MAP); + const voiceCategories = Object.entries(VOICE_CATEGORY_MAP) return ( = ({
选择音色 - {voicesLoading && ( - 加载中... - )} + {voicesLoading && 加载中...}
{voiceCategories.map(([cat, info]) => { - const voice = voices.find((v) => v.category === cat); - const isSelected = voice && config.voice_id === voice.id; + const voice = voices.find((v) => v.category === cat) + const isSelected = voice && config.voice_id === voice.id return ( - ); + ) })}
@@ -236,9 +227,7 @@ const TtsPanel: React.FC = ({
语速 - - {config.speed.toFixed(2)}x - + {config.speed.toFixed(2)}x
= ({ {/* 试听按钮 */}
-
@@ -311,9 +296,7 @@ const TtsPanel: React.FC = ({
字幕联动 - {config.subtitle_sync - ? "TTS 文本自动同步到字幕" - : "字幕需手动编辑"} + {config.subtitle_sync ? "TTS 文本自动同步到字幕" : "字幕需手动编辑"}
= ({
)} - ); -}; + ) +} -export default TtsPanel; +export default TtsPanel diff --git a/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx b/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx index 49f93eaac..fefa9121d 100644 --- a/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx @@ -3,15 +3,10 @@ * 三个 Tab:图片水印 / 文字水印 / 滚动水印 * 通用设置:位置、不透明度 */ -import React, { useCallback, useState } from "react"; -import { Drawer } from "antd"; -import type { - WatermarkConfig, - WatermarkType, - WatermarkPosition, - ScrollDirection, -} from "../types"; -import { DEFAULT_WATERMARK } from "../types"; +import React, { useCallback, useState } from "react" +import { Drawer } from "antd" +import type { WatermarkConfig, WatermarkType, WatermarkPosition, ScrollDirection } from "../types" +import { DEFAULT_WATERMARK } from "../types" /* ──────────── 常量 ──────────── */ @@ -20,7 +15,7 @@ const WATERMARK_TABS: { key: WatermarkType; label: string; icon: string }[] = [ { key: "image", label: "图片水印", icon: "🖼️" }, { key: "text", label: "文字水印", icon: "📝" }, { key: "scroll", label: "滚动水印", icon: "📜" }, -]; +] const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [ { value: "top_left", label: "左上角" }, @@ -28,125 +23,120 @@ const POSITION_OPTIONS: { value: WatermarkPosition; label: string }[] = [ { value: "bottom_left", label: "左下角" }, { value: "bottom_right", label: "右下角" }, { value: "center", label: "居中" }, -]; +] const SCROLL_DIRECTION_OPTIONS: { - value: ScrollDirection; - label: string; + value: ScrollDirection + label: string }[] = [ { value: "horizontal", label: "水平滚动" }, { value: "vertical", label: "垂直滚动" }, { value: "diagonal", label: "对角滚动" }, -]; +] /* ──────────── Props ──────────── */ interface WatermarkPanelProps { - open: boolean; - onClose: () => void; - config: WatermarkConfig; - onChange: (config: WatermarkConfig) => void; + open: boolean + onClose: () => void + config: WatermarkConfig + onChange: (config: WatermarkConfig) => void } -const WatermarkPanel: React.FC = ({ - open, - onClose, - config, - onChange, -}) => { +const WatermarkPanel: React.FC = ({ open, onClose, config, onChange }) => { /* ── 图片上传预览 URL(本地预览用) ── */ - const [localImageUrl, setLocalImageUrl] = useState(""); + const [localImageUrl, setLocalImageUrl] = useState("") /* ── 切换水印类型 ── */ const handleTypeChange = useCallback( (type: WatermarkType) => { - onChange({ ...DEFAULT_WATERMARK, type }); + onChange({ ...DEFAULT_WATERMARK, type }) }, [onChange], - ); + ) /* ── 通用设置变更 ── */ const handlePositionChange = useCallback( (position: WatermarkPosition) => { - onChange({ ...config, position }); + onChange({ ...config, position }) }, [config, onChange], - ); + ) const handleOpacityChange = useCallback( (opacity: number) => { - onChange({ ...config, opacity }); + onChange({ ...config, opacity }) }, [config, onChange], - ); + ) /* ── 图片水印设置 ── */ const handleImageUrlChange = useCallback( (url: string) => { - setLocalImageUrl(url); - onChange({ ...config, image_url: url }); + setLocalImageUrl(url) + onChange({ ...config, image_url: url }) }, [config, onChange], - ); + ) const handleImageWidthChange = useCallback( (width: number) => { - onChange({ ...config, width }); + onChange({ ...config, width }) }, [config, onChange], - ); + ) const handleImageHeightChange = useCallback( (height: number) => { - onChange({ ...config, height }); + onChange({ ...config, height }) }, [config, onChange], - ); + ) /* ── 文字水印设置 ── */ const handleTextChange = useCallback( (text: string) => { - onChange({ ...config, text }); + onChange({ ...config, text }) }, [config, onChange], - ); + ) const handleFontSizeChange = useCallback( (font_size: number) => { - onChange({ ...config, font_size }); + onChange({ ...config, font_size }) }, [config, onChange], - ); + ) const handleColorChange = useCallback( (color: string) => { - onChange({ ...config, color }); + onChange({ ...config, color }) }, [config, onChange], - ); + ) /* ── 滚动水印设置 ── */ const handleScrollDirectionChange = useCallback( (scroll_direction: ScrollDirection) => { - onChange({ ...config, scroll_direction }); + onChange({ ...config, scroll_direction }) }, [config, onChange], - ); + ) const handleScrollSpeedChange = useCallback( (scroll_speed: number) => { - onChange({ ...config, scroll_speed }); + onChange({ ...config, scroll_speed }) }, [config, onChange], - ); + ) /* ── 重置 ── */ const handleReset = useCallback(() => { - setLocalImageUrl(""); - onChange({ ...DEFAULT_WATERMARK }); - }, [onChange]); + setLocalImageUrl("") + onChange({ ...DEFAULT_WATERMARK }) + }, [onChange]) /* ── 当前激活的 Tab ── */ - const activeTab = config.type; + const activeTab = config.type return ( = ({ src={localImageUrl || config.image_url} alt="水印预览" onError={(e) => { - (e.target as HTMLImageElement).style.display = "none"; + ;(e.target as HTMLImageElement).style.display = "none" }} />
@@ -256,9 +246,7 @@ const WatermarkPanel: React.FC = ({ value={config.font_size ?? 24} onChange={(e) => handleFontSizeChange(Number(e.target.value))} /> - - {config.font_size ?? 24}px - + {config.font_size ?? 24}px
@@ -271,9 +259,7 @@ const WatermarkPanel: React.FC = ({ value={config.color ?? "#ffffff"} onChange={(e) => handleColorChange(e.target.value)} /> - - {config.color ?? "#ffffff"} - + {config.color ?? "#ffffff"}
@@ -298,9 +284,7 @@ const WatermarkPanel: React.FC = ({ - handlePositionChange(e.target.value as WatermarkPosition) - } + onChange={(e) => handlePositionChange(e.target.value as WatermarkPosition)} > {POSITION_OPTIONS.map((opt) => (
- ); -}; + ) +} -export default WatermarkPanel; +export default WatermarkPanel diff --git a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts index 283177711..51ad76704 100755 --- a/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts +++ b/apps/web/src/pages/editing-planner/hooks/useEditPlanClips.ts @@ -10,15 +10,15 @@ * - 从素材批量导入 * - 乐观更新 + 撤销重做 */ -import { useCallback, useState } from "react"; -import { message } from "antd"; -import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; +import { useCallback, useState } from "react" +import { message } from "antd" +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" import type { EditPlanClip, CreateEditPlanClipRequest, UpdateEditPlanClipRequest, ClipReorderItem, -} from "@/api/editPlans"; +} from "@/api/editPlans" import { getEditPlanClips, createEditPlanClip, @@ -27,13 +27,13 @@ import { reorderEditPlanClips, batchDeleteEditPlanClips, createClipsFromAssets, -} from "@/api/editPlans"; -import { useUndoRedo } from "./useUndoRedo"; +} from "@/api/editPlans" +import { useUndoRedo } from "./useUndoRedo" -const QUERY_KEY = "editPlanClips"; +const QUERY_KEY = "editPlanClips" export function useEditPlanClips(planId: string | undefined) { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() /* ── 片段列表查询 ── */ const { @@ -45,14 +45,14 @@ export function useEditPlanClips(planId: string | undefined) { queryFn: () => getEditPlanClips(planId!, { limit: 500 }), enabled: !!planId, staleTime: 30_000, - }); + }) - const clips: EditPlanClip[] = clipListData?.items ?? []; - const clipsTotal = clipListData?.total ?? 0; + const clips: EditPlanClip[] = clipListData?.items ?? [] + const clipsTotal = clipListData?.total ?? 0 /* ── 选中片段 ── */ - const [selectedClipId, setSelectedClipId] = useState(null); - const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null; + const [selectedClipId, setSelectedClipId] = useState(null) + const selectedClip = clips.find((c) => c.id === selectedClipId) ?? null /* ── 本地撤销/重做(用于拖拽等即时操作的回退) ── */ const { @@ -63,147 +63,138 @@ export function useEditPlanClips(planId: string | undefined) { canUndo, canRedo, reset: resetLocalClips, - } = useUndoRedo([]); + } = useUndoRedo([]) // 当服务端数据变化时同步本地 // 注意:实际使用时以服务端为准,本地仅用于拖拽等临时操作 /* ── 创建片段 ── */ const createMutation = useMutation({ - mutationFn: (data: CreateEditPlanClipRequest) => - createEditPlanClip(planId!, data), + mutationFn: (data: CreateEditPlanClipRequest) => createEditPlanClip(planId!, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); - message.success("片段已添加"); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + message.success("片段已添加") }, onError: () => { - message.error("添加片段失败"); + message.error("添加片段失败") }, - }); + }) const addClip = useCallback( (data: Omit & { order?: number }) => { - if (!planId) return; - const order = data.order ?? clips.length; - createMutation.mutate({ ...data, order }); + if (!planId) return + const order = data.order ?? clips.length + createMutation.mutate({ ...data, order }) }, [planId, clips.length, createMutation], - ); + ) /* ── 更新片段 ── */ const updateMutation = useMutation({ - mutationFn: ({ - clipId, - data, - }: { - clipId: string; - data: UpdateEditPlanClipRequest; - }) => updateEditPlanClip(planId!, clipId, data), + mutationFn: ({ clipId, data }: { clipId: string; data: UpdateEditPlanClipRequest }) => + updateEditPlanClip(planId!, clipId, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) }, onError: () => { - message.error("更新片段失败"); + message.error("更新片段失败") }, - }); + }) const updateClip = useCallback( (clipId: string, data: UpdateEditPlanClipRequest) => { - if (!planId) return; - updateMutation.mutate({ clipId, data }); + if (!planId) return + updateMutation.mutate({ clipId, data }) }, [planId, updateMutation], - ); + ) /* ── 删除片段 ── */ const deleteMutation = useMutation({ mutationFn: (clipId: string) => deleteEditPlanClip(planId!, clipId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); - message.success("片段已删除"); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + message.success("片段已删除") }, onError: () => { - message.error("删除片段失败"); + message.error("删除片段失败") }, - }); + }) const removeClip = useCallback( (clipId: string) => { - if (!planId) return; + if (!planId) return if (selectedClipId === clipId) { - setSelectedClipId(null); + setSelectedClipId(null) } - deleteMutation.mutate(clipId); + deleteMutation.mutate(clipId) }, [planId, selectedClipId, deleteMutation], - ); + ) /* ── 批量删除 ── */ const batchDeleteMutation = useMutation({ - mutationFn: (clipIds: string[]) => - batchDeleteEditPlanClips(planId!, clipIds), + mutationFn: (clipIds: string[]) => batchDeleteEditPlanClips(planId!, clipIds), onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); - message.success(`已删除 ${res.deleted_count} 个片段`); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + message.success(`已删除 ${res.deleted_count} 个片段`) }, onError: () => { - message.error("批量删除失败"); + message.error("批量删除失败") }, - }); + }) const batchRemoveClips = useCallback( (clipIds: string[]) => { - if (!planId || clipIds.length === 0) return; + if (!planId || clipIds.length === 0) return if (selectedClipId && clipIds.includes(selectedClipId)) { - setSelectedClipId(null); + setSelectedClipId(null) } - batchDeleteMutation.mutate(clipIds); + batchDeleteMutation.mutate(clipIds) }, [planId, selectedClipId, batchDeleteMutation], - ); + ) /* ── 重排序(拖拽结束后一次性提交) ── */ const reorderMutation = useMutation({ - mutationFn: (items: ClipReorderItem[]) => - reorderEditPlanClips(planId!, items), + mutationFn: (items: ClipReorderItem[]) => reorderEditPlanClips(planId!, items), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) }, onError: () => { - message.error("排序失败"); + message.error("排序失败") // 失败后刷新回服务端状态 - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) }, - }); + }) const reorderClips = useCallback( (items: ClipReorderItem[]) => { - if (!planId || items.length === 0) return; - reorderMutation.mutate(items); + if (!planId || items.length === 0) return + reorderMutation.mutate(items) }, [planId, reorderMutation], - ); + ) /* ── 从素材批量导入 ── */ const importFromAssetsMutation = useMutation({ - mutationFn: (assetIds: string[]) => - createClipsFromAssets(planId!, assetIds), + mutationFn: (assetIds: string[]) => createClipsFromAssets(planId!, assetIds), onSuccess: (res) => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }); - message.success(`已导入 ${res.created_count} 个素材片段`); + queryClient.invalidateQueries({ queryKey: [QUERY_KEY, planId] }) + message.success(`已导入 ${res.created_count} 个素材片段`) }, onError: () => { - message.error("导入素材失败"); + message.error("导入素材失败") }, - }); + }) const importFromAssets = useCallback( (assetIds: string[]) => { - if (!planId || assetIds.length === 0) return; - importFromAssetsMutation.mutate(assetIds); + if (!planId || assetIds.length === 0) return + importFromAssetsMutation.mutate(assetIds) }, [planId, importFromAssetsMutation], - ); + ) return { // 数据 @@ -236,7 +227,7 @@ export function useEditPlanClips(planId: string | undefined) { canUndo, canRedo, resetLocalClips, - }; + } } -export default useEditPlanClips; +export default useEditPlanClips diff --git a/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts b/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts index 0d7bf6beb..f9f654ad1 100644 --- a/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts +++ b/apps/web/src/pages/editing-planner/hooks/useUndoRedo.ts @@ -2,62 +2,61 @@ * 撤销/重做 Hook — 通用历史栈管理 * 支持任意状态的快照式 undo/redo */ -import { useState, useCallback, useRef } from "react"; +import { useState, useCallback, useRef } from "react" -const MAX_HISTORY = 50; +const MAX_HISTORY = 50 export function useUndoRedo(initialState: T) { - const [present, setPresent] = useState(initialState); - const pastRef = useRef([]); - const futureRef = useRef([]); - const [canUndo, setCanUndo] = useState(false); - const [canRedo, setCanRedo] = useState(false); + const [present, setPresent] = useState(initialState) + const pastRef = useRef([]) + const futureRef = useRef([]) + const [canUndo, setCanUndo] = useState(false) + const [canRedo, setCanRedo] = useState(false) const set = useCallback((next: T | ((prev: T) => T)) => { setPresent((curr) => { - const resolved = - typeof next === "function" ? (next as (p: T) => T)(curr) : next; - pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr]; - futureRef.current = []; - setCanUndo(true); - setCanRedo(false); - return resolved; - }); - }, []); + const resolved = typeof next === "function" ? (next as (p: T) => T)(curr) : next + pastRef.current = [...pastRef.current.slice(-(MAX_HISTORY - 1)), curr] + futureRef.current = [] + setCanUndo(true) + setCanRedo(false) + return resolved + }) + }, []) const undo = useCallback(() => { setPresent((curr) => { - const past = pastRef.current; - if (past.length === 0) return curr; - const prev = past[past.length - 1]; - pastRef.current = past.slice(0, -1); - futureRef.current = [curr, ...futureRef.current]; - setCanUndo(pastRef.current.length > 0); - setCanRedo(true); - return prev; - }); - }, []); + const past = pastRef.current + if (past.length === 0) return curr + const prev = past[past.length - 1] + pastRef.current = past.slice(0, -1) + futureRef.current = [curr, ...futureRef.current] + setCanUndo(pastRef.current.length > 0) + setCanRedo(true) + return prev + }) + }, []) const redo = useCallback(() => { setPresent((curr) => { - const future = futureRef.current; - if (future.length === 0) return curr; - const next = future[0]; - futureRef.current = future.slice(1); - pastRef.current = [...pastRef.current, curr]; - setCanUndo(true); - setCanRedo(futureRef.current.length > 0); - return next; - }); - }, []); + const future = futureRef.current + if (future.length === 0) return curr + const next = future[0] + futureRef.current = future.slice(1) + pastRef.current = [...pastRef.current, curr] + setCanUndo(true) + setCanRedo(futureRef.current.length > 0) + return next + }) + }, []) const reset = useCallback((state: T) => { - pastRef.current = []; - futureRef.current = []; - setPresent(state); - setCanUndo(false); - setCanRedo(false); - }, []); + pastRef.current = [] + futureRef.current = [] + setPresent(state) + setCanUndo(false) + setCanRedo(false) + }, []) - return { state: present, set, undo, redo, canUndo, canRedo, reset }; + return { state: present, set, undo, redo, canUndo, canRedo, reset } } diff --git a/apps/web/src/pages/editing-planner/types.ts b/apps/web/src/pages/editing-planner/types.ts index 3271f0e9b..645369f5a 100755 --- a/apps/web/src/pages/editing-planner/types.ts +++ b/apps/web/src/pages/editing-planner/types.ts @@ -3,7 +3,7 @@ * 片段 = 时间规划 + 类型标记,不绑定任何素材 */ -export type ClipType = "voice" | "pip"; +export type ClipType = "voice" | "pip" /* ──────── 转场特效 ──────── */ @@ -23,59 +23,59 @@ export type TransitionType = | "wipe_up" | "wipe_down" | "circlecrop" - | "rectcrop"; + | "rectcrop" /** 片段间转场配置 */ export interface TransitionConfig { /** 转场类型 */ - type: TransitionType; + type: TransitionType /** 转场时长(秒),0.3 ~ 2.0 */ - duration: number; + duration: number } /** 默认转场配置 */ export const DEFAULT_TRANSITION: TransitionConfig = { type: "none", duration: 0.5, -}; +} /* ──────── 片段调速 ──────── */ /** 片段调速配置 */ export interface SpeedConfig { /** 播放速度,0.25 ~ 4.0 */ - rate: number; + rate: number /** 音调修正(变速不变调) */ - pitchCorrection: boolean; + pitchCorrection: boolean } /** 默认调速配置 */ export const DEFAULT_SPEED: SpeedConfig = { rate: 1.0, pitchCorrection: true, -}; +} /* ──────── TTS 配音 ──────── */ /** 配音模式 */ -export type TtsMode = "none" | "upload" | "tts"; +export type TtsMode = "none" | "upload" | "tts" /** TTS 配音配置 */ export interface TtsConfig { /** 配音模式 */ - mode: TtsMode; + mode: TtsMode /** TTS 合成文本 */ - text: string; + text: string /** 音色 ID */ - voice_id: string; + voice_id: string /** 语速 0.5 ~ 2.0 */ - speed: number; + speed: number /** 语调(半音)-12 ~ +12 */ - pitch: number; + pitch: number /** 音量 0 ~ 100 */ - volume: number; + volume: number /** 字幕联动 */ - subtitle_sync: boolean; + subtitle_sync: boolean } /** 默认 TTS 配置 */ @@ -87,56 +87,55 @@ export const DEFAULT_TTS_CONFIG: TtsConfig = { pitch: 0, volume: 100, subtitle_sync: true, -}; +} /* ──────── 裁剪配置 ──────── */ /** 片段裁剪配置 — 定义素材的入点/出点 */ export interface TrimConfig { /** 入点(秒),素材原始时间轴上的起始位置 */ - start_time: number; + start_time: number /** 出点(秒),素材原始时间轴上的结束位置 */ - end_time: number; + end_time: number /** 素材原始总时长(秒),用于"恢复原始长度" */ - original_duration?: number; + original_duration?: number } /* ──────── 水印配置 ──────── */ /** 水印类型 */ -export type WatermarkType = "none" | "image" | "text" | "scroll"; +export type WatermarkType = "none" | "image" | "text" | "scroll" /** 水印位置 */ -export type WatermarkPosition = - "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center"; +export type WatermarkPosition = "top_left" | "top_right" | "bottom_left" | "bottom_right" | "center" /** 滚动水印方向 */ -export type ScrollDirection = "horizontal" | "vertical" | "diagonal"; +export type ScrollDirection = "horizontal" | "vertical" | "diagonal" /** 水印配置 */ export interface WatermarkConfig { /** 水印类型 */ - type: WatermarkType; + type: WatermarkType /** 图片水印 URL */ - image_url?: string; + image_url?: string /** 水印宽度(像素或百分比 0~1) */ - width?: number; + width?: number /** 水印高度(像素或百分比 0~1) */ - height?: number; + height?: number /** 水印位置 */ - position: WatermarkPosition; + position: WatermarkPosition /** 水印不透明度 0~1 */ - opacity: number; + opacity: number /** 文字水印内容 */ - text?: string; + text?: string /** 文字水印字号 */ - font_size?: number; + font_size?: number /** 文字水印颜色 */ - color?: string; + color?: string /** 滚动水印方向 */ - scroll_direction?: ScrollDirection; + scroll_direction?: ScrollDirection /** 滚动水印速度(像素/秒) */ - scroll_speed?: number; + scroll_speed?: number } /** 默认水印配置 */ @@ -144,38 +143,38 @@ export const DEFAULT_WATERMARK: WatermarkConfig = { type: "none", position: "bottom_right", opacity: 0.7, -}; +} /* ──────── 片头片尾配置 ──────── */ /** 片头片尾素材类型 */ -export type IntroOutroKind = "none" | "video" | "image"; +export type IntroOutroKind = "none" | "video" | "image" /** 片头/片尾单项配置 */ export interface IntroOutroItem { /** 素材类型 */ - kind: IntroOutroKind; + kind: IntroOutroKind /** 素材 URL */ - url?: string; + url?: string /** 显示时长(秒) */ - duration: number; + duration: number /** 过渡动画 */ - transition?: TransitionType; + transition?: TransitionType /** 过渡时长(秒) */ - transition_duration?: number; + transition_duration?: number } /** 片头片尾完整配置 */ export interface IntroOutroConfig { - intro: IntroOutroItem; - outro: IntroOutroItem; + intro: IntroOutroItem + outro: IntroOutroItem } /** 默认片头片尾配置 */ export const DEFAULT_INTRO_OUTRO: IntroOutroConfig = { intro: { kind: "none", duration: 3 }, outro: { kind: "none", duration: 3 }, -}; +} /* ──────── 画中画配置 ──────── */ @@ -189,59 +188,59 @@ export type PipGridPosition = | "center_right" | "bottom_left" | "bottom_center" - | "bottom_right"; + | "bottom_right" /** 入场动画类型 */ -export type PipAnimType = "none" | "fade_in" | "slide_in"; +export type PipAnimType = "none" | "fade_in" | "slide_in" /** 入场方向 */ -export type PipSlideDirection = "left" | "right" | "up" | "down"; +export type PipSlideDirection = "left" | "right" | "up" | "down" /** 画中画图层 */ export interface PipLayer { - id: string; + id: string /** 图层名称(用户可编辑) */ - name: string; + name: string /** 素材类型 */ - material_type: "image" | "video"; + material_type: "image" | "video" /** 素材 URL */ - material_url: string; + material_url: string /** 素材缩略图 */ - thumbnail_url?: string; + thumbnail_url?: string /** 九宫格快捷位置 */ - grid_position: PipGridPosition; + grid_position: PipGridPosition /** 精确 X 坐标(百分比 0~100) */ - x: number; + x: number /** 精确 Y 坐标(百分比 0~100) */ - y: number; + y: number /** 宽度(百分比 0~100,相对主画面) */ - width: number; + width: number /** 高度(百分比 0~100,相对主画面) */ - height: number; + height: number /** 锁定宽高比 */ - aspect_lock: boolean; + aspect_lock: boolean /** 圆角(百分比 0~50) */ - border_radius: number; + border_radius: number /** 不透明度(0~100) */ - opacity: number; + opacity: number /** 开始时间(秒) */ - start_time: number; + start_time: number /** 持续时长(秒) */ - duration: number; + duration: number /** 入场动画 */ - animation: PipAnimType; + animation: PipAnimType /** 入场方向 */ - slide_direction: PipSlideDirection; + slide_direction: PipSlideDirection /** 图层顺序(z-index) */ - z_index: number; + z_index: number } /** 画中画配置 */ export interface PipConfig { /** 是否启用画中画 */ - enabled: boolean; + enabled: boolean /** 图层列表 */ - layers: PipLayer[]; + layers: PipLayer[] } /** 默认 PiP 图层 */ @@ -263,13 +262,13 @@ export const DEFAULT_PIP_LAYER: PipLayer = { animation: "none", slide_direction: "right", z_index: 1, -}; +} /** 默认 PiP 配置 */ export const DEFAULT_PIP_CONFIG: PipConfig = { enabled: false, layers: [], -}; +} /* ──────── 滤镜调色 ──────── */ @@ -284,7 +283,7 @@ export type FilterPreset = | "cinema" | "bw" | "sunshine" - | "film"; + | "film" /** 预设滤镜标签 */ export const FILTER_PRESET_LABELS: Record = { @@ -298,26 +297,26 @@ export const FILTER_PRESET_LABELS: Record = { bw: "黑白", sunshine: "暖阳", film: "胶片", -}; +} /** 滤镜调色配置 */ export interface FilterConfig { /** 是否启用滤镜 */ - enabled: boolean; + enabled: boolean /** 预设滤镜 */ - preset: FilterPreset; + preset: FilterPreset /** 亮度(-100 ~ 100) */ - brightness: number; + brightness: number /** 对比度(-100 ~ 100) */ - contrast: number; + contrast: number /** 饱和度(-100 ~ 100) */ - saturation: number; + saturation: number /** 色温(-100 ~ 100,负值偏蓝,正值偏黄) */ - temperature: number; + temperature: number /** 色调(-100 ~ 100,负值偏绿,正值偏品红) */ - tint: number; + tint: number /** 锐度(0 ~ 100) */ - sharpness: number; + sharpness: number } /** 默认滤镜调色配置 */ @@ -330,13 +329,12 @@ export const DEFAULT_FILTER_CONFIG: FilterConfig = { temperature: 0, tint: 0, sharpness: 0, -}; +} /* ──────── 绿幕抠像 ──────── */ /** 绿幕抠像颜色预设 */ -export type ChromaKeyColorPreset = - "green" | "blue" | "red" | "pure_green" | "soft_green"; +export type ChromaKeyColorPreset = "green" | "blue" | "red" | "pure_green" | "soft_green" /** 颜色预设标签 */ export const CHROMA_KEY_PRESET_LABELS: Record = { @@ -345,7 +343,7 @@ export const CHROMA_KEY_PRESET_LABELS: Record = { red: "红", pure_green: "精绿", soft_green: "柔绿", -}; +} /** 颜色预设对应的默认色值 */ export const CHROMA_KEY_PRESET_COLORS: Record = { @@ -354,22 +352,22 @@ export const CHROMA_KEY_PRESET_COLORS: Record = { red: "#FF0000", pure_green: "#00C800", soft_green: "#40E040", -}; +} /** 绿幕抠像配置 */ export interface ChromaKeyConfig { /** 是否启用绿幕抠像 */ - enabled: boolean; + enabled: boolean /** 颜色预设 */ - color_preset: ChromaKeyColorPreset; + color_preset: ChromaKeyColorPreset /** 抠像目标颜色(HEX) */ - color: string; + color: string /** 相似度(0 ~ 100,越大容忍的色差范围越广) */ - similarity: number; + similarity: number /** 边缘平滑(0 ~ 100,越大边缘越柔和) */ - blend: number; + blend: number /** 溢色抑制(0 ~ 100,去除边缘颜色溢出) */ - spill: number; + spill: number } /** 默认绿幕抠像配置 */ @@ -380,12 +378,12 @@ export const DEFAULT_CHROMA_KEY_CONFIG: ChromaKeyConfig = { similarity: 30, blend: 10, spill: 20, -}; +} /* ──────── 贴纸配置 ──────── */ /** 贴纸类型 */ -export type StickerType = "emoji" | "image" | "text"; +export type StickerType = "emoji" | "image" | "text" /** 文字花字预设 */ export type TextStickerPreset = @@ -396,45 +394,45 @@ export type TextStickerPreset = | "shadow" // 投影 | "outline" // 描边 | "gradient" // 渐变 - | "handwrite"; // 手写 + | "handwrite" // 手写 /** 贴纸项 */ export interface StickerItem { - id: string; + id: string /** 贴纸类型 */ - type: StickerType; + type: StickerType /** 内容(emoji 字符 / 图片 URL / 文字内容) */ - content: string; + content: string /** X 坐标(百分比 0~100) */ - x: number; + x: number /** Y 坐标(百分比 0~100) */ - y: number; + y: number /** 宽度(百分比 0~100) */ - width: number; + width: number /** 高度(百分比 0~100) */ - height: number; + height: number /** 旋转角度(度 -180~180) */ - rotation: number; + rotation: number /** 不透明度(0~100) */ - opacity: number; + opacity: number /** 开始时间(秒) */ - start_time: number; + start_time: number /** 持续时长(秒,0 表示全程显示) */ - duration: number; + duration: number /** 图层顺序 */ - z_index: number; + z_index: number /** 文字花字预设(仅 type=text 时有效) */ - text_preset: TextStickerPreset; + text_preset: TextStickerPreset /** 文字颜色(仅 type=text 时有效) */ - text_color: string; + text_color: string /** 文字大小(px,仅 type=text 时有效) */ - font_size: number; + font_size: number } /** 贴纸配置 */ export interface StickerConfig { - enabled: boolean; - items: StickerItem[]; + enabled: boolean + items: StickerItem[] } /** 默认贴纸项 */ @@ -454,13 +452,13 @@ export const DEFAULT_STICKER_ITEM: StickerItem = { text_preset: "normal", text_color: "#FFFFFF", font_size: 24, -}; +} /** 默认贴纸配置 */ export const DEFAULT_STICKER_CONFIG: StickerConfig = { enabled: false, items: [], -}; +} /** 文字花字预设标签 */ export const TEXT_STICKER_PRESET_LABELS: Record = { @@ -472,27 +470,27 @@ export const TEXT_STICKER_PRESET_LABELS: Record = { outline: "描边", gradient: "渐变", handwrite: "手写", -}; +} /* ──────── 封面配置 ──────── */ /** 封面来源模式 */ -export type CoverMode = "auto" | "frame" | "upload"; +export type CoverMode = "auto" | "frame" | "upload" /** 封面配置 */ export interface CoverConfig { /** 是否启用自定义封面 */ - enabled: boolean; + enabled: boolean /** 封面来源模式 */ - mode: CoverMode; + mode: CoverMode /** 抽帧时间点(秒,mode=frame 时使用) */ - frame_time: number; + frame_time: number /** 上传的封面 URL(mode=upload 时使用) */ - upload_url: string; + upload_url: string /** AI 智能推荐的抽帧时间(由后端分析得出) */ - ai_suggested_time: number | null; + ai_suggested_time: number | null /** 封面缩略图 URL */ - thumbnail_url: string; + thumbnail_url: string } /** 默认封面配置 */ @@ -503,33 +501,33 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = { upload_url: "", ai_suggested_time: null, thumbnail_url: "", -}; +} /* ──────── 片段数据 ──────── */ export interface ClipData { - id: string; - type: ClipType; // 片段类型:voice(口播)或 pip(画中画) - duration: number; // 时长(秒) - startOffset: number; // 仅 voice 类型:在口播素材中的起始时间(秒) + id: string + type: ClipType // 片段类型:voice(口播)或 pip(画中画) + duration: number // 时长(秒) + startOffset: number // 仅 voice 类型:在口播素材中的起始时间(秒) /** 素材库素材 ID(main/pip 类型片段使用) */ - media_asset_id?: string; + media_asset_id?: string // 保留兼容字段(后端序列化需要) - template_segment_id?: string; - script_text?: string; - order?: number; + template_segment_id?: string + script_text?: string + order?: number /** 配音素材 ID(voice 类型片段使用) */ - voice_asset_id?: string; + voice_asset_id?: string /** 配音素材文件 URL(voice 类型片段使用) */ - voice_file_url?: string; + voice_file_url?: string /** 与前一片段之间的转场效果 */ - transition?: TransitionConfig; + transition?: TransitionConfig /** 播放速度配置 */ - speed?: SpeedConfig; + speed?: SpeedConfig /** TTS 配音配置 */ - tts_config?: TtsConfig; + tts_config?: TtsConfig /** 裁剪配置 — 定义素材入点/出点 */ - trim_config?: TrimConfig; + trim_config?: TrimConfig } /* ──────── 标题设置 ──────── */ @@ -539,14 +537,14 @@ export interface ClipData { * 前端 UI 使用 camelCase,发送到后端时映射为 snake_case */ export interface TitleSettings { - aiAutoSelect: boolean; - title: string; - position: string; - font: string; - size: number; - bold: boolean; - italic: boolean; - stroke: boolean; - shadow: boolean; - color: string; + aiAutoSelect: boolean + title: string + position: string + font: string + size: number + bold: boolean + italic: boolean + stroke: boolean + shadow: boolean + color: string } diff --git a/apps/web/src/pages/editing-planner/types/subtitle.ts b/apps/web/src/pages/editing-planner/types/subtitle.ts index 336effe1f..82f7d704a 100755 --- a/apps/web/src/pages/editing-planner/types/subtitle.ts +++ b/apps/web/src/pages/editing-planner/types/subtitle.ts @@ -5,29 +5,29 @@ /* ──────────── 类型 ──────────── */ -export type SubtitleMode = "manual" | "asr"; +export type SubtitleMode = "manual" | "asr" export interface SubtitleStyleConfig { /** 是否启用字幕 */ - enabled: boolean; + enabled: boolean /** 字幕模式:手动输入 / ASR 自动识别 */ - mode: SubtitleMode; + mode: SubtitleMode /** 字体大小 px */ - fontSize: number; + fontSize: number /** 字体颜色 */ - fontColor: string; + fontColor: string /** 描边 */ - stroke: boolean; + stroke: boolean /** 阴影 */ - shadow: boolean; + shadow: boolean /** 字幕位置 */ - position: "top" | "center" | "bottom"; + position: "top" | "center" | "bottom" /** 字体 */ - font: string; + font: string /** 动画效果 */ - animation: string; + animation: string /** ASR 语言(仅 ASR 模式) */ - asrLanguage: "zh" | "en"; + asrLanguage: "zh" | "en" } /* ──────────── 默认值 ──────────── */ @@ -43,4 +43,4 @@ export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = { font: "思源黑体", animation: "none", asrLanguage: "zh", -}; +} diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index ff57f4c33..2a6b9dbcc 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -4,9 +4,9 @@ * 左右布局:左侧 generate-form + 右侧 generate-preview * 保留所有现有业务逻辑(API 调用、URL 参数、CloneModal、TTS 等) */ -import React, { useState, useRef, useCallback, useEffect } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { Typography, message, Select } from "antd"; +import React, { useState, useRef, useCallback, useEffect } from "react" +import { useQuery, useMutation } from "@tanstack/react-query" +import { Typography, message, Select } from "antd" import { AudioOutlined, ThunderboltOutlined, @@ -22,39 +22,39 @@ import { PlusOutlined, MinusOutlined, CloseOutlined, -} from "@ant-design/icons"; -import type { AssetItem } from "@/api/assets"; -import { getAssets, getAssetLibraries } from "@/api/assets"; +} from "@ant-design/icons" +import type { AssetItem } from "@/api/assets" +import { getAssets, getAssetLibraries } from "@/api/assets" import { createEditPlan, generateEditPlan, updateEditPlan, getGenerationTaskResults, -} from "@/api/editPlans"; -import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans"; -import { getEditingTemplates } from "@/api/editingPlanner"; -import { getTitles } from "@/api/titles"; -import apiClient from "@/api/client"; -import { fetchPresetVoices } from "@/api/voices"; -import type { PresetVoiceItem } from "@/api/voices"; -import { formatDuration } from "@/api/voiceClone"; -import type { VoiceClone } from "@/api/voiceClone"; -import CloneModal from "@/components/voice/CloneModal"; -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"; -import { getTags, createTag } from "@/api/tags"; -import { useCloneProgress } from "@/hooks/useCloneProgress"; -import { useSearchParams, useNavigate } from "react-router-dom"; -import { getEditPlan } from "@/api/editPlans"; -import "./generate.css"; +} from "@/api/editPlans" +import type { GeneratedVideo, EditPlanConfig } from "@/api/editPlans" +import { getEditingTemplates } from "@/api/editingPlanner" +import { getTitles } from "@/api/titles" +import apiClient from "@/api/client" +import { fetchPresetVoices } from "@/api/voices" +import type { PresetVoiceItem } from "@/api/voices" +import { formatDuration } from "@/api/voiceClone" +import type { VoiceClone } from "@/api/voiceClone" +import CloneModal from "@/components/voice/CloneModal" +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" +import { getTags, createTag } from "@/api/tags" +import { useCloneProgress } from "@/hooks/useCloneProgress" +import { useSearchParams, useNavigate } from "react-router-dom" +import { getEditPlan } from "@/api/editPlans" +import "./generate.css" -const { Text } = Typography; +const { Text } = Typography /* ── 克隆声音状态配置 ── */ const CLONE_STATUS_CONFIG: Record = { ready: { label: "就绪", color: "var(--secondary-color, #10b981)" }, processing: { label: "克隆中", color: "var(--accent-color, #f59e0b)" }, failed: { label: "失败", color: "var(--error-color, #ef4444)" }, -}; +} /* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */ const MODE_GRADIENTS: Record = { @@ -62,14 +62,14 @@ const MODE_GRADIENTS: Record = { one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)", voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)", voice_pip: "linear-gradient(135deg, #10b981, #059669)", -}; +} /* ── 配音预设卡片:从 API 动态生成,不再硬编码 ── */ const VOICE_GENDER_ICON: Record = { female: "🎀", male: "🎙️", child: "🧒", neutral: "✨", -}; +} /* ── 步骤定义 ── */ const STEPS = [ @@ -78,7 +78,7 @@ const STEPS = [ { key: 3, label: "选择标题" }, { key: 4, label: "选择配音" }, { key: 5, label: "确认生成" }, -]; +] /* ── 标题选项从 API 加载,不再硬编码 ── */ @@ -87,288 +87,280 @@ const STEPS = [ ================================================================ */ const GeneratePage: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() /* ── 步骤状态 ── */ - const [currentStep, setCurrentStep] = useState(1); + const [currentStep, setCurrentStep] = useState(1) /* ── 模板(从 API 加载用户自制模板) ── */ - const [selectedTemplate, setSelectedTemplate] = useState(""); + 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); + setSelectedTemplate(userTemplates[0].id) } - }, [userTemplates, selectedTemplate]); + }, [userTemplates, selectedTemplate]) /* ── 素材 ── */ - const [selectedMaterials, setSelectedMaterials] = useState([]); + const [selectedMaterials, setSelectedMaterials] = useState([]) /* 素材选择模式:手动选择 / 自动匹配 */ - const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual"); + const [materialMode, setMaterialMode] = useState<"manual" | "auto">("manual") /* ── 标题 ── */ - const [title, setTitle] = useState(""); + const [title, setTitle] = useState("") const { data: userTitles = [] } = useQuery({ queryKey: ["titles"], queryFn: () => getTitles(), staleTime: 30_000, - }); + }) /* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */ useEffect(() => { - const tpl = userTemplates.find((t) => t.id === selectedTemplate); + const tpl = userTemplates.find((t) => t.id === selectedTemplate) if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) { - setTitle(tpl.title_config.content); + setTitle(tpl.title_config.content) } - }, [selectedTemplate, userTemplates]); + }, [selectedTemplate, userTemplates]) /* ── 配音 ── */ - const [selectedVoice, setSelectedVoice] = useState(""); - const [playingVoice, setPlayingVoice] = useState(null); - const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">( - "preset", - ); - const [customVoiceText, setCustomVoiceText] = useState(""); + const [selectedVoice, setSelectedVoice] = useState("") + const [playingVoice, setPlayingVoice] = useState(null) + const [voiceMode, setVoiceMode] = useState<"preset" | "custom" | "clone">("preset") + const [customVoiceText, setCustomVoiceText] = useState("") /* ── 生成数量 ── */ - const [generateCount, setGenerateCount] = useState(1); + const [generateCount, setGenerateCount] = useState(1) /* ── 克隆声音 ── */ - const [selectedClonedVoice, setSelectedClonedVoice] = useState(""); - const [cloneModalOpen, setCloneModalOpen] = useState(false); - const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress(); + const [selectedClonedVoice, setSelectedClonedVoice] = useState("") + const [cloneModalOpen, setCloneModalOpen] = useState(false) + const { clones: clonedVoices, addClone, hasProcessing } = useCloneProgress() /* ── 高级设置(隐藏但保留) ── */ - const [videoRatio] = useState("16:9"); - const [duration] = useState(30); - const [style] = useState("business"); - const [autoSubtitles] = useState(true); - const [bgm] = useState(true); + const [videoRatio] = useState("16:9") + const [duration] = useState(30) + const [style] = useState("business") + const [autoSubtitles] = useState(true) + const [bgm] = useState(true) /* ── 生成状态 ── */ - const [generating, setGenerating] = useState(false); - const [progress, setProgress] = useState(0); - const [generated, setGenerated] = useState(false); - const [generateError, setGenerateError] = useState(null); - const [generatedVideos, setGeneratedVideos] = useState([]); - const [videoUrl, setVideoUrl] = useState(""); - const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [generating, setGenerating] = useState(false) + const [progress, setProgress] = useState(0) + const [generated, setGenerated] = useState(false) + const [generateError, setGenerateError] = useState(null) + const [generatedVideos, setGeneratedVideos] = useState([]) + const [videoUrl, setVideoUrl] = useState("") + const [thumbnailUrl, setThumbnailUrl] = useState("") - const progressTimer = useRef>(undefined); - const audioRef = useRef(null); + const progressTimer = useRef>(undefined) + const audioRef = useRef(null) /* ── URL 参数:从剪辑计划跳转过来时携带 edit_plan_id + plan_config ── */ - const [searchParams] = useSearchParams(); - const editPlanId = searchParams.get("edit_plan_id"); - const planConfigStr = searchParams.get("plan_config"); + const [searchParams] = useSearchParams() + const editPlanId = searchParams.get("edit_plan_id") + const planConfigStr = searchParams.get("plan_config") /** 解析 plan_config 并自动填充表单 */ useEffect(() => { - if (!planConfigStr) return; + if (!planConfigStr) return try { const config = JSON.parse(planConfigStr) as { - title_config?: { content?: string; ai_auto_select?: boolean }; - subtitle_config?: { enabled?: boolean }; - bgm_config?: { enabled?: boolean; music_id?: string }; - mode?: string; - total_duration?: number; - segments?: Array<{ media_asset_id?: string; material_type?: string }>; - }; + title_config?: { content?: string; ai_auto_select?: boolean } + subtitle_config?: { enabled?: boolean } + bgm_config?: { enabled?: boolean; music_id?: string } + mode?: string + total_duration?: number + segments?: Array<{ media_asset_id?: string; material_type?: string }> + } if (config.title_config?.content) { - setTitle(config.title_config.content); + setTitle(config.title_config.content) } if (config.segments && config.segments.length > 0) { const assetIds = config.segments .map((s) => s.media_asset_id) - .filter((id): id is string => !!id); + .filter((id): id is string => !!id) if (assetIds.length > 0) { - setSelectedMaterials(assetIds); + setSelectedMaterials(assetIds) } } } catch (err) { - console.warn("解析 plan_config 失败:", err); + console.warn("解析 plan_config 失败:", err) } - }, [planConfigStr]); + }, [planConfigStr]) /** 如果没有 plan_config,尝试通过 edit_plan_id 从后端拉取配置 */ useEffect(() => { - if (!editPlanId || planConfigStr) return; + if (!editPlanId || planConfigStr) return const loadPlanConfig = async () => { try { - const plan = await getEditPlan(editPlanId); - if (plan.name) setTitle(plan.name); - const cfg = plan.config; + const plan = await getEditPlan(editPlanId) + if (plan.name) setTitle(plan.name) + const cfg = plan.config if (cfg?.asset_ids) { - setSelectedMaterials( - cfg.asset_ids.filter((v): v is string => typeof v === "string"), - ); + setSelectedMaterials(cfg.asset_ids.filter((v): v is string => typeof v === "string")) } } catch (err) { - console.warn("加载剪辑计划配置失败:", err); + console.warn("加载剪辑计划配置失败:", err) } - }; - loadPlanConfig(); - }, [editPlanId, planConfigStr]); + } + loadPlanConfig() + }, [editPlanId, planConfigStr]) /* ── 预置音色 API ── */ const { data: presetVoicesData, isLoading: presetVoicesLoading } = useQuery({ queryKey: ["preset-voices"], queryFn: fetchPresetVoices, - }); - const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []; + }) + const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [] /* ── TTS 自定义合成状态 ── */ - const [customAudioUrl, setCustomAudioUrl] = useState(null); - const [ttsError, setTtsError] = useState(null); - const [ttsJobId, setTtsJobId] = useState(null); + const [customAudioUrl, setCustomAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const [ttsJobId, setTtsJobId] = useState(null) /** 合成完成后保留的 job ID,用于"存为素材" */ - const [completedTtsJobId, setCompletedTtsJobId] = useState( - null, - ); + const [completedTtsJobId, setCompletedTtsJobId] = useState(null) /* ── 存为素材弹窗状态 ── */ - const [saveModalOpen, setSaveModalOpen] = useState(false); - const [saveName, setSaveName] = useState(""); - const [saveTagIds, setSaveTagIds] = useState([]); - const [saveNewTag, setSaveNewTag] = useState(""); + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [saveName, setSaveName] = useState("") + const [saveTagIds, setSaveTagIds] = useState([]) + const [saveNewTag, setSaveNewTag] = useState("") /* ── 标签列表(用于存为素材弹窗) ── */ const { data: allTags = [] } = useQuery({ queryKey: ["generate-save-tags"], queryFn: getTags, staleTime: 30_000, - }); + }) /* ── 素材数据 API ── */ const { data: libraries = [] } = useQuery({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, - }); - const [selectedLibraryId, setSelectedLibraryId] = useState(""); + }) + const [selectedLibraryId, setSelectedLibraryId] = useState("") // 自动选中第一个素材库 useEffect(() => { if (libraries.length > 0 && !selectedLibraryId) { - setSelectedLibraryId(libraries[0].id); + setSelectedLibraryId(libraries[0].id) } - }, [libraries, selectedLibraryId]); + }, [libraries, selectedLibraryId]) - const { data: materials = [], isLoading: materialsLoading } = useQuery< - AssetItem[] - >({ + const { data: materials = [], isLoading: materialsLoading } = useQuery({ queryKey: ["generate-assets", selectedLibraryId], queryFn: () => getAssets(selectedLibraryId), enabled: !!selectedLibraryId, - }); + }) const handleCloneSuccess = useCallback( (voice: VoiceClone) => { - addClone(voice); - setCloneModalOpen(false); - message.success("音色克隆成功!"); + addClone(voice) + setCloneModalOpen(false) + message.success("音色克隆成功!") }, [addClone], - ); + ) /* ── 事件 ── */ const toggleVoicePlay = useCallback( (voiceId: string, previewUrl: string | null) => { if (playingVoice === voiceId) { - audioRef.current?.pause(); - audioRef.current = null; - setPlayingVoice(null); - return; + audioRef.current?.pause() + audioRef.current = null + setPlayingVoice(null) + return } - audioRef.current?.pause(); + audioRef.current?.pause() if (!previewUrl) { - message.warning("该音色暂无试听音频"); - return; + message.warning("该音色暂无试听音频") + return } - const audio = new Audio(previewUrl); - audioRef.current = audio; + const audio = new Audio(previewUrl) + audioRef.current = audio audio.play().catch(() => { - message.error("播放失败,请检查网络"); - }); + message.error("播放失败,请检查网络") + }) audio.onended = () => { - setPlayingVoice(null); - audioRef.current = null; - }; - setPlayingVoice(voiceId); + setPlayingVoice(null) + audioRef.current = null + } + setPlayingVoice(voiceId) }, [playingVoice], - ); + ) /* ── TTS mutation ── */ const synthesizeMutation = useMutation({ mutationFn: synthesizeSpeech, onSuccess: (data) => { - setTtsJobId(data.job_id); - message.info("语音合成已提交,等待处理…"); + setTtsJobId(data.job_id) + message.info("语音合成已提交,等待处理…") }, onError: () => { - setTtsError("语音合成请求失败,请重试"); + setTtsError("语音合成请求失败,请重试") }, - }); + }) /** 轮询 TTS 任务状态 */ useEffect(() => { - if (!ttsJobId) return; - let cancelled = false; - let timer: ReturnType; + if (!ttsJobId) return + let cancelled = false + let timer: ReturnType const poll = async () => { try { - const status = await getTTSJobStatus(ttsJobId); - if (cancelled) return; + const status = await getTTSJobStatus(ttsJobId) + if (cancelled) return if (status.status === "completed") { - setCustomAudioUrl(status.output_audio_url); - setCompletedTtsJobId(ttsJobId); - setTtsJobId(null); - setTtsError(null); - message.success("语音合成完成!"); - return; + setCustomAudioUrl(status.output_audio_url) + setCompletedTtsJobId(ttsJobId) + setTtsJobId(null) + setTtsError(null) + message.success("语音合成完成!") + return } if (status.status === "failed" || status.status === "cancelled") { - setTtsError(status.error_message || "语音合成失败"); - setTtsJobId(null); - return; + setTtsError(status.error_message || "语音合成失败") + setTtsJobId(null) + return } - timer = setTimeout(poll, 2000); + timer = setTimeout(poll, 2000) } catch { if (!cancelled) { - setTtsError("查询合成状态失败"); - setTtsJobId(null); + setTtsError("查询合成状态失败") + setTtsJobId(null) } } - }; + } - timer = setTimeout(poll, 2000); + timer = setTimeout(poll, 2000) return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [ttsJobId]); + cancelled = true + clearTimeout(timer) + } + }, [ttsJobId]) /** 触发自定义文本 TTS 合成 */ const handleSynthesizeVoice = useCallback(() => { if (!customVoiceText.trim()) { - message.warning("请先输入配音文案"); - return; + message.warning("请先输入配音文案") + return } - setTtsError(null); - setCustomAudioUrl(null); + setTtsError(null) + setCustomAudioUrl(null) synthesizeMutation.mutate({ text: customVoiceText.trim(), voice_id: selectedVoice || undefined, language: "zh-CN", - }); - }, [customVoiceText, selectedVoice, synthesizeMutation]); + }) + }, [customVoiceText, selectedVoice, synthesizeMutation]) /* ── 存为素材 mutation ── */ const saveToLibraryMutation = useMutation({ @@ -391,64 +383,64 @@ const GeneratePage: React.FC = () => { ), duration: 5, - }); - setSaveModalOpen(false); - setSaveName(""); - setSaveTagIds([]); - setSaveNewTag(""); - setCompletedTtsJobId(null); - setCustomAudioUrl(null); + }) + setSaveModalOpen(false) + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + setCompletedTtsJobId(null) + setCustomAudioUrl(null) }, onError: (err: Error) => { - message.error(`保存失败:${err.message || "请重试"}`); + message.error(`保存失败:${err.message || "请重试"}`) }, - }); + }) /** 打开存为素材弹窗 */ const handleOpenSaveModal = useCallback(() => { - setSaveName(""); - setSaveTagIds([]); - setSaveNewTag(""); - setSaveModalOpen(true); - }, []); + setSaveName("") + setSaveTagIds([]) + setSaveNewTag("") + setSaveModalOpen(true) + }, []) /** 确认保存 */ const handleConfirmSave = useCallback(() => { - if (!completedTtsJobId) return; + if (!completedTtsJobId) return saveToLibraryMutation.mutate({ name: saveName.trim() || undefined, tag_ids: saveTagIds.length > 0 ? saveTagIds : undefined, - }); - }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]); + }) + }, [completedTtsJobId, saveName, saveTagIds, saveToLibraryMutation]) /** 在弹窗中新增标签(先创建再选中) */ const handleAddTagInModal = useCallback( async (tagName: string) => { - const trimmed = tagName.trim(); - if (!trimmed) return; + const trimmed = tagName.trim() + if (!trimmed) return /* 已在选中列表则跳过 */ - const existing = allTags.find((t) => t.name === trimmed); + const existing = allTags.find((t) => t.name === trimmed) if (existing) { if (!saveTagIds.includes(existing.id)) { - setSaveTagIds((prev) => [...prev, existing.id]); + setSaveTagIds((prev) => [...prev, existing.id]) } - return; + return } try { - const created = await createTag(trimmed); - setSaveTagIds((prev) => [...prev, created.id]); - setSaveNewTag(""); + const created = await createTag(trimmed) + setSaveTagIds((prev) => [...prev, created.id]) + setSaveNewTag("") } catch { - message.error(`创建标签"${trimmed}"失败`); + message.error(`创建标签"${trimmed}"失败`) } }, [allTags, saveTagIds], - ); + ) /** 保存成功后跳转到素材库 */ const handleGoToLibrary = useCallback(() => { - navigate("/app/voice-materials"); - }, [navigate]); + navigate("/app/voice-materials") + }, [navigate]) const handleGenerate = useCallback(async () => { console.log("[handleGenerate] 开始生成, 参数:", { @@ -456,43 +448,39 @@ const GeneratePage: React.FC = () => { selectedTemplate, selectedMaterials, voiceMode, - }); + }) if (!title.trim()) { - message.warning("请先选择或输入标题"); - return; + message.warning("请先选择或输入标题") + return } if (materialMode === "manual" && selectedMaterials.length === 0) { - message.warning("请至少选择一个素材"); - return; + message.warning("请至少选择一个素材") + return } if (voiceMode === "clone" && !selectedClonedVoice) { - message.warning("请先选择一个克隆音色"); - return; + message.warning("请先选择一个克隆音色") + return } - setGenerating(true); - setProgress(0); - setGenerated(false); - setGenerateError(null); + setGenerating(true) + setProgress(0) + setGenerated(false) + setGenerateError(null) try { const voiceConfig: Pick< EditPlanConfig, - | "voice_id" - | "voice_clone_profile_id" - | "custom_audio_url" - | "custom_text" - > = {}; + "voice_id" | "voice_clone_profile_id" | "custom_audio_url" | "custom_text" + > = {} if (voiceMode === "preset") { - voiceConfig.voice_id = selectedVoice || undefined; + voiceConfig.voice_id = selectedVoice || undefined } else if (voiceMode === "clone") { - voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined; + voiceConfig.voice_clone_profile_id = selectedClonedVoice || undefined } else if (voiceMode === "custom") { - voiceConfig.voice_id = selectedVoice || undefined; - if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl; - if (customVoiceText.trim()) - voiceConfig.custom_text = customVoiceText.trim(); + voiceConfig.voice_id = selectedVoice || undefined + if (customAudioUrl) voiceConfig.custom_audio_url = customAudioUrl + if (customVoiceText.trim()) voiceConfig.custom_text = customVoiceText.trim() } const plan = await createEditPlan({ @@ -511,216 +499,175 @@ const GeneratePage: React.FC = () => { }, total_duration: duration, source_edit_plan_id: editPlanId || undefined, - }); + }) // 后端要求计划处于 editing 状态才能触发渲染,自动转换状态 - await updateEditPlan(plan.id, { status: "editing" }); + await updateEditPlan(plan.id, { status: "editing" }) - await generateEditPlan(plan.id); + await generateEditPlan(plan.id) const poll = async () => { try { - const status = await apiClient.get( - `/edit-plans/${plan.id}/generation-status`, - ); - const data = status.data; + 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); + setProgress(100) + setGenerating(false) + setGenerated(true) // 获取生成的视频结果 if (data.generation_task_id) { try { - const videos = await getGenerationTaskResults( - data.generation_task_id, - ); - setGeneratedVideos(videos); + const videos = await getGenerationTaskResults(data.generation_task_id) + setGeneratedVideos(videos) if (videos.length > 0) { - setVideoUrl( - videos[0].file_url || videos[0].download_url || "", - ); - setThumbnailUrl(videos[0].thumbnail_url || ""); + setVideoUrl(videos[0].file_url || videos[0].download_url || "") + setThumbnailUrl(videos[0].thumbnail_url || "") } } catch (err) { - console.error("[获取生成结果失败]", err); + console.error("[获取生成结果失败]", err) } } - message.success("视频生成完成!"); - return; + message.success("视频生成完成!") + return } if (data.plan_status === "failed") { - setGenerating(false); + setGenerating(false) // 提取后端返回的错误详情,便于排查 // 注意:后端返回的 error_message/error/message 可能是对象而非字符串 const rawMsg = data.error_message || data.error || data.message || - (data.clips || []).find( - (c: { status: string }) => c.status === "failed", - )?.error_message || - "视频生成失败,请联系管理员或重试"; + (data.clips || []).find((c: { status: string }) => c.status === "failed") + ?.error_message || + "视频生成失败,请联系管理员或重试" // 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等嵌套结构) const safeExtract = (val: unknown): string => { - if (typeof val === "string") return val; + if (typeof val === "string") return val if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record; - if (typeof obj.message === "string") return obj.message; - if (typeof obj.msg === "string") return obj.msg; - if (typeof obj.detail === "string") return obj.detail; - if (obj.message && typeof obj.message === "object") - return safeExtract(obj.message); - return JSON.stringify(val); + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail + if (obj.message && typeof obj.message === "object") return safeExtract(obj.message) + return JSON.stringify(val) } - return String(val ?? ""); - }; - const errorMsg = safeExtract(rawMsg); - console.error("[生成失败] planId:", plan.id, "响应:", data); - setGenerateError(errorMsg); - message.error(errorMsg); - return; + return String(val ?? "") + } + const errorMsg = safeExtract(rawMsg) + console.error("[生成失败] planId:", plan.id, "响应:", data) + setGenerateError(errorMsg) + 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)); + 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; + progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< + typeof setInterval + > } catch (pollErr) { // 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次 - console.error("[轮询出错] planId:", plan.id, pollErr); - progressTimer.current = setTimeout( - poll, - 3000, - ) as unknown as ReturnType; + console.error("[轮询出错] planId:", plan.id, pollErr) + progressTimer.current = setTimeout(poll, 3000) as unknown as ReturnType< + typeof setInterval + > } - }; + } - progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType< - typeof setInterval - >; + progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType } catch (err: unknown) { - console.error("[handleGenerate] 生成失败:", err); - setGenerating(false); + console.error("[handleGenerate] 生成失败:", err) + setGenerating(false) // 提取 axios 响应中的后端错误信息 const axiosErr = err as { response?: { data?: { - message?: string | object; - error?: string | object; - detail?: string | object; - msg?: string | object; - }; - }; - message?: string; - }; + message?: string | object + error?: string | object + detail?: string | object + msg?: string | object + } + } + message?: string + } // 安全提取错误消息:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等) const extractString = (val: unknown): string => { - if (typeof val === "string") return val; + if (typeof val === "string") return val if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record; - if (typeof obj.message === "string") return obj.message; - if (typeof obj.msg === "string") return obj.msg; - if (typeof obj.detail === "string") return obj.detail; + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail // 嵌套对象:递归提取 if (typeof obj.message === "object" && obj.message !== null) - return extractString(obj.message); - if (typeof obj.msg === "object" && obj.msg !== null) - return extractString(obj.msg); - return JSON.stringify(val); + return extractString(obj.message) + if (typeof obj.msg === "object" && obj.msg !== null) return extractString(obj.msg) + return JSON.stringify(val) } - return ""; - }; + return "" + } const backendMsg = extractString(axiosErr.response?.data?.message) || extractString(axiosErr.response?.data?.error) || extractString(axiosErr.response?.data?.detail) || extractString(axiosErr.response?.data?.msg) || axiosErr.message || - ""; - console.error( - "[handleGenerate] 错误信息:", - backendMsg, - "完整错误:", - axiosErr, - ); + "" + console.error("[handleGenerate] 错误信息:", backendMsg, "完整错误:", axiosErr) // 确保 errorMsg 一定是字符串(后端可能返回 {code, message} 嵌套对象) const safeExtractErr = (val: unknown): string => { - if (typeof val === "string") return val; + if (typeof val === "string") return val if (typeof val === "object" && val !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定 - const obj = val as Record; - if (typeof obj.message === "string") return obj.message; - if (typeof obj.msg === "string") return obj.msg; - if (typeof obj.detail === "string") return obj.detail; + const obj = val as Record + if (typeof obj.message === "string") return obj.message + if (typeof obj.msg === "string") return obj.msg + if (typeof obj.detail === "string") return obj.detail // 嵌套对象:递归提取 - if (typeof obj.message === "object") - return safeExtractErr(obj.message); - return JSON.stringify(val); + if (typeof obj.message === "object") return safeExtractErr(obj.message) + return JSON.stringify(val) } - return String(val ?? ""); - }; - const rawError = safeExtractErr(backendMsg); + return String(val ?? "") + } + const rawError = safeExtractErr(backendMsg) // 将技术错误翻译为用户友好提示(不暴露状态机、字段名等内部概念) const translateError = (msg: string): string => { - if (!msg) return "生成失败,请检查网络后重试或联系管理员"; + if (!msg) return "生成失败,请检查网络后重试或联系管理员" // 状态机相关错误 - if ( - msg.includes("editing") || - msg.includes("draft") || - msg.includes("状态") - ) { - return "正在准备生成,请稍候再试"; + if (msg.includes("editing") || msg.includes("draft") || msg.includes("状态")) { + return "正在准备生成,请稍候再试" } // 参数校验错误 - if ( - msg.includes("template_id") || - msg.includes("not found") || - msg.includes("不存在") - ) { - return "所选模板或素材不可用,请重新选择"; + if (msg.includes("template_id") || msg.includes("not found") || msg.includes("不存在")) { + return "所选模板或素材不可用,请重新选择" } - if ( - msg.includes("asset") && - (msg.includes("not found") || msg.includes("missing")) - ) { - return "素材数据异常,请返回素材库重新检查"; + if (msg.includes("asset") && (msg.includes("not found") || msg.includes("missing"))) { + return "素材数据异常,请返回素材库重新检查" } // 网络/超时 - if ( - msg.includes("timeout") || - msg.includes("network") || - msg.includes("ECONN") - ) { - return "网络连接超时,请检查网络后重试"; + if (msg.includes("timeout") || msg.includes("network") || msg.includes("ECONN")) { + return "网络连接超时,请检查网络后重试" } // 配额/限制 - if ( - msg.includes("quota") || - msg.includes("limit") || - msg.includes("exceed") - ) { - return "已达到生成次数上限,请稍后再试或联系客服"; + if (msg.includes("quota") || msg.includes("limit") || msg.includes("exceed")) { + return "已达到生成次数上限,请稍后再试或联系客服" } // 兜底:返回原始消息(如果已经是中文人话)或默认提示 - if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) - return msg; - return "生成失败,请稍后重试或联系管理员"; - }; - const finalMsg = translateError(rawError); - setGenerateError(finalMsg); - message.error(finalMsg); + if (msg.length > 0 && msg.length < 100 && !msg.includes("{")) return msg + return "生成失败,请稍后重试或联系管理员" + } + const finalMsg = translateError(rawError) + setGenerateError(finalMsg) + message.error(finalMsg) } }, [ title, @@ -739,91 +686,81 @@ const GeneratePage: React.FC = () => { selectedTemplate, generateCount, materialMode, - ]); + ]) /* ── 下载视频 ── */ const handleDownload = useCallback(async () => { - if (!generatedVideos.length) return; - const video = generatedVideos[0]; + if (!generatedVideos.length) return + const video = generatedVideos[0] try { // 优先使用 download_url(签名 URL),回退到 file_url - const url = video.download_url || video.file_url; + const url = video.download_url || video.file_url if (url) { - const a = document.createElement("a"); - a.href = url; - a.download = video.name || "generated-video.mp4"; - a.target = "_blank"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); + const a = document.createElement("a") + a.href = url + a.download = video.name || "generated-video.mp4" + a.target = "_blank" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) } } catch (err) { - console.error("[下载失败]", err); - message.error("下载失败,请重试"); + console.error("[下载失败]", err) + message.error("下载失败,请重试") } - }, [generatedVideos]); + }, [generatedVideos]) /* ── 分享视频 ── */ const handleShare = useCallback(async () => { - if (!generatedVideos.length) return; - const video = generatedVideos[0]; - const shareUrl = video.file_url || window.location.href; + if (!generatedVideos.length) return + const video = generatedVideos[0] + const shareUrl = video.file_url || window.location.href try { - await navigator.clipboard.writeText(shareUrl); - message.success("视频链接已复制到剪贴板"); + await navigator.clipboard.writeText(shareUrl) + message.success("视频链接已复制到剪贴板") } catch { // fallback: 显示 URL 让用户手动复制 - message.info(`视频链接: ${shareUrl}`); + message.info(`视频链接: ${shareUrl}`) } - }, [generatedVideos]); + }, [generatedVideos]) /* ── 步骤导航 ── */ const goNext = useCallback(() => { if (currentStep === 1 && !selectedTemplate) { - message.warning("请先选择一个模板"); - return; + message.warning("请先选择一个模板") + return } - if ( - currentStep === 2 && - materialMode === "manual" && - selectedMaterials.length === 0 - ) { - message.warning("请至少选择一个素材"); - return; + if (currentStep === 2 && materialMode === "manual" && selectedMaterials.length === 0) { + message.warning("请至少选择一个素材") + return } if (currentStep === 3 && !title.trim()) { - message.warning("请选择或输入标题"); - return; + message.warning("请选择或输入标题") + return } if (currentStep < 5) { - setCurrentStep((s) => s + 1); + setCurrentStep((s) => s + 1) } - }, [ - currentStep, - selectedTemplate, - selectedMaterials.length, - title, - materialMode, - ]); + }, [currentStep, selectedTemplate, selectedMaterials.length, title, materialMode]) const goPrev = useCallback(() => { if (currentStep > 1) { - setCurrentStep((s) => s - 1); + setCurrentStep((s) => s - 1) } - }, [currentStep]); + }, [currentStep]) /* ── 辅助 ── */ const getTemplateName = () => - userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择"; + userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择" const getVoiceName = () => { if (voiceMode === "clone") { - const cv = clonedVoices.find((v) => v.id === selectedClonedVoice); - return cv ? cv.name : "未选择"; + const cv = clonedVoices.find((v) => v.id === selectedClonedVoice) + return cv ? cv.name : "未选择" } - const pv = presetVoices.find((v) => v.voice_id === selectedVoice); - return pv ? pv.name : "未选择"; - }; + const pv = presetVoices.find((v) => v.voice_id === selectedVoice) + return pv ? pv.name : "未选择" + } /* ================================================================ 渲染 — 步骤内容 @@ -852,8 +789,8 @@ const GeneratePage: React.FC = () => { aria-pressed={selectedTemplate === tpl.id} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setSelectedTemplate(tpl.id); + e.preventDefault() + setSelectedTemplate(tpl.id) } }} > @@ -900,7 +837,7 @@ const GeneratePage: React.FC = () => { )} - ); + ) /** 步骤 2:选择素材(双模式:手动选择 / 自动匹配) */ const renderStep2 = () => ( @@ -928,10 +865,7 @@ const GeneratePage: React.FC = () => { {/* ── 素材库选择(两种模式共用) ── */}
- setSelectedLibraryId(e.target.value)}> {libraries.map((lib) => (
{/* 素材列表 */}
{materialsLoading ? ( - - 加载素材中… - + 加载素材中… ) : materials.length === 0 ? ( - + 暂无素材,请先在素材库中上传 ) : (
{materials.map((m) => { - const checked = selectedMaterials.includes(m.id); + const checked = selectedMaterials.includes(m.id) return ( - ); + ) })}
)} @@ -1039,8 +963,7 @@ const GeneratePage: React.FC = () => {

智能素材匹配

系统将根据所选模板和标题,从素材库中自动分析并匹配最合适的素材进行视频生成。 - 无需手动挑选,AI - 会综合素材质量、时长、内容相关性等维度进行智能筛选。 + 无需手动挑选,AI 会综合素材质量、时长、内容相关性等维度进行智能筛选。

📊 质量评分筛选 @@ -1072,7 +995,7 @@ const GeneratePage: React.FC = () => {
)}
- ); + ) /** 步骤 3:选择标题 */ const renderStep3 = () => ( @@ -1092,9 +1015,7 @@ const GeneratePage: React.FC = () => { value: t.content, }))} filterOption={(input, option) => - ((option?.label as string) || "") - .toLowerCase() - .includes(input.toLowerCase()) + ((option?.label as string) || "").toLowerCase().includes(input.toLowerCase()) } notFoundContent={ userTitles.length === 0 ? ( @@ -1115,14 +1036,12 @@ const GeneratePage: React.FC = () => { /> {userTitles.length === 0 && ( -

+

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

)} - ); + ) /** 步骤 4:选择配音 */ const renderStep4 = () => ( @@ -1136,26 +1055,22 @@ const GeneratePage: React.FC = () => { key={v.voice_id} className={`xx-voice-choice-item ${voiceMode === "preset" && selectedVoice === v.voice_id ? "selected" : ""}`} onClick={() => { - setVoiceMode("preset"); - setSelectedVoice(v.voice_id); + setVoiceMode("preset") + setSelectedVoice(v.voice_id) }} role="button" tabIndex={0} - aria-pressed={ - voiceMode === "preset" && selectedVoice === v.voice_id - } + aria-pressed={voiceMode === "preset" && selectedVoice === v.voice_id} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setVoiceMode("preset"); - setSelectedVoice(v.voice_id); + e.preventDefault() + setVoiceMode("preset") + setSelectedVoice(v.voice_id) } }} > -
- {VOICE_GENDER_ICON[v.gender] ?? "✨"} -
+
{VOICE_GENDER_ICON[v.gender] ?? "✨"}

{v.name}

{v.description}

@@ -1179,8 +1094,8 @@ const GeneratePage: React.FC = () => { aria-pressed={voiceMode === "clone"} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - setVoiceMode("clone"); + e.preventDefault() + setVoiceMode("clone") } }} > @@ -1203,10 +1118,7 @@ const GeneratePage: React.FC = () => {
- setSelectedVoice(e.target.value)}> {presetVoicesLoading ? ( @@ -1277,8 +1189,7 @@ const GeneratePage: React.FC = () => { disabled={!customVoiceText.trim() || synthesizeMutation.isPending} onClick={handleSynthesizeVoice} > - {" "} - {synthesizeMutation.isPending ? "合成中…" : "合成语音"} + {synthesizeMutation.isPending ? "合成中…" : "合成语音"}
{ttsError && ( @@ -1301,9 +1212,7 @@ const GeneratePage: React.FC = () => { gap: 12, }} > - - ✓ 语音合成完成 - + ✓ 语音合成完成
@@ -1355,20 +1255,16 @@ const GeneratePage: React.FC = () => {
{saveTagIds.map((id) => { - const tag = allTags.find((t) => t.id === id); + const tag = allTags.find((t) => t.id === id) return tag ? ( {tag.name} - setSaveTagIds((prev) => - prev.filter((x) => x !== id), - ) - } + onClick={() => setSaveTagIds((prev) => prev.filter((x) => x !== id))} /> - ) : null; + ) : null })} { onChange={(e) => setSaveNewTag(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { - e.preventDefault(); - handleAddTagInModal(saveNewTag); + e.preventDefault() + handleAddTagInModal(saveNewTag) } }} /> @@ -1392,24 +1288,17 @@ const GeneratePage: React.FC = () => { ))}
)}
-
)} @@ -1534,7 +1417,7 @@ const GeneratePage: React.FC = () => { )} - ); + ) /** 步骤 5:确认生成 */ const renderStep5 = () => ( @@ -1548,9 +1431,7 @@ const GeneratePage: React.FC = () => {
素材 - {materialMode === "auto" - ? "自动匹配" - : `${selectedMaterials.length} 个素材`} + {materialMode === "auto" ? "自动匹配" : `${selectedMaterials.length} 个素材`}
@@ -1617,10 +1498,7 @@ const GeneratePage: React.FC = () => { >
- + 视频生成完成! @@ -1650,10 +1528,7 @@ const GeneratePage: React.FC = () => { }} />
- + 生成失败 @@ -1667,25 +1542,25 @@ const GeneratePage: React.FC = () => {
)}
- ); + ) /** 渲染当前步骤 */ const renderCurrentStep = () => { switch (currentStep) { case 1: - return renderStep1(); + return renderStep1() case 2: - return renderStep2(); + return renderStep2() case 3: - return renderStep3(); + return renderStep3() case 4: - return renderStep4(); + return renderStep4() case 5: - return renderStep5(); + return renderStep5() default: - return null; + return null } - }; + } /* ================================================================ 渲染 — 主页面 @@ -1721,15 +1596,11 @@ const GeneratePage: React.FC = () => { {/* ── 步骤条 ── */}
{STEPS.map((step, idx) => { - const isActive = currentStep === step.key; - const isDone = currentStep > step.key; - const cls = [ - "xx-step-item", - isActive ? "active" : "", - isDone ? "done" : "", - ] + const isActive = currentStep === step.key + const isDone = currentStep > step.key + const cls = ["xx-step-item", isActive ? "active" : "", isDone ? "done" : ""] .filter(Boolean) - .join(" "); + .join(" ") return ( {idx > 0 && } @@ -1737,7 +1608,7 @@ const GeneratePage: React.FC = () => { className={cls} onClick={() => { // 允许点击已完成的步骤回退 - if (isDone) setCurrentStep(step.key); + if (isDone) setCurrentStep(step.key) }} role="button" tabIndex={0} @@ -1746,7 +1617,7 @@ const GeneratePage: React.FC = () => { {step.label}
- ); + ) })}
@@ -1759,11 +1630,7 @@ const GeneratePage: React.FC = () => { {/* 底部操作按钮 */}
- {currentStep < 5 ? ( @@ -1820,9 +1687,7 @@ const GeneratePage: React.FC = () => {
{/* 字幕预览 */} -
- {title || "3秒抓住注意力,30秒讲清卖点"} -
+
{title || "3秒抓住注意力,30秒讲清卖点"}
{/* 时间线标题 */}
剪辑计划预览
@@ -1844,11 +1709,7 @@ const GeneratePage: React.FC = () => { {/* 生成操作按钮 */}
- -
@@ -1888,7 +1746,7 @@ const GeneratePage: React.FC = () => { onSuccess={handleCloneSuccess} /> - ); -}; + ) +} -export default GeneratePage; +export default GeneratePage diff --git a/apps/web/src/pages/generate/generate.css b/apps/web/src/pages/generate/generate.css index 75c8c826c..ed067a456 100644 --- a/apps/web/src/pages/generate/generate.css +++ b/apps/web/src/pages/generate/generate.css @@ -408,30 +408,18 @@ } .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); } @@ -482,11 +470,7 @@ .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; } @@ -629,11 +613,7 @@ 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); @@ -647,11 +627,7 @@ 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; } @@ -774,11 +750,7 @@ .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/history/TaskHistory.tsx b/apps/web/src/pages/history/TaskHistory.tsx index 5d90a5643..def38c9b8 100644 --- a/apps/web/src/pages/history/TaskHistory.tsx +++ b/apps/web/src/pages/history/TaskHistory.tsx @@ -3,23 +3,23 @@ * 页面头部 + 圆角胶囊 Tab 筛选(含计数)+ 卡片式任务列表 + 分页 + 空状态 * 使用 useQuery 对接后端真实 API(api/tasks.ts) */ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui"; -import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks"; -import "./history.css"; +import React, { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Button } from "@/components/ui" +import { getUserTasks, retryTask, type TaskItem } from "@/api/tasks" +import "./history.css" /* ============================================================ * 类型 & 常量 * ============================================================ */ -type TaskStatus = "completed" | "processing" | "pending" | "failed"; +type TaskStatus = "completed" | "processing" | "pending" | "failed" const statusLabel: Record = { completed: "已完成", processing: "进行中", pending: "排队中", failed: "失败", -}; +} /** 将后端 status 字符串映射为前端 TaskStatus */ const normalizeStatus = (s: string): TaskStatus => { @@ -33,26 +33,26 @@ const normalizeStatus = (s: string): TaskStatus => { queued: "pending", failed: "failed", error: "failed", - }; - return map[s] ?? "pending"; -}; + } + return map[s] ?? "pending" +} /** 格式化日期 */ const formatDate = (iso?: string | null): string => { - if (!iso) return "—"; - const d = new Date(iso); - if (isNaN(d.getTime())) return "—"; - const pad = (n: number) => String(n).padStart(2, "0"); - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; -}; + if (!iso) return "—" + const d = new Date(iso) + if (isNaN(d.getTime())) return "—" + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` +} /* ============================================================ * Tab 配置 * ============================================================ */ interface TabConfig { - key: string; - label: string; - statusFilter?: TaskStatus; + key: string + label: string + statusFilter?: TaskStatus } const tabs: TabConfig[] = [ @@ -60,20 +60,20 @@ const tabs: TabConfig[] = [ { key: "processing", label: "进行中", statusFilter: "processing" }, { key: "completed", label: "已完成", statusFilter: "completed" }, { key: "failed", label: "失败", statusFilter: "failed" }, -]; +] /* ============================================================ * 分页配置 * ============================================================ */ -const PAGE_SIZE = 10; +const PAGE_SIZE = 10 /* ============================================================ * 组件 * ============================================================ */ const TaskHistory: React.FC = () => { - const [activeTab, setActiveTab] = useState("all"); - const [currentPage, setCurrentPage] = useState(1); - const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("all") + const [currentPage, setCurrentPage] = useState(1) + const queryClient = useQueryClient() // ── 获取任务列表 ── const { @@ -86,15 +86,15 @@ const TaskHistory: React.FC = () => { queryKey: ["tasks"], queryFn: getUserTasks, staleTime: 30_000, - }); + }) // ── 重试任务 mutation ── const retryMutation = useMutation({ mutationFn: retryTask, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }) }, - }); + }) // 将后端数据映射为页面展示用的结构 const mappedTasks = tasks.map((t) => ({ @@ -108,16 +108,16 @@ const TaskHistory: React.FC = () => { progress: t.progress, retryable: t.retryable, errorMessage: t.error_message, - })); + })) // 获取当前 Tab 的筛选状态 - const currentTab = tabs.find((t) => t.key === activeTab); - const statusFilter = currentTab?.statusFilter; + const currentTab = tabs.find((t) => t.key === activeTab) + const statusFilter = currentTab?.statusFilter // 过滤任务 const filteredTasks = statusFilter ? mappedTasks.filter((t) => t.status === statusFilter) - : mappedTasks; + : mappedTasks // 计算各 Tab 的数量 const tabCounts: Record = { @@ -125,31 +125,28 @@ const TaskHistory: React.FC = () => { processing: mappedTasks.filter((t) => t.status === "processing").length, completed: mappedTasks.filter((t) => t.status === "completed").length, failed: mappedTasks.filter((t) => t.status === "failed").length, - }; + } // 分页 - const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE); - const paginatedTasks = filteredTasks.slice( - (currentPage - 1) * PAGE_SIZE, - currentPage * PAGE_SIZE, - ); + const totalPages = Math.ceil(filteredTasks.length / PAGE_SIZE) + const paginatedTasks = filteredTasks.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) // 切换 Tab 时重置页码 const handleTabChange = (key: string) => { - setActiveTab(key); - setCurrentPage(1); - }; + setActiveTab(key) + setCurrentPage(1) + } // 重试任务 const handleRetry = (taskId: string) => { - retryMutation.mutate(taskId); - }; + retryMutation.mutate(taskId) + } // 查看任务详情 const handleView = (taskId: string) => { // TODO: 跳转到任务详情页(待路由实现) - console.log("查看任务:", taskId); - }; + console.log("查看任务:", taskId) + } // ── Loading 状态 ── if (isLoading) { @@ -164,7 +161,7 @@ const TaskHistory: React.FC = () => {

加载中...

- ); + ) } // ── Error 状态 ── @@ -179,16 +176,12 @@ const TaskHistory: React.FC = () => {

加载失败

{error?.message || "网络异常,请稍后重试"}

- - ); + ) } return ( @@ -218,11 +211,7 @@ const TaskHistory: React.FC = () => {
📭

暂无任务记录

-

- {activeTab === "all" - ? "点击上方按钮开始创建任务" - : "当前分类下没有任务"} -

+

{activeTab === "all" ? "点击上方按钮开始创建任务" : "当前分类下没有任务"}

) : (
@@ -237,9 +226,7 @@ const TaskHistory: React.FC = () => {
{/* 状态标签 */} - + {statusLabel[task.status]} @@ -269,11 +256,7 @@ const TaskHistory: React.FC = () => { {retryMutation.isPending ? "重试中..." : "重试"} ) : ( - )} @@ -309,13 +292,11 @@ const TaskHistory: React.FC = () => { > › - - 共 {filteredTasks.length} 条 - + 共 {filteredTasks.length} 条 )} - ); -}; + ) +} -export default TaskHistory; +export default TaskHistory diff --git a/apps/web/src/pages/home/HomePage.tsx b/apps/web/src/pages/home/HomePage.tsx index a77c83c36..921ac054b 100644 --- a/apps/web/src/pages/home/HomePage.tsx +++ b/apps/web/src/pages/home/HomePage.tsx @@ -6,16 +6,16 @@ * * 零 antd 直接导入,全部使用 CSS 变量 */ -import React from "react"; -import { useNavigate } from "react-router-dom"; -import { Button } from "@/components/ui"; -import { useAuthStore } from "@/store/authStore"; -import "./home-page.css"; +import React from "react" +import { useNavigate } from "react-router-dom" +import { Button } from "@/components/ui" +import { useAuthStore } from "@/store/authStore" +import "./home-page.css" /* ── HeroSection ─────────────────────────────────────────── */ const HeroSection: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() return (
@@ -33,18 +33,10 @@ const HeroSection: React.FC = () => { 秒内将长视频转化为适合各平台传播的精品短视频。

- -
@@ -56,11 +48,7 @@ const HeroSection: React.FC = () => {
🎬
- @@ -74,8 +62,8 @@ const HeroSection: React.FC = () => {
- ); -}; + ) +} /* ── FeatureSection ──────────────────────────────────────── */ @@ -100,16 +88,14 @@ const FEATURES = [ title: "多平台一键发布", desc: "支持抖音、快手、小红书、微信视频号等主流平台,一键同步发布。", }, -]; +] const FeatureSection: React.FC = () => { return (

核心功能

-

- 从素材上传到视频发布,全流程 AI 赋能,让短视频创作更简单 -

+

从素材上传到视频发布,全流程 AI 赋能,让短视频创作更简单

{FEATURES.map((f) => (
@@ -121,8 +107,8 @@ const FeatureSection: React.FC = () => {
- ); -}; + ) +} /* ── WorkflowSection ─────────────────────────────────────── */ @@ -131,16 +117,14 @@ const STEPS = [ { icon: "🧠", title: "AI 处理", desc: "AI 自动分析、剪辑、配音、加字幕" }, { icon: "👀", title: "预览调整", desc: "在线预览生成结果,支持微调编辑" }, { icon: "🚀", title: "一键发布", desc: "多平台同步发布,追踪数据表现" }, -]; +] const WorkflowSection: React.FC = () => { return (

工作流程

-

- 四步完成短视频创作,从素材到发布仅需 30 秒 -

+

四步完成短视频创作,从素材到发布仅需 30 秒

{STEPS.map((step, idx) => (
@@ -154,8 +138,8 @@ const WorkflowSection: React.FC = () => {
- ); -}; + ) +} /* ── PricingSection ──────────────────────────────────────── */ @@ -165,12 +149,7 @@ const PLANS = [ price: "免费", period: "", desc: "适合个人体验,快速上手", - features: [ - "每月 5 次 AI 生成", - "720p 视频导出", - "基础模板库", - "1 个平台账号绑定", - ], + features: ["每月 5 次 AI 生成", "720p 视频导出", "基础模板库", "1 个平台账号绑定"], highlighted: false, cta: "免费开始", }, @@ -207,10 +186,10 @@ const PLANS = [ highlighted: false, cta: "联系销售", }, -]; +] const PricingSection: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() return (
@@ -227,9 +206,7 @@ const PricingSection: React.FC = () => {

{plan.name}

{plan.price} - {plan.period && ( - {plan.period} - )} + {plan.period && {plan.period}}

{plan.desc}

    @@ -251,72 +228,54 @@ const PricingSection: React.FC = () => {
- ); -}; + ) +} /* ── CTASection ──────────────────────────────────────────── */ const CTASection: React.FC = () => { - const navigate = useNavigate(); + const navigate = useNavigate() return (

开始用 AI 创作短视频

-

- 免费注册,立即体验 AI 智能视频创作。无需信用卡,零风险上手。 -

-
- ); -}; + ) +} /* ── 主页面 ─────────────────────────────────────────────── */ const HomePage: React.FC = () => { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const navigate = useNavigate(); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const navigate = useNavigate() // 已登录用户自动跳转到 dashboard React.useEffect(() => { if (isAuthenticated) { - navigate("/app/dashboard", { replace: true }); + navigate("/app/dashboard", { replace: true }) } - }, [isAuthenticated, navigate]); + }, [isAuthenticated, navigate]) return (
{/* 顶部导航栏 */}
-
- -
@@ -335,7 +294,7 @@ const HomePage: React.FC = () => {

© 2026 小虾智剪 · AI智能视频创作平台

- ); -}; + ) +} -export default HomePage; +export default HomePage diff --git a/apps/web/src/pages/home/home-page.css b/apps/web/src/pages/home/home-page.css index cafa7bbc2..d8b37881b 100755 --- a/apps/web/src/pages/home/home-page.css +++ b/apps/web/src/pages/home/home-page.css @@ -13,8 +13,7 @@ min-height: 100vh; background: var(--bg-secondary); color: var(--text-primary); - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow-x: hidden; } @@ -148,8 +147,7 @@ display: flex; align-items: center; justify-content: center; - box-shadow: 0 20px 60px - color-mix(in srgb, var(--primary-color) 18%, transparent); + box-shadow: 0 20px 60px color-mix(in srgb, var(--primary-color) 18%, transparent); } .hp-hero-video-inner { @@ -407,8 +405,7 @@ .hp-pricing-card--highlight { border-color: var(--primary, var(--primary-color)); - box-shadow: 0 8px 32px - color-mix(in srgb, var(--primary-color) 12%, transparent); + box-shadow: 0 8px 32px color-mix(in srgb, var(--primary-color) 12%, transparent); } .hp-pricing-badge { diff --git a/apps/web/src/pages/my-templates/MyTemplates.tsx b/apps/web/src/pages/my-templates/MyTemplates.tsx index 6af1503e6..1a579d3af 100644 --- a/apps/web/src/pages/my-templates/MyTemplates.tsx +++ b/apps/web/src/pages/my-templates/MyTemplates.tsx @@ -3,8 +3,8 @@ * 卡片视图展示用户已保存的剪辑模板 * 支持搜索、分类筛选、编辑/复制/删除/使用模板生成 */ -import React, { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import React, { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { Typography, Card, @@ -20,7 +20,7 @@ import { Popconfirm, Row, Col, -} from "antd"; +} from "antd" import { SearchOutlined, EditOutlined, @@ -29,8 +29,8 @@ import { VideoCameraOutlined, AppstoreOutlined, PlusOutlined, -} from "@ant-design/icons"; -import { useNavigate } from "react-router-dom"; +} from "@ant-design/icons" +import { useNavigate } from "react-router-dom" import { getEditingTemplates, getTemplateCategories, @@ -40,17 +40,17 @@ import { MODE_COLORS, type EditingTemplate, type TemplateMode, -} from "@/api/editingPlanner"; -import "./MyTemplates.css"; +} from "@/api/editingPlanner" +import "./MyTemplates.css" -const { Title, Text } = Typography; +const { Title, Text } = Typography const MyTemplates: React.FC = () => { - const navigate = useNavigate(); - const queryClient = useQueryClient(); + const navigate = useNavigate() + const queryClient = useQueryClient() - const [searchText, setSearchText] = useState(""); - const [filterCategory, setFilterCategory] = useState(""); + const [searchText, setSearchText] = useState("") + const [filterCategory, setFilterCategory] = useState("") /* ── 数据查询 ── */ const { data: templates = [], isLoading } = useQuery({ @@ -60,25 +60,24 @@ const MyTemplates: React.FC = () => { category: filterCategory || undefined, tag: searchText || undefined, }), - }); + }) const { data: categories = [] } = useQuery({ queryKey: ["template-categories"], queryFn: getTemplateCategories, - }); + }) /* ── Mutations ── */ const deleteMutation = useMutation({ mutationFn: deleteEditingTemplate, onSuccess: () => { - message.success("模板已删除"); - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); + message.success("模板已删除") + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) }, onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("删除失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("删除失败") }, - }); + }) const copyMutation = useMutation({ mutationFn: (tpl: EditingTemplate) => @@ -93,39 +92,35 @@ const MyTemplates: React.FC = () => { estimated_duration: tpl.estimated_duration ?? Math.round( - tpl.segments.reduce( - (s, seg) => s + (seg.duration_min + seg.duration_max) / 2, - 0, - ), + tpl.segments.reduce((s, seg) => s + (seg.duration_min + seg.duration_max) / 2, 0), ), segments: tpl.segments.map(({ id: _id, ...rest }) => rest), }), onSuccess: () => { - message.success("模板已复制"); - queryClient.invalidateQueries({ queryKey: ["editing-templates"] }); + message.success("模板已复制") + queryClient.invalidateQueries({ queryKey: ["editing-templates"] }) }, onError: (err: unknown) => { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("复制失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("复制失败") }, - }); + }) /* ── 操作 ── */ const handleEdit = (tpl: EditingTemplate) => { - navigate(`/editing-planner?template=${tpl.id}`); - }; + navigate(`/editing-planner?template=${tpl.id}`) + } const handleGenerate = (tpl: EditingTemplate) => { - navigate(`/editing-planner?template=${tpl.id}&generate=1`); - }; + navigate(`/editing-planner?template=${tpl.id}&generate=1`) + } const handleCopy = (tpl: EditingTemplate) => { - copyMutation.mutate(tpl); - }; + copyMutation.mutate(tpl) + } const handleDelete = (id: string) => { - deleteMutation.mutate(id); - }; + deleteMutation.mutate(id) + } return (
@@ -174,14 +169,8 @@ const MyTemplates: React.FC = () => {
) : templates.length === 0 ? ( - - @@ -200,9 +189,7 @@ const MyTemplates: React.FC = () => { handleCopy(tpl)} /> , - handleGenerate(tpl)} - /> + handleGenerate(tpl)} /> , { {tpl.name} - + {MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} 用户自制 @@ -231,13 +216,10 @@ const MyTemplates: React.FC = () => {
- {tpl.segments.length} 片段 · 预估 ~ - {tpl.estimated_duration}s + {tpl.segments.length} 片段 · 预估 ~{tpl.estimated_duration}s {tpl.category && ( - - {tpl.category} - + {tpl.category} )}
@@ -253,12 +235,8 @@ const MyTemplates: React.FC = () => {
- {tpl.title_config.ai_auto_select && ( - AI标题 - )} - {tpl.subtitle_config.enabled && ( - 字幕 - )} + {tpl.title_config.ai_auto_select && AI标题} + {tpl.subtitle_config.enabled && 字幕} {tpl.bgm_config.enabled && BGM}
@@ -269,7 +247,7 @@ const MyTemplates: React.FC = () => { )}
- ); -}; + ) +} -export default MyTemplates; +export default MyTemplates diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index 822032a11..cb3f42c98 100644 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -4,8 +4,8 @@ * 功能:克隆音色卡片列表、试听播放、状态标签、删除、编辑名称、空状态引导 * 使用 useCloneProgress hook 实现 processing 状态自动轮询 */ -import React, { useState, useCallback, useRef } from "react"; -import { useNavigate } from "react-router-dom"; +import React, { useState, useCallback, useRef } from "react" +import { useNavigate } from "react-router-dom" import { PlayCircleOutlined, PauseCircleOutlined, @@ -14,62 +14,55 @@ import { PlusOutlined, SoundOutlined, 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 { - deleteVoiceClone, - updateVoiceClone, - formatDuration, -} from "@/api/voiceClone"; -import type { VoiceClone, VoiceCloneStatus } from "@/api/voiceClone"; -import "./my-voices.css"; +} 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 { deleteVoiceClone, updateVoiceClone, formatDuration } from "@/api/voiceClone" +import type { VoiceClone, VoiceCloneStatus } from "@/api/voiceClone" +import "./my-voices.css" /* ============================================================ * 工具函数 * ============================================================ */ function formatDate(isoStr: string): string { - const d = new Date(isoStr); + const d = new Date(isoStr) return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", - }); + }) } /* ============================================================ * 状态配置 * ============================================================ */ -const STATUS_CONFIG: Record< - VoiceCloneStatus, - { label: string; dotClass: string } -> = { +const STATUS_CONFIG: Record = { ready: { label: "就绪", dotClass: "xx-mv-status-dot--ready" }, processing: { label: "克隆中", dotClass: "xx-mv-status-dot--processing" }, failed: { label: "失败", dotClass: "xx-mv-status-dot--failed" }, -}; +} /* ============================================================ * Toast 组件 * ============================================================ */ interface ToastItem { - id: number; - message: string; - type: "success" | "error"; + id: number + message: string + type: "success" | "error" } -let _toastId = 0; +let _toastId = 0 /* ============================================================ * 音色卡片组件 * ============================================================ */ interface VoiceCardProps { - voice: VoiceClone; - isPlaying: boolean; - onTogglePlay: (voice: VoiceClone) => void; - onEdit: (voice: VoiceClone) => void; - onDelete: (voice: VoiceClone) => void; + voice: VoiceClone + isPlaying: boolean + onTogglePlay: (voice: VoiceClone) => void + onEdit: (voice: VoiceClone) => void + onDelete: (voice: VoiceClone) => void } const VoiceCard: React.FC = ({ @@ -79,8 +72,8 @@ const VoiceCard: React.FC = ({ onEdit, onDelete, }) => { - const statusCfg = STATUS_CONFIG[voice.status]; - const isReady = voice.status === "ready"; + const statusCfg = STATUS_CONFIG[voice.status] + const isReady = voice.status === "ready" return (
@@ -104,9 +97,7 @@ const VoiceCard: React.FC = ({ {formatDate(voice.created_at)} {voice.duration_seconds > 0 && ( - - {formatDuration(voice.duration_seconds)} - + {formatDuration(voice.duration_seconds)} )}
@@ -168,117 +159,108 @@ const VoiceCard: React.FC = ({ - ); -}; + ) +} /* ============================================================ * 主页面组件 * ============================================================ */ const MyVoices: React.FC = () => { - const navigate = useNavigate(); - const { clones, loading, removeClone, updateClone, hasProcessing } = - useCloneProgress(); - const [playingId, setPlayingId] = useState(null); - const [toasts, setToasts] = useState([]); - const [editModalOpen, setEditModalOpen] = useState(false); - const [editingVoice, setEditingVoice] = useState(null); - const [editName, setEditName] = useState(""); - const [deleteConfirmId, setDeleteConfirmId] = useState(null); - const audioRef = useRef(null); + const navigate = useNavigate() + const { clones, loading, removeClone, updateClone, hasProcessing } = useCloneProgress() + const [playingId, setPlayingId] = useState(null) + const [toasts, setToasts] = useState([]) + const [editModalOpen, setEditModalOpen] = useState(false) + const [editingVoice, setEditingVoice] = useState(null) + const [editName, setEditName] = useState("") + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + const audioRef = useRef(null) // Toast const showToast = useCallback((message: string, type: ToastItem["type"]) => { - const id = ++_toastId; - setToasts((prev) => [...prev, { id, message, type }]); - setTimeout( - () => setToasts((prev) => prev.filter((t) => t.id !== id)), - 3000, - ); - }, []); + const id = ++_toastId + setToasts((prev) => [...prev, { id, message, type }]) + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000) + }, []) // 试听播放 const handleTogglePlay = useCallback( (voice: VoiceClone) => { if (playingId === voice.id) { - audioRef.current?.pause(); - setPlayingId(null); - return; + audioRef.current?.pause() + setPlayingId(null) + return } if (audioRef.current) { - audioRef.current.pause(); + audioRef.current.pause() } if (!voice.sample_url) { - showToast("暂无试听音频", "error"); - return; + showToast("暂无试听音频", "error") + return } - const audio = new Audio(voice.sample_url); - audioRef.current = audio; - audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")); - audio.onended = () => setPlayingId(null); - setPlayingId(voice.id); + const audio = new Audio(voice.sample_url) + audioRef.current = audio + audio.play().catch(() => showToast("播放失败,请检查音频文件", "error")) + audio.onended = () => setPlayingId(null) + setPlayingId(voice.id) }, [playingId, showToast], - ); + ) // 编辑 const handleEdit = (voice: VoiceClone) => { - setEditingVoice(voice); - setEditName(voice.name); - setEditModalOpen(true); - }; + setEditingVoice(voice) + setEditName(voice.name) + setEditModalOpen(true) + } const handleEditConfirm = async () => { - if (!editingVoice || !editName.trim()) return; + if (!editingVoice || !editName.trim()) return try { const updated = await updateVoiceClone(editingVoice.id, { name: editName.trim(), - }); - updateClone(updated); - setEditModalOpen(false); - setEditingVoice(null); - showToast("名称已更新", "success"); + }) + updateClone(updated) + setEditModalOpen(false) + setEditingVoice(null) + showToast("名称已更新", "success") } catch { - showToast("更新失败,请重试", "error"); + showToast("更新失败,请重试", "error") } - }; + } // 删除 const handleDelete = (voice: VoiceClone) => { - setDeleteConfirmId(voice.id); - }; + setDeleteConfirmId(voice.id) + } const handleDeleteConfirm = async () => { - if (!deleteConfirmId) return; + if (!deleteConfirmId) return try { - await deleteVoiceClone(deleteConfirmId); - removeClone(deleteConfirmId); - setDeleteConfirmId(null); - showToast("音色已删除", "success"); + await deleteVoiceClone(deleteConfirmId) + removeClone(deleteConfirmId) + setDeleteConfirmId(null) + showToast("音色已删除", "success") } catch { - showToast("删除失败,请重试", "error"); + showToast("删除失败,请重试", "error") } - }; + } // 克隆新音色 const handleCloneNew = () => { - navigate("/app/voices"); - }; + navigate("/app/voices") + } // 统计 - const readyCount = clones.filter((v) => v.status === "ready").length; - const processingCount = clones.filter( - (v) => v.status === "processing", - ).length; + const readyCount = clones.filter((v) => v.status === "ready").length + const processingCount = clones.filter((v) => v.status === "processing").length return (
克隆新音色 @@ -342,9 +324,7 @@ const MyVoices: React.FC = () => {
🎤

还没有克隆音色

-

- 上传你的声音样本,AI 将克隆生成你的专属音色 -

+

上传你的声音样本,AI 将克隆生成你的专属音色

@@ -362,13 +342,11 @@ const MyVoices: React.FC = () => { > ) => - setEditName(e.target.value) - } + onChange={(e: React.ChangeEvent) => setEditName(e.target.value)} placeholder="输入音色名称" autoFocus onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === "Enter") handleEditConfirm(); + if (e.key === "Enter") handleEditConfirm() }} /> @@ -397,7 +375,7 @@ const MyVoices: React.FC = () => {
)}
- ); -}; + ) +} -export default MyVoices; +export default MyVoices diff --git a/apps/web/src/pages/my-voices/my-voices.css b/apps/web/src/pages/my-voices/my-voices.css index d24d55155..7ad6506f6 100644 --- a/apps/web/src/pages/my-voices/my-voices.css +++ b/apps/web/src/pages/my-voices/my-voices.css @@ -107,8 +107,7 @@ .xx-mv-card:hover { border-color: var(--color-primary-400, #818cf8); - box-shadow: 0 4px 20px - color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent); + box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color, #4f46e5) 8%, transparent); } .xx-mv-card--processing { @@ -138,19 +137,11 @@ font-size: 20px; color: var(--text-inverse, #fff); flex-shrink: 0; - background: linear-gradient( - 135deg, - var(--secondary-color), - var(--color-secondary-700, #059669) - ); + background: linear-gradient(135deg, var(--secondary-color), var(--color-secondary-700, #059669)); } .xx-mv-card-avatar--processing { - background: linear-gradient( - 135deg, - var(--accent-color), - var(--accent-dark, #d97706) - ); + background: linear-gradient(135deg, var(--accent-color), var(--accent-dark, #d97706)); animation: xx-mv-pulse 2s ease-in-out infinite; } diff --git a/apps/web/src/pages/products/ProductDetail.tsx b/apps/web/src/pages/products/ProductDetail.tsx index f722af8b5..a83ae9719 100644 --- a/apps/web/src/pages/products/ProductDetail.tsx +++ b/apps/web/src/pages/products/ProductDetail.tsx @@ -3,9 +3,9 @@ * 路由:/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 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, @@ -18,15 +18,15 @@ import { ExpandOutlined, LoadingOutlined, WarningOutlined, -} from "@ant-design/icons"; +} from "@ant-design/icons" import { getProduct, deleteProduct, getProductDownloadUrl, type ProductItem, -} from "../../api/products"; -import { Button } from "../../components/ui"; -import "./products.css"; +} from "../../api/products" +import { Button } from "../../components/ui" +import "./products.css" /* ============================================================ * 工具函数 @@ -34,31 +34,31 @@ 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")}`; -}; + 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`; -}; + 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); + 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 = { @@ -66,15 +66,15 @@ const STATUS_MAP: Record = { 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 { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const queryClient = useQueryClient() /* ── 获取产品详情 ── */ const { @@ -87,190 +87,179 @@ const ProductDetail: React.FC = () => { queryFn: () => getProduct(id!), enabled: !!id, staleTime: 10_000, - }); + }) /* ── 删除 mutation ── */ const deleteMutation = useMutation({ mutationFn: () => deleteProduct(id!), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["products"] }); - navigate("/app/products"); + queryClient.invalidateQueries({ queryKey: ["products"] }) + navigate("/app/products") }, - }); + }) /* ── 视频播放器状态 ── */ - const videoRef = useRef(null); - const progressRef = useRef(null); - const hideTimerRef = useRef>(); + 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 [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); + setShowControls(true) + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) if (isPlaying) { - hideTimerRef.current = setTimeout(() => setShowControls(false), 3000); + hideTimerRef.current = setTimeout(() => setShowControls(false), 3000) } - }, [isPlaying]); + }, [isPlaying]) /* ── 播放控制 ── */ const togglePlay = useCallback(() => { - const v = videoRef.current; - if (!v) return; + const v = videoRef.current + if (!v) return if (v.paused) { - v.play().catch(() => {}); + v.play().catch(() => {}) } else { - v.pause(); + 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; + 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 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; + const v = videoRef.current + if (!v) return if (isMuted) { - v.muted = false; - v.volume = volume || 1; - setIsMuted(false); + v.muted = false + v.volume = volume || 1 + setIsMuted(false) } else { - v.muted = true; - setIsMuted(true); + v.muted = true + setIsMuted(true) } - }, [isMuted, volume]); + }, [isMuted, volume]) const toggleFullscreen = useCallback(() => { - const el = containerRef.current; - if (!el) return; + const el = containerRef.current + if (!el) return if (!document.fullscreenElement) { - el.requestFullscreen?.().catch(() => {}); + el.requestFullscreen?.().catch(() => {}) } else { - document.exitFullscreen?.().catch(() => {}); + document.exitFullscreen?.().catch(() => {}) } - }, []); + }, []) /* ── 视频事件监听 ── */ useEffect(() => { - const v = videoRef.current; - if (!v) return; + 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 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)); + setBuffered(v.buffered.end(v.buffered.length - 1)) } - }; - const onEnded = () => setIsPlaying(false); - const onFSChange = () => setIsFullscreen(!!document.fullscreenElement); + } + 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); + 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); - }; - }, []); + 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(); + resetHideTimer() return () => { - if (hideTimerRef.current) clearTimeout(hideTimerRef.current); - }; - }, [isPlaying, resetHideTimer]); + if (hideTimerRef.current) clearTimeout(hideTimerRef.current) + } + }, [isPlaying, resetHideTimer]) /* ── 下载 ── */ const handleDownload = async () => { - if (!product || product.status !== "completed") return; + 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(); + 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}`; + 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 (!id) return + deleteMutation.mutate() + } /* ── 加载状态 ── */ if (isLoading) { return (
- -

- 加载中… -

+ +

加载中…

- ); + ) } /* ── 错误状态 ── */ @@ -278,38 +267,29 @@ const ProductDetail: React.FC = () => { 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); + } + const progress = Math.round((currentTime / (duration || 1)) * 100) + const bufferedPct = Math.round((buffered / (duration || 1)) * 100) return (
{/* ── 顶部导航 ── */}
-
@@ -366,9 +346,7 @@ const ProductDetail: React.FC = () => { {product.title} ) : (
- +

视频暂不可用

)} @@ -381,20 +359,10 @@ const ProductDetail: React.FC = () => { onClick={(e) => e.stopPropagation()} > {/* 进度条 */} -
+
-
-
+
+
@@ -442,10 +410,7 @@ const ProductDetail: React.FC = () => {
状态 - + {statusInfo.label}
@@ -457,15 +422,11 @@ const ProductDetail: React.FC = () => {
分辨率 - - {product.resolution || "-"} - + {product.resolution || "-"}
文件大小 - - {formatFileSize(product.file_size ?? 0)} - + {formatFileSize(product.file_size ?? 0)}
查重率 @@ -477,15 +438,13 @@ const ProductDetail: React.FC = () => {
创建时间 - - {formatDate(product.created_at ?? "")} - + {formatDate(product.created_at ?? "")}
- ); -}; + ) +} -export default ProductDetail; +export default ProductDetail diff --git a/apps/web/src/pages/products/ProductLibrary.tsx b/apps/web/src/pages/products/ProductLibrary.tsx old mode 100755 new mode 100644 index 0ef36d844..c94ced06a --- a/apps/web/src/pages/products/ProductLibrary.tsx +++ b/apps/web/src/pages/products/ProductLibrary.tsx @@ -3,16 +3,10 @@ * 卡片网格布局,支持视频播放/下载/分享、批量操作、筛选 * 使用 useQuery 对接后端真实 API(api/products.ts) */ -import React, { - useMemo, - useState, - useRef, - useEffect, - useCallback, -} from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { useNavigate } from "react-router-dom"; -import { message, Popconfirm } from "antd"; +import React, { useMemo, useState, useRef, useEffect, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "react-router-dom" +import { message, Popconfirm } from "antd" import { SearchOutlined, VideoCameraOutlined, @@ -25,8 +19,8 @@ import { CheckOutlined, CloudUploadOutlined, EyeOutlined, -} from "@ant-design/icons"; -import { Button, Input, Select } from "@/components/ui"; +} from "@ant-design/icons" +import { Button, Input, Select } from "@/components/ui" import { getProducts, deleteProduct, @@ -36,33 +30,33 @@ import { getBatchDownloadStatus, type ProductItem as ApiProductItem, type ReviewStatus, -} from "@/api/products"; -import "./products.css"; +} from "@/api/products" +import "./products.css" /* ============================================================ * 类型 & 常量 * ============================================================ */ -type ProductStatus = "completed" | "processing" | "review" | "failed"; +type ProductStatus = "completed" | "processing" | "review" | "failed" interface ProductItem { - id: string; - name: string; - status: ProductStatus; - duration: number; // 秒 - date: string; - gradient: string; - duplicateRate: number; // 查重率百分比 - isPublished: boolean; - resolution: string; - fileSize: number; // MB - videoUrl?: string; - thumbnailUrl?: string; + id: string + name: string + status: ProductStatus + duration: number // 秒 + date: string + gradient: string + duplicateRate: number // 查重率百分比 + isPublished: boolean + resolution: string + fileSize: number // MB + videoUrl?: string + thumbnailUrl?: string /** 复核状态 */ - reviewStatus?: ReviewStatus; + reviewStatus?: ReviewStatus /** 所属项目 ID */ - projectId?: string; + projectId?: string /** 所属项目名称 */ - projectName?: string; + projectName?: string } /** 将后端 status 映射为前端 ProductStatus */ @@ -75,9 +69,9 @@ const normalizeStatus = (s: string): ProductStatus => { review: "review", failed: "failed", error: "failed", - }; - return map[s] ?? "processing"; -}; + } + return map[s] ?? "processing" +} /** 渐变色列表(按 id hash 选取) */ const GRADIENTS = [ @@ -93,14 +87,14 @@ const GRADIENTS = [ "linear-gradient(135deg, #3b0764 0%, #7c3aed 50%, #a78bfa 100%)", "linear-gradient(135deg, #052e16 0%, #16a34a 50%, #86efac 100%)", "linear-gradient(135deg, #450a0e 0%, #e11d48 50%, #fb7185 100%)", -]; +] /** 根据 id 生成稳定的渐变色 */ const gradientForId = (id: string): string => { - let hash = 0; - for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0; - return GRADIENTS[Math.abs(hash) % GRADIENTS.length]; -}; + let hash = 0 + for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0 + return GRADIENTS[Math.abs(hash) % GRADIENTS.length] +} /** 将后端 ProductItem 映射为前端 ProductItem */ const mapApiProduct = (item: ApiProductItem): ProductItem => ({ @@ -108,9 +102,7 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({ name: item.title, status: normalizeStatus(item.status), duration: item.duration_seconds ?? 0, - date: item.created_at - ? new Date(item.created_at).toISOString().slice(0, 10) - : "—", + date: item.created_at ? new Date(item.created_at).toISOString().slice(0, 10) : "—", gradient: gradientForId(item.id), duplicateRate: item.duplicate_rate ?? 0, isPublished: false, // TODO: 后端发布状态字段待补齐 @@ -121,71 +113,63 @@ const mapApiProduct = (item: ApiProductItem): ProductItem => ({ reviewStatus: item.review_status, projectId: item.project_id, projectName: item.project_name, -}); +}) /* ============================================================ * 工具函数 * ============================================================ */ -const statusConfig: Record = - { - completed: { text: "已完成", className: "completed" }, - processing: { text: "处理中", className: "processing" }, - review: { text: "待复核", className: "review" }, - failed: { text: "失败", className: "failed" }, - }; +const statusConfig: Record = { + completed: { text: "已完成", className: "completed" }, + processing: { text: "处理中", className: "processing" }, + review: { text: "待复核", className: "review" }, + failed: { text: "失败", className: "failed" }, +} /** 格式化时间 mm:ss */ const formatTime = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; -}; + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` +} /** 格式化文件大小 */ const formatSize = (mb: number): string => { - if (mb <= 0) return "-"; - if (mb < 1) return `${(mb * 1024).toFixed(0)} KB`; - return `${mb.toFixed(1)} MB`; -}; + if (mb <= 0) return "-" + if (mb < 1) return `${(mb * 1024).toFixed(0)} KB` + return `${mb.toFixed(1)} MB` +} /** 复核状态配置 */ -const reviewStatusConfig: Record< - ReviewStatus, - { text: string; className: string } -> = { +const reviewStatusConfig: Record = { pending_review: { text: "待复核", className: "review-pending" }, approved: { text: "已通过", className: "review-approved" }, rejected: { text: "需修改", className: "review-rejected" }, -}; +} /** 复核状态循环顺序 */ -const REVIEW_STATUS_CYCLE: ReviewStatus[] = [ - "pending_review", - "approved", - "rejected", -]; +const REVIEW_STATUS_CYCLE: ReviewStatus[] = ["pending_review", "approved", "rejected"] /** 获取下一个复核状态 */ const getNextReviewStatus = (current?: ReviewStatus): ReviewStatus => { - if (!current) return "approved"; - const idx = REVIEW_STATUS_CYCLE.indexOf(current); - return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length]; -}; + if (!current) return "approved" + const idx = REVIEW_STATUS_CYCLE.indexOf(current) + return REVIEW_STATUS_CYCLE[(idx + 1) % REVIEW_STATUS_CYCLE.length] +} /* ============================================================ * ProductCard 组件 * ============================================================ */ const ProductCard: React.FC<{ - product: ProductItem; - isSelected: boolean; - batchMode: boolean; - onToggleSelect: (id: string) => void; - onPlay: (product: ProductItem) => void; - onDownload: (product: ProductItem) => void; - onShare: (product: ProductItem) => void; - onDelete: (id: string) => void; - onPublish: (product: ProductItem) => void; - onReviewStatusChange: (id: string) => void; + product: ProductItem + isSelected: boolean + batchMode: boolean + onToggleSelect: (id: string) => void + onPlay: (product: ProductItem) => void + onDownload: (product: ProductItem) => void + onShare: (product: ProductItem) => void + onDelete: (id: string) => void + onPublish: (product: ProductItem) => void + onReviewStatusChange: (id: string) => void }> = ({ product, isSelected, @@ -198,30 +182,26 @@ const ProductCard: React.FC<{ onPublish, onReviewStatusChange, }) => { - const st = statusConfig[product.status]; + const st = statusConfig[product.status] /** 查重率样式 */ const dupClass = - product.duplicateRate <= 5 - ? "good" - : product.duplicateRate <= 15 - ? "warn" - : "bad"; + product.duplicateRate <= 5 ? "good" : product.duplicateRate <= 15 ? "warn" : "bad" /** 点击卡片 */ const handleCardClick = () => { if (batchMode) { - onToggleSelect(product.id); + onToggleSelect(product.id) } else { - onPlay(product); + onPlay(product) } - }; + } /** 点击复选框 */ const handleCheckboxClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onToggleSelect(product.id); - }; + e.stopPropagation() + onToggleSelect(product.id) + } return (
{ - e.stopPropagation(); - onReviewStatusChange(product.id); + e.stopPropagation() + onReviewStatusChange(product.id) }} title={`点击切换复核状态(当前:${reviewStatusConfig[product.reviewStatus].text})`} > @@ -258,8 +238,8 @@ const ProductCard: React.FC<{
{ - e.stopPropagation(); - onReviewStatusChange(product.id); + e.stopPropagation() + onReviewStatusChange(product.id) }} title="点击设置复核状态" > @@ -277,18 +257,13 @@ const ProductCard: React.FC<{ style={{ width: "100%", height: "100%", objectFit: "cover" }} /> ) : ( -
+
)}
{product.duration > 0 && ( - - {formatTime(product.duration)} - + {formatTime(product.duration)} )}
@@ -327,10 +302,7 @@ const ProductCard: React.FC<{ 分享 {product.isPublished ? ( - + ✅ 已发布 ) : ( @@ -347,10 +319,7 @@ const ProductCard: React.FC<{ {/* 删除按钮(不在批量模式下显示) */} {!batchMode && ( -
e.stopPropagation()} - > +
e.stopPropagation()}> onDelete(product.id)} @@ -368,76 +337,76 @@ const ProductCard: React.FC<{
)}
- ); -}; + ) +} /* ============================================================ * VideoPlayer 弹窗组件 * ============================================================ */ const VideoPlayer: React.FC<{ - product: ProductItem; - onClose: () => void; - onDownload: (product: ProductItem) => void; - onShare: (product: ProductItem) => void; - onViewDetail: (product: ProductItem) => void; + product: ProductItem + onClose: () => void + onDownload: (product: ProductItem) => void + onShare: (product: ProductItem) => void + onViewDetail: (product: ProductItem) => void }> = ({ product, onClose, onDownload, onShare, onViewDetail }) => { - const videoRef = useRef(null); - const progressRef = useRef(null); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(product.duration); + const videoRef = useRef(null) + const progressRef = useRef(null) + const [isPlaying, setIsPlaying] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(product.duration) - const hasVideo = !!product.videoUrl; + const hasVideo = !!product.videoUrl /** 播放/暂停 */ const handlePlayPause = useCallback(() => { - const video = videoRef.current; - if (!video) return; + const video = videoRef.current + if (!video) return if (isPlaying) { - video.pause(); + video.pause() } else { - video.play().catch(() => {}); + video.play().catch(() => {}) } - setIsPlaying(!isPlaying); - }, [isPlaying]); + setIsPlaying(!isPlaying) + }, [isPlaying]) /** 视频事件监听 */ useEffect(() => { - const video = videoRef.current; - if (!video) return; - const onTime = () => setCurrentTime(video.currentTime); - const onDur = () => setDuration(video.duration || product.duration); - const onEnd = () => setIsPlaying(false); - video.addEventListener("timeupdate", onTime); - video.addEventListener("loadedmetadata", onDur); - video.addEventListener("ended", onEnd); + const video = videoRef.current + if (!video) return + const onTime = () => setCurrentTime(video.currentTime) + const onDur = () => setDuration(video.duration || product.duration) + const onEnd = () => setIsPlaying(false) + video.addEventListener("timeupdate", onTime) + video.addEventListener("loadedmetadata", onDur) + video.addEventListener("ended", onEnd) return () => { - video.removeEventListener("timeupdate", onTime); - video.removeEventListener("loadedmetadata", onDur); - video.removeEventListener("ended", onEnd); - }; - }, [product.duration]); + video.removeEventListener("timeupdate", onTime) + video.removeEventListener("loadedmetadata", onDur) + video.removeEventListener("ended", onEnd) + } + }, [product.duration]) /** 进度条点击 */ const handleProgressClick = (e: React.MouseEvent) => { - if (!progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - const newTime = percent * duration; - setCurrentTime(newTime); - if (videoRef.current) videoRef.current.currentTime = newTime; - }; + if (!progressRef.current) return + const rect = progressRef.current.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + const newTime = percent * duration + setCurrentTime(newTime) + if (videoRef.current) videoRef.current.currentTime = newTime + } /** ESC 关闭 */ useEffect(() => { const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", handleKey); - return () => window.removeEventListener("keydown", handleKey); - }, [onClose]); + if (e.key === "Escape") onClose() + } + window.addEventListener("keydown", handleKey) + return () => window.removeEventListener("keydown", handleKey) + }, [onClose]) - const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 return (
@@ -479,15 +448,8 @@ const VideoPlayer: React.FC<{ {/* 进度条 */}
-
-
+
+
{formatTime(currentTime)} @@ -502,28 +464,20 @@ const VideoPlayer: React.FC<{
分辨率: - - {product.resolution} - + {product.resolution}
时长: - - {formatTime(product.duration)} - + {formatTime(product.duration)}
文件大小: - - {formatSize(product.fileSize)} - + {formatSize(product.fileSize)}
查重率: - {product.duplicateRate > 0 - ? `${product.duplicateRate.toFixed(1)}%` - : "-"} + {product.duplicateRate > 0 ? `${product.duplicateRate.toFixed(1)}%` : "-"}
@@ -564,15 +518,15 @@ const VideoPlayer: React.FC<{
- ); -}; + ) +} /* ============================================================ * 主组件 * ============================================================ */ const ProductLibrary: React.FC = () => { - const queryClient = useQueryClient(); - const navigate = useNavigate(); + const queryClient = useQueryClient() + const navigate = useNavigate() /* ── 获取成品列表 ── */ const { @@ -585,83 +539,78 @@ const ProductLibrary: React.FC = () => { queryKey: ["products"], queryFn: () => getProducts(), staleTime: 30_000, - }); + }) - // 映射为前端类型,防御非数组返回 - const products = useMemo( - () => (Array.isArray(apiProducts) ? apiProducts : []).map(mapApiProduct), - [apiProducts], - ); + // 映射为前端类型 + const products = useMemo(() => apiProducts.map(mapApiProduct), [apiProducts]) /* ── 删除 mutation ── */ const deleteMutation = useMutation({ mutationFn: deleteProduct, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["products"] }); - message.success("已删除"); + queryClient.invalidateQueries({ queryKey: ["products"] }) + message.success("已删除") }, onError: () => { - message.error("删除失败"); + message.error("删除失败") }, - }); + }) /* ── 复核状态 mutation ── */ const reviewMutation = useMutation({ mutationFn: ({ id, status }: { id: string; status: ReviewStatus }) => updateReviewStatus(id, status), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["products"] }); - message.success("复核状态已更新"); + queryClient.invalidateQueries({ queryKey: ["products"] }) + message.success("复核状态已更新") }, onError: () => { - message.error("更新复核状态失败"); + message.error("更新复核状态失败") }, - }); + }) /* 筛选 */ - const [searchText, setSearchText] = useState(""); - const [filterStatus, setFilterStatus] = useState("all"); - const [filterTime, setFilterTime] = useState("all"); - const [filterDuration, setFilterDuration] = useState("all"); - const [filterProject, setFilterProject] = useState("all"); - const [filterReviewStatus, setFilterReviewStatus] = useState("all"); + const [searchText, setSearchText] = useState("") + const [filterStatus, setFilterStatus] = useState("all") + const [filterTime, setFilterTime] = useState("all") + const [filterDuration, setFilterDuration] = useState("all") + const [filterProject, setFilterProject] = useState("all") + const [filterReviewStatus, setFilterReviewStatus] = useState("all") /* 批量操作 */ - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()) /* 播放器 */ - const [playingProduct, setPlayingProduct] = useState( - null, - ); + const [playingProduct, setPlayingProduct] = useState(null) /* 派生数据 */ - const batchMode = selectedIds.size > 0; + const batchMode = selectedIds.size > 0 const filteredProducts = useMemo(() => { - let list = products; + let list = products /* 状态筛选 */ if (filterStatus !== "all") { - list = list.filter((p) => p.status === filterStatus); + list = list.filter((p) => p.status === filterStatus) } /* 时间筛选 */ if (filterTime !== "all") { - const now = new Date(); + const now = new Date() list = list.filter((p) => { - const d = new Date(p.date); - const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24); + const d = new Date(p.date) + const diff = (now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24) switch (filterTime) { case "today": - return diff < 1; + return diff < 1 case "week": - return diff <= 7; + return diff <= 7 case "month": - return diff <= 30; + return diff <= 30 default: - return true; + return true } - }); + }) } /* 时长筛选 */ @@ -669,38 +618,38 @@ const ProductLibrary: React.FC = () => { list = list.filter((p) => { switch (filterDuration) { case "short": - return p.duration > 0 && p.duration <= 60; + return p.duration > 0 && p.duration <= 60 case "medium": - return p.duration > 60 && p.duration <= 180; + return p.duration > 60 && p.duration <= 180 case "long": - return p.duration > 180; + return p.duration > 180 default: - return true; + return true } - }); + }) } /* 项目筛选 */ if (filterProject !== "all") { - list = list.filter((p) => p.projectId === filterProject); + list = list.filter((p) => p.projectId === filterProject) } /* 复核状态筛选 */ if (filterReviewStatus !== "all") { if (filterReviewStatus === "none") { - list = list.filter((p) => !p.reviewStatus); + list = list.filter((p) => !p.reviewStatus) } else { - list = list.filter((p) => p.reviewStatus === filterReviewStatus); + list = list.filter((p) => p.reviewStatus === filterReviewStatus) } } /* 搜索 */ if (searchText.trim()) { - const q = searchText.trim().toLowerCase(); - list = list.filter((p) => p.name.toLowerCase().includes(q)); + const q = searchText.trim().toLowerCase() + list = list.filter((p) => p.name.toLowerCase().includes(q)) } - return list; + return list }, [ products, filterStatus, @@ -709,157 +658,156 @@ const ProductLibrary: React.FC = () => { filterProject, filterReviewStatus, searchText, - ]); + ]) /* 全选 */ const allSelected = - filteredProducts.length > 0 && - filteredProducts.every((p) => selectedIds.has(p.id)); + filteredProducts.length > 0 && filteredProducts.every((p) => selectedIds.has(p.id)) const handleSelectAll = () => { if (allSelected) { - setSelectedIds(new Set()); + setSelectedIds(new Set()) } else { - setSelectedIds(new Set(filteredProducts.map((p) => p.id))); + setSelectedIds(new Set(filteredProducts.map((p) => p.id))) } - }; + } /* 切换单个选择 */ const handleToggleSelect = (id: string) => { setSelectedIds((prev) => { - const next = new Set(prev); + const next = new Set(prev) if (next.has(id)) { - next.delete(id); + next.delete(id) } else { - next.add(id); + next.add(id) } - return next; - }); - }; + return next + }) + } /* 下载 — 调用真实 API 获取下载链接 */ const handleDownload = async (product: ProductItem) => { - if (product.status !== "completed") return; + if (product.status !== "completed") return try { - const { url } = await getProductDownloadUrl(product.id); + const { url } = await getProductDownloadUrl(product.id) // 打开下载链接 - const a = document.createElement("a"); - a.href = url; - a.download = ""; - a.click(); - message.success(`正在下载"${product.name}"`); + const a = document.createElement("a") + a.href = url + a.download = "" + a.click() + message.success(`正在下载"${product.name}"`) } catch { - message.error("获取下载链接失败"); + message.error("获取下载链接失败") } - }; + } /* 分享 */ const handleShare = (product: ProductItem) => { - if (product.status !== "completed") return; - const link = `${window.location.origin}/share/${product.id}`; + if (product.status !== "completed") return + const link = `${window.location.origin}/share/${product.id}` navigator.clipboard?.writeText(link).then( () => message.success(`分享链接已复制:${link}`), () => message.success(`分享链接:${link}(请手动复制)`), - ); - }; + ) + } /* 查看详情 — 跳转到产品详情页 */ const handleViewDetail = (product: ProductItem) => { - setPlayingProduct(null); // 关闭播放器 - navigate(`/app/products/${product.id}`); - }; + setPlayingProduct(null) // 关闭播放器 + navigate(`/app/products/${product.id}`) + } /* 删除 */ const handleDelete = (id: string) => { - deleteMutation.mutate(id); + deleteMutation.mutate(id) setSelectedIds((prev) => { - const next = new Set(prev); - next.delete(id); - return next; - }); - }; + const next = new Set(prev) + next.delete(id) + return next + }) + } /* 发布 — TODO: 后端发布 API 待实现 */ const handlePublish = (_product: ProductItem) => { // TODO: 对接后端发布 API(当前后端未提供发布接口) - message.info("发布功能待后端 API 补齐"); - }; + message.info("发布功能待后端 API 补齐") + } /* 切换复核状态 */ const handleReviewStatusChange = (id: string) => { - const current = products.find((p) => p.id === id)?.reviewStatus; - const nextStatus = getNextReviewStatus(current); - reviewMutation.mutate({ id, status: nextStatus }); - }; + const current = products.find((p) => p.id === id)?.reviewStatus + const nextStatus = getNextReviewStatus(current) + reviewMutation.mutate({ id, status: nextStatus }) + } /* 批量下载 — 使用 batch-download API + 轮询 */ - const [batchDownloading, setBatchDownloading] = useState(false); + const [batchDownloading, setBatchDownloading] = useState(false) const handleBatchDownload = async () => { - const ids = Array.from(selectedIds); - if (ids.length === 0) return; - setBatchDownloading(true); + const ids = Array.from(selectedIds) + if (ids.length === 0) return + setBatchDownloading(true) try { // 发起批量下载任务 - const { job_id } = await batchDownload(ids); - message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`); + const { job_id } = await batchDownload(ids) + message.info(`批量下载任务已创建,正在打包 ${ids.length} 个视频...`) // 轮询下载状态(最多 60 次,每次 2 秒) - let attempts = 0; - const maxAttempts = 60; + let attempts = 0 + const maxAttempts = 60 const poll = async (): Promise => { if (attempts >= maxAttempts) { - message.warning("打包超时,请稍后在消息中心查看"); - return; + message.warning("打包超时,请稍后在消息中心查看") + return } - attempts++; - await new Promise((r) => setTimeout(r, 2000)); - const status = await getBatchDownloadStatus(job_id); + attempts++ + await new Promise((r) => setTimeout(r, 2000)) + const status = await getBatchDownloadStatus(job_id) if (status.status === "completed" && status.download_url) { - const a = document.createElement("a"); - a.href = status.download_url; - a.download = ""; - a.click(); - message.success(`已打包下载 ${ids.length} 个视频`); - setSelectedIds(new Set()); + const a = document.createElement("a") + a.href = status.download_url + a.download = "" + a.click() + message.success(`已打包下载 ${ids.length} 个视频`) + setSelectedIds(new Set()) } else if (status.status === "failed") { - message.error("批量下载失败,请重试"); + message.error("批量下载失败,请重试") } else { // 继续轮询 - await poll(); + await poll() } - }; - await poll(); + } + await poll() } catch { - message.error("发起批量下载失败"); + message.error("发起批量下载失败") } finally { - setBatchDownloading(false); + setBatchDownloading(false) } - }; + } /* 批量删除 */ const handleBatchDelete = async () => { - const ids = Array.from(selectedIds); - let successCount = 0; + const ids = Array.from(selectedIds) + let successCount = 0 for (const id of ids) { try { - await deleteProduct(id); - successCount++; + await deleteProduct(id) + successCount++ } catch { // 忽略单个失败 } } - queryClient.invalidateQueries({ queryKey: ["products"] }); - setSelectedIds(new Set()); - message.success(`已批量删除 ${successCount}/${ids.length} 个视频`); - }; + queryClient.invalidateQueries({ queryKey: ["products"] }) + setSelectedIds(new Set()) + message.success(`已批量删除 ${successCount}/${ids.length} 个视频`) + } /* 批量发布 */ const handleBatchPublish = () => { // TODO: 对接后端批量发布 API - message.info("批量发布功能待后端 API 补齐"); - setSelectedIds(new Set()); - }; + message.info("批量发布功能待后端 API 补齐") + setSelectedIds(new Set()) + } // ── Loading 状态 ── if (isLoading) { @@ -870,15 +818,15 @@ const ProductLibrary: React.FC = () => {

加载中...

- ); + ) } // ── Error 状态 ── if (isError) { - console.error("[ProductLibrary] 加载失败:", error); - const errorMsg = error?.message || "加载失败"; + console.error("[ProductLibrary] 加载失败:", error) + const errorMsg = error?.message || "加载失败" // 404 视为空数据(API 尚未就绪或无数据) - const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found"); + const is404 = errorMsg.includes("404") || errorMsg.includes("Not Found") if (is404) { return (
@@ -901,23 +849,19 @@ const ProductLibrary: React.FC = () => {

- ); + ) } return (

{errorMsg || "加载失败,请稍后重试"}

-
- ); + ) } return ( @@ -928,11 +872,7 @@ const ProductLibrary: React.FC = () => { 成片库
-
@@ -951,9 +891,7 @@ const ProductLibrary: React.FC = () => { {allSelected ? "取消全选" : "全选"} - - 已选择 {selectedIds.size} 项 - + 已选择 {selectedIds.size} 项
-
@@ -1132,7 +1062,7 @@ const ProductLibrary: React.FC = () => { /> )}
- ); -}; + ) +} -export default ProductLibrary; +export default ProductLibrary diff --git a/apps/web/src/pages/profile/Settings.tsx b/apps/web/src/pages/profile/Settings.tsx index ae4f3f56e..d66eea05a 100644 --- a/apps/web/src/pages/profile/Settings.tsx +++ b/apps/web/src/pages/profile/Settings.tsx @@ -3,22 +3,22 @@ * P1-2: 添加 PageHead * P1-3: antd Form/Input/Button/Alert → 自定义 UI 组件 */ -import React, { useState } from "react"; -import { Button, Input, Modal } from "@/components/ui"; -import { useAuthStore } from "@/store/authStore"; -import PageHead from "@/components/layout/PageHead"; -import "./ProfileSettings.css"; +import React, { useState } from "react" +import { Button, Input, Modal } from "@/components/ui" +import { useAuthStore } from "@/store/authStore" +import PageHead from "@/components/layout/PageHead" +import "./ProfileSettings.css" const Settings: React.FC = () => { - const user = useAuthStore((state) => state.user); - const [displayName, setDisplayName] = useState(user?.display_name || ""); + const user = useAuthStore((state) => state.user) + const [displayName, setDisplayName] = useState(user?.display_name || "") const handleSave = () => { Modal.info({ title: "提示", content: "个人资料修改接口暂未开放,保存功能即将上线。", - }); - }; + }) + } return (
@@ -55,20 +55,15 @@ const Settings: React.FC = () => {
-
- ); -}; + ) +} -export default Settings; -export const Component = Settings; +export default Settings +export const Component = Settings diff --git a/apps/web/src/pages/subscription/Billing.tsx b/apps/web/src/pages/subscription/Billing.tsx index ae493f7a1..68a8107a9 100644 --- a/apps/web/src/pages/subscription/Billing.tsx +++ b/apps/web/src/pages/subscription/Billing.tsx @@ -3,29 +3,29 @@ * 展示当前订阅信息 + 自动续费开关 * P1-3: antd Switch→自定义ToggleSwitch, antd Spin→自定义Spinner */ -import React, { useState, useEffect } from "react"; -import { message } from "antd"; -import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription"; -import type { SubscriptionInfo } from "@/api/subscription"; -import PageHead from "@/components/layout/PageHead"; -import "./Billing.css"; +import React, { useState, useEffect } from "react" +import { message } from "antd" +import { getCurrentSubscription, toggleAutoRenew } from "@/api/subscription" +import type { SubscriptionInfo } from "@/api/subscription" +import PageHead from "@/components/layout/PageHead" +import "./Billing.css" const formatDate = (iso: string): string => { - const d = new Date(iso); + const d = new Date(iso) return d.toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", - }); -}; + }) +} /** 自定义 ToggleSwitch 组件 */ const ToggleSwitch: React.FC<{ - checked: boolean; - onChange: (checked: boolean) => void; - loading?: boolean; - checkedChildren?: string; - unCheckedChildren?: string; + checked: boolean + onChange: (checked: boolean) => void + loading?: boolean + checkedChildren?: string + unCheckedChildren?: string }> = ({ checked, onChange, loading, checkedChildren, unCheckedChildren }) => ( -); +) /** 自定义 Spinner 组件 */ -const Spinner: React.FC<{ size?: "small" | "large" }> = ({ - size = "large", -}) => ( +const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
-); +) const Billing: React.FC = () => { - const [subscription, setSubscription] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [autoRenewChecked, setAutoRenewChecked] = useState(false); - const [autoRenewLoading, setAutoRenewLoading] = useState(false); + const [subscription, setSubscription] = useState(null) + const [loading, setLoading] = useState(true) + const [autoRenewChecked, setAutoRenewChecked] = useState(false) + const [autoRenewLoading, setAutoRenewLoading] = useState(false) useEffect(() => { - loadData(); - }, []); + loadData() + }, []) const loadData = async () => { try { - const data = await getCurrentSubscription(); - setSubscription(data); - setAutoRenewChecked(data.auto_renew); + const data = await getCurrentSubscription() + setSubscription(data) + setAutoRenewChecked(data.auto_renew) } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("加载订阅数据失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("加载订阅数据失败") } finally { - setLoading(false); + setLoading(false) } - }; + } const handleToggleAutoRenew = async (checked: boolean) => { - setAutoRenewLoading(true); + setAutoRenewLoading(true) try { - const res = await toggleAutoRenew(checked); - message.success(res.message); - setAutoRenewChecked(checked); + const res = await toggleAutoRenew(checked) + message.success(res.message) + setAutoRenewChecked(checked) if (subscription) { - setSubscription({ ...subscription, auto_renew: checked }); + setSubscription({ ...subscription, auto_renew: checked }) } } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("操作失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败") } finally { - setAutoRenewLoading(false); + setAutoRenewLoading(false) } - }; + } if (loading) { return (
- ); + ) } return ( @@ -127,9 +119,7 @@ const Billing: React.FC = () => {
下次扣费 - - {formatDate(subscription.current_period_end)} - + {formatDate(subscription.current_period_end)}
@@ -156,8 +146,8 @@ const Billing: React.FC = () => { )}
- ); -}; + ) +} -export default Billing; -export const Component = Billing; +export default Billing +export const Component = Billing diff --git a/apps/web/src/pages/subscription/Plans.css b/apps/web/src/pages/subscription/Plans.css index 89d4f732c..a1ebdfeb9 100755 --- a/apps/web/src/pages/subscription/Plans.css +++ b/apps/web/src/pages/subscription/Plans.css @@ -68,8 +68,7 @@ .xx-plan-card:hover { border-color: var(--primary-color); - box-shadow: 0 32px 90px - color-mix(in srgb, var(--primary-color) 15%, transparent); + box-shadow: 0 32px 90px color-mix(in srgb, var(--primary-color) 15%, transparent); transform: translateY(-4px); } @@ -216,15 +215,10 @@ /* 企业版按钮 */ .xx-subscribe-btn.enterprise { - background: linear-gradient( - 135deg, - var(--accent-color), - var(--accent-dark) - ) !important; + background: linear-gradient(135deg, var(--accent-color), var(--accent-dark)) !important; color: var(--text-inverse) !important; border: none !important; - box-shadow: 0 14px 26px - color-mix(in srgb, var(--accent-color) 22%, transparent) !important; + box-shadow: 0 14px 26px color-mix(in srgb, var(--accent-color) 22%, transparent) !important; } @media (max-width: 480px) { diff --git a/apps/web/src/pages/subscription/Plans.tsx b/apps/web/src/pages/subscription/Plans.tsx index 35123fbcb..4fc642653 100644 --- a/apps/web/src/pages/subscription/Plans.tsx +++ b/apps/web/src/pages/subscription/Plans.tsx @@ -1,10 +1,10 @@ /** * 定价页面 - V21 UI 四级定价 */ -import React, { useState } from "react"; -import { message } from "antd"; -import PageHead from "@/components/layout/PageHead"; -import "./Plans.css"; +import React, { useState } from "react" +import { message } from "antd" +import PageHead from "@/components/layout/PageHead" +import "./Plans.css" const PLANS = [ { @@ -21,13 +21,7 @@ const PLANS = [ price: 99, description: "适合内容创作者", recommended: false, - features: [ - "无限视频成片", - "高级标题模板", - "全部配音音色", - "1080p导出", - "API接口", - ], + features: ["无限视频成片", "高级标题模板", "全部配音音色", "1080p导出", "API接口"], }, { id: "pro", @@ -35,14 +29,7 @@ const PLANS = [ price: 299, description: "适合专业团队", recommended: true, - features: [ - "无限视频成片", - "自定义标题", - "全部配音音色", - "4K导出", - "优先渲染队列", - "专属客服", - ], + features: ["无限视频成片", "自定义标题", "全部配音音色", "4K导出", "优先渲染队列", "专属客服"], }, { id: "enterprise", @@ -50,54 +37,37 @@ const PLANS = [ price: null, description: "适合企业用户", recommended: false, - features: [ - "私有化部署", - "自定义品牌形象", - " SLA服务保障", - "技术支持", - "团队协作", - "数据报表", - ], + features: ["私有化部署", "自定义品牌形象", " SLA服务保障", "技术支持", "团队协作", "数据报表"], }, -]; +] const Plans: React.FC = () => { - const [subscribing, setSubscribing] = useState(false); + const [subscribing, setSubscribing] = useState(false) const handleSubscribe = async (_planId: string) => { try { - setSubscribing(true); - message.success("订阅成功"); + setSubscribing(true) + message.success("订阅成功") } catch (error: unknown) { - message.error("订阅失败"); + message.error("订阅失败") } finally { - setSubscribing(false); + setSubscribing(false) } - }; + } return (
- +
{PLANS.map((plan) => ( -
+
{plan.recommended &&
推荐
} - {plan.id === "enterprise" && ( -
定制
- )} + {plan.id === "enterprise" &&
定制
}

{plan.name}

-
+
{plan.price !== null ? ( <> ¥ @@ -122,27 +92,19 @@ const Plans: React.FC = () => {
))}
- ); -}; + ) +} -export default Plans; -export const Component = Plans; +export default Plans +export const Component = Plans diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.css b/apps/web/src/pages/subscription/UpgradeSubscription.css index e348a7383..6df52c29b 100755 --- a/apps/web/src/pages/subscription/UpgradeSubscription.css +++ b/apps/web/src/pages/subscription/UpgradeSubscription.css @@ -34,8 +34,7 @@ .xx-upgrade-card:hover { border-color: var(--primary-color); - box-shadow: 0 8px 30px - color-mix(in srgb, var(--primary-color) 10%, transparent); + box-shadow: 0 8px 30px color-mix(in srgb, var(--primary-color) 10%, transparent); } .xx-upgrade-card.selected { diff --git a/apps/web/src/pages/subscription/UpgradeSubscription.tsx b/apps/web/src/pages/subscription/UpgradeSubscription.tsx index b24f06d40..720beeb2d 100644 --- a/apps/web/src/pages/subscription/UpgradeSubscription.tsx +++ b/apps/web/src/pages/subscription/UpgradeSubscription.tsx @@ -2,40 +2,33 @@ * 升级/降级/续费页面 * P1-3: antd Button/Modal/Radio/Spin → 自定义 UI 组件 */ -import React, { useState, useEffect } from "react"; -import { message } from "antd"; -import { Button, Modal } from "@/components/ui"; -import { useNavigate } from "react-router-dom"; +import React, { useState, useEffect } from "react" +import { message } from "antd" +import { Button, Modal } from "@/components/ui" +import { useNavigate } from "react-router-dom" import { getCurrentSubscription, changePlan, toggleAutoRenew, cancelSubscription, -} from "@/api/subscription"; -import type { - SubscriptionInfo, - PlanType, - BillingCycle, -} from "@/api/subscription"; -import PageHead from "@/components/layout/PageHead"; -import "./UpgradeSubscription.css"; +} from "@/api/subscription" +import type { SubscriptionInfo, PlanType, BillingCycle } from "@/api/subscription" +import PageHead from "@/components/layout/PageHead" +import "./UpgradeSubscription.css" -const PLANS_META: Record< - string, - { name: string; price: number; yearlyPrice: number } -> = { +const PLANS_META: Record = { free: { name: "体验版", price: 0, yearlyPrice: 0 }, standard: { name: "标准版", price: 99, yearlyPrice: 990 }, pro: { name: "专业版", price: 299, yearlyPrice: 2990 }, enterprise: { name: "企业版", price: 0, yearlyPrice: 0 }, -}; +} /** 自定义计费周期切换组件 */ const BillingCycleSwitch: React.FC<{ - value: BillingCycle; - onChange: (cycle: BillingCycle) => void; - monthlyPrice: number; - yearlyPrice: number; + value: BillingCycle + onChange: (cycle: BillingCycle) => void + monthlyPrice: number + yearlyPrice: number }> = ({ value, onChange, monthlyPrice, yearlyPrice }) => (
-); +) /** 自定义 Spinner 组件 */ -const Spinner: React.FC<{ size?: "small" | "large" }> = ({ - size = "large", -}) => ( +const Spinner: React.FC<{ size?: "small" | "large" }> = ({ size = "large" }) => (
-); +) const UpgradeSubscription: React.FC = () => { - const navigate = useNavigate(); - const [subscription, setSubscription] = useState( - null, - ); - const [loading, setLoading] = useState(true); - const [submitting, setSubmitting] = useState(false); - const [selectedPlan, setSelectedPlan] = useState("standard"); - const [billingCycle, setBillingCycle] = useState("monthly"); + const navigate = useNavigate() + const [subscription, setSubscription] = useState(null) + const [loading, setLoading] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [selectedPlan, setSelectedPlan] = useState("standard") + const [billingCycle, setBillingCycle] = useState("monthly") useEffect(() => { - loadSubscription(); - }, []); + loadSubscription() + }, []) const loadSubscription = async () => { try { - const data = await getCurrentSubscription(); - setSubscription(data); - setSelectedPlan(data.plan_id); + const data = await getCurrentSubscription() + setSubscription(data) + setSelectedPlan(data.plan_id) } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("获取订阅信息失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("获取订阅信息失败") } finally { - setLoading(false); + setLoading(false) } - }; + } const handleUpgrade = async () => { - if (!subscription) return; - if ( - selectedPlan === subscription.plan_id && - billingCycle === subscription.billing_cycle - ) { - message.info("当前已是该套餐"); - return; + if (!subscription) return + if (selectedPlan === subscription.plan_id && billingCycle === subscription.billing_cycle) { + message.info("当前已是该套餐") + return } - const plan = PLANS_META[selectedPlan]; - const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price; + const plan = PLANS_META[selectedPlan] + const price = billingCycle === "yearly" ? plan.yearlyPrice : plan.price Modal.confirm({ title: "确认变更套餐", @@ -116,39 +101,37 @@ const UpgradeSubscription: React.FC = () => { cancelText: "取消", onOk: async () => { try { - setSubmitting(true); + setSubmitting(true) const res = await changePlan({ target_plan_id: selectedPlan, billing_cycle: billingCycle, - }); + }) if (res.success) { - message.success(res.message); - setSubscription(res.new_subscription ?? null); + message.success(res.message) + setSubscription(res.new_subscription ?? null) } else { - message.error(res.message); + message.error(res.message) } } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("套餐变更失败,请重试"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("套餐变更失败,请重试") } finally { - setSubmitting(false); + setSubmitting(false) } }, - }); - }; + }) + } const handleToggleAutoRenew = async (enabled: boolean) => { try { - const res = await toggleAutoRenew(enabled); - message.success(res.message); + const res = await toggleAutoRenew(enabled) + message.success(res.message) if (subscription) { - setSubscription({ ...subscription, auto_renew: enabled }); + setSubscription({ ...subscription, auto_renew: enabled }) } } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("操作失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("操作失败") } - }; + } const handleCancel = () => { Modal.confirm({ @@ -158,26 +141,25 @@ const UpgradeSubscription: React.FC = () => { cancelText: "再想想", onOk: async () => { try { - const res = await cancelSubscription(); - message.success(res.message); - navigate("/app/subscription"); + const res = await cancelSubscription() + message.success(res.message) + navigate("/app/subscription") } catch (err: unknown) { - if (!(err as { __msgShown?: boolean })?.__msgShown) - message.error("取消失败"); + if (!(err as { __msgShown?: boolean })?.__msgShown) message.error("取消失败") } }, - }); - }; + }) + } if (loading) { return (
- ); + ) } - const currentPlan = subscription?.plan_id ?? "free"; + const currentPlan = subscription?.plan_id ?? "free" return (
@@ -188,8 +170,8 @@ const UpgradeSubscription: React.FC = () => {
{(["standard", "pro", "enterprise"] as PlanType[]).map((planId) => { - const plan = PLANS_META[planId]; - const isCurrent = planId === currentPlan; + const plan = PLANS_META[planId] + const isCurrent = planId === currentPlan return (
{ />
- ); + ) })}
@@ -223,9 +205,7 @@ const UpgradeSubscription: React.FC = () => { {subscription && subscription.status === "active" && (
- - 自动续费:{subscription.auto_renew ? "已开启" : "已关闭"} - + 自动续费:{subscription.auto_renew ? "已开启" : "已关闭"}
)} - {subscription && - subscription.status === "active" && - currentPlan !== "free" && ( - - )} + {subscription && subscription.status === "active" && currentPlan !== "free" && ( + + )}
- ); -}; + ) +} -export default UpgradeSubscription; -export const Component = UpgradeSubscription; +export default UpgradeSubscription +export const Component = UpgradeSubscription diff --git a/apps/web/src/pages/tasks/TaskCenter.tsx b/apps/web/src/pages/tasks/TaskCenter.tsx index 530f00564..a61c2af5f 100644 --- a/apps/web/src/pages/tasks/TaskCenter.tsx +++ b/apps/web/src/pages/tasks/TaskCenter.tsx @@ -2,18 +2,9 @@ * 任务中心页面 * 展示用户的所有任务(生成任务、素材导入等),支持状态筛选、类型筛选、分页、重试 */ -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { - Table, - Tabs, - Select, - Tag, - Button, - message, - Popconfirm, - Tooltip, -} from "antd"; +import { useState } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd" import { CheckCircleOutlined, ClockCircleOutlined, @@ -23,16 +14,16 @@ import { MinusCircleOutlined, RedoOutlined, InfoCircleOutlined, -} from "@ant-design/icons"; -import type { ColumnsType } from "antd/es/table"; +} from "@ant-design/icons" +import type { ColumnsType } from "antd/es/table" import { getTasks, retryTask, type TaskItem, type TaskStatus, type TaskListParams, -} from "@/api/tasks"; -import "./tasks.css"; +} from "@/api/tasks" +import "./tasks.css" /* ──────────── 常量 ──────────── */ @@ -44,20 +35,17 @@ const STATUS_TABS: { key: TaskStatus | "all"; label: string }[] = [ { key: "completed", label: "已完成" }, { key: "failed", label: "失败" }, { key: "cancelled", label: "已取消" }, -]; +] /** 类型筛选选项 */ const TYPE_OPTIONS = [ { value: "all", label: "全部类型" }, { value: "generation", label: "生成任务" }, { value: "ingest", label: "素材导入" }, -]; +] /** 状态标签配置 */ -const STATUS_CONFIG: Record< - TaskStatus, - { label: string; color: string; icon: React.ReactNode } -> = { +const STATUS_CONFIG: Record = { pending: { label: "等待中", color: "default", @@ -88,54 +76,52 @@ const STATUS_CONFIG: Record< color: "default", icon: , }, -}; +} /** 任务类型标签 */ const TYPE_LABELS: Record = { generation: { label: "生成任务", color: "blue" }, ingest: { label: "素材导入", color: "green" }, -}; +} /* ──────────── 工具函数 ──────────── */ /** 格式化耗时 */ const formatDuration = (seconds?: number): string => { - if (!seconds) return "-"; - if (seconds < 60) return `${Math.round(seconds)}秒`; - const minutes = Math.floor(seconds / 60); - const secs = Math.round(seconds % 60); - if (minutes < 60) return `${minutes}分${secs}秒`; - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - return `${hours}小时${mins}分`; -}; + if (!seconds) return "-" + if (seconds < 60) return `${Math.round(seconds)}秒` + const minutes = Math.floor(seconds / 60) + const secs = Math.round(seconds % 60) + if (minutes < 60) return `${minutes}分${secs}秒` + const hours = Math.floor(minutes / 60) + const mins = minutes % 60 + return `${hours}小时${mins}分` +} /** 格式化时间 */ const formatTime = (dateStr?: string | null): string => { - if (!dateStr) return "-"; - const date = new Date(dateStr); + if (!dateStr) return "-" + const date = new Date(dateStr) return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", - }); -}; + }) +} /* ──────────── 主组件 ──────────── */ export default function TaskCenter() { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() // 筛选状态 - const [statusFilter, setStatusFilter] = useState("all"); - const [typeFilter, setTypeFilter] = useState("all"); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [expandedTaskId, setExpandedTaskId] = useState(null); - const [expandedTaskDetail, setExpandedTaskDetail] = useState( - null, - ); + const [statusFilter, setStatusFilter] = useState("all") + const [typeFilter, setTypeFilter] = useState("all") + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [expandedTaskId, setExpandedTaskId] = useState(null) + const [expandedTaskDetail, setExpandedTaskDetail] = useState(null) // 查询参数 const queryParams: TaskListParams = { @@ -143,7 +129,7 @@ export default function TaskCenter() { page_size: pageSize, ...(statusFilter !== "all" && { status: statusFilter }), ...(typeFilter !== "all" && { task_type: typeFilter }), - }; + } // 获取任务列表 const { data, isLoading, error } = useQuery({ @@ -151,39 +137,37 @@ export default function TaskCenter() { queryFn: () => getTasks(queryParams), refetchInterval: (query) => { // 有进行中的任务时自动刷新 - const tasks = query.state.data?.items ?? []; - const hasRunning = tasks.some( - (t) => t.status === "running" || t.status === "waiting", - ); - return hasRunning ? 5000 : false; + const tasks = query.state.data?.items ?? [] + const hasRunning = tasks.some((t) => t.status === "running" || t.status === "waiting") + return hasRunning ? 5000 : false }, - }); + }) // 重试任务 const retryMutation = useMutation({ mutationFn: retryTask, onSuccess: () => { - message.success("任务已重新提交"); - queryClient.invalidateQueries({ queryKey: ["tasks"] }); + message.success("任务已重新提交") + queryClient.invalidateQueries({ queryKey: ["tasks"] }) }, onError: () => { - message.error("重试失败,请检查任务状态"); + message.error("重试失败,请检查任务状态") }, - }); + }) // 展开查看详情 const handleExpand = async (expanded: boolean, record: TaskItem) => { if (!expanded) { - setExpandedTaskId(null); - setExpandedTaskDetail(null); - return; + setExpandedTaskId(null) + setExpandedTaskDetail(null) + return } - setExpandedTaskId(record.id); + setExpandedTaskId(record.id) // 如果是失败任务,获取详情(含 error_info) if (record.status === "failed" && record.error_info) { - setExpandedTaskDetail(record); + setExpandedTaskDetail(record) } - }; + } // 表格列定义 const columns: ColumnsType = [ @@ -205,8 +189,8 @@ export default function TaskCenter() { key: "task_type", width: 100, render: (type: string) => { - const config = TYPE_LABELS[type] || { label: type, color: "default" }; - return {config.label}; + const config = TYPE_LABELS[type] || { label: type, color: "default" } + return {config.label} }, }, { @@ -219,19 +203,15 @@ export default function TaskCenter() { label: status, color: "default", icon: null, - }; + } return ( - + {config.label} {status === "running" && record.progress > 0 && ( {record.progress}% )} - ); + ) }, }, { @@ -240,27 +220,21 @@ export default function TaskCenter() { key: "current_step", width: 150, ellipsis: true, - render: (step: string) => ( - {step || "-"} - ), + render: (step: string) => {step || "-"}, }, { title: "耗时", dataIndex: "duration_seconds", key: "duration_seconds", width: 100, - render: (seconds: number) => ( - {formatDuration(seconds)} - ), + render: (seconds: number) => {formatDuration(seconds)}, }, { title: "创建时间", dataIndex: "created_at", key: "created_at", width: 120, - render: (time: string) => ( - {formatTime(time)} - ), + render: (time: string) => {formatTime(time)}, }, { title: "操作", @@ -287,7 +261,7 @@ export default function TaskCenter() { 重试 - ); + ) } if (record.status === "failed") { return ( @@ -296,26 +270,26 @@ export default function TaskCenter() { size="small" icon={} onClick={() => { - setExpandedTaskId(record.id); - setExpandedTaskDetail(record); + setExpandedTaskId(record.id) + setExpandedTaskDetail(record) }} > 详情 - ); + ) } - return -; + return - }, }, - ]; + ] // 展开行渲染(错误详情) const expandedRowRender = (record: TaskItem) => { - const detail = expandedTaskDetail || record; - const errorInfo = detail.error_info; + const detail = expandedTaskDetail || record + const errorInfo = detail.error_info if (!errorInfo && !detail.error_message) { - return
暂无错误详情
; + return
暂无错误详情
} return ( @@ -353,8 +327,8 @@ export default function TaskCenter() { )}
- ); - }; + ) + } // 错误处理 if (error) { @@ -366,7 +340,7 @@ export default function TaskCenter() {
- ); + ) } return ( @@ -383,8 +357,8 @@ export default function TaskCenter() { { - setStatusFilter(key as TaskStatus | "all"); - setPage(1); + setStatusFilter(key as TaskStatus | "all") + setPage(1) }} items={STATUS_TABS.map((tab) => ({ key: tab.key, @@ -398,8 +372,8 @@ export default function TaskCenter() { = ({ accept="audio/*" style={{ display: "none" }} onChange={(e) => { - const f = e.target.files?.[0]; - if (f) setFile(f); + const f = e.target.files?.[0] + if (f) setFile(f) }} /> {file ? (
{file.name} - - {formatFileSize(file.size)} - + {formatFileSize(file.size)}
- ); -}; + ) +} /* ============================================================ * 卡片组件 * ============================================================ */ interface VoiceCardProps { - material: VoiceMaterial; - isPlaying: boolean; - currentTime: number; - isSelected: boolean; - batchMode: boolean; - volume: number; - tagMap: Map; - onPlay: () => void; - onPause: () => void; - onSeek: (time: number) => void; - onEdit: () => void; - onDelete: () => void; - onToggleSelect: (id: string) => void; - onVolumeChange: (e: React.ChangeEvent) => void; - onToggleMute: () => void; + material: VoiceMaterial + isPlaying: boolean + currentTime: number + isSelected: boolean + batchMode: boolean + volume: number + tagMap: Map + onPlay: () => void + onPause: () => void + onSeek: (time: number) => void + onEdit: () => void + onDelete: () => void + onToggleSelect: (id: string) => void + onVolumeChange: (e: React.ChangeEvent) => void + onToggleMute: () => void } const VoiceMaterialCard: React.FC = ({ @@ -603,38 +557,34 @@ const VoiceMaterialCard: React.FC = ({ onVolumeChange, onToggleMute, }) => { - const progressRef = useRef(null); + const progressRef = useRef(null) const handleProgressMouseDown = (e: React.MouseEvent) => { - if (!progressRef.current) return; - e.preventDefault(); + if (!progressRef.current) return + e.preventDefault() const doSeek = (ev: MouseEvent) => { - if (!progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = Math.max( - 0, - Math.min(1, (ev.clientX - rect.left) / rect.width), - ); - onSeek(percent * material.duration); - }; - doSeek(e.nativeEvent); - const handleMove = (ev: MouseEvent) => doSeek(ev); + if (!progressRef.current) return + const rect = progressRef.current.getBoundingClientRect() + const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)) + onSeek(percent * material.duration) + } + doSeek(e.nativeEvent) + const handleMove = (ev: MouseEvent) => doSeek(ev) const handleUp = () => { - document.removeEventListener("mousemove", handleMove); - document.removeEventListener("mouseup", handleUp); - }; - document.addEventListener("mousemove", handleMove); - document.addEventListener("mouseup", handleUp); - }; + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleUp) + } + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleUp) + } - const progress = - material.duration > 0 ? (currentTime / material.duration) * 100 : 0; + const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0 const handleCardClick = () => { if (batchMode) { - onToggleSelect(material.id); + onToggleSelect(material.id) } - }; + } return (
= ({
{ - e.stopPropagation(); - onToggleSelect(material.id); + e.stopPropagation() + onToggleSelect(material.id) }} > {isSelected && } @@ -660,8 +610,8 @@ const VoiceMaterialCard: React.FC = ({ type="button" className="vmat-card-action-btn" onClick={(e) => { - e.stopPropagation(); - onEdit(); + e.stopPropagation() + onEdit() }} title="编辑" > @@ -671,8 +621,8 @@ const VoiceMaterialCard: React.FC = ({ type="button" className="vmat-card-action-btn vmat-card-action-btn--danger" onClick={(e) => { - e.stopPropagation(); - onDelete(); + e.stopPropagation() + onDelete() }} title="删除" > @@ -697,9 +647,7 @@ const VoiceMaterialCard: React.FC = ({
{/* 描述 */} - {material.description && ( -

{material.description}

- )} + {material.description &&

{material.description}

} {/* 标签 */}
@@ -707,8 +655,8 @@ const VoiceMaterialCard: React.FC = ({ { - e.stopPropagation(); - onEdit(); + e.stopPropagation() + onEdit() }} > 添加标签 @@ -727,9 +675,7 @@ const VoiceMaterialCard: React.FC = ({ .map((id) => tagMap.get(id)?.name ?? id) .join("、")} > - - +{material.tagIds.length - MAX_CARD_TAGS} - + +{material.tagIds.length - MAX_CARD_TAGS} )} @@ -749,33 +695,19 @@ const VoiceMaterialCard: React.FC = ({ type="button" className="vmat-play-btn" onClick={(e) => { - e.stopPropagation(); - isPlaying ? onPause() : onPlay(); + e.stopPropagation() + isPlaying ? onPause() : onPlay() }} disabled={!material.fileUrl} > {isPlaying ? : } -
-
- {isPlaying && ( -
- )} +
+
+ {isPlaying &&
}
- {isPlaying - ? formatDuration(currentTime) - : formatDuration(material.duration)} + {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} {/* 音量控制 */}
@@ -783,8 +715,8 @@ const VoiceMaterialCard: React.FC = ({ type="button" className="vmat-volume-btn" onClick={(e) => { - e.stopPropagation(); - onToggleMute(); + e.stopPropagation() + onToggleMute() }} title={volume === 0 ? "取消静音" : "静音"} > @@ -798,34 +730,34 @@ const VoiceMaterialCard: React.FC = ({ step={0.05} value={volume} onChange={(e) => { - e.stopPropagation(); - onVolumeChange(e); + e.stopPropagation() + onVolumeChange(e) }} onClick={(e) => e.stopPropagation()} />
- ); -}; + ) +} /* ============================================================ * 列表行组件 * ============================================================ */ interface VoiceRowProps { - material: VoiceMaterial; - isPlaying: boolean; - currentTime: number; - isSelected: boolean; - batchMode: boolean; - tagMap: Map; - onPlay: () => void; - onPause: () => void; - onSeek: (time: number) => void; - onEdit: () => void; - onDelete: () => void; - onToggleSelect: (id: string) => void; + material: VoiceMaterial + isPlaying: boolean + currentTime: number + isSelected: boolean + batchMode: boolean + tagMap: Map + onPlay: () => void + onPause: () => void + onSeek: (time: number) => void + onEdit: () => void + onDelete: () => void + onToggleSelect: (id: string) => void } const VoiceMaterialRow: React.FC = ({ @@ -842,32 +774,28 @@ const VoiceMaterialRow: React.FC = ({ onDelete, onToggleSelect, }) => { - const progressRef = useRef(null); + const progressRef = useRef(null) const handleProgressMouseDown = (e: React.MouseEvent) => { - if (!progressRef.current) return; - e.preventDefault(); + if (!progressRef.current) return + e.preventDefault() const doSeek = (ev: MouseEvent) => { - if (!progressRef.current) return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = Math.max( - 0, - Math.min(1, (ev.clientX - rect.left) / rect.width), - ); - onSeek(percent * material.duration); - }; - doSeek(e.nativeEvent); - const handleMove = (ev: MouseEvent) => doSeek(ev); + if (!progressRef.current) return + const rect = progressRef.current.getBoundingClientRect() + const percent = Math.max(0, Math.min(1, (ev.clientX - rect.left) / rect.width)) + onSeek(percent * material.duration) + } + doSeek(e.nativeEvent) + const handleMove = (ev: MouseEvent) => doSeek(ev) const handleUp = () => { - document.removeEventListener("mousemove", handleMove); - document.removeEventListener("mouseup", handleUp); - }; - document.addEventListener("mousemove", handleMove); - document.addEventListener("mouseup", handleUp); - }; + document.removeEventListener("mousemove", handleMove) + document.removeEventListener("mouseup", handleUp) + } + document.addEventListener("mousemove", handleMove) + document.addEventListener("mouseup", handleUp) + } - const progress = - material.duration > 0 ? (currentTime / material.duration) * 100 : 0; + const progress = material.duration > 0 ? (currentTime / material.duration) * 100 : 0 return (
= ({
{ - e.stopPropagation(); - onToggleSelect(material.id); + e.stopPropagation() + onToggleSelect(material.id) }} > {isSelected && } @@ -891,8 +819,8 @@ const VoiceMaterialRow: React.FC = ({ type="button" className="vmat-row-play" onClick={(e) => { - e.stopPropagation(); - isPlaying ? onPause() : onPlay(); + e.stopPropagation() + isPlaying ? onPause() : onPlay() }} disabled={!material.fileUrl} > @@ -902,9 +830,7 @@ const VoiceMaterialRow: React.FC = ({ {/* 名称 + 描述 */}

{material.name}

- {material.description && ( -

{material.description}

- )} + {material.description &&

{material.description}

}
{/* 性别 */} @@ -933,9 +859,7 @@ const VoiceMaterialRow: React.FC = ({ .map((id) => tagMap.get(id)?.name ?? id) .join("、")} > - - +{material.tagIds.length - MAX_ROW_TAGS} - + +{material.tagIds.length - MAX_ROW_TAGS} )} @@ -943,28 +867,14 @@ const VoiceMaterialRow: React.FC = ({
{/* 进度条(可拖拽) */} -
-
- {isPlaying && ( -
- )} +
+
+ {isPlaying &&
}
{/* 时长 */} - {isPlaying - ? formatDuration(currentTime) - : formatDuration(material.duration)} + {isPlaying ? formatDuration(currentTime) : formatDuration(material.duration)} {/* 文件大小 */} @@ -976,8 +886,8 @@ const VoiceMaterialRow: React.FC = ({ type="button" className="vmat-row-action-btn" onClick={(e) => { - e.stopPropagation(); - onEdit(); + e.stopPropagation() + onEdit() }} title="编辑" > @@ -987,8 +897,8 @@ const VoiceMaterialRow: React.FC = ({ type="button" className="vmat-row-action-btn vmat-row-action-btn--danger" onClick={(e) => { - e.stopPropagation(); - onDelete(); + e.stopPropagation() + onDelete() }} title="删除" > @@ -996,83 +906,80 @@ const VoiceMaterialRow: React.FC = ({
- ); -}; + ) +} /* ============================================================ * 主组件 * ============================================================ */ const VoiceMaterialLibrary: React.FC = () => { - const queryClient = useQueryClient(); + const queryClient = useQueryClient() // ── 获取 voice 类型素材库(用于上传) ────────────────────── const { data: libraries = [] } = useQuery({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, staleTime: 60_000, - }); + }) - const voiceLibrary = useMemo( - () => libraries.find((lib) => lib.kind === "voice"), - [libraries], - ); + const voiceLibrary = useMemo(() => libraries.find((lib) => lib.kind === "voice"), [libraries]) // 自动创建 voice 素材库(如果不存在) const createLibMutation = useMutation({ mutationFn: () => createAssetLibrary({ name: "配音素材库", kind: "voice" }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); + queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }) }, - }); + }) // 页面加载时,如果没有 voice 库则创建 useEffect(() => { if (libraries.length > 0 && !voiceLibrary && !createLibMutation.isPending) { - createLibMutation.mutate(); + createLibMutation.mutate() } - }, [libraries, voiceLibrary, createLibMutation]); + }, [libraries, voiceLibrary, createLibMutation]) // ── 获取标签列表 ─────────────────────────────────────────── const { data: tags = [] } = useQuery({ queryKey: ["tags"], queryFn: getTags, staleTime: 60_000, - }); + }) /** 标签 ID → TagItem 映射(用于卡片/行渲染) */ const tagMap = useMemo(() => { - const m = new Map(); - tags.forEach((t) => m.set(t.id, t)); - return m; - }, [tags]); + const m = new Map() + tags.forEach((t) => m.set(t.id, t)) + return m + }, [tags]) /** 创建标签 mutation(供 TagSelector 调用) */ const createTagMutation = useMutation({ mutationFn: (name: string) => createTag(name), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["tags"] }) }, - }); + }) /** 创建标签并返回 TagItem(供 TagSelector 使用) */ const handleCreateTag = useCallback( async (name: string): Promise => { - return createTagMutation.mutateAsync(name); + return createTagMutation.mutateAsync(name) }, [createTagMutation], - ); + ) // ── 视图状态 ────────────────────────────────────────────── - const [viewMode, setViewMode] = useState("card"); - const [searchText, setSearchText] = useState(""); - const [filterGender, setFilterGender] = useState("all"); - const [filterTagId, setFilterTagId] = useState("all"); + const [viewMode, setViewMode] = useState("card") + const [searchText, setSearchText] = useState("") + const [filterGender, setFilterGender] = useState("all") + const [filterTagId, setFilterTagId] = useState("all") // ── 获取配音素材列表(筛选参数透传后端) ───────────────── - const filterKeyword = searchText.trim() || undefined; - const filterGenderParam = filterGender !== "all" ? filterGender : undefined; - const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined; + const filterKeyword = searchText.trim() || undefined + const filterGenderParam = filterGender !== "all" ? filterGender : undefined + const filterTagIdsParam = filterTagId !== "all" ? [filterTagId] : undefined const { data: assets = [], isLoading } = useQuery({ queryKey: [ @@ -1091,44 +998,41 @@ const VoiceMaterialLibrary: React.FC = () => { tag_ids: filterTagIdsParam, }), staleTime: 30_000, - }); + }) - const materials: VoiceMaterial[] = useMemo( - () => assets.map(mapAssetToMaterial), - [assets], - ); + const materials: VoiceMaterial[] = useMemo(() => assets.map(mapAssetToMaterial), [assets]) // ── 获取预设音色列表(AI 配音用) ───────────────────────── const { data: presetVoicesData } = useQuery({ queryKey: ["preset-voices"], queryFn: fetchPresetVoices, staleTime: 60_000, - }); - const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? []; + }) + const presetVoices: PresetVoiceItem[] = presetVoicesData?.items ?? [] // ── 上传 mutation ───────────────────────────────────────── const uploadMutation = useMutation({ mutationFn: async (data: { - file: File; - name: string; - gender: VoiceGender; - description: string; - tagIds: string[]; + file: File + name: string + gender: VoiceGender + description: string + tagIds: string[] }) => { - setUploadProgress(0); + setUploadProgress(0) try { // 1. 获取或等待 voice library - let lib = voiceLibrary; + let lib = voiceLibrary if (!lib) { if (createLibMutation.isPending) { - await createLibMutation.mutateAsync(); + await createLibMutation.mutateAsync() } const libs = await queryClient.fetchQuery({ queryKey: ["asset-libraries"], queryFn: getAssetLibraries, - }); - lib = libs.find((l) => l.kind === "voice"); - if (!lib) throw new Error("无法创建配音素材库"); + }) + lib = libs.find((l) => l.kind === "voice") + if (!lib) throw new Error("无法创建配音素材库") } // 2. 上传文件(带进度) @@ -1136,10 +1040,10 @@ const VoiceMaterialLibrary: React.FC = () => { file: data.file, library_id: lib.id, onProgress: (p) => setUploadProgress(p), - }); + }) // 3. 获取音频时长 - const duration = await getAudioDuration(data.file); + const duration = await getAudioDuration(data.file) // 4. 创建素材记录 const asset = await createAsset({ @@ -1152,33 +1056,33 @@ const VoiceMaterialLibrary: React.FC = () => { description: data.description, duration, }), - }); + }) // 5. 打标签(标签走独立 API) if (data.tagIds.length > 0) { - await tagAsset(asset.id, data.tagIds); + await tagAsset(asset.id, data.tagIds) } } finally { - setUploadProgress(null); + setUploadProgress(null) } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) }, onError: (err: Error) => { - message.error(err.message || "上传失败,请重试"); + message.error(err.message || "上传失败,请重试") }, - }); + }) // ── 编辑 mutation ───────────────────────────────────────── const editMutation = useMutation({ mutationFn: async (data: { - id: string; - name: string; - gender: VoiceGender; - description: string; - tagIds: string[]; + id: string + name: string + gender: VoiceGender + description: string + tagIds: string[] }) => { // 1. 更新基础信息 await updateAsset(data.id, { @@ -1187,192 +1091,177 @@ const VoiceMaterialLibrary: React.FC = () => { gender: data.gender, description: data.description, }), - }); + }) // 2. 对比标签差异,调用 tag/untag API - const currentAsset = materials.find((m) => m.id === data.id); - const oldTagIds = currentAsset?.tagIds ?? []; - const newTagIds = data.tagIds; + const currentAsset = materials.find((m) => m.id === data.id) + const oldTagIds = currentAsset?.tagIds ?? [] + const newTagIds = data.tagIds - const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)); - const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)); + const toAdd = newTagIds.filter((id) => !oldTagIds.includes(id)) + const toRemove = oldTagIds.filter((id) => !newTagIds.includes(id)) if (toAdd.length > 0) { - await tagAsset(data.id, toAdd); + await tagAsset(data.id, toAdd) } for (const tagId of toRemove) { - await untagAsset(data.id, tagId); + await untagAsset(data.id, tagId) } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - queryClient.invalidateQueries({ queryKey: ["tags"] }); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + queryClient.invalidateQueries({ queryKey: ["tags"] }) }, - }); + }) // ── 删除 mutation ───────────────────────────────────────── const deleteMutation = useMutation({ mutationFn: (assetId: string) => deleteAsset(assetId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) }, - }); + }) // ── 播放状态 ────────────────────────────────────────────── - const [playingId, setPlayingId] = useState(null); - const [currentTime, setCurrentTime] = useState(0); - const audioRef = useRef(null); + const [playingId, setPlayingId] = useState(null) + const [currentTime, setCurrentTime] = useState(0) + const audioRef = useRef(null) // ── 弹窗状态 ────────────────────────────────────────────── - const [uploadOpen, setUploadOpen] = useState(false); - const [editingMaterial, setEditingMaterial] = useState( - null, - ); + const [uploadOpen, setUploadOpen] = useState(false) + const [editingMaterial, setEditingMaterial] = useState(null) // ── 批量操作 / 上传进度 / 音量 ──────────────────────────── - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [uploadProgress, setUploadProgress] = useState(null); - const [volume, setVolume] = useState(0.7); - const [pausedMaterial, setPausedMaterial] = useState( - null, - ); - const [batchCustomTag, setBatchCustomTag] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [uploadProgress, setUploadProgress] = useState(null) + const [volume, setVolume] = useState(0.7) + const [pausedMaterial, setPausedMaterial] = useState(null) + const [batchCustomTag, setBatchCustomTag] = useState("") // ── AI 配音(TTS 合成)状态 ──────────────────────────────── - const [ttsOpen, setTtsOpen] = useState(false); - const [ttsText, setTtsText] = useState(""); - const [ttsVoiceId, setTtsVoiceId] = useState(""); - const [ttsSpeed, setTtsSpeed] = useState(1.0); - const [ttsJobId, setTtsJobId] = useState(null); - const [ttsStatus, setTtsStatus] = useState< - "idle" | "synthesizing" | "done" | "error" - >("idle"); - const [ttsAudioUrl, setTtsAudioUrl] = useState(null); - const [ttsError, setTtsError] = useState(null); - const ttsTimerRef = useRef | null>(null); + const [ttsOpen, setTtsOpen] = useState(false) + const [ttsText, setTtsText] = useState("") + const [ttsVoiceId, setTtsVoiceId] = useState("") + const [ttsSpeed, setTtsSpeed] = useState(1.0) + const [ttsJobId, setTtsJobId] = useState(null) + const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle") + const [ttsAudioUrl, setTtsAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const ttsTimerRef = useRef | null>(null) // ── 播放控制 ────────────────────────────────────────────── const stopPlayback = useCallback(() => { if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; + audioRef.current.pause() + audioRef.current = null } - setPlayingId(null); - setCurrentTime(0); - setPausedMaterial(null); - }, []); + setPlayingId(null) + setCurrentTime(0) + setPausedMaterial(null) + }, []) const startPlayback = useCallback( (material: VoiceMaterial) => { - if (!material.fileUrl) return; - stopPlayback(); + if (!material.fileUrl) return + stopPlayback() - const audio = new Audio(material.fileUrl); - audio.volume = volume; - audioRef.current = audio; + const audio = new Audio(material.fileUrl) + audio.volume = volume + audioRef.current = audio audio.addEventListener("timeupdate", () => { - setCurrentTime(audio.currentTime); - }); + setCurrentTime(audio.currentTime) + }) audio.addEventListener("ended", () => { - setPlayingId(null); - setCurrentTime(0); - audioRef.current = null; - setPausedMaterial(null); - }); + setPlayingId(null) + setCurrentTime(0) + audioRef.current = null + setPausedMaterial(null) + }) audio.play().catch(() => { - audioRef.current = null; - setPlayingId(null); - }); + audioRef.current = null + setPlayingId(null) + }) - setPlayingId(material.id); - setCurrentTime(0); - setPausedMaterial(null); + setPlayingId(material.id) + setCurrentTime(0) + setPausedMaterial(null) }, [stopPlayback, volume], - ); + ) const handlePlay = useCallback( (material: VoiceMaterial) => { - if (playingId === material.id) return; + if (playingId === material.id) return // 恢复暂停 - if ( - pausedMaterial?.id === material.id && - audioRef.current && - audioRef.current.paused - ) { - audioRef.current.play().catch(() => {}); - setPlayingId(material.id); - setPausedMaterial(null); - return; + if (pausedMaterial?.id === material.id && audioRef.current && audioRef.current.paused) { + audioRef.current.play().catch(() => {}) + setPlayingId(material.id) + setPausedMaterial(null) + return } - startPlayback(material); + startPlayback(material) }, [playingId, pausedMaterial, startPlayback], - ); + ) const handlePause = useCallback((material?: VoiceMaterial) => { if (audioRef.current) { - audioRef.current.pause(); + audioRef.current.pause() } - setPlayingId(null); - if (material) setPausedMaterial(material); - }, []); + setPlayingId(null) + if (material) setPausedMaterial(material) + }, []) const handleSeek = useCallback( (material: VoiceMaterial, time: number) => { if (audioRef.current) { - audioRef.current.currentTime = time; - setCurrentTime(time); + audioRef.current.currentTime = time + setCurrentTime(time) } else { - startPlayback(material); + startPlayback(material) setTimeout(() => { if (audioRef.current) { - audioRef.current.currentTime = time; + audioRef.current.currentTime = time } - }, 100); + }, 100) } }, [startPlayback], - ); + ) - const handleVolumeChange = useCallback( - (e: React.ChangeEvent) => { - const v = parseFloat(e.target.value); - setVolume(v); - if (audioRef.current) audioRef.current.volume = v; - }, - [], - ); + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const v = parseFloat(e.target.value) + setVolume(v) + if (audioRef.current) audioRef.current.volume = v + }, []) const toggleMute = useCallback(() => { if (volume > 0) { - setVolume(0); - if (audioRef.current) audioRef.current.volume = 0; + setVolume(0) + if (audioRef.current) audioRef.current.volume = 0 } else { - setVolume(0.7); - if (audioRef.current) audioRef.current.volume = 0.7; + setVolume(0.7) + if (audioRef.current) audioRef.current.volume = 0.7 } - }, [volume]); + }, [volume]) // 组件卸载时清理 audio useEffect(() => { return () => { if (audioRef.current) { - audioRef.current.pause(); - audioRef.current = null; + audioRef.current.pause() + audioRef.current = null } - }; - }, []); + } + }, []) /* ── 数据操作 ──────────────────────────────────────────── */ - const handleUpload = ( - data: Omit & { file?: File }, - ) => { - if (!data.file) return; + const handleUpload = (data: Omit & { file?: File }) => { + if (!data.file) return uploadMutation.mutate( { file: data.file, @@ -1383,29 +1272,27 @@ const VoiceMaterialLibrary: React.FC = () => { }, { onSuccess: () => { - setUploadOpen(false); + setUploadOpen(false) }, }, - ); - }; + ) + } - const handleEdit = ( - data: Omit & { file?: File }, - ) => { - if (!editingMaterial) return; + const handleEdit = (data: Omit & { file?: File }) => { + if (!editingMaterial) return editMutation.mutate({ id: editingMaterial.id, name: data.name, gender: data.gender, description: data.description, tagIds: data.tagIds, - }); - setEditingMaterial(null); - }; + }) + setEditingMaterial(null) + } const handleDelete = (id: string) => { - const material = materials.find((m) => m.id === id); - if (!material) return; + const material = materials.find((m) => m.id === id) + if (!material) return Modal.confirm({ title: "确认删除", content: `确定删除配音素材「${material.name}」吗?删除后不可恢复。`, @@ -1413,214 +1300,203 @@ const VoiceMaterialLibrary: React.FC = () => { okButtonProps: { danger: true }, cancelText: "取消", onOk: () => { - if (playingId === id) stopPlayback(); - deleteMutation.mutate(id); + if (playingId === id) stopPlayback() + deleteMutation.mutate(id) }, - }); - }; + }) + } /* ── 筛选 ─────────────────────────────────────────────── */ const filtered = React.useMemo(() => { - let list = materials; + let list = materials if (filterGender !== "all") { - list = list.filter((m) => m.gender === filterGender); + list = list.filter((m) => m.gender === filterGender) } if (filterTagId !== "all") { - list = list.filter((m) => m.tagIds.includes(filterTagId)); + list = list.filter((m) => m.tagIds.includes(filterTagId)) } if (searchText.trim()) { - const q = searchText.trim().toLowerCase(); + const q = searchText.trim().toLowerCase() list = list.filter( (m) => m.name.toLowerCase().includes(q) || m.description.toLowerCase().includes(q) || - m.tagIds.some((id) => - tagMap.get(id)?.name?.toLowerCase().includes(q), - ), - ); + m.tagIds.some((id) => tagMap.get(id)?.name?.toLowerCase().includes(q)), + ) } - return list; - }, [materials, filterGender, filterTagId, searchText, tagMap]); + return list + }, [materials, filterGender, filterTagId, searchText, tagMap]) /* ── 标签使用计数(药丸条展示,按 tag ID 统计) ──────────── */ const tagCountMap = React.useMemo(() => { - const map: Record = {}; + const map: Record = {} materials.forEach((m) => m.tagIds.forEach((id) => { - map[id] = (map[id] || 0) + 1; + map[id] = (map[id] || 0) + 1 }), - ); - return map; - }, [materials]); + ) + return map + }, [materials]) /* ── 批量操作 ─────────────────────────────────────────── */ - const batchMode = selectedIds.size > 0; - const allSelected = - filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)); + const batchMode = selectedIds.size > 0 + const allSelected = filtered.length > 0 && filtered.every((m) => selectedIds.has(m.id)) const handleToggleSelect = useCallback((id: string) => { setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }, []); + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) const handleSelectAll = useCallback(() => { - if (allSelected) setSelectedIds(new Set()); - else setSelectedIds(new Set(filtered.map((m) => m.id))); - }, [allSelected, filtered]); + if (allSelected) setSelectedIds(new Set()) + else setSelectedIds(new Set(filtered.map((m) => m.id))) + }, [allSelected, filtered]) const handleBatchDelete = useCallback(async () => { - const ids = Array.from(selectedIds); - let successCount = 0; + const ids = Array.from(selectedIds) + let successCount = 0 for (const id of ids) { try { - await deleteAsset(id); - successCount++; + await deleteAsset(id) + successCount++ } catch { /* ignore individual failures */ } - if (playingId === id) stopPlayback(); + if (playingId === id) stopPlayback() } - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - setSelectedIds(new Set()); - message.success(`已批量删除 ${successCount}/${ids.length} 个素材`); - }, [selectedIds, playingId, stopPlayback, queryClient]); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setSelectedIds(new Set()) + message.success(`已批量删除 ${successCount}/${ids.length} 个素材`) + }, [selectedIds, playingId, stopPlayback, queryClient]) const handleBatchTag = useCallback( async (tagId: string) => { - const ids = Array.from(selectedIds); - let successCount = 0; + const ids = Array.from(selectedIds) + let successCount = 0 for (const id of ids) { try { - await tagAsset(id, [tagId]); - successCount++; + await tagAsset(id, [tagId]) + successCount++ } catch { /* ignore individual failures */ } } - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - setSelectedIds(new Set()); - const tagName = tagMap.get(tagId)?.name ?? tagId; + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setSelectedIds(new Set()) + const tagName = tagMap.get(tagId)?.name ?? tagId if (successCount === 0) { - message.error(`批量打标签失败,请重试`); + message.error(`批量打标签失败,请重试`) } else { - message.success( - `已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`, - ); + message.success(`已为 ${successCount}/${ids.length} 个素材添加标签「${tagName}」`) } }, [selectedIds, queryClient, tagMap], - ); + ) /** 批量打标签 — 自定义输入:按名称查找或创建标签,再批量打标 */ const handleBatchCustomTag = useCallback( async (name: string) => { // 先查找同名标签(不区分大小写) - let existing = tags.find( - (t) => t.name.toLowerCase() === name.toLowerCase(), - ); + let existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase()) if (!existing) { try { - existing = await createTagMutation.mutateAsync(name); + existing = await createTagMutation.mutateAsync(name) } catch { - message.error(`创建标签「${name}」失败`); - return; + message.error(`创建标签「${name}」失败`) + return } } - await handleBatchTag(existing.id); + await handleBatchTag(existing.id) }, [tags, createTagMutation, handleBatchTag], - ); + ) // ── TTS 合成处理 ───────────────────────────────────────── /** 开始 AI 配音合成 */ const handleTtsSynthesize = useCallback(async () => { if (!ttsText.trim()) { - message.warning("请输入要合成的文本"); - return; + message.warning("请输入要合成的文本") + return } - setTtsError(null); - setTtsStatus("synthesizing"); - setTtsAudioUrl(null); - setTtsJobId(null); + setTtsError(null) + setTtsStatus("synthesizing") + setTtsAudioUrl(null) + setTtsJobId(null) try { const resp = await synthesizeSpeech({ text: ttsText.trim(), voice_id: ttsVoiceId || undefined, speed: ttsSpeed, - }); - setTtsJobId(resp.job_id); + }) + setTtsJobId(resp.job_id) // 轮询任务状态 ttsTimerRef.current = setInterval(async () => { try { - const job = await getTTSJobStatus(resp.job_id); + const job = await getTTSJobStatus(resp.job_id) if (job.status === "completed") { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("done"); - setTtsAudioUrl(job.output_audio_url); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("done") + setTtsAudioUrl(job.output_audio_url) } else if (job.status === "failed") { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("error"); - setTtsError(job.error_message || "合成失败"); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError(job.error_message || "合成失败") } } catch { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("error"); - setTtsError("查询合成状态失败"); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError("查询合成状态失败") } - }, 2000); + }, 2000) } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "合成请求失败"; - setTtsStatus("error"); - setTtsError(msg); + const msg = err instanceof Error ? err.message : "合成请求失败" + setTtsStatus("error") + setTtsError(msg) } - }, [ttsText, ttsVoiceId, ttsSpeed]); + }, [ttsText, ttsVoiceId, ttsSpeed]) /** 保存 TTS 结果到素材库 */ const handleTtsSave = useCallback(async () => { - if (!ttsJobId) return; + if (!ttsJobId) return try { await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 20) || "AI配音", - }); - message.success("已保存到配音素材库"); - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - setTtsOpen(false); + }) + message.success("已保存到配音素材库") + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setTtsOpen(false) } catch { - message.error("保存失败"); + message.error("保存失败") } - }, [ttsJobId, ttsText, queryClient]); + }, [ttsJobId, ttsText, queryClient]) // TTS 定时器清理 useEffect(() => { return () => { - if (ttsTimerRef.current) clearInterval(ttsTimerRef.current); - }; - }, []); + if (ttsTimerRef.current) clearInterval(ttsTimerRef.current) + } + }, []) /* ── 渲染 ─────────────────────────────────────────────── */ - const isUploading = uploadMutation.isPending; - const isEditing = editMutation.isPending; + const isUploading = uploadMutation.isPending + const isEditing = editMutation.isPending const pageActions = (
-
- ); + ) return (
@@ -1709,9 +1585,7 @@ const VoiceMaterialLibrary: React.FC = () => { tabIndex={0} > {tag.name} - - {tagCountMap[tag.id] || 0} - + {tagCountMap[tag.id] || 0} ))}
@@ -1740,9 +1614,7 @@ const VoiceMaterialLibrary: React.FC = () => { {allSelected ? "取消全选" : "全选"} - - 已选择 {selectedIds.size} 项 - + 已选择 {selectedIds.size} 项
{ } onKeyDown={(e: React.KeyboardEvent) => { if (e.key === "Enter" && batchCustomTag.trim()) { - handleBatchCustomTag(batchCustomTag.trim()); - setBatchCustomTag(""); + handleBatchCustomTag(batchCustomTag.trim()) + setBatchCustomTag("") } }} /> @@ -1778,11 +1650,7 @@ const VoiceMaterialLibrary: React.FC = () => { title="批量打标签" trigger="click" > - @@ -1792,11 +1660,7 @@ const VoiceMaterialLibrary: React.FC = () => { okText="删除" cancelText="取消" > - @@ -1935,15 +1799,15 @@ const VoiceMaterialLibrary: React.FC = () => { title="AI 配音" open={ttsOpen} onCancel={() => { - setTtsOpen(false); + setTtsOpen(false) if (ttsTimerRef.current) { - clearInterval(ttsTimerRef.current); - ttsTimerRef.current = null; + clearInterval(ttsTimerRef.current) + ttsTimerRef.current = null } - setTtsStatus("idle"); - setTtsAudioUrl(null); - setTtsError(null); - setTtsJobId(null); + setTtsStatus("idle") + setTtsAudioUrl(null) + setTtsError(null) + setTtsJobId(null) }} footer={null} width={560} @@ -2051,13 +1915,7 @@ const VoiceMaterialLibrary: React.FC = () => {
- ); -}; + ) +} -export default VoiceMaterialLibrary; +export default VoiceMaterialLibrary diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index e8e83b0d2..78188d8fe 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -7,14 +7,8 @@ * Tab 2:我的克隆 — getVoiceClonesWithTotal() * 统计:fetchVoices({ limit: 1 }) 获取 preset_count / clone_count */ -import React, { - useMemo, - useState, - useRef, - useEffect, - useCallback, -} from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import React, { useMemo, useState, useRef, useEffect, useCallback } from "react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { SoundOutlined, PlayCircleOutlined, @@ -29,67 +23,59 @@ import { DeleteOutlined, ReloadOutlined, CloseCircleOutlined, -} from "@ant-design/icons"; -import { Modal, Upload, message } from "antd"; -import { Button, Input, Select, Tooltip } from "@/components/ui"; -import PageHead from "@/components/layout/PageHead"; -import { - fetchPresetVoices, - fetchVoices, - type PresetVoiceItem, -} from "@/api/voices"; +} from "@ant-design/icons" +import { Modal, Upload, message } from "antd" +import { Button, Input, Select, Tooltip } from "@/components/ui" +import PageHead from "@/components/layout/PageHead" +import { fetchPresetVoices, fetchVoices, type PresetVoiceItem } from "@/api/voices" import { getVoiceClonesWithTotal, deleteVoiceClone, retryVoiceClone, toVoiceClone, type VoiceClone, -} from "@/api/voiceClone"; -import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts"; -import { - uploadAssetDirect, - getAssetLibraries, - createAsset, -} from "@/api/assets"; -import CloneModal from "@/components/voice/CloneModal"; -import "./voices.css"; +} from "@/api/voiceClone" +import { synthesizeSpeech, getTTSJobStatus, saveTtsToLibrary } from "@/api/tts" +import { uploadAssetDirect, getAssetLibraries, createAsset } from "@/api/assets" +import CloneModal from "@/components/voice/CloneModal" +import "./voices.css" /* ============================================================ * 类型 * ============================================================ */ -type VoiceGender = "male" | "female" | "child" | "elderly"; -type VoiceLanguage = "zh" | "en" | "ja" | "ko"; -type TabKey = "preset" | "cloned"; +type VoiceGender = "male" | "female" | "child" | "elderly" +type VoiceLanguage = "zh" | "en" | "ja" | "ko" +type TabKey = "preset" | "cloned" /** 前端展示用的预置音色(从 PresetVoiceItem 映射) */ interface PresetVoiceDisplay { - id: string; - name: string; - gender: VoiceGender; - language: VoiceLanguage; - duration: number; - tags: string[]; - description: string; - voiceId: string; - previewUrl: string; - starred: boolean; + id: string + name: string + gender: VoiceGender + language: VoiceLanguage + duration: number + tags: string[] + description: string + voiceId: string + previewUrl: string + starred: boolean } /** 前端展示用的克隆音色(从 VoiceClone 映射) */ interface ClonedVoiceDisplay { - id: string; - name: string; - description: string; - sourceName: string; - status: "ready" | "processing" | "failed"; - createdAt: string; - duration: number; - tags: string[]; - voiceId: string; - language: string; - gender: string; - errorMessage: string | null; - sampleUrl?: string; + id: string + name: string + description: string + sourceName: string + status: "ready" | "processing" | "failed" + createdAt: string + duration: number + tags: string[] + voiceId: string + language: string + gender: string + errorMessage: string | null + sampleUrl?: string } /* ============================================================ @@ -107,7 +93,7 @@ const mapPresetToDisplay = (item: PresetVoiceItem): PresetVoiceDisplay => ({ voiceId: item.voice_id, previewUrl: item.preview_url || "", starred: false, -}); +}) const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({ id: clone.id, @@ -123,7 +109,7 @@ const mapCloneToDisplay = (clone: VoiceClone): ClonedVoiceDisplay => ({ gender: clone.gender || "", errorMessage: clone.error_message || null, sampleUrl: clone.sample_url || undefined, -}); +}) /* ============================================================ * 工具函数 @@ -134,9 +120,9 @@ const genderLabel = (g: VoiceGender) => { female: "女声", child: "童声", elderly: "老年", - }; - return map[g]; -}; + } + return map[g] +} const languageLabel = (l: VoiceLanguage) => { const map: Record = { @@ -144,38 +130,38 @@ const languageLabel = (l: VoiceLanguage) => { en: "英文", ja: "日文", ko: "韩文", - }; - return map[l]; -}; + } + return map[l] +} const formatTime = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; -}; + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` +} -const genderClass = (g: VoiceGender) => `xx-voice-gender--${g}`; +const genderClass = (g: VoiceGender) => `xx-voice-gender--${g}` /* ============================================================ * VoiceCard 组件(预置音色 + 克隆音色统一) * ============================================================ */ interface VoiceCardProps { - id: string; - name: string; - subtitle: string; - tags: string[]; - duration: number; - gender: VoiceGender; - isPlaying: boolean; - isSelected: boolean; - currentTime: number; - starred?: boolean; - status?: "ready" | "processing" | "failed"; - onPlay: () => void; - onPause: () => void; - onSeek: (time: number) => void; - onSelect?: () => void; - onToggleStar?: () => void; + id: string + name: string + subtitle: string + tags: string[] + duration: number + gender: VoiceGender + isPlaying: boolean + isSelected: boolean + currentTime: number + starred?: boolean + status?: "ready" | "processing" | "failed" + onPlay: () => void + onPause: () => void + onSeek: (time: number) => void + onSelect?: () => void + onToggleStar?: () => void } const VoiceCard: React.FC = ({ @@ -196,16 +182,16 @@ const VoiceCard: React.FC = ({ onSelect, onToggleStar, }) => { - const progressRef = useRef(null); + const progressRef = useRef(null) const handleProgressClick = (e: React.MouseEvent) => { - if (!progressRef.current || status !== "ready") return; - const rect = progressRef.current.getBoundingClientRect(); - const percent = (e.clientX - rect.left) / rect.width; - onSeek(percent * duration); - }; + if (!progressRef.current || status !== "ready") return + const rect = progressRef.current.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + onSeek(percent * duration) + } - const progress = duration > 0 ? (currentTime / duration) * 100 : 0; + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 return (
= ({ -
-
+
+
{isPlaying ? formatTime(currentTime) : formatTime(duration)} @@ -284,8 +263,8 @@ const VoiceCard: React.FC = ({
)}
- ); -}; + ) +} /* ============================================================ * 克隆音色卡片组件(任务 3.12) @@ -299,28 +278,28 @@ const CLONE_STATUS_CONFIG: Record< ready: { label: "可用", className: "xx-clone-status--ready" }, processing: { label: "处理中", className: "xx-clone-status--processing" }, failed: { label: "失败", className: "xx-clone-status--failed" }, -}; +} /** Toast 类型 */ interface Toast { - id: number; - message: string; - type: "success" | "error"; + id: number + message: string + type: "success" | "error" } -let toastIdSeq = 0; +let toastIdSeq = 0 /** 克隆音色卡片 */ interface CloneVoiceCardProps { - voice: ClonedVoiceDisplay; - isPlaying: boolean; - currentTime: number; - onPlay: () => void; - onPause: () => void; - onUse: () => void; - onDelete: () => void; - onRetry: () => void; - onShowDetail: () => void; + voice: ClonedVoiceDisplay + isPlaying: boolean + currentTime: number + onPlay: () => void + onPause: () => void + onUse: () => void + onDelete: () => void + onRetry: () => void + onShowDetail: () => void } const CloneVoiceCard: React.FC = ({ @@ -334,15 +313,11 @@ const CloneVoiceCard: React.FC = ({ onRetry, onShowDetail, }) => { - const statusCfg = CLONE_STATUS_CONFIG[voice.status]; - const isFailed = voice.status === "failed"; - const isProcessing = voice.status === "processing"; + const statusCfg = CLONE_STATUS_CONFIG[voice.status] + const isFailed = voice.status === "failed" + const isProcessing = voice.status === "processing" const genderText = - voice.gender === "male" - ? "男声" - : voice.gender === "female" - ? "女声" - : voice.gender; + voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender return (
= ({ type="button" className="xx-clone-action-btn xx-clone-action-btn--danger" onClick={(e) => { - e.stopPropagation(); - onDelete(); + e.stopPropagation() + onDelete() }} > @@ -369,8 +344,8 @@ const CloneVoiceCard: React.FC = ({ type="button" className="xx-clone-action-btn" onClick={(e) => { - e.stopPropagation(); - onRetry(); + e.stopPropagation() + onRetry() }} > @@ -381,9 +356,7 @@ const CloneVoiceCard: React.FC = ({ {/* 头部:头像 + 名称 + 状态 */}
-
+
@@ -398,9 +371,7 @@ const CloneVoiceCard: React.FC = ({
{/* 描述 */} - {voice.description && ( -

{voice.description}

- )} + {voice.description &&

{voice.description}

} {/* 元信息 */}
@@ -430,8 +401,8 @@ const CloneVoiceCard: React.FC = ({ type="button" className="xx-clone-play-btn" onClick={(e) => { - e.stopPropagation(); - isPlaying ? onPause() : onPlay(); + e.stopPropagation() + isPlaying ? onPause() : onPlay() }} title={isPlaying ? "暂停" : "试听"} > @@ -451,8 +422,8 @@ const CloneVoiceCard: React.FC = ({ type="button" className="xx-clone-use-btn" onClick={(e) => { - e.stopPropagation(); - onUse(); + e.stopPropagation() + onUse() }} > 使用 @@ -470,8 +441,8 @@ const CloneVoiceCard: React.FC = ({ type="button" className="xx-clone-retry-btn" onClick={(e) => { - e.stopPropagation(); - onRetry(); + e.stopPropagation() + onRetry() }} > @@ -480,34 +451,26 @@ const CloneVoiceCard: React.FC = ({ )}
- ); -}; + ) +} /** 克隆音色详情弹窗 */ const CloneDetailModal: React.FC<{ - voice: ClonedVoiceDisplay; - onClose: () => void; - onUse: () => void; - onDelete: () => void; - onRetry: () => void; + voice: ClonedVoiceDisplay + onClose: () => void + onUse: () => void + onDelete: () => void + onRetry: () => void }> = ({ voice, onClose, onUse, onDelete, onRetry }) => { - const statusCfg = CLONE_STATUS_CONFIG[voice.status]; + const statusCfg = CLONE_STATUS_CONFIG[voice.status] const genderText = - voice.gender === "male" - ? "男声" - : voice.gender === "female" - ? "女声" - : voice.gender || "未知"; - const langText = voice.language || "未知"; + voice.gender === "male" ? "男声" : voice.gender === "female" ? "女声" : voice.gender || "未知" + const langText = voice.language || "未知" return (
e.stopPropagation()}> - @@ -524,9 +487,7 @@ const CloneDetailModal: React.FC<{
- {voice.description && ( -

{voice.description}

- )} + {voice.description &&

{voice.description}

}
@@ -546,10 +507,7 @@ const CloneDetailModal: React.FC<{ {voice.createdAt}
{voice.errorMessage && ( -
+
错误 {voice.errorMessage}
@@ -558,21 +516,11 @@ const CloneDetailModal: React.FC<{
{voice.status === "failed" && ( - )} - {voice.status === "ready" && ( @@ -583,8 +531,8 @@ const CloneDetailModal: React.FC<{
- ); -}; + ) +} /** 骨架屏 */ const CloneCardSkeleton: React.FC = () => ( @@ -598,151 +546,147 @@ const CloneCardSkeleton: React.FC = () => (
-); +) /* ── 辅助函数 ─────────────────────────────────────────── */ const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} const getAudioDuration = (file: File): Promise => new Promise((resolve) => { - const audio = new Audio(); - const url = URL.createObjectURL(file); + const audio = new Audio() + const url = URL.createObjectURL(file) audio.addEventListener("loadedmetadata", () => { - resolve(audio.duration); - URL.revokeObjectURL(url); - }); + resolve(audio.duration) + URL.revokeObjectURL(url) + }) audio.addEventListener("error", () => { - resolve(0); - URL.revokeObjectURL(url); - }); - audio.src = url; - }); + resolve(0) + URL.revokeObjectURL(url) + }) + audio.src = url + }) /** 音色上传元数据(传递给 createAsset 的 metadata) */ interface VoiceUploadMetadata { - gender?: string; - description?: string; - duration?: number; - [key: string]: unknown; + gender?: string + description?: string + duration?: number + [key: string]: unknown } const buildVoiceMetadata = (data: { - gender?: string; - description?: string; - duration?: number; + gender?: string + description?: string + duration?: number }): VoiceUploadMetadata => { - const metadata: VoiceUploadMetadata = {}; - if (data.gender) metadata.gender = data.gender; - if (data.description) metadata.description = data.description; - if (data.duration) metadata.duration = Math.round(data.duration); - return metadata; -}; + const metadata: VoiceUploadMetadata = {} + if (data.gender) metadata.gender = data.gender + if (data.description) metadata.description = data.description + if (data.duration) metadata.duration = Math.round(data.duration) + return metadata +} /* ============================================================ * 主组件 * ============================================================ */ const VoiceLibrary: React.FC = () => { - const [activeTab, setActiveTab] = useState("preset"); - const [searchText, setSearchText] = useState(""); - const [filterGender, setFilterGender] = useState("all"); - const [filterLang, setFilterLang] = useState("all"); + const [activeTab, setActiveTab] = useState("preset") + const [searchText, setSearchText] = useState("") + const [filterGender, setFilterGender] = useState("all") + const [filterLang, setFilterLang] = useState("all") - const [playingId, setPlayingId] = useState(null); - const [currentTime, setCurrentTime] = useState(0); - const intervalRef = useRef(null); + const [playingId, setPlayingId] = useState(null) + const [currentTime, setCurrentTime] = useState(0) + const intervalRef = useRef(null) - const queryClient = useQueryClient(); - const [detailVoice, setDetailVoice] = useState( - null, - ); - const [toasts, setToasts] = useState([]); - const [cloneModalOpen, setCloneModalOpen] = useState(false); + const queryClient = useQueryClient() + const [detailVoice, setDetailVoice] = useState(null) + const [toasts, setToasts] = useState([]) + const [cloneModalOpen, setCloneModalOpen] = useState(false) /* ── 上传音频弹窗状态 ── */ - const [uploadOpen, setUploadOpen] = useState(false); - const [uploadFile, setUploadFile] = useState(null); - const [uploadName, setUploadName] = useState(""); - const [uploadGender, setUploadGender] = useState("female"); - const [uploadDesc, setUploadDesc] = useState(""); - const [uploadProgress, setUploadProgress] = useState(null); + const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFile, setUploadFile] = useState(null) + const [uploadName, setUploadName] = useState("") + const [uploadGender, setUploadGender] = useState("female") + const [uploadDesc, setUploadDesc] = useState("") + const [uploadProgress, setUploadProgress] = useState(null) /* ── AI 配音弹窗状态 ── */ - const [ttsOpen, setTtsOpen] = useState(false); - const [ttsText, setTtsText] = useState(""); - const [ttsVoiceId, setTtsVoiceId] = useState(""); - const [ttsSpeed, setTtsSpeed] = useState(1.0); - const [ttsJobId, setTtsJobId] = useState(null); - const [ttsStatus, setTtsStatus] = useState< - "idle" | "synthesizing" | "done" | "error" - >("idle"); - const [ttsAudioUrl, setTtsAudioUrl] = useState(null); - const [ttsError, setTtsError] = useState(null); - const ttsTimerRef = useRef | null>(null); + const [ttsOpen, setTtsOpen] = useState(false) + const [ttsText, setTtsText] = useState("") + const [ttsVoiceId, setTtsVoiceId] = useState("") + const [ttsSpeed, setTtsSpeed] = useState(1.0) + const [ttsJobId, setTtsJobId] = useState(null) + const [ttsStatus, setTtsStatus] = useState<"idle" | "synthesizing" | "done" | "error">("idle") + const [ttsAudioUrl, setTtsAudioUrl] = useState(null) + const [ttsError, setTtsError] = useState(null) + const ttsTimerRef = useRef | null>(null) const showToast = useCallback((message: string, type: Toast["type"]) => { - const id = ++toastIdSeq; - setToasts((prev) => [...prev, { id, message, type }]); + const id = ++toastIdSeq + setToasts((prev) => [...prev, { id, message, type }]) setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - }, 3000); - }, []); + setToasts((prev) => prev.filter((t) => t.id !== id)) + }, 3000) + }, []) /** 删除克隆音色 */ const deleteMutation = useMutation({ mutationFn: deleteVoiceClone, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["voice-clones"] }); - showToast("音色已删除", "success"); + queryClient.invalidateQueries({ queryKey: ["voice-clones"] }) + showToast("音色已删除", "success") }, onError: () => { - showToast("删除失败", "error"); + showToast("删除失败", "error") }, - }); + }) /** 重试克隆 */ const retryMutation = useMutation({ mutationFn: retryVoiceClone, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["voice-clones"] }); - showToast("已重新提交克隆", "success"); + queryClient.invalidateQueries({ queryKey: ["voice-clones"] }) + showToast("已重新提交克隆", "success") }, onError: () => { - showToast("重试失败", "error"); + showToast("重试失败", "error") }, - }); + }) /* ── 上传音频 mutation ── */ const uploadMutation = useMutation({ mutationFn: async (data: { - file: File; - name: string; - gender: VoiceGender; - description: string; + file: File + name: string + gender: VoiceGender + description: string }) => { - setUploadProgress(0); + 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("配音素材库不存在,请先在配音素材库页面创建"); + }) + const lib = libs.find((l) => l.kind === "voice") + if (!lib) throw new Error("配音素材库不存在,请先在配音素材库页面创建") /* 直传文件 */ const { storage_key } = await uploadAssetDirect({ file: data.file, library_id: lib.id, onProgress: (p) => setUploadProgress(p), - }); + }) /* 获取音频时长 */ - const duration = await getAudioDuration(data.file); + const duration = await getAudioDuration(data.file) /* 创建素材记录 */ await createAsset({ @@ -755,90 +699,90 @@ const VoiceLibrary: React.FC = () => { description: data.description, duration, }), - }); + }) } finally { - setUploadProgress(null); + setUploadProgress(null) } }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - setUploadOpen(false); - setUploadFile(null); - setUploadName(""); - setUploadDesc(""); - showToast("上传成功", "success"); + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + setUploadOpen(false) + setUploadFile(null) + setUploadName("") + setUploadDesc("") + showToast("上传成功", "success") }, onError: (err: Error) => { - showToast(err.message || "上传失败,请重试", "error"); + showToast(err.message || "上传失败,请重试", "error") }, - }); + }) /* ── TTS 合成 ── */ const handleTtsSynthesize = useCallback(async () => { if (!ttsText.trim()) { - message.warning("请输入要合成的文本"); - return; + message.warning("请输入要合成的文本") + return } - setTtsError(null); - setTtsStatus("synthesizing"); - setTtsAudioUrl(null); - setTtsJobId(null); + setTtsError(null) + setTtsStatus("synthesizing") + setTtsAudioUrl(null) + setTtsJobId(null) try { const resp = await synthesizeSpeech({ text: ttsText.trim(), voice_id: ttsVoiceId || undefined, speed: ttsSpeed, - }); - setTtsJobId(resp.job_id); + }) + setTtsJobId(resp.job_id) /* 轮询状态 */ ttsTimerRef.current = setInterval(async () => { try { - const job = await getTTSJobStatus(resp.job_id); + const job = await getTTSJobStatus(resp.job_id) if (job.status === "completed") { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("done"); - setTtsAudioUrl(job.output_audio_url); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("done") + setTtsAudioUrl(job.output_audio_url) } else if (job.status === "failed") { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("error"); - setTtsError(job.error_message || "合成失败"); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError(job.error_message || "合成失败") } } catch { - clearInterval(ttsTimerRef.current!); - ttsTimerRef.current = null; - setTtsStatus("error"); - setTtsError("查询合成状态失败"); + clearInterval(ttsTimerRef.current!) + ttsTimerRef.current = null + setTtsStatus("error") + setTtsError("查询合成状态失败") } - }, 2000); + }, 2000) } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "合成请求失败"; - setTtsStatus("error"); - setTtsError(msg); + const msg = err instanceof Error ? err.message : "合成请求失败" + setTtsStatus("error") + setTtsError(msg) } - }, [ttsText, ttsVoiceId, ttsSpeed]); + }, [ttsText, ttsVoiceId, ttsSpeed]) /* ── TTS 保存到素材库 ── */ const handleTtsSave = useCallback(async () => { - if (!ttsJobId) return; + if (!ttsJobId) return try { - await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) }); - queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }); - showToast("已保存到配音素材库", "success"); - setTtsOpen(false); + await saveTtsToLibrary(ttsJobId, { name: ttsText.slice(0, 50) }) + queryClient.invalidateQueries({ queryKey: ["assets", "voice"] }) + showToast("已保存到配音素材库", "success") + setTtsOpen(false) } catch (err: unknown) { - const msg = err instanceof Error ? err.message : "保存失败"; - showToast(msg, "error"); + const msg = err instanceof Error ? err.message : "保存失败" + showToast(msg, "error") } - }, [ttsJobId, ttsText, queryClient, showToast]); + }, [ttsJobId, ttsText, queryClient, showToast]) /* ── TTS 定时器清理 ── */ useEffect(() => { return () => { - if (ttsTimerRef.current) clearInterval(ttsTimerRef.current); - }; - }, []); + if (ttsTimerRef.current) clearInterval(ttsTimerRef.current) + } + }, []) /* ── 数据查询(任务 3.11:替换 Mock) ──────────────── */ @@ -846,140 +790,139 @@ const VoiceLibrary: React.FC = () => { const { data: presetData, isLoading: presetLoading } = useQuery({ queryKey: ["preset-voices"], queryFn: fetchPresetVoices, - }); + }) /** 克隆音色列表 */ const { data: cloneData, isLoading: cloneLoading } = useQuery({ queryKey: ["voice-clones"], queryFn: () => getVoiceClonesWithTotal({ limit: 50 }), - }); + }) /** 统一统计(preset_count / clone_count) */ const { data: unifiedStats } = useQuery({ queryKey: ["voices-unified"], queryFn: () => fetchVoices({ limit: 1 }), - }); + }) const presetVoices = useMemo( () => (presetData?.items ?? []).map(mapPresetToDisplay), [presetData], - ); + ) const clonedVoices = useMemo( - () => - (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))), + () => (cloneData?.items ?? []).map((p) => mapCloneToDisplay(toVoiceClone(p))), [cloneData], - ); - const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0; - const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0; + ) + const presetCount = unifiedStats?.preset_count ?? presetData?.total ?? 0 + const cloneCount = unifiedStats?.clone_count ?? cloneData?.total ?? 0 const filteredPreset = useMemo(() => { - let list = presetVoices; + let list = presetVoices if (filterGender !== "all") { - list = list.filter((v) => v.gender === filterGender); + list = list.filter((v) => v.gender === filterGender) } if (filterLang !== "all") { - list = list.filter((v) => v.language === filterLang); + list = list.filter((v) => v.language === filterLang) } if (searchText.trim()) { - const q = searchText.trim().toLowerCase(); + const q = searchText.trim().toLowerCase() list = list.filter( (v) => v.name.toLowerCase().includes(q) || v.description.toLowerCase().includes(q) || v.tags.some((tag) => tag.toLowerCase().includes(q)), - ); + ) } - return list; - }, [presetVoices, filterGender, filterLang, searchText]); + return list + }, [presetVoices, filterGender, filterLang, searchText]) const handlePlay = useCallback( (voiceId: string, duration: number) => { - if (playingId === voiceId) return; + if (playingId === voiceId) return if (intervalRef.current) { - clearInterval(intervalRef.current); + clearInterval(intervalRef.current) } - setPlayingId(voiceId); - setCurrentTime(0); + setPlayingId(voiceId) + setCurrentTime(0) intervalRef.current = window.setInterval(() => { setCurrentTime((prev) => { if (prev >= duration) { if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + clearInterval(intervalRef.current) + intervalRef.current = null } - setPlayingId(null); - return 0; + setPlayingId(null) + return 0 } - return prev + 0.1; - }); - }, 100); + return prev + 0.1 + }) + }, 100) }, [playingId], - ); + ) const handlePause = useCallback(() => { if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; + clearInterval(intervalRef.current) + intervalRef.current = null } - setPlayingId(null); - }, []); + setPlayingId(null) + }, []) const handleSeek = useCallback( (voiceId: string, time: number, duration: number) => { - setCurrentTime(time); + setCurrentTime(time) if (playingId !== voiceId) { - handlePlay(voiceId, duration); + handlePlay(voiceId, duration) } }, [playingId, handlePlay], - ); + ) useEffect(() => { return () => { if (intervalRef.current) { - clearInterval(intervalRef.current); + clearInterval(intervalRef.current) } - }; - }, []); + } + }, []) const handleToggleStar = (_voiceId: string) => { // TODO: 后端暂未提供收藏接口 - }; + } /** 克隆音色操作 handlers */ const handleCloneDelete = (voice: ClonedVoiceDisplay) => { if (window.confirm(`确定删除音色「${voice.name}」吗?`)) { - deleteMutation.mutate(voice.id); - if (detailVoice?.id === voice.id) setDetailVoice(null); + deleteMutation.mutate(voice.id) + if (detailVoice?.id === voice.id) setDetailVoice(null) } - }; + } const handleCloneRetry = (voice: ClonedVoiceDisplay) => { - retryMutation.mutate(voice.id); - }; + retryMutation.mutate(voice.id) + } const handleCloneUse = (_voice: ClonedVoiceDisplay) => { - showToast("已选择音色", "success"); - }; + showToast("已选择音色", "success") + } const handleShowDetail = (voice: ClonedVoiceDisplay) => { - setDetailVoice(voice); - }; + setDetailVoice(voice) + } const handleClonePlayPause = (voiceId: string, duration: number) => { if (playingId === voiceId) { - handlePause(); + handlePause() } else { - handlePlay(voiceId, duration); + handlePlay(voiceId, duration) } - }; + } /** 克隆音色成功回调 */ const handleCloneSuccess = (_voice: VoiceClone) => { - queryClient.invalidateQueries({ queryKey: ["voice-clones"] }); - showToast("克隆已提交,正在生成中", "success"); - }; + queryClient.invalidateQueries({ queryKey: ["voice-clones"] }) + showToast("克隆已提交,正在生成中", "success") + } const pageActions = (
@@ -1008,7 +951,7 @@ const VoiceLibrary: React.FC = () => { AI 配音
- ); + ) return (
@@ -1022,8 +965,8 @@ const VoiceLibrary: React.FC = () => {

暂无克隆音色

上传音频素材即可克隆专属音色

-
)} {/* 处理中提示 */} - {!cloneLoading && - clonedVoices.some((v) => v.status === "processing") && ( -
- - 部分音色正在克隆处理中,完成后将自动出现在列表中。 -
- )} + {!cloneLoading && clonedVoices.some((v) => v.status === "processing") && ( +
+ + 部分音色正在克隆处理中,完成后将自动出现在列表中。 +
+ )}
)} @@ -1218,11 +1156,11 @@ const VoiceLibrary: React.FC = () => { title="上传音频" open={uploadOpen} onCancel={() => { - if (uploadProgress !== null) return; // 上传中不可关闭 - setUploadOpen(false); - setUploadFile(null); - setUploadName(""); - setUploadDesc(""); + if (uploadProgress !== null) return // 上传中不可关闭 + setUploadOpen(false) + setUploadFile(null) + setUploadName("") + setUploadDesc("") }} footer={null} width={520} @@ -1241,13 +1179,13 @@ const VoiceLibrary: React.FC = () => { accept="audio/*" maxCount={1} beforeUpload={(file) => { - setUploadFile(file); - if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, "")); - return false; + setUploadFile(file) + if (!uploadName) setUploadName(file.name.replace(/\.[^.]+$/, "")) + return false }} onRemove={() => { - setUploadFile(null); - setUploadProgress(null); + setUploadFile(null) + setUploadProgress(null) }} showUploadList={false} disabled={uploadProgress !== null} @@ -1287,9 +1225,7 @@ const VoiceLibrary: React.FC = () => { gap: 10, }} > - +
{ padding: "6px 0", borderRadius: 8, border: `1px solid ${uploadGender === g ? "var(--primary-color)" : "var(--border-color)"}`, - background: - uploadGender === g - ? "var(--primary-soft)" - : "transparent", - color: - uploadGender === g - ? "var(--primary-color)" - : "var(--text-secondary)", + background: uploadGender === g ? "var(--primary-soft)" : "transparent", + color: uploadGender === g ? "var(--primary-color)" : "var(--text-secondary)", fontSize: 13, fontWeight: uploadGender === g ? 600 : 400, cursor: uploadProgress !== null ? "not-allowed" : "pointer", @@ -1464,10 +1394,10 @@ const VoiceLibrary: React.FC = () => {
)}
- ); -}; + ) +} -export default VoiceLibrary; +export default VoiceLibrary diff --git a/apps/web/src/pages/voices/voices.css b/apps/web/src/pages/voices/voices.css index 859fc6af5..15f1234bc 100644 --- a/apps/web/src/pages/voices/voices.css +++ b/apps/web/src/pages/voices/voices.css @@ -291,11 +291,7 @@ grid-column: 1 / -1; height: 20px; border-radius: var(--radius-full); - background: repeating-linear-gradient( - 90deg, - var(--primary-color) 0 4px, - transparent 4px 8px - ); + background: repeating-linear-gradient(90deg, var(--primary-color) 0 4px, transparent 4px 8px); opacity: 0.15; transition: var(--transition-all); } @@ -512,11 +508,7 @@ } .xx-clone-avatar--processing { - background: linear-gradient( - 135deg, - var(--text-tertiary), - var(--text-secondary) - ); + background: linear-gradient(135deg, var(--text-tertiary), var(--text-secondary)); animation: xx-clone-pulse 2s ease-in-out infinite; } diff --git a/apps/web/src/router/index.tsx b/apps/web/src/router/index.tsx index 60449a213..6b1ef82db 100755 --- a/apps/web/src/router/index.tsx +++ b/apps/web/src/router/index.tsx @@ -2,41 +2,41 @@ * Phase 1 路由重构 * 扁平化路由:去掉 Project 层级,所有资源直接归属用户 */ -import { createBrowserRouter, Navigate } from "react-router-dom"; -import React from "react"; -import MainLayout from "@/components/layout/MainLayout"; -import Login from "@/pages/auth/Login"; -import Register from "@/pages/auth/Register"; -import ForgotPassword from "@/pages/auth/ForgotPassword"; -import ResetPassword from "@/pages/auth/ResetPassword"; -import HomePage from "@/pages/home/HomePage"; -import { useAuthStore } from "@/store/authStore"; +import { createBrowserRouter, Navigate } from "react-router-dom" +import React from "react" +import MainLayout from "@/components/layout/MainLayout" +import Login from "@/pages/auth/Login" +import Register from "@/pages/auth/Register" +import ForgotPassword from "@/pages/auth/ForgotPassword" +import ResetPassword from "@/pages/auth/ResetPassword" +import HomePage from "@/pages/home/HomePage" +import { useAuthStore } from "@/store/authStore" /** 受保护的路由组件 */ // eslint-disable-next-line react-refresh/only-export-components const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const hasAccessToken = Boolean(localStorage.getItem("access_token")); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const hasAccessToken = Boolean(localStorage.getItem("access_token")) if (!isAuthenticated || !hasAccessToken) { - return ; + return } - return <>{children}; -}; + return <>{children} +} /** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */ // eslint-disable-next-line react-refresh/only-export-components const HomeRoute: React.FC = () => { - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const hasAccessToken = Boolean(localStorage.getItem("access_token")); + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + const hasAccessToken = Boolean(localStorage.getItem("access_token")) if (isAuthenticated && hasAccessToken) { - return ; + return } - return ; -}; + return +} /** 路由配置 */ export const router = createBrowserRouter([ @@ -293,6 +293,6 @@ export const router = createBrowserRouter([ path: "*", element: , }, -]); +]) -export default router; +export default router diff --git a/apps/web/src/store/authStore.ts b/apps/web/src/store/authStore.ts index c78f9bc45..cef495665 100644 --- a/apps/web/src/store/authStore.ts +++ b/apps/web/src/store/authStore.ts @@ -2,34 +2,30 @@ * 认证状态管理 * 使用 Zustand 管理全局认证状态 */ -import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { create } from "zustand" +import { persist } from "zustand/middleware" interface User { - id: string; - user_id: string; - email: string; - username: string; - display_name: string; - is_email_verified: boolean; - email_verified: boolean; + id: string + user_id: string + email: string + username: string + display_name: string + is_email_verified: boolean + email_verified: boolean } interface AuthState { - user: User | null; - accessToken: string | null; - refreshToken: string | null; - isAuthenticated: boolean; + user: User | null + accessToken: string | null + refreshToken: string | null + isAuthenticated: boolean // Actions - setAuth: ( - user: User, - accessToken: string, - refreshToken?: string | null, - ) => void; + setAuth: (user: User, accessToken: string, refreshToken?: string | null) => void - clearAuth: () => void; - setUser: (user: User) => void; + clearAuth: () => void + setUser: (user: User) => void } export const useAuthStore = create()( @@ -41,33 +37,33 @@ export const useAuthStore = create()( isAuthenticated: false, setAuth: (user, accessToken, refreshToken = null) => { - localStorage.setItem("access_token", accessToken); + localStorage.setItem("access_token", accessToken) if (refreshToken) { - localStorage.setItem("refresh_token", refreshToken); + localStorage.setItem("refresh_token", refreshToken) } else { - localStorage.removeItem("refresh_token"); + localStorage.removeItem("refresh_token") } set({ user, accessToken, refreshToken, isAuthenticated: true, - }); + }) }, clearAuth: () => { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); + localStorage.removeItem("access_token") + localStorage.removeItem("refresh_token") set({ user: null, accessToken: null, refreshToken: null, isAuthenticated: false, - }); + }) }, setUser: (user) => { - set({ user }); + set({ user }) }, }), { @@ -80,4 +76,4 @@ export const useAuthStore = create()( }), }, ), -); +) diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 494e1e85b..936a02d62 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -44,11 +44,7 @@ --primary-hover: var(--color-primary-500); --primary-dark: var(--color-primary-700); --primary-soft: var(--color-primary-50); - --gradient-primary: linear-gradient( - 135deg, - var(--color-primary-500), - var(--color-primary-600) - ); + --gradient-primary: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600)); /* 辅色 — Emerald */ --color-secondary-50: #ecfdf5; @@ -155,11 +151,9 @@ --space-4xl: 96px; /* ── 8. 字体 ─────────────────────────────────────────────── */ - --font-family-base: - -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + --font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans SC", sans-serif; - --font-family-mono: - "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace; + --font-family-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace; --font-size-xs: 11px; --font-size-sm: 12px; @@ -217,8 +211,7 @@ --transition-slow: 300ms ease; --transition-slower: 500ms ease; - --transition-colors: - color 200ms ease, background-color 200ms ease, border-color 200ms ease; + --transition-colors: color 200ms ease, background-color 200ms ease, border-color 200ms ease; --transition-shadow: box-shadow 200ms ease; --transition-transform: transform 200ms ease; --transition-all: all 200ms ease; @@ -297,11 +290,7 @@ --primary-hover: var(--color-primary-400, #a5b4fc); --primary-dark: var(--color-primary-600); --primary-soft: rgba(99, 102, 241, 0.15); - --gradient-primary: linear-gradient( - 135deg, - var(--color-primary-500), - var(--color-primary-600) - ); + --gradient-primary: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600)); /* 辅色 */ --secondary-color: var(--color-secondary-400, #34d399); @@ -382,23 +371,9 @@ body::before { inset: 0; z-index: 0; pointer-events: none; - background: - radial-gradient( - circle at 18% 20%, - rgba(79, 70, 229, 0.11), - transparent 30% - ), - radial-gradient( - circle at 86% 14%, - rgba(16, 185, 129, 0.08), - transparent 28% - ), - radial-gradient( - circle at 48% 80%, - rgba(99, 102, 241, 0.08), - transparent 34% - ), - #f8fafc; + background: radial-gradient(circle at 18% 20%, rgba(79, 70, 229, 0.11), transparent 30%), + radial-gradient(circle at 86% 14%, rgba(16, 185, 129, 0.08), transparent 28%), + radial-gradient(circle at 48% 80%, rgba(99, 102, 241, 0.08), transparent 34%), #f8fafc; } body::after { @@ -407,11 +382,7 @@ body::after { inset: 0; z-index: 0; pointer-events: none; - background-image: radial-gradient( - circle, - rgba(79, 70, 229, 0.1) 1px, - transparent 1px - ); + background-image: radial-gradient(circle, rgba(79, 70, 229, 0.1) 1px, transparent 1px); background-size: 38px 38px; opacity: 0.32; } diff --git a/apps/web/src/test/components/Login.test.tsx b/apps/web/src/test/components/Login.test.tsx index 02b3b0af2..6a584fc79 100644 --- a/apps/web/src/test/components/Login.test.tsx +++ b/apps/web/src/test/components/Login.test.tsx @@ -1,11 +1,11 @@ /** * Login 组件单元测试 */ -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { describe, it, expect } from "vitest"; -import { BrowserRouter } from "react-router-dom"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import Login from "@/pages/auth/Login"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { describe, it, expect } from "vitest" +import { BrowserRouter } from "react-router-dom" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import Login from "@/pages/auth/Login" const renderLogin = () => { const queryClient = new QueryClient({ @@ -13,7 +13,7 @@ const renderLogin = () => { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) return render( @@ -21,34 +21,34 @@ const renderLogin = () => { , - ); -}; + ) +} describe.skip("Login Component", () => { it("should render login form", () => { - renderLogin(); + renderLogin() - expect(screen.getByPlaceholderText("邮箱")).toBeInTheDocument(); - expect(screen.getByPlaceholderText("密码")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "登录" })).toBeInTheDocument(); - }); + expect(screen.getByPlaceholderText("邮箱")).toBeInTheDocument() + expect(screen.getByPlaceholderText("密码")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "登录" })).toBeInTheDocument() + }) it("should show validation errors for empty fields", async () => { - renderLogin(); + renderLogin() - const submitButton = screen.getByRole("button", { name: "登录" }); - fireEvent.click(submitButton); + const submitButton = screen.getByRole("button", { name: "登录" }) + fireEvent.click(submitButton) await waitFor(() => { - expect(screen.getByText("请输入邮箱")).toBeInTheDocument(); - expect(screen.getByText("请输入密码")).toBeInTheDocument(); - }); - }); + expect(screen.getByText("请输入邮箱")).toBeInTheDocument() + expect(screen.getByText("请输入密码")).toBeInTheDocument() + }) + }) it("should navigate to register page", () => { - renderLogin(); + renderLogin() - const registerLink = screen.getByText("立即注册"); - expect(registerLink).toBeInTheDocument(); - }); -}); + const registerLink = screen.getByText("立即注册") + expect(registerLink).toBeInTheDocument() + }) +}) diff --git a/apps/web/src/test/hooks/useAuth.test.tsx b/apps/web/src/test/hooks/useAuth.test.tsx index 57a824980..171305dae 100644 --- a/apps/web/src/test/hooks/useAuth.test.tsx +++ b/apps/web/src/test/hooks/useAuth.test.tsx @@ -1,23 +1,23 @@ /** * useAuth Hook 单元测试 */ -import { renderHook, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { useLogin, useRegister, useLogout } from "@/hooks/useAuth"; -import * as authApi from "@/api/auth"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { BrowserRouter } from "react-router-dom"; -import React from "react"; +import { renderHook, waitFor } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import { useLogin, useRegister, useLogout } from "@/hooks/useAuth" +import * as authApi from "@/api/auth" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { BrowserRouter } from "react-router-dom" +import React from "react" // Mock API -vi.mock("@/api/auth"); +vi.mock("@/api/auth") vi.mock("react-router-dom", async () => { - const actual = await vi.importActual("react-router-dom"); + const actual = await vi.importActual("react-router-dom") return { ...actual, useNavigate: () => vi.fn(), - }; -}); + } +}) // Test wrapper const createWrapper = () => { @@ -26,19 +26,19 @@ const createWrapper = () => { queries: { retry: false }, mutations: { retry: false }, }, - }); + }) return ({ children }: { children: React.ReactNode }) => ( {children} - ); -}; + ) +} describe.skip("useAuth", () => { beforeEach(() => { - vi.clearAllMocks(); - localStorage.clear(); - }); + vi.clearAllMocks() + localStorage.clear() + }) it("should login successfully", async () => { const mockResponse = { @@ -49,26 +49,26 @@ describe.skip("useAuth", () => { email: "test@example.com", username: "testuser", display_name: "Test User", - }; + } - vi.mocked(authApi.login).mockResolvedValue(mockResponse); + vi.mocked(authApi.login).mockResolvedValue(mockResponse) vi.mocked(authApi.getCurrentUser).mockResolvedValue({ id: "1", email: "test@example.com", username: "testuser", display_name: "Test User", - }); + }) const { result } = renderHook(() => useLogin(), { wrapper: createWrapper(), - }); + }) await waitFor(() => { - expect(result.current).toBeDefined(); - }); + expect(result.current).toBeDefined() + }) - expect(authApi.login).toBeDefined(); - }); + expect(authApi.login).toBeDefined() + }) it("should register successfully", async () => { const mockResponse = { @@ -77,34 +77,34 @@ describe.skip("useAuth", () => { username: "testuser", display_name: "Test User", message: "注册成功", - }; + } - vi.mocked(authApi.register).mockResolvedValue(mockResponse); + vi.mocked(authApi.register).mockResolvedValue(mockResponse) const { result } = renderHook(() => useRegister(), { wrapper: createWrapper(), - }); + }) await waitFor(() => { - expect(result.current).toBeDefined(); - }); + expect(result.current).toBeDefined() + }) - expect(authApi.register).toBeDefined(); - }); + expect(authApi.register).toBeDefined() + }) it("should logout successfully", async () => { - localStorage.setItem("access_token", "mock-token"); + localStorage.setItem("access_token", "mock-token") - vi.mocked(authApi.logout).mockResolvedValue(undefined); + vi.mocked(authApi.logout).mockResolvedValue(undefined) const { result } = renderHook(() => useLogout(), { wrapper: createWrapper(), - }); + }) await waitFor(() => { - expect(result.current).toBeDefined(); - }); + expect(result.current).toBeDefined() + }) - expect(authApi.logout).toBeDefined(); - }); -}); + expect(authApi.logout).toBeDefined() + }) +}) diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index 73101f24b..ae3c9729c 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -1,9 +1,9 @@ /** * 测试环境设置 */ -import { expect, afterEach } from "vitest"; -import { cleanup } from "@testing-library/react"; -import * as matchers from "@testing-library/jest-dom/matchers"; +import { expect, afterEach } from "vitest" +import { cleanup } from "@testing-library/react" +import * as matchers from "@testing-library/jest-dom/matchers" // Mock window.matchMedia for Ant Design components Object.defineProperty(window, "matchMedia", { @@ -18,15 +18,15 @@ Object.defineProperty(window, "matchMedia", { removeEventListener: () => {}, dispatchEvent: () => false, }), -}); +}) // 扩展 Vitest 的 expect -expect.extend(matchers); +expect.extend(matchers) // 每次测试后清理 afterEach(() => { - cleanup(); -}); + cleanup() +}) // 全局类型声明 declare global { @@ -40,4 +40,4 @@ declare global { } // 导出空对象以使文件成为模块 -export {}; +export {} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 45d38571b..c2e690cce 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,9 +1,9 @@ /** * 性能优化配置 */ -import { defineConfig, configDefaults } from "vitest/config"; -import react from "@vitejs/plugin-react"; -import path from "path"; +import { defineConfig, configDefaults } from "vitest/config" +import react from "@vitejs/plugin-react" +import path from "path" // https://vitejs.dev/config/ @@ -70,4 +70,4 @@ export default defineConfig({ "axios", ], }, -}); +}) diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index aea7ef72d..1f87e0c77 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,9 +1,9 @@ /** * Vitest 配置文件 */ -import { defineConfig } from "vitest/config"; -import react from "@vitejs/plugin-react"; -import path from "path"; +import { defineConfig } from "vitest/config" +import react from "@vitejs/plugin-react" +import path from "path" export default defineConfig({ plugins: [react()], @@ -14,13 +14,7 @@ export default defineConfig({ coverage: { provider: "v8", reporter: ["text", "json", "html"], - exclude: [ - "node_modules/", - "src/test/", - "**/*.d.ts", - "**/*.config.*", - "**/mockData", - ], + exclude: ["node_modules/", "src/test/", "**/*.d.ts", "**/*.config.*", "**/mockData"], }, }, resolve: { @@ -28,4 +22,4 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, -}); +}) diff --git a/scripts/ci/auto_fix_formatting.py b/scripts/ci/auto_fix_formatting.py old mode 100644 new mode 100755 index d6530fadf..74baed74e --- a/scripts/ci/auto_fix_formatting.py +++ b/scripts/ci/auto_fix_formatting.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""CI中自动修复Python代码格式(black + isort),并推送回PR分支。 +"""CI中自动修复代码格式(Python: black + isort | Frontend: prettier),并推送回PR分支。 只在PR事件中执行,避免直接修改主干。 当code quality检查因格式问题失败时触发,自动修复后push回原分支。 @@ -12,9 +12,9 @@ import sys import urllib.request -def run(cmd, check=True, capture=True): +def run(cmd, check=True, capture=True, cwd=None): """运行shell命令""" - result = subprocess.run(cmd, shell=True, capture_output=capture, text=True) + result = subprocess.run(cmd, shell=True, capture_output=capture, text=True, cwd=cwd) if check and result.returncode != 0: print(f"命令失败: {cmd}", file=sys.stderr) if result.stderr: @@ -23,13 +23,13 @@ def run(cmd, check=True, capture=True): return result -def get_changed_py_files(pr_number, api_url, token): - """获取PR中变更的Python文件列表""" +def get_changed_files(pr_number, api_url, token): + """获取PR中变更的文件列表""" url = f"{api_url}/pulls/{pr_number}/files?limit=100" req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) with urllib.request.urlopen(req) as resp: files = json.loads(resp.read()) - return [f["filename"] for f in files if f["filename"].endswith(".py") and f["status"] != "removed"] + return [f["filename"] for f in files if f["status"] != "removed"] def get_pr_head_branch(pr_number, api_url, token): @@ -41,6 +41,70 @@ def get_pr_head_branch(pr_number, api_url, token): return pr["head"]["ref"] +def fix_python(target_py_files, scan_mode): + """修复 Python 文件格式 (black + isort)""" + if not target_py_files: + print("没有需要修复的 Python 文件,跳过") + return + + target_str = " ".join(target_py_files) + print() + print("--- black 格式化 ---") + result = run(f"python3 -m black {target_str}", check=False) + print(result.stdout[-500:] if result.stdout else "") + if result.returncode != 0: + print("black执行失败,但继续尝试isort", file=sys.stderr) + + print() + print("--- isort 排序 ---") + result = run(f"python3 -m isort {target_str}", check=False) + print(result.stdout[-500:] if result.stdout else "") + if result.returncode != 0: + print("isort执行失败", file=sys.stderr) + + +def fix_frontend(target_fe_files, scan_mode, repo_root): + """修复前端文件格式 (prettier)""" + if not target_fe_files: + print("没有需要修复的前端文件,跳过") + return + + # 检查 prettier 是否可用 + web_dir = os.path.join(repo_root, "apps", "web") + prettier_bin = os.path.join(web_dir, "node_modules", ".bin", "prettier") + + if not os.path.exists(prettier_bin): + print() + print("--- 安装前端依赖 (prettier) ---") + result = run("npm install --no-audit --no-fund --prefer-offline", check=False, cwd=web_dir) + if result.returncode != 0: + print("npm install 失败,跳过 prettier 修复", file=sys.stderr) + return + print("依赖安装完成") + + if not os.path.exists(prettier_bin): + print("prettier 仍不可用,跳过", file=sys.stderr) + return + + print() + print("--- prettier 格式化 ---") + + if scan_mode == "incremental": + # 增量模式:只格式化变更的前端文件 + # 转换为相对于 apps/web 的路径或用绝对路径 + target_str = " ".join(target_fe_files) + # 从项目根目录运行,prettier 会找配置文件 + cmd = f"{prettier_bin} --write {target_str}" + else: + # 全量模式:格式化整个前端目录 + cmd = f"{prettier_bin} --write ." + + result = run(cmd, check=False, cwd=web_dir if scan_mode != "incremental" else repo_root) + print(result.stdout[-800:] if result.stdout else "") + if result.stderr: + print(result.stderr[-500:], file=sys.stderr) + + def main(): # 只在PR事件中执行 event_name = os.environ.get("GITHUB_EVENT_NAME", "") @@ -58,39 +122,57 @@ def main(): repo = os.environ.get("GITHUB_REPOSITORY", "") token = os.environ.get("GITHUB_TOKEN", "") scan_mode = os.environ.get("SCAN_MODE", "full") - changed_py_files = os.environ.get("CHANGED_PY_FILES", "") + changed_files_env = os.environ.get("CHANGED_FILES", "") if not token: print("缺少GITHUB_TOKEN,无法推送修复", file=sys.stderr) sys.exit(1) + repo_root = os.getcwd() + print("=== 检测到代码格式问题,尝试自动修复 ===") print(f"PR #{pr_number}") print(f"扫描模式: {scan_mode}") + # 前端文件扩展名 + fe_extensions = ( + ".ts", + ".tsx", + ".js", + ".jsx", + ".css", + ".scss", + ".less", + ".json", + ".html", + ".md", + ".yaml", + ".yml", + ) + # Python 文件扩展名 + py_extensions = (".py",) + # 确定要修复的文件范围 - if scan_mode == "incremental" and changed_py_files: - target_files = changed_py_files.split() - print(f"增量模式,修复 {len(target_files)} 个变更的Python文件") + if scan_mode == "incremental" and changed_files_env: + all_changed = changed_files_env.split() + target_py_files = [f for f in all_changed if f.endswith(py_extensions)] + target_fe_files = [f for f in all_changed if f.endswith(fe_extensions)] + print(f"增量模式: {len(target_py_files)} 个Python文件, {len(target_fe_files)} 个前端文件") else: - target_files = ["alembic", "apps", "packages", "tests", "scripts"] - print("全量模式,修复所有Python文件") + target_py_files = ["alembic", "apps", "packages", "tests", "scripts"] + # 全量模式下 prettier 在前端目录内部运行,无需传文件列表 + target_fe_files = ["apps/web"] # 标记为有前端文件需要处理 + print("全量模式,修复所有文件") - # 自动修复 - target_str = " ".join(target_files) - print() - print("--- black 格式化 ---") - result = run(f"python3 -m black {target_str}", check=False) - print(result.stdout[-500:] if result.stdout else "") - if result.returncode != 0: - print("black执行失败,但继续尝试isort", file=sys.stderr) + # Python 格式化 + fix_python(target_py_files, scan_mode) - print() - print("--- isort 排序 ---") - result = run(f"python3 -m isort {target_str}", check=False) - print(result.stdout[-500:] if result.stdout else "") - if result.returncode != 0: - print("isort执行失败", file=sys.stderr) + # 前端格式化 + # 全量模式下直接传 web 目录标记 + if scan_mode != "incremental": + fix_frontend(["apps/web"], scan_mode, repo_root) + else: + fix_frontend(target_fe_files, scan_mode, repo_root) # 检查是否有改动 result = run("git status --porcelain") @@ -110,7 +192,7 @@ def main(): # 提交修复 run("git add -A") - run('git commit -m "style: auto-format with black + isort [ci skip]"') + run('git commit -m "style: auto-format with black + isort + prettier [ci skip]"') # 获取来源分支并推送 head_branch = get_pr_head_branch(pr_number, f"{api_url}/repos/{repo}", token)