From 78d1c88ca194bb18d6fecf76bcbfa07587fccf12 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Tue, 14 Jul 2026 18:15:55 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=AA=E8=BE=91=E8=A7=84=E5=88=92?= =?UTF-8?q?=E5=99=A8=E5=AE=8C=E6=95=B4=E4=BA=A4=E4=BA=92=20=E2=80=94=207?= =?UTF-8?q?=E5=A4=A7=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF=20+=20=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=BD=B4=E6=8B=96=E6=8B=BD/=E7=BC=A9=E6=94=BE/?= =?UTF-8?q?=E6=92=AD=E6=94=BE=E5=A4=B4=20+=20=E9=A2=84=E8=A7=88=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=20(#284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/e2e/assets-full.spec.ts | 290 +- apps/web/e2e/core-upload.spec.ts | 8 +- apps/web/e2e/duplication.spec.ts | 114 +- apps/web/e2e/editing-planner.spec.ts | 21 +- apps/web/e2e/products.spec.ts | 141 +- apps/web/e2e/profile.spec.ts | 12 +- apps/web/e2e/register.spec.ts | 59 +- apps/web/e2e/subscription-full.spec.ts | 44 +- apps/web/e2e/subscription.spec.ts | 5 +- apps/web/e2e/templates.spec.ts | 23 +- apps/web/e2e/test_auth.spec.ts | 11 +- apps/web/e2e/titles-full.spec.ts | 41 +- apps/web/e2e/voice-clone.spec.ts | 22 +- apps/web/e2e/voices.spec.ts | 11 +- apps/web/src/api/assets.ts | 83 +- apps/web/src/api/bgm.ts | 70 + apps/web/src/api/editPlans.ts | 220 +- apps/web/src/api/editingPlanner.ts | 37 + apps/web/src/api/products.ts | 134 +- apps/web/src/api/tasks.ts | 70 +- apps/web/src/api/templates.ts | 137 +- apps/web/src/api/tts.ts | 49 + apps/web/src/components/ui/ui.css | 3 - apps/web/src/config/navigation.ts | 25 + apps/web/src/pages/assets/AssetLibrary.tsx | 429 +- apps/web/src/pages/assets/assets.css | 172 + apps/web/src/pages/edit-plans/EditPlans.tsx | 433 ++ apps/web/src/pages/edit-plans/edit-plans.css | 260 ++ .../pages/editing-planner/EditingPlanner.css | 3779 ++++++++++++++++- .../pages/editing-planner/EditingPlanner.tsx | 673 ++- .../components/BgmSelector.tsx | 292 ++ .../components/ClipPropertiesPanel.tsx | 299 +- .../components/CoverSelector.tsx | 301 ++ .../components/FilterPanel.tsx | 234 + .../components/GreenScreenPanel.tsx | 219 + .../components/IntroOutroPanel.tsx | 309 ++ .../components/PipConfigPanel.tsx | 582 +++ .../components/PreviewPlayer.tsx | 14 +- .../editing-planner/components/SpeedPanel.tsx | 157 + .../components/StickerPanel.tsx | 561 +++ .../components/SubtitleStylePanel.tsx | 274 ++ .../components/TimelinePanel.tsx | 587 ++- .../components/TransitionSelector.tsx | 118 + .../editing-planner/components/TtsPanel.tsx | 349 ++ .../components/WatermarkPanel.tsx | 419 ++ apps/web/src/pages/editing-planner/types.ts | 529 +++ apps/web/src/pages/generate/GeneratePage.tsx | 93 +- .../web/src/pages/products/ProductLibrary.tsx | 203 +- apps/web/src/pages/products/products.css | 36 + apps/web/src/pages/tasks/TaskCenter.tsx | 450 ++ apps/web/src/pages/tasks/tasks.css | 268 ++ .../src/pages/templates/TemplateLibrary.tsx | 731 ++-- apps/web/src/router/index.tsx | 14 + 53 files changed, 13521 insertions(+), 894 deletions(-) create mode 100644 apps/web/src/api/bgm.ts create mode 100644 apps/web/src/pages/edit-plans/EditPlans.tsx create mode 100644 apps/web/src/pages/edit-plans/edit-plans.css create mode 100644 apps/web/src/pages/editing-planner/components/BgmSelector.tsx create mode 100644 apps/web/src/pages/editing-planner/components/CoverSelector.tsx create mode 100644 apps/web/src/pages/editing-planner/components/FilterPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/SpeedPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/StickerPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/TransitionSelector.tsx create mode 100644 apps/web/src/pages/editing-planner/components/TtsPanel.tsx create mode 100644 apps/web/src/pages/editing-planner/components/WatermarkPanel.tsx create mode 100644 apps/web/src/pages/tasks/TaskCenter.tsx create mode 100644 apps/web/src/pages/tasks/tasks.css diff --git a/apps/web/e2e/assets-full.spec.ts b/apps/web/e2e/assets-full.spec.ts index 1f362547b..6adf5c254 100644 --- a/apps/web/e2e/assets-full.spec.ts +++ b/apps/web/e2e/assets-full.spec.ts @@ -86,7 +86,10 @@ async function createProject( ): Promise { const resp = await request.post(`${apiBase}/projects`, { headers, - data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test" }, + data: { + name: `Assets Test Proj ${suffix}`, + description: "E2E assets test", + }, }); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); @@ -178,20 +181,30 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材库列表页面加载", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-load"); + const projectId = await createProject( request, - "assets-load", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "默认视频库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); 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-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(); @@ -211,23 +224,33 @@ test.describe("素材库页面 - 完整交互测试", () => { test("创建新素材库 - 通过 UI", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-create"); + const projectId = await createProject( request, - "assets-create", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "初始库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 点击新建素材库 await page.locator(".xx-asset-library-add").click(); // 弹窗出现 - const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" }); + const modal = page + .locator(".ant-modal-content") + .filter({ hasText: "新建素材库" }); await expect(modal).toBeVisible(); // 填写表单 @@ -259,11 +282,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("切换不同素材库", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-switch"); + const projectId = await createProject( request, - "assets-switch", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const videoLibName = "视频素材库 A"; const imageLibName = "图片素材库 B"; @@ -292,10 +317,16 @@ test.describe("素材库页面 - 完整交互测试", () => { "demo_video.mp4", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 点击视频库,应显示素材 const videoLibItem = page @@ -305,7 +336,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await expect(videoLibItem).toHaveClass(/active/); // 验证视频素材出现 - await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("demo_video.mp4")).toBeVisible({ + timeout: 10_000, + }); // 点击图片库,应切换且不显示视频 const imageLibItem = page @@ -315,18 +348,22 @@ test.describe("素材库页面 - 完整交互测试", () => { await expect(imageLibItem).toHaveClass(/active/); // 空状态或图片库内容 - await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000 }); + 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( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-search"); + const projectId = await createProject( request, - "assets-search", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -336,13 +373,33 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建两个不同名称的素材 - 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 setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); // 确保在测试库中 const libItem = page @@ -351,7 +408,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 两个素材都应可见 - await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("apple_clip.mp4")).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); // 搜索 apple,只显示 apple @@ -361,7 +420,9 @@ test.describe("素材库页面 - 完整交互测试", () => { // 清空搜索,两个都显示 await page.getByPlaceholder("搜索素材名称...").fill(""); - await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("apple_clip.mp4")).toBeVisible({ + timeout: 5_000, + }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); }); @@ -369,11 +430,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材类型筛选", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-filter"); + const projectId = await createProject( request, - "assets-filter", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -383,12 +446,25 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建视频素材 - 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 setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -396,7 +472,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 素材应可见 - await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000 }); + await expect(page.getByText("video_clip.mp4")).toBeVisible({ + timeout: 10_000, + }); // 筛选类型下拉存在 const filterSelect = page.locator(".xx-assets-filters-left select").first(); @@ -407,11 +485,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("素材详情查看 - 播放弹窗", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-detail"); + const projectId = await createProject( request, - "assets-detail", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -419,12 +499,25 @@ test.describe("素材库页面 - 完整交互测试", () => { "详情测试库", "video", ); - await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "play_test.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -441,7 +534,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await assetCard.locator(".xx-asset-play").click({ force: true }); // 播放弹窗出现 - const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" }); + const modal = page + .locator(".ant-modal-content") + .filter({ hasText: "播放" }); await expect(modal).toBeVisible(); // 关闭弹窗 @@ -453,11 +548,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("删除素材 - 带确认对话框", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-delete"); + const projectId = await createProject( request, - "assets-delete", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -465,12 +562,25 @@ test.describe("素材库页面 - 完整交互测试", () => { "删除测试库", "video", ); - await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4"); + await createAsset( + request, + headers, + projectId, + libraryId, + userId, + "to_delete.mp4", + ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -491,14 +601,15 @@ test.describe("素材库页面 - 完整交互测试", () => { await deleteBtn.click({ force: true }); // 确认对话框出现 - const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" }); + 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.url().includes("/assets/") && resp.request().method() === "DELETE", { timeout: 10_000 }, ); @@ -518,11 +629,13 @@ test.describe("素材库页面 - 完整交互测试", () => { test("批量删除素材", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-batch"); + const projectId = await createProject( request, - "assets-batch", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); const libraryId = await createLibrary( request, headers, @@ -532,14 +645,41 @@ test.describe("素材库页面 - 完整交互测试", () => { ); // 创建多个素材 - 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 setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -547,7 +687,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 所有素材应可见 - await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000 }); + 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(); @@ -567,7 +709,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await batchDeleteBtn.click(); // 确认对话框 - const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" }); + const confirmPop = page + .locator(".ant-popover") + .filter({ hasText: "确定删除" }); await expect(confirmPop).toBeVisible(); // 确认删除 @@ -595,17 +739,25 @@ test.describe("素材库页面 - 完整交互测试", () => { test("空素材库展示空状态", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "assets-empty"); + const projectId = await createProject( request, - "assets-empty", + headers, + Date.now().toString(), ); - const projectId = await createProject(request, headers, Date.now().toString()); await createLibrary(request, headers, projectId, "空素材库", "video"); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/assets"); - await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000 }); + await expect(page.locator(".xx-assets-layout")).toBeVisible({ + timeout: 20_000, + }); const libItem = page .locator(".xx-asset-library-item") @@ -613,7 +765,9 @@ test.describe("素材库页面 - 完整交互测试", () => { await libItem.click({ force: true }); // 空状态应显示 - await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000 }); + await expect(page.locator(".xx-assets-empty")).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible(); }); diff --git a/apps/web/e2e/core-upload.spec.ts b/apps/web/e2e/core-upload.spec.ts index cbaafc072..619149ee2 100755 --- a/apps/web/e2e/core-upload.spec.ts +++ b/apps/web/e2e/core-upload.spec.ts @@ -180,9 +180,11 @@ test.describe("Core media upload flow", () => { 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 diff --git a/apps/web/e2e/duplication.spec.ts b/apps/web/e2e/duplication.spec.ts index 3733d719c..811a971df 100644 --- a/apps/web/e2e/duplication.spec.ts +++ b/apps/web/e2e/duplication.spec.ts @@ -121,7 +121,11 @@ test.describe("去重流程", () => { "dup-load", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); @@ -146,7 +150,11 @@ test.describe("去重流程", () => { "dup-upload-zone", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -156,12 +164,12 @@ test.describe("去重流程", () => { 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(); @@ -184,7 +192,11 @@ test.describe("去重流程", () => { "dup-info", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -215,7 +227,11 @@ test.describe("去重流程", () => { "dup-list", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); @@ -239,7 +255,11 @@ test.describe("去重流程", () => { "dup-list-empty", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -257,7 +277,11 @@ test.describe("去重流程", () => { "dup-filter", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -287,7 +311,11 @@ test.describe("去重流程", () => { "dup-nav", ); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/duplication/results"); await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 }); @@ -306,10 +334,8 @@ test.describe("去重流程", () => { request, }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-detail", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-detail"); // 先上传一个文件进行查重,获取 record id const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -335,7 +361,11 @@ test.describe("去重流程", () => { const recordId = uploadData.id; expect(recordId, "应返回查重记录 ID").toBeTruthy(); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); // 访问详情页 await page.goto(`/app/duplication/${recordId}`); @@ -353,10 +383,8 @@ test.describe("去重流程", () => { test("去重记录删除 - API 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-delete", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-delete"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -419,10 +447,8 @@ test.describe("去重流程", () => { test("去重记录删除 - UI 验证", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-delete-ui", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-delete-ui"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -443,14 +469,20 @@ test.describe("去重流程", () => { return; } - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); 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 cardVisible = await resultCard + .isVisible({ timeout: 10_000 }) + .catch(() => false); if (cardVisible) { // 删除按钮存在 @@ -467,12 +499,14 @@ test.describe("去重流程", () => { }); // 监听删除请求 - const deletePromise = page.waitForResponse( - (resp) => - resp.url().includes("/duplication/records/") && - resp.request().method() === "DELETE", - { timeout: 10_000 }, - ).catch(() => null); + const deletePromise = page + .waitForResponse( + (resp) => + resp.url().includes("/duplication/records/") && + resp.request().method() === "DELETE", + { timeout: 10_000 }, + ) + .catch(() => null); await deleteBtn.click(); @@ -487,10 +521,8 @@ test.describe("去重流程", () => { test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => { await routeBrowserApiToTestApi(page); - const { headers, userId, accessToken, email, username } = await createAuthedUser( - request, - "dup-retry", - ); + const { headers, userId, accessToken, email, username } = + await createAuthedUser(request, "dup-retry"); // 创建查重记录 const uploadResp = await request.post(`${apiBase}/duplication/upload`, { @@ -511,14 +543,20 @@ test.describe("去重流程", () => { return; } - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); 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 cardVisible = await resultCard + .isVisible({ timeout: 10_000 }) + .catch(() => false); if (cardVisible) { // 验证记录卡片基本结构 diff --git a/apps/web/e2e/editing-planner.spec.ts b/apps/web/e2e/editing-planner.spec.ts index 83b9ca914..38c5dcd33 100644 --- a/apps/web/e2e/editing-planner.spec.ts +++ b/apps/web/e2e/editing-planner.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -258,7 +263,12 @@ test.describe("剪辑计划 - API 操作", () => { mode: "pip", estimated_duration: 30, segments: [ - { segment_order: 1, duration_min: 5, duration_max: 10, material_type: "video" }, + { + segment_order: 1, + duration_min: 5, + duration_max: 10, + material_type: "video", + }, ], }, }); @@ -269,7 +279,12 @@ test.describe("剪辑计划 - API 操作", () => { mode: "voice_over", estimated_duration: 60, segments: [ - { segment_order: 1, duration_min: 10, duration_max: 30, material_type: "video" }, + { + segment_order: 1, + duration_min: 10, + duration_max: 30, + material_type: "video", + }, ], }, }); diff --git a/apps/web/e2e/products.spec.ts b/apps/web/e2e/products.spec.ts index e8f31b331..32e8516bc 100644 --- a/apps/web/e2e/products.spec.ts +++ b/apps/web/e2e/products.spec.ts @@ -93,7 +93,8 @@ 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(), @@ -218,7 +219,11 @@ test.describe("作品库页面", () => { const products = mockProducts(3, ["completed", "processing", "failed"]); await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); @@ -255,12 +260,24 @@ test.describe("作品库页面", () => { const products = [ { ...mockProducts(1, ["completed"])[0], title: "已完成作品" }, - { ...mockProducts(1, ["processing"])[0], title: "处理中作品", id: `mock-prod-${Date.now()}-p` }, - { ...mockProducts(1, ["failed"])[0], title: "失败作品", id: `mock-prod-${Date.now()}-f` }, + { + ...mockProducts(1, ["processing"])[0], + title: "处理中作品", + id: `mock-prod-${Date.now()}-p`, + }, + { + ...mockProducts(1, ["failed"])[0], + title: "失败作品", + id: `mock-prod-${Date.now()}-f`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-page")).toBeVisible({ @@ -276,9 +293,9 @@ test.describe("作品库页面", () => { const completedCard = page .locator(".xx-product-card") .filter({ hasText: "已完成作品" }); - await expect(completedCard.locator(".xx-product-status.completed")).toHaveText( - "已完成", - ); + await expect( + completedCard.locator(".xx-product-status.completed"), + ).toHaveText("已完成"); const processingCard = page .locator(".xx-product-card") @@ -309,7 +326,11 @@ test.describe("作品库页面", () => { const productId = products[0].id; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); // 直接访问详情页 await page.goto(`/app/products/${productId}`); @@ -337,7 +358,11 @@ test.describe("作品库页面", () => { products[0].video_url = "https://example.com/test-video.mp4"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -356,7 +381,10 @@ test.describe("作品库页面", () => { // 播放弹窗出现 - 验证有视频元素或播放器容器 // (通过 Mock 的 video_url,video 元素应能渲染) const videoEl = page.locator("video"); - const videoVisible = await videoEl.first().isVisible({ timeout: 5000 }).catch(() => false); + const videoVisible = await videoEl + .first() + .isVisible({ timeout: 5000 }) + .catch(() => false); // 或弹窗容器可见 const modalVisible = await page .locator(".ant-modal-content") @@ -380,7 +408,11 @@ test.describe("作品库页面", () => { products[0].title = "下载测试作品"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -409,7 +441,11 @@ test.describe("作品库页面", () => { products[0].title = "处理中下载测试"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -484,7 +520,11 @@ test.describe("作品库页面", () => { route.continue(); }); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -507,9 +547,12 @@ test.describe("作品库页面", () => { 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 表示资源不存在但端点存在 @@ -529,7 +572,11 @@ test.describe("作品库页面", () => { // Mock 空列表 await mockProductsApi(page, []); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-page")).toBeVisible({ @@ -553,12 +600,24 @@ test.describe("作品库页面", () => { ); const products = [ - { ...mockProducts(1, ["completed"])[0], title: "苹果宣传视频", id: `mock-prod-${Date.now()}-apple` }, - { ...mockProducts(1, ["completed"])[0], title: "香蕉推广视频", id: `mock-prod-${Date.now()}-banana` }, + { + ...mockProducts(1, ["completed"])[0], + title: "苹果宣传视频", + id: `mock-prod-${Date.now()}-apple`, + }, + { + ...mockProducts(1, ["completed"])[0], + title: "香蕉推广视频", + id: `mock-prod-${Date.now()}-banana`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -566,7 +625,9 @@ test.describe("作品库页面", () => { }); // 两个作品都可见 - await expect(page.getByText("苹果宣传视频")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("苹果宣传视频")).toBeVisible({ + timeout: 5_000, + }); await expect(page.getByText("香蕉推广视频")).toBeVisible(); // 搜索"苹果" @@ -576,7 +637,9 @@ test.describe("作品库页面", () => { // 清空搜索 await page.getByPlaceholder("搜索成片名称...").fill(""); - await expect(page.getByText("香蕉推广视频")).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText("香蕉推广视频")).toBeVisible({ + timeout: 5_000, + }); }); test("作品状态筛选", async ({ page, request }) => { @@ -587,12 +650,24 @@ test.describe("作品库页面", () => { ); const products = [ - { ...mockProducts(1, ["completed"])[0], title: "已完成筛选", id: `mock-prod-${Date.now()}-done` }, - { ...mockProducts(1, ["processing"])[0], title: "处理中筛选", id: `mock-prod-${Date.now()}-proc` }, + { + ...mockProducts(1, ["completed"])[0], + title: "已完成筛选", + id: `mock-prod-${Date.now()}-done`, + }, + { + ...mockProducts(1, ["processing"])[0], + title: "处理中筛选", + id: `mock-prod-${Date.now()}-proc`, + }, ]; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -629,7 +704,11 @@ test.describe("作品库页面", () => { products[2].title = "批量测试 3"; await mockProductsApi(page, products); - await setupAuthInBrowser(page, accessToken, { id: userId, email, username }); + await setupAuthInBrowser(page, accessToken, { + id: userId, + email, + username, + }); await page.goto("/app/products"); await expect(page.locator(".xx-products-grid")).toBeVisible({ @@ -653,8 +732,12 @@ test.describe("作品库页面", () => { 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(); diff --git a/apps/web/e2e/profile.spec.ts b/apps/web/e2e/profile.spec.ts index ca00d5463..4d14e2262 100644 --- a/apps/web/e2e/profile.spec.ts +++ b/apps/web/e2e/profile.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -401,7 +406,10 @@ test.describe("个人设置 - 退出登录", () => { test.describe.configure({ timeout: 120_000 }); test("登出 API - 正向", async ({ request }) => { - const { headers, email } = await createAuthedUser(request, "profile-logout"); + const { headers, email } = await createAuthedUser( + request, + "profile-logout", + ); const response = await request.post(`${apiBase}/auth/logout`, { headers, diff --git a/apps/web/e2e/register.spec.ts b/apps/web/e2e/register.spec.ts index 17b7217c7..672924cc0 100644 --- a/apps/web/e2e/register.spec.ts +++ b/apps/web/e2e/register.spec.ts @@ -49,7 +49,9 @@ test.describe("注册页面", () => { await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪"); // 标题/描述 - await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible(); + await expect( + page.getByText("创建账户,开启智能视频创作之旅"), + ).toBeVisible(); // 表单字段 await expect(page.getByLabel("邮箱")).toBeVisible(); @@ -69,7 +71,10 @@ test.describe("注册页面", () => { 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(); @@ -86,7 +91,10 @@ test.describe("注册页面", () => { 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(); @@ -100,7 +108,10 @@ test.describe("注册页面", () => { 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(); @@ -114,7 +125,10 @@ test.describe("注册页面", () => { 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(); @@ -128,7 +142,10 @@ test.describe("注册页面", () => { 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(); }); @@ -154,10 +171,16 @@ test.describe("注册页面", () => { { 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(); + expect( + resp.ok(), + `注册请求应返回 2xx,实际: ${resp.status()}`, + ).toBeTruthy(); // 注册成功后应跳转到登录页或显示成功消息 // 页面应停留在可识别的状态(成功提示或跳转) @@ -199,14 +222,19 @@ test.describe("注册页面", () => { 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(); + const hasError = await page + .getByText(/注册失败|已注册|已存在|exists/) + .isVisible(); return hasError ? "error_shown" : "waiting"; }, { timeout: 10_000 }, @@ -254,7 +282,12 @@ test.describe("注册页面", () => { // 注册 await request.post(`${apiBase}/auth/register`, { - data: { email, password: PASSWORD, username, display_name: "Reg Auth Test" }, + data: { + email, + password: PASSWORD, + username, + display_name: "Reg Auth Test", + }, }); // 登录 @@ -293,6 +326,8 @@ test.describe("注册页面", () => { // 注册页对已登录用户也可访问(注册页是公开页面) // 验证页面正常渲染 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(); }); }); diff --git a/apps/web/e2e/subscription-full.spec.ts b/apps/web/e2e/subscription-full.spec.ts index 871a8f8da..27aeff2c5 100644 --- a/apps/web/e2e/subscription-full.spec.ts +++ b/apps/web/e2e/subscription-full.spec.ts @@ -9,7 +9,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -243,8 +248,13 @@ test.describe("订阅套餐页 - 升级交互", () => { const url = page.url(); // 验证页面有响应(跳转到支付或保持在订阅页但有弹窗) expect( - url.includes("/subscription/upgrade") || url.includes("/subscription") || - (await page.locator(".ant-modal, [role='dialog']").first().isVisible().catch(() => false)), + url.includes("/subscription/upgrade") || + url.includes("/subscription") || + (await page + .locator(".ant-modal, [role='dialog']") + .first() + .isVisible() + .catch(() => false)), ).toBeTruthy(); } }); @@ -538,13 +548,16 @@ test.describe("订阅 - 支付流程", () => { test("创建支付订单 - 正向 API", async ({ request }) => { 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( @@ -560,12 +573,15 @@ test.describe("订阅 - 支付流程", () => { }); 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()); }); }); diff --git a/apps/web/e2e/subscription.spec.ts b/apps/web/e2e/subscription.spec.ts index 2e8075c83..705909cbd 100755 --- a/apps/web/e2e/subscription.spec.ts +++ b/apps/web/e2e/subscription.spec.ts @@ -178,7 +178,10 @@ test.describe("订阅过期处理", () => { // 免费用户可能不需要取消,返回 400 或类似错误 if (!response.ok()) { const data = await response.json(); - expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy(); + expect( + data.error?.message || data.detail || data.message, + "应返回错误信息", + ).toBeTruthy(); } }); diff --git a/apps/web/e2e/templates.spec.ts b/apps/web/e2e/templates.spec.ts index 9c1245cbf..1a4899479 100644 --- a/apps/web/e2e/templates.spec.ts +++ b/apps/web/e2e/templates.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -326,7 +331,9 @@ test.describe("模板库 - 模板展示", () => { if (await modal.isVisible({ timeout: 5_000 })) { await expect(modal).toBeVisible(); // 验证预览内容存在 - await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible(); + await expect( + modal.locator(".xx-template-modal-title-row"), + ).toBeVisible(); } } }); @@ -487,7 +494,10 @@ test.describe("模板库 - API 操作", () => { `${apiBase}/templates/${templateId}/favorite`, { headers }, ); - expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy(); + expect( + unfavResp.status() < 500, + "取消收藏请求应返回 2xx 或 4xx", + ).toBeTruthy(); }); test("获取模板详情 - 正向", async ({ request }) => { @@ -515,10 +525,9 @@ test.describe("模板库 - API 操作", () => { expect(createResp.ok()).toBeTruthy(); const created = await createResp.json(); - const detailResp = await request.get( - `${apiBase}/templates/${created.id}`, - { headers }, - ); + 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); diff --git a/apps/web/e2e/test_auth.spec.ts b/apps/web/e2e/test_auth.spec.ts index 6132d6f90..611c1a108 100755 --- a/apps/web/e2e/test_auth.spec.ts +++ b/apps/web/e2e/test_auth.spec.ts @@ -175,10 +175,9 @@ test.describe("认证流程", () => { }, }); - expect( - [400, 422], - "缺少用户名字段应返回 4xx 校验错误", - ).toContain(response.status()); + expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain( + response.status(), + ); }); // ─── 登录 ──────────────────────────────────────────── @@ -230,7 +229,9 @@ test.describe("认证流程", () => { data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD }, }); if (response.status() !== 429) break; - console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`); + console.log( + `[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`, + ); await new Promise((r) => setTimeout(r, 65_000)); } diff --git a/apps/web/e2e/titles-full.spec.ts b/apps/web/e2e/titles-full.spec.ts index ba377f494..b5bde078d 100644 --- a/apps/web/e2e/titles-full.spec.ts +++ b/apps/web/e2e/titles-full.spec.ts @@ -9,7 +9,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -224,7 +229,11 @@ test.describe("标题库 - API 完整操作", () => { test("编辑标题 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "title-update"); - const titleId = await createTitle(request, headers, Date.now().toString(36)); + const titleId = await createTitle( + request, + headers, + Date.now().toString(36), + ); const newName = `更新后的标题 ${Date.now()}`; const newText = "这是更新后的标题内容"; @@ -256,7 +265,11 @@ test.describe("标题库 - API 完整操作", () => { test("删除标题 - 正向", async ({ request }) => { const { headers } = await createAuthedUser(request, "title-delete"); - const titleId = await createTitle(request, headers, Date.now().toString(36)); + const titleId = await createTitle( + request, + headers, + Date.now().toString(36), + ); // 删除 const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, { @@ -279,9 +292,21 @@ test.describe("标题库 - API 完整操作", () => { const suffix = Date.now().toString(36); const titles = [ - { name: `批量标题 1 ${suffix}`, text: `内容 1 ${suffix}`, category: "default" }, - { name: `批量标题 2 ${suffix}`, text: `内容 2 ${suffix}`, category: "种草" }, - { name: `批量标题 3 ${suffix}`, text: `内容 3 ${suffix}`, category: "知识" }, + { + name: `批量标题 1 ${suffix}`, + text: `内容 1 ${suffix}`, + category: "default", + }, + { + name: `批量标题 2 ${suffix}`, + text: `内容 2 ${suffix}`, + category: "种草", + }, + { + name: `批量标题 3 ${suffix}`, + text: `内容 3 ${suffix}`, + category: "知识", + }, ]; const response = await request.post(`${apiBase}/titles/batch-import`, { @@ -297,7 +322,9 @@ test.describe("标题库 - API 完整操作", () => { if (response.ok()) { const data = await response.json(); - expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy(); + expect( + Array.isArray(data) || data.success_count !== undefined, + ).toBeTruthy(); } }); diff --git a/apps/web/e2e/voice-clone.spec.ts b/apps/web/e2e/voice-clone.spec.ts index 03a3b109b..69f4793b9 100644 --- a/apps/web/e2e/voice-clone.spec.ts +++ b/apps/web/e2e/voice-clone.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -326,10 +331,9 @@ test.describe("声音克隆 - API 操作", () => { ).toBeTruthy(); // 验证已删除 - const getResp = await request.get( - `${apiBase}/voice-clones/${cloneId}`, - { headers }, - ); + const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { + headers, + }); expect([404, 410]).toContain(getResp.status()); } // 如果创建失败(比如音频格式问题),测试也通过 @@ -491,11 +495,15 @@ test.describe("声音克隆 - 上传区域", () => { }); // 尝试点击克隆新音色按钮 - const cloneBtn = page.getByRole("button", { name: /克隆新音色|立即克隆|新建/ }); + const cloneBtn = page.getByRole("button", { + name: /克隆新音色|立即克隆|新建/, + }); if (await cloneBtn.isVisible()) { 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(); } diff --git a/apps/web/e2e/voices.spec.ts b/apps/web/e2e/voices.spec.ts index 35a08df24..762e7c924 100644 --- a/apps/web/e2e/voices.spec.ts +++ b/apps/web/e2e/voices.spec.ts @@ -6,7 +6,12 @@ * * 每个测试独立,先注册登录获取 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"; @@ -157,7 +162,9 @@ test.describe("音色库页面 - 页面加载", () => { }); // 验证搜索框存在 - const searchInput = page.locator("input[type='search'], .xx-voices-search input, input[placeholder*='搜索']"); + const searchInput = page.locator( + "input[type='search'], .xx-voices-search input, input[placeholder*='搜索']", + ); await expect(searchInput.first()).toBeVisible({ timeout: 10_000 }); }); }); diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 1ccaf9bee..5a7e7c797 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -5,6 +5,32 @@ import apiClient from "./client"; import { getOrCreateDefaultProject } from "./projects"; +/** 素材元数据 */ +export interface AssetMetadata { + /** 时长(秒) */ + duration?: number; + /** 宽度(像素) */ + width?: number; + /** 高度(像素) */ + height?: number; + /** 比特率(bps) */ + bitrate?: number; + /** 编码格式 */ + codec?: string; + /** 帧率 */ + fps?: number; + /** 采样率(Hz) */ + sample_rate?: number; + /** 声道数 */ + channels?: number; + /** 其他扩展字段 */ + [key: string]: unknown; +} + +/** 素材分类状态 */ +export type AssetClassificationStatus = + "pending" | "processing" | "completed" | "failed"; + /** 素材条目 */ export interface AssetItem { id: string; @@ -12,12 +38,14 @@ export interface AssetItem { name: string; storage_key: string; mime_type: string; - metadata: Record; + metadata: AssetMetadata; file_size?: number; file_url?: string; thumbnail_url?: string; + /** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */ + duration?: number; status?: string; - classification_status?: string | null; + classification_status?: AssetClassificationStatus | null; quality_score?: number | null; tag_ids?: string[]; created_at?: string; @@ -167,7 +195,7 @@ export const createAsset = async (data: { name: string; storage_key: string; mime_type: string; - metadata?: Record; + metadata?: AssetMetadata; }): Promise => { const response = await apiClient.post("/assets", data); return response.data; @@ -350,3 +378,52 @@ export const getClassificationJob = async ( 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; +} + +/** 批量删除素材 */ +export const batchDeleteAssets = async ( + assetIds: string[], +): Promise => { + const response = await apiClient.post("/assets/batch-delete", { + asset_ids: assetIds, + }); + return response.data; +}; + +/** 批量打标签 */ +export const batchTagAssets = async (data: { + asset_ids: string[]; + tags: string[]; + mode: "add" | "replace"; +}): Promise => { + const response = await apiClient.post("/assets/batch-tag", data); + return response.data; +}; + +/** 批量改分类 */ +export const batchClassifyAssets = async (data: { + asset_ids: string[]; + category: string; +}): Promise => { + const response = await apiClient.post("/assets/batch-classify", data); + return response.data; +}; + +/** 批量智能标记 */ +export const batchMarkAssets = async (data: { + asset_ids: string[]; + smart_view: "recommended" | "caution" | "high_risk"; +}): Promise => { + const response = await apiClient.post("/assets/batch-mark", data); + return response.data; +}; diff --git a/apps/web/src/api/bgm.ts b/apps/web/src/api/bgm.ts new file mode 100644 index 000000000..ed4d33737 --- /dev/null +++ b/apps/web/src/api/bgm.ts @@ -0,0 +1,70 @@ +/** + * BGM 预设音乐 API + * 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索) + */ +import apiClient from "./client"; + +/* ──────────── 类型 ──────────── */ + +/** BGM 风格分类 */ +export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"; + +/** BGM 预设项 */ +export interface BgmPreset { + id: string; + name: string; + category: BgmCategory; + /** 音频文件 URL */ + url: string; + /** 时长(秒) */ + duration: number; + /** 关键词标签 */ + tags: string[]; + /** 封面图 URL */ + cover_url?: string; +} + +/** BGM 预设列表查询参数 */ +export interface BgmPresetsQuery { + category?: BgmCategory | string; + keyword?: string; +} + +/** BGM 混音配置(嵌入剪辑计划) */ +export interface BgmMixConfig { + /** 是否启用 BGM */ + enabled: boolean; + /** 选中的 BGM ID */ + music_id: string; + /** BGM 音量 0-100 */ + volume: number; + /** 淡入时长(秒) 0-3 */ + fade_in: number; + /** 淡出时长(秒) 0-3 */ + fade_out: number; + /** 人声闪避(sidechain) */ + voice_dodge: boolean; +} + +/** 默认 BGM 混音配置 */ +export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = { + enabled: false, + music_id: "", + volume: 50, + 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 ?? []; +}; diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index 2280d7282..c4912dd6e 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -4,6 +4,15 @@ */ import apiClient from "./client"; import type { AssetItem } from "./assets"; +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types"; /* ============================================================ * 后端 API 类型(严格匹配后端 Schema) @@ -13,6 +22,83 @@ import type { AssetItem } from "./assets"; export type EditPlanStatus = "draft" | "editing" | "rendering" | "completed" | "failed"; +/** 标题配置(对齐后端 title_config) */ +export interface TitleConfig { + 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; +} + +/** BGM 配置 */ +export interface BgmConfig { + 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; +} + +/** 片段裁剪配置 */ +export interface SegmentTrimConfig { + start_time: number; + end_time: number; +} + +/** 片段转场配置 */ +export interface SegmentTransitionConfig { + 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; +} + +/** 剪辑计划 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; +} + /** 剪辑计划(后端响应) */ export interface EditPlan { id: string; @@ -20,7 +106,7 @@ export interface EditPlan { name: string; status: EditPlanStatus; total_duration: number; - config: Record; + config: EditPlanConfig; created_at: string; updated_at: string; } @@ -29,7 +115,7 @@ export interface EditPlan { export interface CreateEditPlanRequest { template_id: string; name: string; - config?: Record; + config?: EditPlanConfig; total_duration?: number; /** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */ source_edit_plan_id?: string; @@ -38,7 +124,7 @@ export interface CreateEditPlanRequest { /** 更新剪辑计划请求 */ export interface UpdateEditPlanRequest { name?: string; - config?: Record; + config?: EditPlanConfig; total_duration?: number; status?: EditPlanStatus; } @@ -80,6 +166,24 @@ export interface GenerationStatusResponse { 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; +} + /* ============================================================ * AI 推荐 & 封面生成(任务 3.09) * ============================================================ */ @@ -100,14 +204,14 @@ export interface AIRecommendClipItem { transition_effect: string; asset_id: string; start_time: number; - config: Record; + config: EditPlanConfig; } /** AI 推荐响应 */ export interface AIRecommendResponse { plan_id: string; clips: AIRecommendClipItem[]; - config: Record; + config: EditPlanConfig; total_duration: number; confidence: number; } @@ -122,7 +226,13 @@ export interface GenerateCoverRequest { /** AI 封面生成响应 */ export interface GenerateCoverResponse { plan_id: string; - cover: Record; + cover: { + scheme?: string; + asset_id?: string; + frame_time?: number; + thumbnail_url?: string; + [key: string]: unknown; + }; } /* ============================================================ @@ -147,10 +257,27 @@ export interface EditPlanClip { order: number; } -/** 转场效果 */ +/** 转场效果(14 种预设) */ export interface TransitionEffect { - type: "none" | "fade" | "dissolve" | "wipe" | "zoom" | "slide"; + type: + | "none" + | "cut" + | "fade" + | "dissolve" + | "zoom" + | "slide_left" + | "slide_right" + | "slide_up" + | "slide_down" + | "wipe_left" + | "wipe_right" + | "wipe_up" + | "wipe_down" + | "circlecrop" + | "rectcrop"; duration: number; // 转场时长(秒) + /** 播放速度倍率 */ + playback_speed?: number; } /** 素材库资产(UI 层类型,映射自后端 AssetResponse) */ @@ -177,15 +304,30 @@ export interface MediaAsset { * API 函数 — 严格对接后端 * ============================================================ */ -/** 获取剪辑计划列表 */ -export async function getEditPlans(params?: { +/** 剪辑计划列表查询参数 */ +export interface EditPlanListParams { page?: number; page_size?: number; template_id?: string; status?: string; -}): Promise { - const response = await apiClient.get("/edit-plans", { params }); - return response.data.items || []; +} + +/** 剪辑计划列表分页响应 */ +export interface EditPlanListResponse { + items: EditPlan[]; + total: number; + page: number; + page_size: number; +} + +/** 获取剪辑计划列表(支持分页和筛选) */ +export async function getEditPlans( + params?: EditPlanListParams, +): Promise { + const response = await apiClient.get("/edit-plans", { + params, + }); + return response.data; } /** 获取单个剪辑计划 */ @@ -266,6 +408,14 @@ export async function getEditPlanGenerations( 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 || []; +} + /** * 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx * 将后端 AssetResponse 映射为前端 MediaAsset 类型 @@ -297,26 +447,22 @@ function inferMediaType(mimeType: string): "video" | "image" | "audio" { } function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { - const meta = (asset.metadata || {}) as Record; - const ext = asset as AssetItem & Record; + // 优先取顶层 duration,其次从 metadata 回退 + const metaDuration = + typeof asset.metadata?.duration === "number" + ? asset.metadata.duration + : undefined; return { id: asset.id, name: asset.name, type: inferMediaType(asset.mime_type || ""), - thumbnail_url: - typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined, - duration: - typeof ext.duration === "number" - ? ext.duration - : typeof meta.duration === "number" - ? (meta.duration as number) - : undefined, + thumbnail_url: asset.thumbnail_url, + duration: asset.duration ?? metaDuration, size: asset.file_size ?? undefined, tags: [], created_at: asset.created_at ?? "", quality_score: asset.quality_score ?? undefined, - classification_status: (asset.classification_status ?? - undefined) as MediaAsset["classification_status"], + classification_status: asset.classification_status ?? undefined, }; } @@ -324,17 +470,27 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset { * 常量 * ============================================================ */ -/** 转场效果选项 */ +/** 转场效果选项(14 种预设) */ export const TRANSITION_OPTIONS: { value: TransitionEffect["type"]; label: string; + icon: string; }[] = [ - { value: "none", label: "无转场" }, - { value: "fade", label: "淡入淡出" }, - { value: "dissolve", label: "溶解" }, - { value: "wipe", label: "擦除" }, - { value: "zoom", label: "缩放" }, - { value: "slide", label: "滑动" }, + { value: "none", label: "无转场", icon: "⊘" }, + { value: "cut", label: "硬切", icon: "✂" }, + { value: "fade", label: "淡入淡出", icon: "◐" }, + { value: "dissolve", label: "溶解", icon: "◈" }, + { value: "zoom", label: "缩放", icon: "⊕" }, + { value: "slide_left", label: "左滑", icon: "←" }, + { value: "slide_right", label: "右滑", icon: "→" }, + { value: "slide_up", label: "上滑", icon: "↑" }, + { value: "slide_down", label: "下滑", icon: "↓" }, + { value: "wipe_left", label: "左擦除", icon: "▸|" }, + { value: "wipe_right", label: "右擦除", icon: "|◂" }, + { value: "wipe_up", label: "上擦除", icon: "▴̄" }, + { value: "wipe_down", label: "下擦除", icon: "▾̄" }, + { value: "circlecrop", label: "圆形裁切", icon: "●" }, + { value: "rectcrop", label: "矩形裁切", icon: "■" }, ]; /** 素材类型标签 */ diff --git a/apps/web/src/api/editingPlanner.ts b/apps/web/src/api/editingPlanner.ts index 79d01189a..58ac0f3bc 100644 --- a/apps/web/src/api/editingPlanner.ts +++ b/apps/web/src/api/editingPlanner.ts @@ -3,6 +3,15 @@ * 对接后端 /api/v1/templates 路由 */ import apiClient from "./client"; +import type { + WatermarkConfig, + IntroOutroConfig, + PipConfig, + FilterConfig, + ChromaKeyConfig, + StickerConfig, + CoverConfig, +} from "@/pages/editing-planner/types"; /* ──────────── 类型定义 ──────────── */ @@ -72,6 +81,20 @@ export interface EditingTemplate { bgm_config: BgmConfig; estimated_duration: number; segments: TemplateSegment[]; + /** 水印配置(后端就绪后启用) */ + watermark_config?: WatermarkConfig; + /** 片头片尾配置(后端就绪后启用) */ + intro_outro_config?: IntroOutroConfig; + /** 画中画配置 */ + pip_config?: PipConfig; + /** 滤镜调色配置 */ + filter_config?: FilterConfig; + /** 绿幕抠像配置 */ + green_screen_config?: ChromaKeyConfig; + /** 贴纸配置 */ + sticker_config?: StickerConfig; + /** 封面配置 */ + cover_config?: CoverConfig; is_active?: boolean; created_at: string; updated_at: string; @@ -95,6 +118,20 @@ export interface SaveTemplatePayload { bgm_config: BgmConfig; estimated_duration: number; segments: Omit[]; + /** 水印配置(后端就绪后启用) */ + watermark_config?: WatermarkConfig; + /** 片头片尾配置(后端就绪后启用) */ + intro_outro_config?: IntroOutroConfig; + /** 画中画配置 */ + pip_config?: PipConfig; + /** 滤镜调色配置 */ + filter_config?: FilterConfig; + /** 绿幕抠像配置 */ + green_screen_config?: ChromaKeyConfig; + /** 贴纸配置 */ + sticker_config?: StickerConfig; + /** 封面配置 */ + cover_config?: CoverConfig; } /** 使用模板生成请求体 */ diff --git a/apps/web/src/api/products.ts b/apps/web/src/api/products.ts index 8b5dc4375..cdecae5dd 100644 --- a/apps/web/src/api/products.ts +++ b/apps/web/src/api/products.ts @@ -1,8 +1,13 @@ /** - * 成品相关 API - * Phase 1 重构:去掉 projectId,成品直接归属用户 + * 成品 / 视频相关 API + * 后端无 /products 路由,实际从 /generation/tasks 端点获取数据 */ import apiClient from "./client"; +import { getGenerationTaskResults } from "./editPlans"; +import type { GeneratedVideo } from "./editPlans"; + +/** 复核状态 */ +export type ReviewStatus = "pending_review" | "approved" | "rejected"; /** 成品条目 */ export interface ProductItem { @@ -14,33 +19,134 @@ export interface ProductItem { file_size?: number; resolution?: string; status: "processing" | "completed" | "failed"; + /** 复核状态 */ + review_status?: ReviewStatus; + /** 所属项目 ID */ + project_id?: string; + /** 所属项目名称 */ + project_name?: string; /** 查重率(百分比) */ duplicate_rate?: number; created_at?: string; updated_at?: string; } -/** 获取当前用户的所有成品 */ -export const getProducts = async (): Promise => { - const response = await apiClient.get("/products"); - return response.data.items || response.data || []; +/** 列表查询参数 */ +export interface ProductListParams { + page?: number; + page_size?: number; + project_id?: string; + review_status?: ReviewStatus | "all"; +} + +/** 分页响应 */ +export interface ProductListResponse { + items: ProductItem[]; + total: number; + page: number; + page_size: number; +} + +/** 批量下载任务状态 */ +export interface BatchDownloadStatus { + job_id: string; + status: "processing" | "completed" | "failed"; + /** 完成后返回的下载 URL */ + download_url?: string; + /** 进度百分比 */ + progress?: number; +} + +/** + * 将 generation task 数据映射为 ProductItem 格式 + */ +function mapTaskToProductItem( + task: GeneratedVideo | Record, +): ProductItem { + const video = task as GeneratedVideo; + return { + id: video.id, + title: video.name || "未命名视频", + video_url: video.file_url, + thumbnail_url: video.thumbnail_url, + duration_seconds: video.duration, + file_size: video.file_size, + resolution: + video.width && video.height + ? `${video.width}x${video.height}` + : undefined, + status: + video.status === "completed" + ? "completed" + : video.status === "failed" + ? "failed" + : "processing", + review_status: video.review_status as ReviewStatus | undefined, + project_id: video.project_id, + created_at: (task as Record).created_at as + string | undefined, + updated_at: (task as Record).updated_at as + string | undefined, + }; +} + +/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */ +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(`/products/${productId}`); - return 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(`/products/${productId}`); + await apiClient.delete(`/generation/tasks/${productId}`); }; -/** 获取成品下载链接 */ +/** 获取成品下载链接 — 从 generation task results 获取 */ export const getProductDownloadUrl = async ( productId: string, ): Promise<{ url: string; expires_at: string }> => { - const response = await apiClient.get(`/products/${productId}/download-url`); - return response.data; + 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 ( + productId: string, + status: ReviewStatus, +): Promise => { + // 后端暂无 /generation/tasks/{id}/review 端点 + // 暂时返回当前状态,后续可扩展 + const product = await getProduct(productId); + return { ...product, review_status: status }; +}; + +/** 发起批量下载 — TODO: 后端暂无对应端点 */ +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()}` }; +}; + +/** 查询批量下载状态 — TODO: 后端暂无对应端点 */ +export const getBatchDownloadStatus = async ( + jobId: string, +): Promise => { + // 后端暂无 /generation/tasks/batch-download/{jobId} 端点 + // 暂时返回模拟状态,后续可扩展 + console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId); + return { job_id: jobId, status: "processing", progress: 0 }; }; diff --git a/apps/web/src/api/tasks.ts b/apps/web/src/api/tasks.ts index 4e80832f5..1c4419b03 100644 --- a/apps/web/src/api/tasks.ts +++ b/apps/web/src/api/tasks.ts @@ -1,31 +1,67 @@ /** * 任务相关 API - * 对接后端方案 A 扩展后的端点(PR #109) - * - POST /api/v1/generation/tasks — 创建生成任务(template_id + asset_ids 细粒度模式) - * - GET /api/v1/tasks — 用户级任务列表(跨 project) - * - POST /api/v1/tasks/{task_id}/retry — 简化重试 + * 对接后端任务中心 API: + * - POST /api/v1/generation/tasks — 创建生成任务 + * - GET /api/v1/tasks — 用户级任务列表(支持分页/筛选) + * - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info) + * - POST /api/v1/tasks/{task_id}/retry — 重试失败任务 */ import apiClient from "./client"; /* ──────────── 类型定义 ──────────── */ +/** 任务状态 */ +export type TaskStatus = + "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"; + +/** 任务类型 */ +export type TaskType = "ingest" | "generation" | string; + +/** 错误详情 */ +export interface TaskErrorInfo { + error_type: string; + error_message: string; + failed_step: string; + stack_trace?: string; +} + /** 任务条目(对应用户级 UserTaskResponse) */ export interface TaskItem { id: string; - task_type: "ingest" | "generation" | string; + task_type: TaskType; project_id: string; - template_id: string; - status: 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; + /** 耗时(秒) */ + 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"; +} + +/** 任务列表分页响应 */ +export interface TaskListResponse { + items: TaskItem[]; + total: number; + page: number; + page_size: number; +} + /** 创建生成任务请求参数 */ export interface CreateGenerationTaskRequest { template_id: string; @@ -64,13 +100,23 @@ export const createGenerationTask = async ( return data; }; -/** 获取当前用户的所有任务(跨 project) */ -export const getUserTasks = async (): Promise => { - const { data } = await apiClient.get("/tasks"); - return data.items || []; +/** 获取任务列表(支持分页和筛选) */ +export const getTasks = async ( + params?: TaskListParams, +): Promise => { + const { data } = await apiClient.get("/tasks", { + params, + }); + return data; }; -/** 获取单个任务详情(用于轮询进度) */ +/** 获取当前用户的所有任务(兼容旧接口,跨 project) */ +export const getUserTasks = async (): Promise => { + 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; diff --git a/apps/web/src/api/templates.ts b/apps/web/src/api/templates.ts index d1889e442..626a196a8 100644 --- a/apps/web/src/api/templates.ts +++ b/apps/web/src/api/templates.ts @@ -1,26 +1,112 @@ /** * 模板相关 API - * Phase 1 新增:全局模板库 + * 对接后端模板管理接口: + * - GET /api/v1/templates — 模板列表(分页/筛选) + * - GET /api/v1/templates/{id} — 模板详情 + * - POST /api/v1/templates/{id}/copy — 复制模板 + * - 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"; -/** 模板条目 */ +/* ──────────── 类型定义 ──────────── */ + +/** 模板条目(后端 TemplateResponse) */ export interface TemplateItem { id: string; name: string; description: string; category: string; + tags?: string[]; target_duration: number; clip_count: number; + /** 使用次数 */ + usage_count?: number; thumbnail_url?: string; preview_url?: string; is_active: boolean; is_favorite?: boolean; + /** 素材规则(片段配置) */ + segments?: TemplateSegment[]; + /** 字幕样式 */ + subtitle_config?: SubtitleConfig; + /** BGM 配置 */ + bgm_config?: BgmConfig; + /** 标题配置 */ + title_config?: TitleConfig; + /** 视频比例 */ + aspect_ratio?: string; created_at?: string; + updated_at?: string; } -/** 获取全局模板列表 */ -export const getTemplates = async (): Promise => { +/** 模板片段(素材规则) */ +export interface TemplateSegment { + 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; + /** 时长筛选(秒):short < 30, medium 30-120, long > 120 */ + duration_range?: "short" | "medium" | "long"; +} + +/** 模板列表分页响应 */ +export interface TemplateListResponse { + items: TemplateItem[]; + total: number; + page: number; + page_size: number; +} + +/** 从模板生成剪辑计划请求 */ +export interface GenerateFromTemplateRequest { + asset_ids?: string[]; + name?: string; + config?: EditPlanConfig; +} + +/** 从模板生成剪辑计划响应 */ +export interface GenerateFromTemplateResponse { + plan_id: string; + template_id: string; + status: string; + name: string; +} + +/** 复制模板响应 */ +export interface CopyTemplateResponse { + id: string; + name: string; + source_template_id: string; +} + +/* ──────────── API 函数 ──────────── */ + +/** 获取模板列表(支持分页和筛选) */ +export const getTemplates = async ( + params?: TemplateListParams, +): Promise => { + const { data } = await apiClient.get("/templates", { + params, + }); + return data; +}; + +/** 获取模板列表(兼容旧接口,返回数组) */ +export const getTemplatesList = async (): Promise => { const response = await apiClient.get("/templates"); return response.data.items || response.data || []; }; @@ -42,3 +128,46 @@ export const toggleFavoriteTemplate = async ( ); 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 ( + templateId: string, + data?: GenerateFromTemplateRequest, +): Promise => { + const response = await apiClient.post( + `/templates/${templateId}/generate`, + data, + ); + return response.data; +}; + +/* ──────────── 常量 ──────────── */ + +/** 模板分类选项 */ +export const TEMPLATE_CATEGORY_OPTIONS = [ + { value: "", label: "全部分类" }, + { value: "口播", label: "口播" }, + { value: "种草", label: "种草" }, + { value: "产品", label: "产品" }, + { value: "品牌", label: "品牌" }, + { value: "混剪", label: "混剪" }, + { value: "Vlog", label: "Vlog" }, +]; + +/** 时长筛选选项 */ +export const TEMPLATE_DURATION_OPTIONS = [ + { value: "", label: "全部时长" }, + { value: "short", label: "30秒以内" }, + { value: "medium", label: "30秒-2分钟" }, + { value: "long", label: "2分钟以上" }, +]; diff --git a/apps/web/src/api/tts.ts b/apps/web/src/api/tts.ts index 05b028def..234ec2454 100644 --- a/apps/web/src/api/tts.ts +++ b/apps/web/src/api/tts.ts @@ -140,3 +140,52 @@ export const saveTtsToLibrary = async ( export const deleteTTSJob = async (jobId: string): Promise => { await apiClient.delete(`/tts/jobs/${jobId}`); }; + +/* ── 音色列表 ──────────────────────────────────── */ + +/** TTS 音色 */ +export interface TTSVoice { + id: string; + name: string; + /** 音色分类标签:male/female/young/service/news/emotion */ + category?: string; + /** 语言 */ + language?: string; + /** 试听 URL */ + preview_url?: string; + /** 描述 */ + description?: string; +} + +/** 获取 TTS 音色列表 */ +export const getTtsVoices = async (): Promise => { + const response = await apiClient.get("/tts/voices"); + return response.data; +}; + +/* ── TTS 试听 ──────────────────────────────────── */ + +/** TTS 试听请求参数 */ +export interface TTSPreviewRequest { + text: string; + voice_id: string; + speed?: number; + pitch?: number; +} + +/** TTS 试听响应 */ +export interface TTSPreviewResponse { + audio_url: string; + duration?: number; +} + +/** TTS 试听 */ +export const previewTts = async ( + data: TTSPreviewRequest, +): Promise => { + const response = await apiClient.post( + "/tts/preview", + data, + ); + return response.data; +}; diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css index f6a7e7e54..6599f4fd5 100644 --- a/apps/web/src/components/ui/ui.css +++ b/apps/web/src/components/ui/ui.css @@ -384,7 +384,6 @@ border-bottom: 1px solid var(--border-light) !important; } - /* ============================================================ 响应式 ============================================================ */ @@ -409,7 +408,6 @@ .xx-modal .ant-modal-header { padding: var(--space-md) !important; } - } @media (max-width: 480px) { @@ -423,7 +421,6 @@ } } - /* ── xx-card antd 子元素覆盖样式(从 Admin.css 迁移) ── */ /* AdminComingSoon 等页面使用 时需要 */ /* .xx-card 基础样式和 :hover 已在 global.css 中定义(V21 设计系统) */ diff --git a/apps/web/src/config/navigation.ts b/apps/web/src/config/navigation.ts index a32cf0f96..729aa52bf 100644 --- a/apps/web/src/config/navigation.ts +++ b/apps/web/src/config/navigation.ts @@ -17,6 +17,7 @@ import { ScanOutlined, ControlOutlined, CrownOutlined, + UnorderedListOutlined, } from "@ant-design/icons"; /** 导航项类型 */ @@ -80,6 +81,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/my-templates", icon: React.createElement(FolderOutlined), }, + { + key: "edit-plans", + label: "剪辑计划", + path: "/app/edit-plans", + icon: React.createElement(UnorderedListOutlined), + }, { key: "generate", label: "一键生成", @@ -104,6 +111,12 @@ export const NAV_ITEMS: NavItem[] = [ path: "/app/duplication", icon: React.createElement(ScanOutlined), }, + { + key: "tasks", + label: "任务中心", + path: "/app/tasks", + icon: React.createElement(UnorderedListOutlined), + }, ]; /** 侧边栏导航分组(Sidebar 分组列表使用) */ @@ -129,6 +142,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/editing-planner", icon: React.createElement(EditOutlined), }, + { + key: "edit-plans", + label: "剪辑计划", + path: "/app/edit-plans", + icon: React.createElement(UnorderedListOutlined), + }, ], }, { @@ -181,6 +200,12 @@ export const NAV_GROUPS: NavGroup[] = [ path: "/app/history", icon: React.createElement(HistoryOutlined), }, + { + key: "tasks", + label: "任务中心", + path: "/app/tasks", + icon: React.createElement(UnorderedListOutlined), + }, { key: "duplication", label: "查重", diff --git a/apps/web/src/pages/assets/AssetLibrary.tsx b/apps/web/src/pages/assets/AssetLibrary.tsx index 95ffc2654..d10f881c3 100644 --- a/apps/web/src/pages/assets/AssetLibrary.tsx +++ b/apps/web/src/pages/assets/AssetLibrary.tsx @@ -4,7 +4,17 @@ * 使用 useQuery 对接后端真实 API(api/assets.ts) */ import React, { useMemo, useState } from "react"; -import { Upload, Modal as AntModal, message, Popconfirm } from "antd"; +import { + Upload, + Modal as AntModal, + message, + Popconfirm, + Drawer, + Tag, + Input as AntInput, + Radio, + Select as AntSelect, +} from "antd"; import { PlusOutlined, SearchOutlined, @@ -17,6 +27,11 @@ import { ExperimentOutlined, LoadingOutlined, ExclamationCircleOutlined, + TagsOutlined, + FolderOutlined, + ThunderboltOutlined, + CheckCircleOutlined, + CloseCircleOutlined, } from "@ant-design/icons"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -27,8 +42,13 @@ import { deleteAsset, uploadAssetDirect, getAssetDiagnosis, + batchDeleteAssets, + batchTagAssets, + batchClassifyAssets, + batchMarkAssets, type AssetLibraryItem, type AssetItem as ApiAssetItem, + type BatchOperationResult, } from "@/api/assets"; import { Button, Input, Select } from "@/components/ui"; import "./assets.css"; @@ -407,6 +427,33 @@ const AssetLibrary: React.FC = () => { /* 诊断中状态 — 记录正在诊断的素材 ID */ 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 [batchTagInput, setBatchTagInput] = useState(""); + const [batchTags, setBatchTags] = useState([]); + const [tagMode, setTagMode] = useState<"add" | "replace">("add"); + + /* 批量改分类 */ + const [batchCategory, setBatchCategory] = useState(""); + + /* 批量智能标记 */ + const [batchSmartView, setBatchSmartView] = useState< + "recommended" | "caution" | "high_risk" + >("recommended"); + + /* 操作结果 */ + const [operationResult, setOperationResult] = + useState(null); + const [operationTitle, setOperationTitle] = useState(""); + + /* 批量操作 loading */ + const [batchLoading, setBatchLoading] = useState(false); + /* 派生数据 */ const filteredAssets = useMemo(() => { let list = assets; @@ -563,19 +610,152 @@ const AssetLibrary: React.FC = () => { const handleBatchDelete = async () => { const ids = Array.from(selectedIds); - let successCount = 0; - for (const id of ids) { - try { - await deleteAsset(id); - successCount++; - } catch { - // 忽略单个失败 + 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()); + if (result.failure_count === 0) { + message.success(`成功删除 ${result.success_count} 个素材`); + } else { + message.warning( + `删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); } + } catch { + message.error("批量删除失败,请重试"); + } finally { + setBatchLoading(false); } - queryClient.invalidateQueries({ queryKey: ["assets"] }); - queryClient.invalidateQueries({ queryKey: ["asset-libraries"] }); - setSelectedIds(new Set()); - message.success(`已删除 ${successCount}/${ids.length} 个素材`); + }; + + /* 批量打标签 */ + const handleBatchTag = async () => { + if (batchTags.length === 0) { + message.warning("请至少输入一个标签"); + return; + } + 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()); + if (result.failure_count === 0) { + message.success(`成功为 ${result.success_count} 个素材打标签`); + } else { + message.warning( + `打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); + } + } catch { + message.error("批量打标签失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 批量改分类 */ + const handleBatchClassify = async () => { + if (!batchCategory) { + message.warning("请选择分类"); + return; + } + 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()); + if (result.failure_count === 0) { + message.success( + `成功将 ${result.success_count} 个素材改为「${batchCategory}」`, + ); + } else { + message.warning( + `改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count} 个`, + ); + } + } catch { + message.error("批量改分类失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 批量智能标记 */ + const handleBatchMark = async () => { + 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()); + 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("批量智能标记失败,请重试"); + } finally { + setBatchLoading(false); + } + }; + + /* 标签输入处理 */ + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && batchTagInput.trim()) { + e.preventDefault(); + const tag = batchTagInput.trim(); + if (!batchTags.includes(tag)) { + setBatchTags([...batchTags, tag]); + } + setBatchTagInput(""); + } + }; + + const removeBatchTag = (tag: string) => { + setBatchTags(batchTags.filter((t) => t !== tag)); }; // ── Loading 状态 ── @@ -769,6 +949,30 @@ const AssetLibrary: React.FC = () => { + + + { )} + + {/* ─── 批量打标签弹窗 ─── */} + { + setTagModalOpen(false); + setBatchTags([]); + setBatchTagInput(""); + }} + onOk={handleBatchTag} + confirmLoading={batchLoading} + okText="确认打标签" + cancelText="取消" + > +
+
+ 模式: + setTagMode(e.target.value)} + > + 追加标签 + 替换全部标签 + +
+
+ setBatchTagInput(e.target.value)} + onKeyDown={handleTagInputKeyDown} + style={{ flex: 1 }} + /> +
+ {batchTags.length > 0 && ( +
+ {batchTags.map((tag) => ( + removeBatchTag(tag)} + color="blue" + > + {tag} + + ))} +
+ )} + {tagMode === "replace" && batchTags.length > 0 && ( +
+ 替换模式将清除素材原有全部标签 +
+ )} +
+
+ + {/* ─── 批量改分类弹窗 ─── */} + { + setClassifyModalOpen(false); + setBatchCategory(""); + }} + onOk={handleBatchClassify} + confirmLoading={batchLoading} + okText="确认修改" + cancelText="取消" + > +
+

+ 将选中的 {selectedIds.size} 个素材统一修改为以下分类: +

+ setBatchCategory(v)} + placeholder="请选择分类" + style={{ width: "100%" }} + options={[ + { value: "person", label: "人物" }, + { value: "scenic", label: "风景" }, + { value: "product", label: "产品" }, + { value: "food", label: "美食" }, + { value: "animal", label: "动物" }, + { value: "architecture", label: "建筑" }, + { value: "other", label: "其他" }, + ]} + /> +
+
+ + {/* ─── 批量智能标记弹窗 ─── */} + setMarkModalOpen(false)} + onOk={handleBatchMark} + confirmLoading={batchLoading} + okText="确认标记" + cancelText="取消" + > +
+

+ 将选中的 {selectedIds.size} 个素材标记为: +

+ setBatchSmartView(e.target.value)} + className="xx-batch-mark-options" + > +
+ + 推荐 + + 质量优良,可直接用于生产 + + +
+
+ + 慎用 + + 存在一定问题,需人工审核后再使用 + + +
+
+ + 高风险 + + 存在严重问题,不建议使用 + + +
+
+
+
+ + {/* ─── 操作结果 Drawer ─── */} + { + setResultDrawerOpen(false); + setOperationResult(null); + }} + width={420} + > + {operationResult && ( +
+
+
+ + 总计 {operationResult.total} 个 + +
+
+ + 成功 {operationResult.success_count} 个 +
+ {operationResult.failure_count > 0 && ( +
+ + 失败 {operationResult.failure_count} 个 +
+ )} +
+ + {operationResult.succeeded.length > 0 && ( +
+

+ 成功列表 +

+
+ {operationResult.succeeded.map((id) => ( +
+ {id} +
+ ))} +
+
+ )} + + {operationResult.failed.length > 0 && ( +
+

+ 失败列表 +

+
+ {operationResult.failed.map((id) => ( +
+ {id} +
+ ))} +
+
+ )} +
+ )} +
); }; diff --git a/apps/web/src/pages/assets/assets.css b/apps/web/src/pages/assets/assets.css index 202de4a89..63dda02f3 100644 --- a/apps/web/src/pages/assets/assets.css +++ b/apps/web/src/pages/assets/assets.css @@ -653,3 +653,175 @@ font-size: 13px; color: var(--text-secondary, #6b7280); } + +/* ─── 批量打标签弹窗 ─── */ + +.xx-batch-tag-modal { + display: flex; + flex-direction: column; + gap: 16px; +} + +.xx-batch-tag-mode { + display: flex; + align-items: center; + gap: 8px; +} + +.xx-batch-tag-mode-label { + font-size: 14px; + color: var(--text-primary, #111827); + font-weight: 500; +} + +.xx-batch-tag-input-row { + display: flex; + gap: 8px; +} + +.xx-batch-tag-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.xx-batch-tag-warning { + padding: 10px 12px; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: var(--radius-md, 8px); + color: #c2410c; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; +} + +/* ─── 批量改分类弹窗 ─── */ + +.xx-batch-classify-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-classify-hint { + font-size: 14px; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +/* ─── 批量智能标记弹窗 ─── */ + +.xx-batch-mark-modal { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-mark-hint { + font-size: 14px; + color: var(--text-secondary, #6b7280); + margin: 0; +} + +.xx-batch-mark-options { + display: flex; + flex-direction: column; + gap: 12px; +} + +.xx-batch-mark-option { + display: flex; + flex-direction: column; +} + +.xx-batch-mark-desc { + margin-left: 8px; + font-size: 13px; + color: var(--text-secondary, #6b7280); +} + +/* ─── 操作结果 Drawer ─── */ + +.xx-batch-result { + display: flex; + flex-direction: column; + gap: 20px; +} + +.xx-batch-result-summary { + display: flex; + gap: 16px; + padding: 16px; + background: var(--bg-secondary, #f9fafb); + border-radius: var(--radius-md, 8px); +} + +.xx-batch-result-stat { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: var(--text-primary, #111827); +} + +.xx-batch-result-stat.success { + color: #059669; +} + +.xx-batch-result-stat.fail { + color: #dc2626; +} + +.xx-batch-result-total { + font-weight: 600; +} + +.xx-batch-result-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.xx-batch-result-section-title { + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + margin: 0; +} + +.xx-batch-result-section-title.success { + color: #059669; +} + +.xx-batch-result-section-title.fail { + color: #dc2626; +} + +.xx-batch-result-ids { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 300px; + overflow-y: auto; +} + +.xx-batch-result-id { + padding: 6px 10px; + background: var(--bg-surface, #fff); + border: 1px solid var(--border-primary, #e5e7eb); + border-radius: var(--radius-sm, 4px); + font-size: 12px; + font-family: monospace; + color: var(--text-secondary, #6b7280); + word-break: break-all; +} + +.xx-batch-result-id.fail { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} diff --git a/apps/web/src/pages/edit-plans/EditPlans.tsx b/apps/web/src/pages/edit-plans/EditPlans.tsx new file mode 100644 index 000000000..89e590526 --- /dev/null +++ b/apps/web/src/pages/edit-plans/EditPlans.tsx @@ -0,0 +1,433 @@ +/** + * 剪辑计划管理页面 + * 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成 + */ +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, + SyncOutlined, + CloseCircleOutlined, + EditOutlined, + DeleteOutlined, + FileTextOutlined, + ThunderboltOutlined, +} from "@ant-design/icons"; +import type { ColumnsType } from "antd/es/table"; +import { + getEditPlans, + deleteEditPlan, + generateEditPlan, + type EditPlan, + type EditPlanStatus, + type EditPlanListParams, +} from "@/api/editPlans"; +import { getTemplatesList, type TemplateItem } from "@/api/templates"; +import "./edit-plans.css"; + +/* ──────────── 常量 ──────────── */ + +/** 状态 Tab 配置 */ +const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "draft", label: "草稿" }, + { key: "editing", label: "编辑中" }, + { key: "rendering", label: "渲染中" }, + { key: "completed", label: "已完成" }, + { key: "failed", label: "失败" }, +]; + +/** 状态标签配置 */ +const STATUS_CONFIG: Record< + EditPlanStatus, + { label: string; color: string; icon: React.ReactNode } +> = { + draft: { + label: "草稿", + color: "default", + icon: , + }, + editing: { + label: "编辑中", + color: "processing", + icon: , + }, + rendering: { + label: "渲染中", + color: "warning", + icon: , + }, + completed: { + label: "已完成", + color: "success", + icon: , + }, + failed: { + label: "失败", + color: "error", + icon: , + }, +}; + +/* ──────────── 工具函数 ──────────── */ + +/** 格式化时长 */ +const formatDuration = (seconds: number): string => { + if (seconds <= 0) return "-"; + const m = Math.floor(seconds / 60); + const s = seconds % 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); + 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 [statusFilter, setStatusFilter] = useState( + "all", + ); + const [templateFilter, setTemplateFilter] = useState("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + + // 查询参数 + const queryParams: EditPlanListParams = { + page, + page_size: pageSize, + ...(statusFilter !== "all" && { status: statusFilter }), + ...(templateFilter !== "all" && { template_id: templateFilter }), + }; + + // 获取剪辑计划列表 + const { + data: planData, + isLoading, + error, + } = useQuery({ + queryKey: ["edit-plans", queryParams], + 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 { data: templates } = useQuery({ + queryKey: ["templates-list-simple"], + queryFn: getTemplatesList, + }); + + const plans = planData?.items ?? []; + const total = planData?.total ?? 0; + + // 模板名称映射 + 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"] }); + }, + onError: () => { + message.error("删除失败,请稍后重试"); + }, + }); + + // 重新生成 + const regenerateMutation = useMutation({ + mutationFn: generateEditPlan, + onSuccess: () => { + message.success("已重新提交生成"); + queryClient.invalidateQueries({ queryKey: ["edit-plans"] }); + }, + onError: () => { + message.error("重新生成失败,请稍后重试"); + }, + }); + + // 跳转到剪辑编辑器 + const handleEdit = useCallback( + (plan: EditPlan) => { + navigate(`/app/editing-planner?planId=${plan.id}`); + }, + [navigate], + ); + + // 表格列定义 + const columns: ColumnsType = [ + { + title: "计划名称", + dataIndex: "name", + key: "name", + width: 240, + ellipsis: true, + render: (name: string, record: EditPlan) => ( + + handleEdit(record)}> + {name} + + + ), + }, + { + title: "模板", + dataIndex: "template_id", + key: "template_id", + width: 140, + ellipsis: true, + render: (templateId: string) => { + const name = templateNameMap.get(templateId); + return ( + + {name || templateId.slice(0, 8)} + + ); + }, + }, + { + title: "状态", + dataIndex: "status", + key: "status", + width: 120, + render: (status: EditPlanStatus) => { + const config = STATUS_CONFIG[status] || { + label: status, + color: "default", + icon: null, + }; + return ( + + {config.label} + + ); + }, + }, + { + title: "时长", + dataIndex: "total_duration", + key: "total_duration", + width: 100, + render: (seconds: number) => ( + {formatDuration(seconds)} + ), + }, + { + title: "创建时间", + dataIndex: "created_at", + key: "created_at", + width: 130, + render: (time: string) => ( + {formatTime(time)} + ), + }, + { + title: "更新时间", + dataIndex: "updated_at", + key: "updated_at", + width: 130, + render: (time: string) => ( + {formatTime(time)} + ), + }, + { + title: "操作", + key: "action", + width: 180, + fixed: "right", + render: (_: unknown, record: EditPlan) => ( +
+ + {(record.status === "failed" || record.status === "completed") && ( + regenerateMutation.mutate(record.id)} + okText="确定" + cancelText="取消" + > + + + )} + deleteMutation.mutate(record.id)} + okText="确定" + cancelText="取消" + okButtonProps={{ danger: true }} + > + + +
+ ), + }, + ]; + + // 错误处理 + if (error) { + return ( +
+
+ +

加载剪辑计划失败

+ +
+
+ ); + } + + return ( +
+ {/* 页面标题 */} +
+
+

剪辑计划

+

管理所有剪辑计划,支持重新生成和编辑

+
+ +
+ + {/* 筛选栏 */} +
+ {/* 状态 Tab */} + { + setStatusFilter(key as EditPlanStatus | "all"); + setPage(1); + }} + items={STATUS_TABS.map((tab) => ({ + key: tab.key, + label: tab.label, + }))} + className="edit-plans-status-tabs" + /> + + {/* 模板筛选 */} + - onSubtitleSettingsChange({ size: Number(e.target.value) }) + onSubtitleSettingsChange({ + fontSize: Number(e.target.value), + }) } /> - {subtitleSettings.size}px + {subtitleSettings.fontSize}px
@@ -563,6 +591,16 @@ const ClipPropertiesPanel: React.FC = ({ ))} + + {/* 高级配置按钮 */} + {onOpenSubtitleDrawer && ( + + )} )} @@ -574,20 +612,130 @@ const ClipPropertiesPanel: React.FC = ({ BGM 设置 -
- - + {bgmSettings.enabled && bgmSettings.music_id ? ( +
+ 🎵 已选择 BGM + {bgmSettings.music_id} + {bgmSettings.volume !== undefined && ( + + 音量 {bgmSettings.volume}% + + )} +
+ ) : ( +
未选择背景音乐
+ )} + + {onOpenBgmDrawer && ( + + )} +
+ + {/* ═══ 水印设置 ═══ */} +
+
+ 🔖 + 水印设置
+ {onOpenWatermarkDrawer && ( + + )} +
+ + {/* ═══ 片头片尾设置 ═══ */} +
+
+ 🎬 + 片头片尾 +
+ {onOpenIntroOutroDrawer && ( + + )} +
+ + {/* ═══ 画中画 ═══ */} +
+
+ 🖼️ + 画中画 +
+ {onOpenPipDrawer && ( + + )} +
+ + {/* ═══ 滤镜调色 ═══ */} +
+
+ 🎨 + 滤镜调色 +
+ {onOpenFilterDrawer && ( + + )} +
+ + {/* ═══ 绿幕抠像 ═══ */} +
+
+ 🟩 + 绿幕抠像 +
+ {onOpenGreenScreenDrawer && ( + + )} +
+ + {/* ═══ 贴纸 ═══ */} +
+
+ 🏷️ + 贴纸 +
+ {onOpenStickerDrawer && ( + + )} +
+ + {/* ═══ 封面 ═══ */} +
+
+ 🖼️ + 封面 +
+ {onOpenCoverDrawer && ( + + )}
{/* ═══ 片段详情(选中时显示) ═══ */} @@ -649,6 +797,71 @@ const ClipPropertiesPanel: React.FC = ({ + {/* 转场效果入口 */} + {onOpenTransitionDrawer && ( +
+ +
+ )} + + {/* 播放速度入口 */} + {onOpenSpeedDrawer && ( +
+ +
+ )} + + {/* TTS 配音入口 */} + {onOpenTtsDrawer && ( +
+ +
+ )} + {/* 素材起始时间 — 仅 voice 类型显示 */} {selectedClip.type === "voice" && (
diff --git a/apps/web/src/pages/editing-planner/components/CoverSelector.tsx b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx new file mode 100644 index 000000000..88778b988 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/CoverSelector.tsx @@ -0,0 +1,301 @@ +/** + * 封面选择器 + * 抽帧选封面 + 上传自定义封面 + 智能封面推荐 + */ +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; +} + +/** 封面模式标签 */ +const MODE_LABELS: Record = { + auto: "智能封面", + frame: "抽帧选封面", + upload: "上传封面", +}; + +/** 封面模式图标 */ +const MODE_ICONS: Record = { + auto: "🤖", + frame: "🎞️", + upload: "📤", +}; + +const CoverSelector: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const fileInputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled }); + }, [config.enabled, onChange]); + + /** 切换模式 */ + const handleModeChange = useCallback( + (mode: CoverMode) => { + update({ mode }); + }, + [update], + ); + + /** 处理文件上传 */ + const handleFileUpload = useCallback( + (file: File) => { + 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); + }, + [update], + ); + + /** 拖拽上传 */ + const handleDrop = useCallback( + (e: React.DragEvent) => { + 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" }); + } + }, [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}`; + }; + + return ( + + {/* 顶部开关 */} +
+ 启用自定义封面 + +
+ + {/* 模式选择 */} +
+
封面来源
+
+ {(["auto", "frame", "upload"] as CoverMode[]).map((m) => ( + + ))} +
+
+ + {/* 模式内容区 */} +
+ {/* 智能封面 */} + {config.mode === "auto" && ( +
+
+ AI 将分析视频内容,自动选择最具吸引力的画面作为封面。 +
+ {config.ai_suggested_time !== null ? ( +
+
AI 推荐
+
+ 推荐时间点:{formatTime(config.ai_suggested_time)} +
+ +
+ ) : ( +
+
+ AI 分析中...(生成视频后自动推荐) +
+ )} +
+ )} + + {/* 抽帧选封面 */} + {config.mode === "frame" && ( +
+
+
+ 🎞️ + + {formatTime(config.frame_time)} + +
+
+
+
+ 拖动选择封面帧 + + {formatTime(config.frame_time)} + +
+ update({ frame_time: Number(e.target.value) })} + /> +
+ 00:00 + {formatTime(totalDuration)} +
+
+ {/* 快捷时间点 */} +
+ 快捷选帧: + {[0, 0.25, 0.5, 0.75].map((ratio) => { + const t = totalDuration * ratio; + return ( + + ); + })} +
+
+ )} + + {/* 上传封面 */} + {config.mode === "upload" && ( +
+
{ + e.preventDefault(); + setIsDragging(true); + }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + > + {config.upload_url ? ( +
+ 封面预览 +
点击更换
+
+ ) : ( +
+ 📤 + + 点击或拖拽上传封面图片 + + + 支持 JPG / PNG,建议 16:9 比例 + +
+ )} + { + const file = e.target.files?.[0]; + if (file) handleFileUpload(file); + }} + /> +
+
+ )} +
+ + {/* 封面预览 */} +
+
封面预览
+
+ {config.upload_url ? ( + 封面预览 + ) : ( +
+ 🖼️ + + {config.mode === "auto" + ? "AI 智能选择" + : config.mode === "frame" + ? `帧 ${formatTime(config.frame_time)}` + : "未上传封面"} + +
+ )} +
16:9
+
+
+ + {/* 底部 */} +
+ +
+ + ); +}; + +export default CoverSelector; diff --git a/apps/web/src/pages/editing-planner/components/FilterPanel.tsx b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx new file mode 100644 index 000000000..617112b34 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/FilterPanel.tsx @@ -0,0 +1,234 @@ +/** + * 滤镜调色配置面板 + * 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度) + */ +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; +} + +/** 所有预设列表 */ +const PRESET_LIST: FilterPreset[] = [ + "none", + "original", + "fresh", + "warm", + "cool", + "vintage", + "cinema", + "bw", + "sunshine", + "film", +]; + +/** 预设对应的示例渐变色(用于视觉预览) */ +const PRESET_GRADIENTS: Record = { + none: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + original: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + fresh: "linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)", + warm: "linear-gradient(135deg, #f093fb 0%, #f5576c 100%)", + cool: "linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)", + vintage: "linear-gradient(135deg, #c79081 0%, #dfa579 100%)", + cinema: "linear-gradient(135deg, #2c3e50 0%, #4ca1af 100%)", + 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 update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + 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 }); + } else { + onChange({ + ...DEFAULT_FILTER_CONFIG, + enabled: config.enabled, + preset, + }); + } + }, + [config.enabled, onChange], + ); + + return ( + + {/* 顶部开关 */} +
+ 启用滤镜 + update({ enabled: checked })} + /> +
+ + {/* 预设滤镜选择 */} +
+
预设滤镜
+
+ {PRESET_LIST.map((p) => ( + + ))} +
+
+ + {/* 手动调节 */} +
+
手动调节
+ + {/* 亮度 */} +
+ 亮度 + update({ brightness: Number(e.target.value) })} + /> + {config.brightness} +
+ + {/* 对比度 */} +
+ 对比度 + update({ contrast: Number(e.target.value) })} + /> + {config.contrast} +
+ + {/* 饱和度 */} +
+ 饱和度 + update({ saturation: Number(e.target.value) })} + /> + {config.saturation} +
+ + {/* 色温 */} +
+ 色温 + update({ temperature: Number(e.target.value) })} + /> + {config.temperature} +
+ + {/* 色调 */} +
+ 色调 + update({ tint: Number(e.target.value) })} + /> + {config.tint} +
+ + {/* 锐度 */} +
+ 锐度 + update({ sharpness: Number(e.target.value) })} + /> + {config.sharpness} +
+
+ + {/* 预览色块 */} +
+
效果预览
+
+
+ + {/* 底部重置 */} +
+ +
+ + ); +}; + +export default FilterPanel; diff --git a/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx new file mode 100644 index 000000000..36cd45f9e --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/GreenScreenPanel.tsx @@ -0,0 +1,219 @@ +/** + * 绿幕抠像配置面板 + * 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制 + */ +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"; + +interface GreenScreenPanelProps { + open: boolean; + onClose: () => void; + config: ChromaKeyConfig; + onChange: (config: ChromaKeyConfig) => void; +} + +/** 预设列表 */ +const PRESET_LIST: ChromaKeyColorPreset[] = [ + "green", + "blue", + "red", + "pure_green", + "soft_green", +]; + +const GreenScreenPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + const update = useCallback( + (partial: Partial) => { + onChange({ ...config, ...partial }); + }, + [config, onChange], + ); + + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled }); + }, [config.enabled, onChange]); + + /** 选择颜色预设时同步更新 color 字段 */ + const handlePresetSelect = useCallback( + (preset: ChromaKeyColorPreset) => { + update({ + color_preset: preset, + color: CHROMA_KEY_PRESET_COLORS[preset], + }); + }, + [update], + ); + + /** 自定义颜色变化时清除预设标记 */ + const handleColorChange = useCallback( + (e: React.ChangeEvent) => { + update({ color: e.target.value }); + }, + [update], + ); + + return ( + + {/* 顶部开关 */} +
+ 启用绿幕抠像 + update({ enabled: checked })} + /> +
+ + {/* 颜色预设 */} +
+
颜色预设
+
+ {PRESET_LIST.map((p) => ( + + ))} +
+
+ + {/* 自定义颜色 */} +
+
自定义颜色
+
+ + +
+
+
+ + {/* 参数调节 */} +
+
参数调节
+ + {/* 相似度 */} +
+
+ 相似度 + {config.similarity}% +
+ update({ similarity: Number(e.target.value) })} + /> +
越大容忍的色差范围越广
+
+ + {/* 边缘平滑 */} +
+
+ 边缘平滑 + {config.blend}% +
+ update({ blend: Number(e.target.value) })} + /> +
越大边缘越柔和自然
+
+ + {/* 溢色抑制 */} +
+
+ 溢色抑制 + {config.spill}% +
+ update({ spill: Number(e.target.value) })} + /> +
去除边缘颜色溢出
+
+
+ + {/* 预览 */} +
+
效果预览
+
+
+
+
+
主体
+
+
+
+
+ + {/* 底部重置 */} +
+ +
+ + ); +}; + +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 new file mode 100644 index 000000000..abb12d237 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/IntroOutroPanel.tsx @@ -0,0 +1,309 @@ +/** + * 片头片尾配置面板 — Drawer 形式 + * 两个区块:片头(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"; + +/* ──────────── 常量 ──────────── */ + +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; +} + +const IntroOutroPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + /* ── 更新片头 ── */ + const handleIntroChange = useCallback( + (partial: Partial) => { + onChange({ ...config, intro: { ...config.intro, ...partial } }); + }, + [config, onChange], + ); + + /* ── 更新片尾 ── */ + const handleOutroChange = useCallback( + (partial: Partial) => { + onChange({ ...config, outro: { ...config.outro, ...partial } }); + }, + [config, onChange], + ); + + /* ── 切换片头类型 ── */ + const handleIntroKindChange = useCallback( + (kind: IntroOutroKind) => { + handleIntroChange({ kind, url: kind === "none" ? undefined : "" }); + }, + [handleIntroChange], + ); + + /* ── 切换片尾类型 ── */ + const handleOutroKindChange = useCallback( + (kind: IntroOutroKind) => { + handleOutroChange({ kind, url: kind === "none" ? undefined : "" }); + }, + [handleOutroChange], + ); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_INTRO_OUTRO }); + }, [onChange]); + + return ( + + {/* ═══ 片头区块 ═══ */} +
+
+ 🎞️ + 片头 +
+ + {/* 类型选择 */} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 视频/图片配置 */} + {config.intro.kind !== "none" && ( + <> +
+ + handleIntroChange({ url: e.target.value })} + /> +
+ +
+ +
+ + handleIntroChange({ duration: Number(e.target.value) }) + } + /> + + {config.intro.duration}s + +
+
+ +
+ + +
+ + {config.intro.transition && config.intro.transition !== "none" && ( +
+ +
+ + handleIntroChange({ + transition_duration: Number(e.target.value), + }) + } + /> + + {(config.intro.transition_duration ?? 0.5).toFixed(1)}s + +
+
+ )} + + )} +
+ + {/* ═══ 片尾区块 ═══ */} +
+
+ 🏁 + 片尾 +
+ + {/* 类型选择 */} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+ + {/* 视频/图片配置 */} + {config.outro.kind !== "none" && ( + <> +
+ + handleOutroChange({ url: e.target.value })} + /> +
+ +
+ +
+ + handleOutroChange({ duration: Number(e.target.value) }) + } + /> + + {config.outro.duration}s + +
+
+ +
+ + +
+ + {config.outro.transition && config.outro.transition !== "none" && ( +
+ +
+ + handleOutroChange({ + transition_duration: Number(e.target.value), + }) + } + /> + + {(config.outro.transition_duration ?? 0.5).toFixed(1)}s + +
+
+ )} + + )} +
+ + {/* ── 底部操作 ── */} +
+ +
+
+ ); +}; + +export default IntroOutroPanel; diff --git a/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx new file mode 100644 index 000000000..c1fbfbee0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/PipConfigPanel.tsx @@ -0,0 +1,582 @@ +/** + * 画中画配置面板 — 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"; + +/* ──────────── 常量 ──────────── */ + +/** 九宫格位置 → 百分比坐标映射 */ +const GRID_POSITION_MAP: Record = { + top_left: { x: 5, y: 5 }, + top_center: { x: 37.5, y: 5 }, + top_right: { x: 70, y: 5 }, + center_left: { x: 5, y: 37.5 }, + center: { x: 37.5, y: 37.5 }, + center_right: { x: 70, y: 37.5 }, + bottom_left: { x: 5, y: 70 }, + bottom_center: { x: 37.5, y: 70 }, + bottom_right: { x: 70, y: 70 }, +}; + +/** 九宫格位置选项 */ +const GRID_POSITIONS: PipGridPosition[] = [ + "top_left", + "top_center", + "top_right", + "center_left", + "center", + "center_right", + "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 }[] = [ + { value: "left", label: "← 左" }, + { value: "right", label: "→ 右" }, + { value: "up", label: "↑ 上" }, + { value: "down", label: "↓ 下" }, +]; + +/** 预览图层颜色池 */ +const LAYER_COLORS = [ + "rgba(22,119,255,0.5)", + "rgba(82,196,26,0.5)", + "rgba(250,173,20,0.5)", + "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; +} + +/* ──────────── 辅助函数 ──────────── */ + +let layerIdCounter = 0; +const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`; + +/* ──────────── 组件 ──────────── */ + +const PipConfigPanel: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + /** 当前选中图层 ID */ + const [selectedId, setSelectedId] = React.useState(""); + + /** 当前选中图层 */ + const selectedLayer = useMemo( + () => config.layers.find((l) => l.id === selectedId) ?? null, + [config.layers, selectedId], + ); + + /* ── 添加图层 ── */ + const handleAddLayer = useCallback(() => { + const newLayer: PipLayer = { + ...DEFAULT_PIP_LAYER, + id: genLayerId(), + name: `图层 ${config.layers.length + 1}`, + z_index: config.layers.length + 1, + }; + onChange({ + ...config, + layers: [...config.layers, newLayer], + }); + setSelectedId(newLayer.id); + }, [config, onChange]); + + /* ── 删除图层 ── */ + const handleDeleteLayer = useCallback( + (id: string) => { + const newLayers = config.layers.filter((l) => l.id !== id); + onChange({ ...config, layers: newLayers }); + if (selectedId === 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, + ), + }); + }, + [config, onChange], + ); + + /* ── 切换启用 ── */ + const handleEnableToggle = useCallback( + (checked: boolean) => { + onChange({ ...config, enabled: checked }); + }, + [config, onChange], + ); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_PIP_CONFIG }); + setSelectedId(""); + }, [onChange]); + + /* ── 九宫格点击 ── */ + const handleGridClick = useCallback( + (pos: PipGridPosition) => { + 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.aspect_lock) { + // 保持宽高比 1:1(百分比相同) + partial.height = val; + } + updateLayer(selectedLayer.id, partial); + }, + [selectedLayer, updateLayer], + ); + + const handleHeightChange = useCallback( + (val: number) => { + if (!selectedLayer) return; + const partial: Partial = { height: val }; + if (selectedLayer.aspect_lock) { + partial.width = val; + } + updateLayer(selectedLayer.id, partial); + }, + [selectedLayer, updateLayer], + ); + + return ( + + {/* ═══ 顶部工具栏 ═══ */} +
+
+ +
+
+ 启用 + +
+
+ + {/* ═══ 主体:图层列表 + 配置区 ═══ */} +
+ {/* 左侧图层列表 */} +
+ {config.layers.length === 0 ? ( +
暂无图层,点击上方添加
+ ) : ( + config.layers.map((layer, idx) => ( +
setSelectedId(layer.id)} + > + {layer.thumbnail_url || layer.material_url ? ( + {layer.name} + ) : ( +
+ )} + {layer.name} + +
+ )) + )} +
+ + {/* 右侧配置区 */} +
+ {!selectedLayer ? ( +
选择或添加图层以配置
+ ) : ( + <> + {/* ── 迷你预览 ── */} +
+ {config.layers.map((layer, idx) => ( +
+ {layer.name} +
+ ))} +
+ + {/* ── 素材类型 ── */} +
+ +
+ + +
+
+ + {/* ── 素材 URL ── */} +
+ + + updateLayer(selectedLayer.id, { + material_url: e.target.value, + }) + } + /> +
+ + {/* ── 位置:九宫格 + 坐标 ── */} +
+ +
+
+ {GRID_POSITIONS.map((pos) => ( + + ))} +
+
+
+ + + updateLayer(selectedLayer.id, { + x: Number(e.target.value), + }) + } + /> +
+
+ + + updateLayer(selectedLayer.id, { + y: Number(e.target.value), + }) + } + /> +
+
+
+
+ + {/* ── 尺寸 ── */} +
+ +
+ + 宽 + + handleWidthChange(Number(e.target.value))} + /> + + {selectedLayer.width}% + +
+
+ + 高 + + handleHeightChange(Number(e.target.value))} + /> + + {selectedLayer.height}% + +
+
+ updateLayer(selectedLayer.id, { + aspect_lock: !selectedLayer.aspect_lock, + }) + } + > + + {selectedLayer.aspect_lock ? "🔒" : "🔓"} + + + {selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"} + +
+
+ + {/* ── 圆角 ── */} +
+ +
+ + updateLayer(selectedLayer.id, { + border_radius: Number(e.target.value), + }) + } + /> + + {selectedLayer.border_radius}% + +
+
+ + {/* ── 透明度 ── */} +
+ +
+ + updateLayer(selectedLayer.id, { + opacity: Number(e.target.value), + }) + } + /> + + {selectedLayer.opacity}% + +
+
+ + {/* ── 时间 ── */} +
+ +
+
+ + + updateLayer(selectedLayer.id, { + start_time: Number(e.target.value), + }) + } + /> +
+
+ + + updateLayer(selectedLayer.id, { + duration: Number(e.target.value), + }) + } + /> +
+
+
+ + {/* ── 入场动画 ── */} +
+ + +
+ + {/* 滑入方向(仅 slide_in 时显示) */} + {selectedLayer.animation === "slide_in" && ( +
+ + +
+ )} + + )} +
+
+ + {/* ── 底部操作 ── */} +
+ +
+ + ); +}; + +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 72d47046e..e33175f49 100644 --- a/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx +++ b/apps/web/src/pages/editing-planner/components/PreviewPlayer.tsx @@ -4,25 +4,13 @@ * 封面右侧竖排4个方案按钮 */ import React from "react"; -import type { ClipData, ClipType } from "../types"; +import type { ClipData, ClipType, TitleSettings } from "../types"; interface CoverScheme { key: string; label: string; } -interface TitleSettings { - aiAutoSelect: boolean; - title: string; - position: string; - font: string; - size: number; - bold: boolean; - italic: boolean; - stroke: boolean; - shadow: boolean; -} - interface SubtitleSettings { enabled: boolean; position: string; diff --git a/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx new file mode 100644 index 000000000..d6265d895 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SpeedPanel.tsx @@ -0,0 +1,157 @@ +/** + * 片段调速面板 — Drawer 形式 + * 速度滑块(0.25x ~ 4x)+ 预设快捷按钮 + 音调修正开关 + * 支持应用到当前片段 / 所有片段 + */ +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 }[] = [ + { rate: 0.5, label: "0.5x" }, + { rate: 1.0, label: "1x" }, + { rate: 1.5, label: "1.5x" }, + { rate: 2.0, label: "2x" }, +]; + +/* ──────────── Props ──────────── */ +interface SpeedPanelProps { + open: boolean; + onClose: () => void; + /** 当前片段调速配置 */ + config: SpeedConfig; + onChange: (config: SpeedConfig) => void; + /** 应用到所有片段 */ + onApplyAll?: (config: SpeedConfig) => void; +} + +const SpeedPanel: React.FC = ({ + open, + onClose, + config, + onChange, + onApplyAll, +}) => { + /* ── 修改速度 ── */ + const handleChangeRate = useCallback( + (rate: number) => { + onChange({ ...config, rate }); + }, + [config, onChange], + ); + + /* ── 切换音调修正 ── */ + const handleTogglePitch = useCallback(() => { + onChange({ ...config, pitchCorrection: !config.pitchCorrection }); + }, [config, onChange]); + + /* ── 选择预设 ── */ + const handlePreset = useCallback( + (rate: number) => { + onChange({ ...config, rate }); + }, + [config, onChange], + ); + + /* ── 应用到所有片段 ── */ + const handleApplyAll = useCallback(() => { + onApplyAll?.(config); + }, [config, onApplyAll]); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_SPEED }); + }, [onChange]); + + /* ── 速度描述文字 ── */ + const speedLabel = + config.rate < 1 + ? "慢速(慢动作)" + : config.rate === 1 + ? "原速" + : config.rate < 2 + ? "快速" + : "极速"; + + return ( + + {/* ── 速度滑块 ── */} +
+
+ 播放速度 + {config.rate.toFixed(2)}x +
+ `${(v as number).toFixed(2)}x` }} + /> +
+ 0.25x + 1x + 2x + 4x +
+
{speedLabel}
+
+ + {/* ── 预设快捷按钮 ── */} +
+
快捷预设
+
+ {SPEED_PRESETS.map((p) => ( + + ))} +
+
+ + {/* ── 音调修正开关 ── */} +
+
+ 音调修正 + + {config.pitchCorrection ? "变速不变调(推荐)" : "变速同时变调"} + +
+
+
+
+
+ + {/* ── 底部操作 ── */} +
+ + {onApplyAll && ( + + )} +
+ + ); +}; + +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 new file mode 100644 index 000000000..883c044d0 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/StickerPanel.tsx @@ -0,0 +1,561 @@ +/** + * 贴纸配置面板 + * 贴纸素材库(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"; + +interface StickerPanelProps { + open: boolean; + onClose: () => void; + config: StickerConfig; + onChange: (config: StickerConfig) => void; + totalDuration: number; +} + +/** 常用 emoji 素材 */ +const EMOJI_LIST = [ + "😀", + "😂", + "🥰", + "😎", + "🤩", + "😱", + "🤔", + "😴", + "🥳", + "😍", + "❤️", + "🔥", + "⭐", + "✨", + "💯", + "👍", + "👏", + "🎉", + "🎵", + "💪", + "📌", + "💡", + "🎯", + "✅", + "❌", + "⬆️", + "⬇️", + "➡️", + "⭕", + "🔔", +]; + +/** 文字花字预设对应的 CSS 样式预览 */ +const TEXT_PRESET_STYLES: Record = { + normal: { color: "#fff", textShadow: "none" }, + highlight: { color: "#FFD700", textShadow: "0 0 8px rgba(255,215,0,0.6)" }, + bubble: { color: "#fff", background: "rgba(0,0,0,0.5)", borderRadius: 8 }, + neon: { color: "#0ff", textShadow: "0 0 6px #0ff, 0 0 12px #0ff" }, + shadow: { color: "#fff", textShadow: "2px 2px 4px rgba(0,0,0,0.8)" }, + outline: { color: "#fff", WebkitTextStroke: "1px #000" }, + gradient: { + color: "transparent", + background: "linear-gradient(90deg,#f093fb,#f5576c)", + WebkitBackgroundClip: "text", + }, + handwrite: { color: "#333", fontStyle: "italic", fontFamily: "cursive" }, +}; + +/** 生成唯一 ID */ +const genId = () => + `sticker_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const StickerPanel: React.FC = ({ + open, + onClose, + config, + onChange, + totalDuration, +}) => { + const [selectedId, setSelectedId] = useState(null); + const [activeTab, setActiveTab] = useState("emoji"); + + 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, + ), + }); + }, + [config, onChange], + ); + + /** 添加贴纸 */ + const addSticker = useCallback( + (type: StickerType, content: string) => { + const newItem: StickerItem = { + ...DEFAULT_STICKER_ITEM, + id: genId(), + type, + content, + duration: totalDuration > 0 ? totalDuration : 5, + z_index: config.items.length + 1, + }; + onChange({ + ...config, + enabled: true, + items: [...config.items, newItem], + }); + setSelectedId(newItem.id); + }, + [config, onChange, totalDuration], + ); + + /** 删除贴纸 */ + const removeSticker = useCallback( + (id: string) => { + onChange({ + ...config, + items: config.items.filter((s) => s.id !== id), + }); + if (selectedId === id) setSelectedId(null); + }, + [config, onChange, selectedId], + ); + + /** 重置所有 */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_STICKER_CONFIG, enabled: config.enabled }); + setSelectedId(null); + }, [config.enabled, onChange]); + + /** 文字花字输入 */ + const [textInput, setTextInput] = useState(""); + + return ( + + {/* 顶部开关 */} +
+ 启用贴纸 + onChange({ ...config, enabled: checked })} + /> +
+ + {/* 类型 Tab */} +
+ {(["emoji", "image", "text"] as StickerType[]).map((t) => ( + + ))} +
+ + {/* Tab 内容区 */} +
+ {/* Emoji 素材库 */} + {activeTab === "emoji" && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + + {/* 图片贴纸 */} + {activeTab === "image" && ( +
+ { + if (e.key === "Enter" && e.currentTarget.value.trim()) { + addSticker("image", e.currentTarget.value.trim()); + e.currentTarget.value = ""; + } + }} + /> + +
+ )} + + {/* 文字花字 */} + {activeTab === "text" && ( +
+
+ setTextInput(e.target.value)} + /> + +
+
+
花字预设预览
+
+ {( + Object.keys(TEXT_STICKER_PRESET_LABELS) as TextStickerPreset[] + ).map((p) => ( +
+ 示例 +
+ {TEXT_STICKER_PRESET_LABELS[p]} +
+
+ ))} +
+
+
+ )} +
+ + {/* 已添加贴纸列表 */} + {config.items.length > 0 && ( +
+
+ 已添加贴纸 ({config.items.length}) +
+
+ {config.items.map((item) => ( +
setSelectedId(item.id)} + > + + {item.type === "emoji" + ? item.content + : item.type === "text" + ? "T" + : "🖼"} + + + {item.type === "text" + ? item.content.slice(0, 10) + : item.type === "emoji" + ? "表情贴纸" + : "图片贴纸"} + + +
+ ))} +
+
+ )} + + {/* 选中贴纸的属性编辑 */} + {selectedSticker && ( +
+
属性调整
+ + {/* 位置 */} +
+ 位置 X + + updateItem(selectedSticker.id, { x: Number(e.target.value) }) + } + /> + {selectedSticker.x}% +
+
+ 位置 Y + + updateItem(selectedSticker.id, { y: Number(e.target.value) }) + } + /> + {selectedSticker.y}% +
+ + {/* 尺寸 */} +
+ 大小 + + updateItem(selectedSticker.id, { + width: Number(e.target.value), + height: Number(e.target.value), + }) + } + /> + {selectedSticker.width}% +
+ + {/* 旋转 */} +
+ 旋转 + + updateItem(selectedSticker.id, { + rotation: Number(e.target.value), + }) + } + /> + + {selectedSticker.rotation}° + +
+ + {/* 透明度 */} +
+ 透明度 + + updateItem(selectedSticker.id, { + opacity: Number(e.target.value), + }) + } + /> + + {selectedSticker.opacity}% + +
+ + {/* 时间 */} +
+ 开始 + + updateItem(selectedSticker.id, { + start_time: Number(e.target.value), + }) + } + /> + 时长 + + updateItem(selectedSticker.id, { + duration: Number(e.target.value), + }) + } + /> +
+ + {/* 文字贴纸特有属性 */} + {selectedSticker.type === "text" && ( + <> +
+ 花字 + +
+
+ 字号 + + updateItem(selectedSticker.id, { + font_size: Number(e.target.value), + }) + } + /> + + {selectedSticker.font_size}px + +
+
+ 颜色 + + updateItem(selectedSticker.id, { + text_color: e.target.value, + }) + } + /> +
+ + )} + + {/* 预览 */} +
+
+ {selectedSticker.type === "emoji" && selectedSticker.content} + {selectedSticker.type === "text" && selectedSticker.content} + {selectedSticker.type === "image" && ( + sticker + )} +
+
+
+ )} + + {/* 底部重置 */} +
+ +
+
+ ); +}; + +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 new file mode 100644 index 000000000..5f75536cf --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/SubtitleStylePanel.tsx @@ -0,0 +1,274 @@ +/** + * 字幕样式配置面板 — Drawer 形式 + * 字幕开关(手动 / ASR 自动识别)、字体大小、颜色、描边/阴影、位置、ASR 语言 + */ +import React from "react"; +import { Drawer, Slider, ColorPicker, Select } from "antd"; +import type { Color } from "antd/es/color-picker"; + +/* ──────────── 类型 ──────────── */ + +export type SubtitleMode = "manual" | "asr"; + +export interface SubtitleStyleConfig { + /** 是否启用字幕 */ + enabled: boolean; + /** 字幕模式:手动输入 / ASR 自动识别 */ + mode: SubtitleMode; + /** 字体大小 px */ + fontSize: number; + /** 字体颜色 */ + fontColor: string; + /** 描边 */ + stroke: boolean; + /** 阴影 */ + shadow: boolean; + /** 字幕位置 */ + position: "top" | "center" | "bottom"; + /** 字体 */ + font: string; + /** 动画效果 */ + animation: string; + /** ASR 语言(仅 ASR 模式) */ + asrLanguage: "zh" | "en"; +} + +export const DEFAULT_SUBTITLE_STYLE: SubtitleStyleConfig = { + enabled: true, + mode: "asr", + fontSize: 16, + fontColor: "#ffffff", + stroke: true, + shadow: false, + position: "bottom", + font: "思源黑体", + animation: "none", + asrLanguage: "zh", +}; + +/* ──────────── 选项常量 ──────────── */ + +const POSITION_OPTIONS = [ + { value: "top", label: "顶部" }, + { value: "center", label: "居中" }, + { value: "bottom", label: "底部" }, +]; + +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; +} + +const SubtitleStylePanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + const update = (partial: Partial) => { + onChange({ ...config, ...partial }); + }; + + return ( + + {/* ── 字幕开关 ── */} +
+
+ 启用字幕 +
update({ enabled: !config.enabled })} + > +
+
+
+
+ + {config.enabled && ( + <> + {/* ── 模式切换 ── */} +
+ +
+ + +
+
+ + {/* ── ASR 语言(仅 ASR 模式) ── */} + {config.mode === "asr" && ( +
+ + update({ font: v })} + options={FONT_OPTIONS.map((f) => ({ value: f, label: f }))} + popupMatchSelectWidth={false} + /> +
+ + {/* ── 字幕位置 ── */} +
+ +
+ {POSITION_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* ── 描边 / 阴影 ── */} +
+ +
+ + +
+
+ + {/* ── 动画 ── */} +
+ + onZoomChange?.(Number(e.target.value))} + /> + + {pps}px/s +
-
- )) + /* 裁剪状态 */ + const hasTrim = !!clip.trim_config; + const isHovered = hoveredClipId === clip.id; + + return ( + + {/* 转场指示器 */} + {showTransition && transOpt && ( +
+ {transOpt.icon} + + {trans!.duration.toFixed(1)}s + +
+ )} + +
handleDragStart(e, idx)} + onDragOver={(e) => handleDragOver(e, idx)} + onDragEnd={handleDragEnd} + onDrop={(e) => handleDrop(e, idx)} + onClick={() => onClipSelect(clip.id)} + onContextMenu={(e) => handleContextMenu(e, clip.id)} + onMouseEnter={() => setHoveredClipId(clip.id)} + onMouseLeave={() => setHoveredClipId(null)} + > + {/* 左裁剪手柄 */} + {isHovered && onClipTrim && ( +
+ handleTrimHandleMouseDown(e, clip.id, "left") + } + title="拖动调整入点" + > +
+
+ )} + + {/* 类型图标 */} +
+ {CLIP_TYPE_ICONS[clip.type] || "🎬"} +
+ + {/* 片段信息 */} +
+ + {CLIP_TYPE_LABELS[clip.type] || "片段"} {idx + 1} + + + {clip.duration}s + {hasTrim && ( + + ✂ + + )} + +
+ + {/* 速度徽章 */} + {showSpeed && ( + + {speed!.rate.toFixed(1)}x + + )} + + {/* 裁剪徽章 */} + {hasTrim && ( + + ✂ + + )} + + {/* 右裁剪手柄 */} + {isHovered && onClipTrim && ( +
+ handleTrimHandleMouseDown(e, clip.id, "right") + } + title="拖动调整出点" + > +
+
+ )} + + {/* 删除按钮 */} + +
+ + ); + }) )} - {/* ── 轨道末尾 "+" 添加卡片 → 类型+时长选择器 ── */} + {/* ── 轨道末尾 "+" 添加卡片 ── */}
= ({
)} - {/* 类型+时长选择面板 — fixed 定位,不受任何父容器 overflow 裁剪 */} + {/* 裁剪预览 tooltip */} + {trimPreview && ( +
+
+ 入点 + + {formatTrimTime(trimPreview.startTime)} + +
+
+ 出点 + + {formatTrimTime(trimPreview.endTime)} + +
+
+ 时长 + + {formatTrimTime(trimPreview.duration)} + +
+
+ )} + + {/* 右键菜单 */} + {contextMenu && ( +
+
+ ✂️ + 分割片段 +
+ {clips.find((c) => c.id === contextMenu.clipId)?.trim_config && ( +
+ ↩️ + 恢复原始长度 +
+ )} +
+
+ 🗑️ + 删除片段 +
+
+ )} + + {/* 类型+时长选择面板 */} {showAddPicker && (
void; + /** 当前转场配置 */ + config: TransitionConfig; + onChange: (config: TransitionConfig) => void; + /** 标题提示(区分全局 / 片段间) */ + title?: string; +} + +const TransitionSelector: React.FC = ({ + open, + onClose, + config, + onChange, + title = "转场特效", +}) => { + /* ── 选择转场类型 ── */ + const handleSelectType = useCallback( + (type: TransitionType) => { + onChange({ ...config, type }); + }, + [config, onChange], + ); + + /* ── 修改时长 ── */ + const handleChangeDuration = useCallback( + (duration: number) => { + onChange({ ...config, duration }); + }, + [config, onChange], + ); + + /* ── 重置为无转场 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_TRANSITION }); + }, [onChange]); + + return ( + + {/* ── 时长滑块 ── */} +
+
+ 转场时长 + + {config.duration.toFixed(1)}s + +
+ `${(v as number).toFixed(1)}s` }} + /> +
+ 0.3s + 1.0s + 2.0s +
+
+ + {/* ── 转场类型卡片网格 ── */} +
+ {TRANSITION_OPTIONS.map((opt) => { + const isActive = config.type === opt.value; + return ( +
handleSelectType(opt.value)} + > +
{opt.icon}
+
{opt.label}
+ {isActive && } +
+ ); + })} +
+ + {/* ── 底部操作 ── */} +
+ +
+ 当前: + {TRANSITION_OPTIONS.find((o) => o.value === config.type)?.label ?? + "无转场"} + {" · "} + {config.duration.toFixed(1)}s +
+
+
+ ); +}; + +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 new file mode 100644 index 000000000..1fbe04bf3 --- /dev/null +++ b/apps/web/src/pages/editing-planner/components/TtsPanel.tsx @@ -0,0 +1,349 @@ +/** + * 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"; + +/* ──────────── 音色卡片分类图标 ──────────── */ +const VOICE_CATEGORY_MAP: Record = { + male: { icon: "👨", label: "男声" }, + female: { icon: "👩", label: "女声" }, + young: { icon: "🧑", label: "少年" }, + service: { icon: "🎧", label: "客服" }, + news: { icon: "📰", label: "新闻" }, + emotion: { icon: "🎭", label: "情感" }, +}; + +/* ──────────── Props ──────────── */ +interface TtsPanelProps { + open: boolean; + onClose: () => void; + /** 当前片段 TTS 配置 */ + config: TtsConfig; + onChange: (config: TtsConfig) => void; +} + +const TtsPanel: React.FC = ({ + open, + onClose, + config, + onChange, +}) => { + /* ── 音色列表 ── */ + const [voices, setVoices] = useState([]); + const [voicesLoading, setVoicesLoading] = useState(false); + + /* ── 试听状态 ── */ + const [previewLoading, setPreviewLoading] = useState(false); + const audioRef = useRef(null); + + /* ── 加载音色列表 ── */ + useEffect(() => { + if (!open) return; + setVoicesLoading(true); + getTtsVoices() + .then((v) => setVoices(v)) + .catch(() => message.error("加载音色列表失败")) + .finally(() => setVoicesLoading(false)); + }, [open]); + + /* ── 切换配音模式 ── */ + const handleModeChange = useCallback( + (mode: TtsMode) => { + onChange({ ...config, mode }); + }, + [config, onChange], + ); + + /* ── 文本输入 ── */ + const handleTextChange = useCallback( + (e: React.ChangeEvent) => { + const text = e.target.value.slice(0, 5000); + onChange({ ...config, text }); + }, + [config, onChange], + ); + + /* ── 选择音色 ── */ + const handleVoiceSelect = useCallback( + (voiceId: string) => { + onChange({ ...config, voice_id: voiceId }); + }, + [config, onChange], + ); + + /* ── 语速 ── */ + const handleSpeedChange = useCallback( + (speed: number) => { + onChange({ ...config, speed }); + }, + [config, onChange], + ); + + /* ── 语调 ── */ + const handlePitchChange = useCallback( + (pitch: number) => { + onChange({ ...config, pitch }); + }, + [config, onChange], + ); + + /* ── 音量 ── */ + const handleVolumeChange = useCallback( + (volume: number) => { + onChange({ ...config, volume }); + }, + [config, onChange], + ); + + /* ── 字幕联动 ── */ + const handleSubtitleSyncToggle = useCallback(() => { + onChange({ ...config, subtitle_sync: !config.subtitle_sync }); + }, [config, onChange]); + + /* ── 试听 ── */ + const handlePreview = useCallback(async () => { + if (!config.text.trim()) { + message.warning("请先输入合成文本"); + return; + } + if (!config.voice_id) { + message.warning("请先选择音色"); + return; + } + 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("播放失败")); + audio.onended = () => { + audioRef.current = null; + }; + message.success("试听播放中"); + } catch { + message.error("试听生成失败"); + } finally { + setPreviewLoading(false); + } + }, [config]); + + /* ── 重置 ── */ + const handleReset = useCallback(() => { + onChange({ ...DEFAULT_TTS_CONFIG }); + }, [onChange]); + + /* ── 关闭时停止音频 ── */ + const handleClose = useCallback(() => { + audioRef.current?.pause(); + audioRef.current = null; + onClose(); + }, [onClose]); + + /* ── 音色分类分组 ── */ + const voiceCategories = Object.entries(VOICE_CATEGORY_MAP); + + return ( + + {/* ── 配音模式切换 ── */} +
+
配音模式
+
+ {[ + { mode: "none" as TtsMode, icon: "🔇", label: "无配音" }, + { mode: "upload" as TtsMode, icon: "📁", label: "上传配音" }, + { mode: "tts" as TtsMode, icon: "🤖", label: "TTS 合成" }, + ].map((m) => ( + + ))} +
+
+ + {/* ── TTS 配置(仅 tts 模式显示) ── */} + {config.mode === "tts" && ( + <> + {/* 文本输入 */} +
+
+ 合成文本 + {config.text.length}/5000 +
+