ci: Prettier纳入两层防御体系 #520
@@ -18,3 +18,12 @@ repos:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
language_version: python3.12
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v4.0.0-alpha.11
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier (frontend)
|
||||
files: ^apps/web/.*\.(ts|tsx|js|jsx|css|scss|less|json|html|md|yaml|yml)$
|
||||
additional_dependencies:
|
||||
- prettier@3.4.2
|
||||
|
||||
+37
@@ -6,6 +6,43 @@
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[html]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
"prettier.requireConfig": true,
|
||||
"isort.args": ["--profile", "black"],
|
||||
"python.linting.ruffEnabled": true,
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
|
||||
Regular → Executable
+3
-5
@@ -8,16 +8,14 @@ module.exports = {
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"prettier", // 关掉与 Prettier 冲突的 ESLint 规则
|
||||
],
|
||||
ignorePatterns: ["dist", ".eslintrc.cjs"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["react-refresh"],
|
||||
rules: {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false
|
||||
}
|
||||
+4
-4
@@ -91,17 +91,17 @@ VITE_API_URL=http://localhost:8000
|
||||
使用 Zustand 创建 Store:
|
||||
|
||||
```typescript
|
||||
import { create } from "zustand";
|
||||
import { create } from "zustand"
|
||||
|
||||
interface MyStore {
|
||||
data: any;
|
||||
setData: (data: any) => void;
|
||||
data: any
|
||||
setData: (data: any) => void
|
||||
}
|
||||
|
||||
export const useMyStore = create<MyStore>((set) => ({
|
||||
data: null,
|
||||
setData: (data) => set({ data }),
|
||||
}));
|
||||
}))
|
||||
```
|
||||
|
||||
## 部署
|
||||
|
||||
+240
-392
@@ -5,33 +5,29 @@
|
||||
* 删除素材、批量删除、空状态
|
||||
* 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -44,30 +40,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -75,7 +71,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
@@ -90,10 +86,10 @@ async function createProject(
|
||||
name: `Assets Test Proj ${suffix}`,
|
||||
description: "E2E assets test",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
/** 创建素材库 */
|
||||
@@ -107,10 +103,10 @@ async function createLibrary(
|
||||
const resp = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectId, name, kind },
|
||||
});
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
/** 创建素材记录 */
|
||||
@@ -136,10 +132,10 @@ async function createAsset(
|
||||
uploaded_by_user_id: userId,
|
||||
metadata: { duration: 15.5, resolution: "1080p" },
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -150,14 +146,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -171,544 +167,400 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("素材库页面 - 完整交互测试", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("素材库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-load");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video");
|
||||
"assets-load",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "默认视频库", "video")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
|
||||
// 页面布局容器
|
||||
await expect(page.locator(".xx-assets-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 左侧素材库列表
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible();
|
||||
await expect(page.locator(".xx-asset-library-list")).toBeVisible()
|
||||
|
||||
// 右侧内容区(上传区 + 筛选 + 素材网格)
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible();
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible();
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible();
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible()
|
||||
await expect(page.locator(".xx-asset-upload-zone")).toBeVisible()
|
||||
await expect(page.locator(".xx-assets-filters")).toBeVisible()
|
||||
|
||||
// 无错误提示
|
||||
await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 创建素材库 ────────────────────────────────────
|
||||
|
||||
test("创建新素材库 - 通过 UI", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-create");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "初始库", "video");
|
||||
"assets-create",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "初始库", "video")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击新建素材库
|
||||
await page.locator(".xx-asset-library-add").click();
|
||||
await page.locator(".xx-asset-library-add").click()
|
||||
|
||||
// 弹窗出现
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "新建素材库" });
|
||||
await expect(modal).toBeVisible();
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "新建素材库" })
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
// 填写表单
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`;
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName);
|
||||
const newLibName = `E2E 新建库 ${Date.now()}`
|
||||
await modal.getByPlaceholder("请输入素材库名称").fill(newLibName)
|
||||
// 类型选择默认是 video,保持即可
|
||||
|
||||
// 监听创建请求
|
||||
const createPromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/asset-libraries") &&
|
||||
resp.request().method() === "POST",
|
||||
(resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
)
|
||||
|
||||
// 点击创建
|
||||
await modal.getByRole("button", { name: "创建" }).click();
|
||||
await modal.getByRole("button", { name: "创建" }).click()
|
||||
|
||||
const resp = await createPromise;
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy();
|
||||
const resp = await createPromise
|
||||
expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy()
|
||||
|
||||
// 新素材库应出现在列表中
|
||||
await expect(
|
||||
page.locator(".xx-asset-library-item").filter({ hasText: newLibName }),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// ─── 切换素材库 ────────────────────────────────────
|
||||
|
||||
test("切换不同素材库", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-switch");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
"assets-switch",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
const videoLibName = "视频素材库 A";
|
||||
const imageLibName = "图片素材库 B";
|
||||
const videoLibId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibName,
|
||||
"video",
|
||||
);
|
||||
const videoLibName = "视频素材库 A"
|
||||
const imageLibName = "图片素材库 B"
|
||||
const videoLibId = await createLibrary(request, headers, projectId, videoLibName, "video")
|
||||
|
||||
// 在视频库里创建一个素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
videoLibId,
|
||||
userId,
|
||||
"demo_video.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, videoLibId, userId, "demo_video.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击视频库,应显示素材
|
||||
const videoLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: videoLibName });
|
||||
await videoLibItem.click({ force: true });
|
||||
await expect(videoLibItem).toHaveClass(/active/);
|
||||
const videoLibItem = page.locator(".xx-asset-library-item").filter({ hasText: videoLibName })
|
||||
await videoLibItem.click({ force: true })
|
||||
await expect(videoLibItem).toHaveClass(/active/)
|
||||
|
||||
// 验证视频素材出现
|
||||
await expect(page.getByText("demo_video.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击图片库,应切换且不显示视频
|
||||
const imageLibItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: imageLibName });
|
||||
await imageLibItem.click({ force: true });
|
||||
await expect(imageLibItem).toHaveClass(/active/);
|
||||
const imageLibItem = page.locator(".xx-asset-library-item").filter({ hasText: imageLibName })
|
||||
await imageLibItem.click({ force: true })
|
||||
await expect(imageLibItem).toHaveClass(/active/)
|
||||
|
||||
// 空状态或图片库内容
|
||||
await expect(page.getByText("demo_video.mp4")).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 素材搜索 ──────────────────────────────────────
|
||||
|
||||
test("素材搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-search");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"搜索测试库",
|
||||
"video",
|
||||
);
|
||||
"assets-search",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "搜索测试库", "video")
|
||||
|
||||
// 创建两个不同名称的素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"apple_clip.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"banana_clip.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "apple_clip.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "banana_clip.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 确保在测试库中
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "搜索测试库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "搜索测试库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
// 两个素材都应可见
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
})
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
||||
|
||||
// 搜索 apple,只显示 apple
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple");
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible();
|
||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0);
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("apple")
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible()
|
||||
await expect(page.getByText("banana_clip.mp4")).toHaveCount(0)
|
||||
|
||||
// 清空搜索,两个都显示
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("");
|
||||
await page.getByPlaceholder("搜索素材名称...").fill("")
|
||||
await expect(page.getByText("apple_clip.mp4")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible();
|
||||
});
|
||||
})
|
||||
await expect(page.getByText("banana_clip.mp4")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 筛选类型 ──────────────────────────────────────
|
||||
|
||||
test("素材类型筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-filter");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"筛选测试库",
|
||||
"video",
|
||||
);
|
||||
"assets-filter",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "筛选测试库", "video")
|
||||
|
||||
// 创建视频素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"video_clip.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "video_clip.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "筛选测试库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "筛选测试库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
// 素材应可见
|
||||
await expect(page.getByText("video_clip.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 筛选类型下拉存在
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first();
|
||||
await expect(filterSelect).toBeVisible();
|
||||
});
|
||||
const filterSelect = page.locator(".xx-assets-filters-left select").first()
|
||||
await expect(filterSelect).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 素材详情/播放 ────────────────────────────────
|
||||
|
||||
test("素材详情查看 - 播放弹窗", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-detail");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"详情测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"play_test.mp4",
|
||||
);
|
||||
"assets-detail",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "详情测试库", "video")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "play_test.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "详情测试库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "详情测试库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
// 找到素材卡片并点击播放按钮
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "play_test.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "play_test.mp4" })
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 点击播放按钮
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true });
|
||||
await assetCard.locator(".xx-asset-play").click({ force: true })
|
||||
|
||||
// 播放弹窗出现
|
||||
const modal = page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放" });
|
||||
await expect(modal).toBeVisible();
|
||||
const modal = page.locator(".ant-modal-content").filter({ hasText: "播放" })
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
// 关闭弹窗
|
||||
await modal.locator(".ant-modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
await modal.locator(".ant-modal-close").click()
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ─── 删除素材 ──────────────────────────────────────
|
||||
|
||||
test("删除素材 - 带确认对话框", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-delete");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"删除测试库",
|
||||
"video",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"to_delete.mp4",
|
||||
);
|
||||
"assets-delete",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "删除测试库", "video")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "to_delete.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "删除测试库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "删除测试库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "to_delete.mp4" });
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 });
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "to_delete.mp4" })
|
||||
await expect(assetCard).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 悬停显示删除按钮
|
||||
await assetCard.hover();
|
||||
await assetCard.hover()
|
||||
|
||||
// 点击删除
|
||||
const deleteBtn = assetCard.locator(".xx-asset-delete");
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
await deleteBtn.click({ force: true });
|
||||
const deleteBtn = assetCard.locator(".xx-asset-delete")
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
await deleteBtn.click({ force: true })
|
||||
|
||||
// 确认对话框出现
|
||||
const confirmModal = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确认删除" });
|
||||
await expect(confirmModal).toBeVisible();
|
||||
const confirmModal = page.locator(".ant-popover").filter({ hasText: "确认删除" })
|
||||
await expect(confirmModal).toBeVisible()
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||
(resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
)
|
||||
|
||||
// 点击确认删除
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click();
|
||||
await confirmModal.getByRole("button", { name: "删除" }).click()
|
||||
|
||||
const resp = await deletePromise;
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy();
|
||||
const resp = await deletePromise
|
||||
expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy()
|
||||
|
||||
// 素材应从列表中消失
|
||||
await expect(page.getByText("to_delete.mp4")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 批量删除素材 ──────────────────────────────────
|
||||
|
||||
test("批量删除素材", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-batch");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const libraryId = await createLibrary(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
"批量删除库",
|
||||
"video",
|
||||
);
|
||||
"assets-batch",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
const libraryId = await createLibrary(request, headers, projectId, "批量删除库", "video")
|
||||
|
||||
// 创建多个素材
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_1.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_2.mp4",
|
||||
);
|
||||
await createAsset(
|
||||
request,
|
||||
headers,
|
||||
projectId,
|
||||
libraryId,
|
||||
userId,
|
||||
"batch_3.mp4",
|
||||
);
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_1.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_2.mp4")
|
||||
await createAsset(request, headers, projectId, libraryId, userId, "batch_3.mp4")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "批量删除库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "批量删除库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
// 所有素材应可见
|
||||
await expect(page.getByText("batch_1.mp4")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible();
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible();
|
||||
})
|
||||
await expect(page.getByText("batch_2.mp4")).toBeVisible()
|
||||
await expect(page.getByText("batch_3.mp4")).toBeVisible()
|
||||
|
||||
// 点击全选
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" });
|
||||
await expect(selectAllBtn).toBeVisible();
|
||||
await selectAllBtn.click();
|
||||
const selectAllBtn = page.getByRole("button", { name: "全选" })
|
||||
await expect(selectAllBtn).toBeVisible()
|
||||
await selectAllBtn.click()
|
||||
|
||||
// 批量操作栏出现
|
||||
const batchBar = page.locator(".xx-assets-batch-bar");
|
||||
await expect(batchBar).toBeVisible();
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible();
|
||||
const batchBar = page.locator(".xx-assets-batch-bar")
|
||||
await expect(batchBar).toBeVisible()
|
||||
await expect(batchBar.getByText(/已选 3 项/)).toBeVisible()
|
||||
|
||||
// 点击批量删除
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" });
|
||||
await expect(batchDeleteBtn).toBeVisible();
|
||||
await batchDeleteBtn.click();
|
||||
const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" })
|
||||
await expect(batchDeleteBtn).toBeVisible()
|
||||
await batchDeleteBtn.click()
|
||||
|
||||
// 确认对话框
|
||||
const confirmPop = page
|
||||
.locator(".ant-popover")
|
||||
.filter({ hasText: "确定删除" });
|
||||
await expect(confirmPop).toBeVisible();
|
||||
const confirmPop = page.locator(".ant-popover").filter({ hasText: "确定删除" })
|
||||
await expect(confirmPop).toBeVisible()
|
||||
|
||||
// 确认删除
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click();
|
||||
await confirmPop.getByRole("button", { name: "删除" }).click()
|
||||
|
||||
// 验证素材已删除(通过 API 确认)
|
||||
await expect
|
||||
@@ -717,57 +569,53 @@ test.describe("素材库页面 - 完整交互测试", () => {
|
||||
const resp = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
if (!resp.ok()) return "error";
|
||||
const data = await resp.json();
|
||||
const items = data.items || [];
|
||||
return items.length;
|
||||
})
|
||||
if (!resp.ok()) return "error"
|
||||
const data = await resp.json()
|
||||
const items = data.items || []
|
||||
return items.length
|
||||
},
|
||||
{ timeout: 15_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe(0);
|
||||
});
|
||||
.toBe(0)
|
||||
})
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空素材库展示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "assets-empty");
|
||||
const projectId = await createProject(
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video");
|
||||
"assets-empty",
|
||||
)
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
await createLibrary(request, headers, projectId, "空素材库", "video")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const libItem = page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: "空素材库" });
|
||||
await libItem.click({ force: true });
|
||||
const libItem = page.locator(".xx-asset-library-item").filter({ hasText: "空素材库" })
|
||||
await libItem.click({ force: true })
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-assets-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible();
|
||||
});
|
||||
})
|
||||
await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问素材库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/assets");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/assets")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test.describe("App route guard", () => {
|
||||
test("redirects anonymous users to login", async ({ page }) => {
|
||||
await page.goto("/app/dashboard");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/dashboard")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test.describe("Authentication page", () => {
|
||||
test("renders login form", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
await page.goto("/login")
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
await expect(page.getByLabel("密码")).toBeVisible()
|
||||
await expect(page.getByRole("button", { name: "登录" })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
async function loginWithRetry(
|
||||
request: APIRequestContext,
|
||||
@@ -28,73 +24,70 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type TemplateResponse = { id: string };
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
type TemplateResponse = { id: string }
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
|
||||
test.describe("Core generation flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("walks through 5-step wizard and starts generation", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
test("walks through 5-step wizard and starts generation", async ({ page, request }) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-gen-${suffix}@example.com`;
|
||||
const username = `e2e_gen_${suffix}`;
|
||||
const libraryName = `E2E Gen Lib ${suffix}`;
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-${suffix}@example.com`
|
||||
const username = `e2e_gen_${suffix}`
|
||||
const libraryName = `E2E Gen Lib ${suffix}`
|
||||
|
||||
// Register
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status()).toBe(201);
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
|
||||
// Login
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
// Create project
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E Gen Proj ${suffix}` },
|
||||
});
|
||||
expect(project.status()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
|
||||
// Create asset library
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: { project_id: projectData.id, name: libraryName, kind: "video" },
|
||||
});
|
||||
expect(library.status()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
})
|
||||
expect(library.status()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
|
||||
// Upload source video
|
||||
const sourceFileName = "e2e-gen-source.mp4";
|
||||
const sourceFileName = "e2e-gen-source.mp4"
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -106,8 +99,8 @@ test.describe("Core generation flow", () => {
|
||||
buffer: Buffer.from("e2e source data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status()).toBe(200);
|
||||
})
|
||||
expect(upload.status()).toBe(200)
|
||||
|
||||
// Wait for asset to be ready
|
||||
await expect
|
||||
@@ -116,16 +109,16 @@ test.describe("Core generation flow", () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) return `http_${assets.status()}`;
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((a) => a.name === sourceFileName);
|
||||
if (!asset) return "missing";
|
||||
return asset.status;
|
||||
})
|
||||
if (!assets.ok()) return `http_${assets.status()}`
|
||||
const data = (await assets.json()) as AssetListResponse
|
||||
const asset = data.items.find((a) => a.name === sourceFileName)
|
||||
if (!asset) return "missing"
|
||||
return asset.status
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe("ready");
|
||||
.toBe("ready")
|
||||
|
||||
// Create an editing template so the generate page has at least one template
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
@@ -145,22 +138,22 @@ test.describe("Core generation flow", () => {
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
expect(template.status(), await template.text()).toBe(201);
|
||||
const templateData = (await template.json()) as TemplateResponse;
|
||||
expect(templateData.id).toBeTruthy();
|
||||
})
|
||||
expect(template.status(), await template.text()).toBe(201)
|
||||
const templateData = (await template.json()) as TemplateResponse
|
||||
expect(templateData.id).toBeTruthy()
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -174,43 +167,43 @@ test.describe("Core generation flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// Navigate to generate page
|
||||
await page.goto("/app/generate");
|
||||
await page.goto("/app/generate")
|
||||
await expect(page.getByRole("heading", { name: "一键生成" })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// Step 1: template - default selected, click next
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await expect(page.locator(".xx-choice-item.selected")).toBeVisible()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 2: select material
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
|
||||
const librarySelect = page.locator("select").first();
|
||||
await librarySelect.selectOption({ label: libraryName });
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..");
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible()
|
||||
const librarySelect = page.locator("select").first()
|
||||
await librarySelect.selectOption({ label: libraryName })
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..")
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await materialLabel.locator("input[type='checkbox']").check();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
})
|
||||
await materialLabel.locator("input[type='checkbox']").check()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 3: title
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
|
||||
const titleText = `E2E Test ${suffix}`;
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible()
|
||||
const titleText = `E2E Test ${suffix}`
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText)
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 4: voice
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
|
||||
await firstVoiceCard.click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible()
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first()
|
||||
await firstVoiceCard.click()
|
||||
await page.getByRole("button", { name: "下一步" }).click()
|
||||
|
||||
// Step 5: confirm and generate
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible()
|
||||
|
||||
// Wait for plan creation API to be called
|
||||
const createPlanPromise = page.waitForResponse(
|
||||
@@ -219,68 +212,64 @@ test.describe("Core generation flow", () => {
|
||||
response.request().method() === "POST" &&
|
||||
!response.url().includes("/generate"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
)
|
||||
|
||||
// Click generate button
|
||||
await page
|
||||
.locator(".xx-btn-primary")
|
||||
.filter({ hasText: "确认生成" })
|
||||
.first()
|
||||
.click();
|
||||
await page.locator(".xx-btn-primary").filter({ hasText: "确认生成" }).first().click()
|
||||
|
||||
// Verify plan was created successfully
|
||||
const planResp = await createPlanPromise;
|
||||
expect(planResp.ok()).toBeTruthy();
|
||||
const planData = (await planResp.json()) as { id: string };
|
||||
expect(planData.id).toBeTruthy();
|
||||
const planResp = await createPlanPromise
|
||||
expect(planResp.ok()).toBeTruthy()
|
||||
const planData = (await planResp.json()) as { id: string }
|
||||
expect(planData.id).toBeTruthy()
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
await page
|
||||
.getByText(/生成中|生成完成|生成失败/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false);
|
||||
.catch(() => false)
|
||||
// If we see progress or result, great; if not, flow still reached the end
|
||||
// which is sufficient for an E2E smoke test
|
||||
|
||||
// Verify product library page loads (smoke: just verify page renders)
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/app\/products/);
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/app\/products/)
|
||||
// Verify page container exists = page rendered correctly
|
||||
// (works in all states: loading/error/success - more reliable than checking search input)
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 清理所有路由,避免页面关闭时飞地API请求导致测试报错
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" });
|
||||
});
|
||||
await page.unrouteAll({ behavior: "ignoreErrors" })
|
||||
})
|
||||
|
||||
test("generation task API creates and lists tasks", async ({ request }) => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-gen-api-${suffix}@example.com`;
|
||||
const username = `e2e_gen_api_${suffix}`;
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-gen-api-${suffix}@example.com`
|
||||
const username = `e2e_gen_api_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status()).toBe(201);
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `E2E API Proj ${suffix}` },
|
||||
});
|
||||
expect(project.status()).toBe(200);
|
||||
})
|
||||
expect(project.status()).toBe(200)
|
||||
|
||||
// List generation tasks via task center API
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers });
|
||||
expect(tasks.status()).toBe(200);
|
||||
const tasksData = await tasks.json();
|
||||
expect(Array.isArray(tasksData.items)).toBe(true);
|
||||
});
|
||||
});
|
||||
const tasks = await request.get(`${apiBase}/tasks`, { headers })
|
||||
expect(tasks.status()).toBe(200)
|
||||
const tasksData = await tasks.json()
|
||||
expect(Array.isArray(tasksData.items)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
@@ -29,38 +25,35 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("Title library flow", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test("loads title library page and displays titles", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-${suffix}@example.com`;
|
||||
const username = `e2e_title_${suffix}`;
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("loads title library page and displays titles", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-title-${suffix}@example.com`
|
||||
const username = `e2e_title_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
})
|
||||
expect(register.status(), await register.text()).toBe(201)
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status(), await login.text()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
// Create a title so the titles page has at least one title card to display
|
||||
// (titles are loaded from API; new users have none by default)
|
||||
@@ -71,19 +64,19 @@ test.describe("Title library flow", () => {
|
||||
text: `E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201);
|
||||
})
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201)
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -97,45 +90,42 @@ test.describe("Title library flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
await expect(page.locator(".xx-title-card").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstTitleText = await page
|
||||
.locator(".xx-title-card-text")
|
||||
.first()
|
||||
.textContent();
|
||||
const firstTitleText = await page.locator(".xx-title-card-text").first().textContent()
|
||||
if (firstTitleText) {
|
||||
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText);
|
||||
await expect(page.getByText(firstTitleText)).toBeVisible();
|
||||
await page.getByPlaceholder("搜索标题关键词...").fill(firstTitleText)
|
||||
await expect(page.getByText(firstTitleText)).toBeVisible()
|
||||
}
|
||||
|
||||
await expect(page.locator(".xx-title-card-stat").first()).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".xx-title-card-stat").first()).toBeVisible()
|
||||
})
|
||||
|
||||
test("titles API creates and lists titles", async ({ request }) => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-title-api-${suffix}@example.com`;
|
||||
const username = `e2e_title_api_${suffix}`;
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-title-api-${suffix}@example.com`
|
||||
const username = `e2e_title_api_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, username, password: PASSWORD, display_name: username },
|
||||
});
|
||||
expect(register.status()).toBe(201);
|
||||
})
|
||||
expect(register.status()).toBe(201)
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
const titleText = `E2E Test Title ${suffix}`;
|
||||
const titleText = `E2E Test Title ${suffix}`
|
||||
const createResp = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -143,20 +133,20 @@ test.describe("Title library flow", () => {
|
||||
text: titleText,
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(createResp.status(), await createResp.text()).toBe(201);
|
||||
})
|
||||
expect(createResp.status(), await createResp.text()).toBe(201)
|
||||
const created = (await createResp.json()) as {
|
||||
id: string;
|
||||
text: string;
|
||||
};
|
||||
expect(created.id).toBeTruthy();
|
||||
id: string
|
||||
text: string
|
||||
}
|
||||
expect(created.id).toBeTruthy()
|
||||
|
||||
const listResp = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(listResp.status()).toBe(200);
|
||||
const listResp = await request.get(`${apiBase}/titles`, { headers })
|
||||
expect(listResp.status()).toBe(200)
|
||||
const listData = (await listResp.json()) as {
|
||||
items: Array<{ id: string; text: string }>;
|
||||
};
|
||||
const found = listData.items.find((t) => t.id === created.id);
|
||||
expect(found).toBeTruthy();
|
||||
});
|
||||
});
|
||||
items: Array<{ id: string; text: string }>
|
||||
}
|
||||
const found = listData.items.find((t) => t.id === created.id)
|
||||
expect(found).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
@@ -29,31 +25,28 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type ProjectResponse = { id: string }
|
||||
type LibraryResponse = { id: string }
|
||||
|
||||
test.describe("Core media upload flow", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test("uploads a video asset and shows it in the asset library", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
test("uploads a video asset and shows it in the asset library", async ({ page, request }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const suffix = Date.now().toString(36);
|
||||
const email = `e2e-mov-${suffix}@example.com`;
|
||||
const username = `e2e_mov_${suffix}`;
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const suffix = Date.now().toString(36)
|
||||
const email = `e2e-mov-${suffix}@example.com`
|
||||
const username = `e2e_mov_${suffix}`
|
||||
|
||||
const register = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
@@ -62,15 +55,15 @@ test.describe("Core media upload flow", () => {
|
||||
password: PASSWORD,
|
||||
display_name: username,
|
||||
},
|
||||
});
|
||||
expect(register.status(), await register.text()).toBe(201);
|
||||
})
|
||||
expect(register.status(), await register.text()).toBe(201)
|
||||
|
||||
const registerData = (await register.json()) as { user_id: string };
|
||||
const registerData = (await register.json()) as { user_id: string }
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.status(), await login.text()).toBe(200)
|
||||
const loginData = (await login.json()) as { access_token: string }
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` }
|
||||
|
||||
const project = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
@@ -78,9 +71,9 @@ test.describe("Core media upload flow", () => {
|
||||
name: `E2E Project ${suffix}`,
|
||||
description: "Playwright upload smoke",
|
||||
},
|
||||
});
|
||||
expect(project.status(), await project.text()).toBe(200);
|
||||
const projectData = (await project.json()) as ProjectResponse;
|
||||
})
|
||||
expect(project.status(), await project.text()).toBe(200)
|
||||
const projectData = (await project.json()) as ProjectResponse
|
||||
|
||||
const library = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -89,20 +82,20 @@ test.describe("Core media upload flow", () => {
|
||||
name: `E2E Video Library ${suffix}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
})
|
||||
expect(library.status(), await library.text()).toBe(200)
|
||||
const libraryData = (await library.json()) as LibraryResponse
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -116,12 +109,12 @@ test.describe("Core media upload flow", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
await page.goto("/app/assets");
|
||||
await page.goto("/app/assets")
|
||||
await expect(page.locator(".xx-assets-layout")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
@@ -134,12 +127,12 @@ test.describe("Core media upload flow", () => {
|
||||
buffer: Buffer.from("playwright mov upload smoke"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
})
|
||||
expect(upload.status(), await upload.text()).toBe(200)
|
||||
|
||||
await expect(
|
||||
page.getByText(/上传失败|素材列表加载失败|素材库加载失败/),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.getByText(/上传失败|素材列表加载失败|素材库加载失败/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -147,52 +140,44 @@ test.describe("Core media upload flow", () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
})
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
return `http_${assets.status()}`
|
||||
}
|
||||
const data = (await assets.json()) as {
|
||||
items: Array<{
|
||||
name: string;
|
||||
status: string;
|
||||
file_type?: string;
|
||||
mime_type?: string;
|
||||
}>;
|
||||
};
|
||||
const asset = data.items.find(
|
||||
(item) => item.name === "e2e-sample.MOV",
|
||||
);
|
||||
return asset
|
||||
? `${asset.mime_type || asset.file_type || ""}:${asset.status}`
|
||||
: "missing";
|
||||
name: string
|
||||
status: string
|
||||
file_type?: string
|
||||
mime_type?: string
|
||||
}>
|
||||
}
|
||||
const asset = data.items.find((item) => item.name === "e2e-sample.MOV")
|
||||
return asset ? `${asset.mime_type || asset.file_type || ""}:${asset.status}` : "missing"
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/)
|
||||
|
||||
// Select the test library from sidebar
|
||||
await page
|
||||
.locator(".xx-asset-library-item")
|
||||
.filter({ hasText: `E2E Video Library ${suffix}` })
|
||||
.click({ force: true });
|
||||
.click({ force: true })
|
||||
|
||||
await page.reload();
|
||||
await page.reload()
|
||||
await expect(page.locator(".xx-assets-content")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible(
|
||||
{
|
||||
timeout: 20_000,
|
||||
},
|
||||
);
|
||||
})
|
||||
await expect(page.getByText("e2e-sample.MOV", { exact: true })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
})
|
||||
|
||||
// Verify asset card shows status
|
||||
const assetCard = page
|
||||
.locator(".xx-asset-card")
|
||||
.filter({ hasText: "e2e-sample.MOV" });
|
||||
await expect(assetCard).toBeVisible();
|
||||
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible();
|
||||
const assetCard = page.locator(".xx-asset-card").filter({ hasText: "e2e-sample.MOV" })
|
||||
await expect(assetCard).toBeVisible()
|
||||
await expect(assetCard.locator(".xx-asset-diagnose-btn")).toBeVisible()
|
||||
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
await expect(page.getByText(/素材加载失败|上传失败/)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
+198
-239
@@ -4,33 +4,29 @@
|
||||
* 覆盖:去重上传页面、上传区域、去重记录列表、去重详情、
|
||||
* 删除记录、重试去重
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -43,30 +39,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -74,7 +70,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -85,14 +81,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -106,236 +102,216 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("去重流程", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
// ─── 上传页面加载 ──────────────────────────────────
|
||||
|
||||
test("去重上传页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-load")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await page.goto("/app/duplication")
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "视频查重" })).toBeVisible()
|
||||
|
||||
// 描述
|
||||
await expect(
|
||||
page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段"),
|
||||
).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("上传视频文件,系统将自动检测与已有素材的重复片段")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 上传区域展示 ──────────────────────────────────
|
||||
|
||||
test("上传区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-upload-zone",
|
||||
);
|
||||
)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 拖拽上传区域
|
||||
const uploadZone = page.locator(".dup-upload-zone");
|
||||
await expect(uploadZone).toBeVisible();
|
||||
const uploadZone = page.locator(".dup-upload-zone")
|
||||
await expect(uploadZone).toBeVisible()
|
||||
|
||||
// 上传图标和文字
|
||||
await expect(
|
||||
uploadZone.getByText("点击或拖拽视频文件到此区域"),
|
||||
).toBeVisible();
|
||||
await expect(uploadZone.getByText("点击或拖拽视频文件到此区域")).toBeVisible()
|
||||
|
||||
// 格式提示
|
||||
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible();
|
||||
await expect(uploadZone.getByText(/支持 MP4、AVI、MOV、MKV/)).toBeVisible()
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible();
|
||||
await expect(page.locator(".dup-upload-formats")).toBeVisible()
|
||||
|
||||
// 选择文件按钮
|
||||
const selectBtn = page.getByRole("button", { name: "选择文件" });
|
||||
await expect(selectBtn).toBeVisible();
|
||||
const selectBtn = page.getByRole("button", { name: "选择文件" })
|
||||
await expect(selectBtn).toBeVisible()
|
||||
|
||||
// 隐藏的文件 input
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await expect(fileInput).toHaveCount(1);
|
||||
});
|
||||
const fileInput = page.locator('input[type="file"]')
|
||||
await expect(fileInput).toHaveCount(1)
|
||||
})
|
||||
|
||||
// ─── 格式说明区 ────────────────────────────────────
|
||||
|
||||
test("格式说明和提示区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-info",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-info")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 右侧说明区
|
||||
const infoCard = page.locator(".dup-info-card");
|
||||
await expect(infoCard).toBeVisible();
|
||||
const infoCard = page.locator(".dup-info-card")
|
||||
await expect(infoCard).toBeVisible()
|
||||
|
||||
// 查重说明
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible();
|
||||
await expect(infoCard.getByText("查重说明")).toBeVisible()
|
||||
|
||||
// 支持格式
|
||||
await expect(infoCard.getByText("支持格式")).toBeVisible();
|
||||
await expect(infoCard.getByText("支持格式")).toBeVisible()
|
||||
|
||||
// 温馨提示
|
||||
await expect(infoCard.getByText("温馨提示")).toBeVisible();
|
||||
await expect(infoCard.getByText("温馨提示")).toBeVisible()
|
||||
|
||||
// 格式标签
|
||||
await expect(page.locator(".dup-format-tags")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".dup-format-tags")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 去重记录列表页面 ──────────────────────────────
|
||||
|
||||
test("去重记录列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-list")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await page.goto("/app/duplication/results")
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "查重记录" })).toBeVisible()
|
||||
|
||||
// 筛选按钮
|
||||
await expect(page.locator(".dup-filter")).toBeVisible();
|
||||
await expect(page.locator(".dup-filter")).toBeVisible()
|
||||
|
||||
// 上传查重按钮
|
||||
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible();
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "上传查重" })).toBeVisible()
|
||||
})
|
||||
|
||||
test("去重记录列表 - 空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-list-empty",
|
||||
);
|
||||
)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 空状态(新用户没有记录)
|
||||
const emptyState = page.locator(".dup-results-empty");
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 });
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible();
|
||||
});
|
||||
const emptyState = page.locator(".dup-results-empty")
|
||||
await expect(emptyState).toBeVisible({ timeout: 10_000 })
|
||||
await expect(emptyState.getByText(/暂无查重记录/)).toBeVisible()
|
||||
})
|
||||
|
||||
test("去重记录列表 - 风险等级筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-filter",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-filter")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 筛选按钮存在
|
||||
const filterBtns = page.locator(".dup-filter-btn");
|
||||
await expect(filterBtns).toHaveCount(4); // 全部、低风险、中风险、高风险
|
||||
const filterBtns = page.locator(".dup-filter-btn")
|
||||
await expect(filterBtns).toHaveCount(4) // 全部、低风险、中风险、高风险
|
||||
|
||||
// 验证按钮文本
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部");
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险");
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险");
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险");
|
||||
await expect(filterBtns.nth(0)).toHaveText("全部")
|
||||
await expect(filterBtns.nth(1)).toHaveText("低风险")
|
||||
await expect(filterBtns.nth(2)).toHaveText("中风险")
|
||||
await expect(filterBtns.nth(3)).toHaveText("高风险")
|
||||
|
||||
// 默认选中"全部"
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/);
|
||||
await expect(filterBtns.nth(0)).toHaveClass(/active/)
|
||||
|
||||
// 点击低风险
|
||||
await filterBtns.nth(1).click();
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/);
|
||||
});
|
||||
await filterBtns.nth(1).click()
|
||||
await expect(filterBtns.nth(1)).toHaveClass(/active/)
|
||||
})
|
||||
|
||||
test("去重记录列表 - 上传查重按钮跳转", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-nav",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(request, "dup-nav")
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 点击上传查重按钮
|
||||
await page.getByRole("button", { name: "上传查重" }).click();
|
||||
await page.getByRole("button", { name: "上传查重" }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/duplication$/);
|
||||
await expect(page.locator(".dup-upload-zone")).toBeVisible();
|
||||
});
|
||||
await expect(page).toHaveURL(/\/app\/duplication$/)
|
||||
await expect(page.locator(".dup-upload-zone")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 去重详情页 ────────────────────────────────────
|
||||
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-detail");
|
||||
test("去重详情页 - 通过 API 创建测试数据后访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-detail",
|
||||
)
|
||||
|
||||
// 先上传一个文件进行查重,获取 record id
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -347,44 +323,41 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 如果查重 API 不可用,跳过详情页测试
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`,
|
||||
);
|
||||
return;
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过详情页测试`)
|
||||
return
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy();
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
expect(recordId, "应返回查重记录 ID").toBeTruthy()
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
// 访问详情页
|
||||
await page.goto(`/app/duplication/${recordId}`);
|
||||
await page.goto(`/app/duplication/${recordId}`)
|
||||
|
||||
// 页面应正常渲染
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 验证无错误
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 删除记录 ──────────────────────────────────────
|
||||
|
||||
test("去重记录删除 - API 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers } =
|
||||
await createAuthedUser(request, "dup-delete");
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers } = await createAuthedUser(request, "dup-delete")
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -396,59 +369,53 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`,
|
||||
);
|
||||
return;
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过删除测试`)
|
||||
return
|
||||
}
|
||||
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
|
||||
// 验证记录存在
|
||||
const listResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
if (listResp.ok()) {
|
||||
const records = await listResp.json();
|
||||
const records = await listResp.json()
|
||||
const recordExists = Array.isArray(records)
|
||||
? records.some((r: { id: string }) => r.id === recordId)
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId);
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy();
|
||||
: (records.items || []).some((r: { id: string }) => r.id === recordId)
|
||||
expect(recordExists, "记录应存在于列表中").toBeTruthy()
|
||||
}
|
||||
|
||||
// 删除记录
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/duplication/records/${recordId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
deleteResp.ok(),
|
||||
`删除查重记录应成功: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
const deleteResp = await request.delete(`${apiBase}/duplication/records/${recordId}`, {
|
||||
headers,
|
||||
})
|
||||
expect(deleteResp.ok(), `删除查重记录应成功: ${deleteResp.status()}`).toBeTruthy()
|
||||
|
||||
// 验证记录已删除
|
||||
const listAfterResp = await request.get(`${apiBase}/duplication/records`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
if (listAfterResp.ok()) {
|
||||
const recordsAfter = await listAfterResp.json();
|
||||
const recordsAfter = await listAfterResp.json()
|
||||
const recordStillExists = Array.isArray(recordsAfter)
|
||||
? recordsAfter.some((r: { id: string }) => r.id === recordId)
|
||||
: (recordsAfter.items || []).some(
|
||||
(r: { id: string }) => r.id === recordId,
|
||||
);
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy();
|
||||
: (recordsAfter.items || []).some((r: { id: string }) => r.id === recordId)
|
||||
expect(recordStillExists, "记录应已被删除").toBeFalsy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("去重记录删除 - UI 验证", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-delete-ui");
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-delete-ui",
|
||||
)
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -460,69 +427,66 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication ui delete test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`,
|
||||
);
|
||||
return;
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过 UI 删除测试`)
|
||||
return
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 记录卡片应存在
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
const resultCard = page.locator(".dup-result-card").first()
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
|
||||
|
||||
if (cardVisible) {
|
||||
// 删除按钮存在
|
||||
const deleteBtn = resultCard.getByRole("button").filter({
|
||||
hasText: "🗑️",
|
||||
});
|
||||
await expect(deleteBtn).toBeVisible();
|
||||
})
|
||||
await expect(deleteBtn).toBeVisible()
|
||||
|
||||
// 删除按钮点击 - 会触发 confirm 对话框
|
||||
// 这里我们通过监听 confirm 来确认删除
|
||||
page.once("dialog", async (dialog) => {
|
||||
expect(dialog.message()).toContain("确定删除");
|
||||
await dialog.accept();
|
||||
});
|
||||
expect(dialog.message()).toContain("确定删除")
|
||||
await dialog.accept()
|
||||
})
|
||||
|
||||
// 监听删除请求
|
||||
const deletePromise = page
|
||||
.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/duplication/records/") &&
|
||||
resp.request().method() === "DELETE",
|
||||
resp.url().includes("/duplication/records/") && resp.request().method() === "DELETE",
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.catch(() => null);
|
||||
.catch(() => null)
|
||||
|
||||
await deleteBtn.click();
|
||||
await deleteBtn.click()
|
||||
|
||||
const deleteResp = await deletePromise;
|
||||
const deleteResp = await deletePromise
|
||||
if (deleteResp) {
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy();
|
||||
expect(deleteResp.ok(), "删除请求应成功").toBeTruthy()
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// ─── 重试去重 ──────────────────────────────────────
|
||||
|
||||
test("重试去重按钮 - 失败记录显示重试", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { headers, userId, accessToken, email, username } =
|
||||
await createAuthedUser(request, "dup-retry");
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { headers, userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"dup-retry",
|
||||
)
|
||||
|
||||
// 创建查重记录
|
||||
const uploadResp = await request.post(`${apiBase}/duplication/upload`, {
|
||||
@@ -534,59 +498,54 @@ test.describe("去重流程", () => {
|
||||
buffer: Buffer.from("e2e duplication retry test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (!uploadResp.ok()) {
|
||||
console.log(
|
||||
`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`,
|
||||
);
|
||||
return;
|
||||
console.log(`[skip] 查重上传 API 不可用 (${uploadResp.status()}),跳过重试测试`)
|
||||
return
|
||||
}
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page.locator(".dup-page")).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// 记录列表中至少有一条记录
|
||||
const resultCard = page.locator(".dup-result-card").first();
|
||||
const cardVisible = await resultCard
|
||||
.isVisible({ timeout: 10_000 })
|
||||
.catch(() => false);
|
||||
const resultCard = page.locator(".dup-result-card").first()
|
||||
const cardVisible = await resultCard.isVisible({ timeout: 10_000 }).catch(() => false)
|
||||
|
||||
if (cardVisible) {
|
||||
// 验证记录卡片基本结构
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible();
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible();
|
||||
await expect(resultCard.locator(".dup-result-card-body")).toBeVisible()
|
||||
await expect(resultCard.locator(".dup-result-card-score")).toBeVisible()
|
||||
|
||||
// 检查是否有重试按钮(失败状态才显示)
|
||||
// 新上传的记录可能是处理中或完成状态,不一定显示重试按钮
|
||||
// 这里只验证 API 重试接口可用
|
||||
const uploadData = await uploadResp.json();
|
||||
const recordId = uploadData.id;
|
||||
const uploadData = await uploadResp.json()
|
||||
const recordId = uploadData.id
|
||||
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/duplication/records/${recordId}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
const retryResp = await request.post(`${apiBase}/duplication/records/${recordId}/retry`, {
|
||||
headers,
|
||||
})
|
||||
// 重试接口应返回 2xx 或明确的状态码
|
||||
expect(retryResp.status()).toBeLessThan(500);
|
||||
expect(retryResp.status()).toBeLessThan(500)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问去重上传页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
await page.goto("/app/duplication")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
|
||||
test("未登录访问去重记录页 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/duplication/results");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/duplication/results")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,36 +6,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -48,30 +41,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -90,14 +83,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -111,7 +104,7 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/** 创建一个编辑模板并返回 id */
|
||||
@@ -146,80 +139,74 @@ async function createEditingTemplate(
|
||||
tags: ["e2e", "test"],
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建模板应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
test.describe("剪辑策划页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/editing-planner");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("剪辑策划页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("剪辑策划页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
// 验证顶栏存在
|
||||
await expect(page.locator(".ep-top-bar")).toBeVisible();
|
||||
await expect(page.locator(".ep-top-bar")).toBeVisible()
|
||||
// 验证模式栏存在
|
||||
await expect(page.locator(".ep-mode-bar")).toBeVisible();
|
||||
await expect(page.locator(".ep-mode-bar")).toBeVisible()
|
||||
// 验证主体区域存在
|
||||
await expect(page.locator(".ep-main-body")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".ep-main-body")).toBeVisible()
|
||||
})
|
||||
|
||||
test("剪辑模式切换正常显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-mode",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-mode")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-mode",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证模式按钮存在(画中画、人物口播等)
|
||||
const modeBtns = page.locator(".ep-mode-btn");
|
||||
await expect(modeBtns.first()).toBeVisible();
|
||||
const modeCount = await modeBtns.count();
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
const modeBtns = page.locator(".ep-mode-btn")
|
||||
await expect(modeBtns.first()).toBeVisible()
|
||||
const modeCount = await modeBtns.count()
|
||||
expect(modeCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("剪辑计划 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("创建剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 创建测试 ${suffix}`;
|
||||
const { headers } = await createAuthedUser(request, "ep-create")
|
||||
const suffix = Date.now().toString(36)
|
||||
const templateName = `E2E 创建测试 ${suffix}`
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -238,22 +225,22 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建剪辑计划应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy();
|
||||
expect(data.name).toBe(templateName);
|
||||
expect(data.mode).toBe("pip");
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回模板 ID").toBeTruthy()
|
||||
expect(data.name).toBe(templateName)
|
||||
expect(data.mode).toBe("pip")
|
||||
})
|
||||
|
||||
test("列出剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "ep-list")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建 2 个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
@@ -271,7 +258,7 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -287,102 +274,81 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出模板应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
const response = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(response.ok(), `列出模板应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.templates || []
|
||||
expect(Array.isArray(items), "返回应为数组").toBeTruthy()
|
||||
expect(items.length, "应至少有 2 个模板").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("获取剪辑计划详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-detail");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "ep-detail")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
|
||||
const response = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
})
|
||||
expect(response.ok(), `获取详情应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(templateId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.mode).toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe(templateId)
|
||||
expect(data.name).toBeTruthy()
|
||||
expect(data.mode).toBeTruthy()
|
||||
})
|
||||
|
||||
test("编辑剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-update");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "ep-update")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
|
||||
const newName = `更新后的剪辑计划 ${Date.now()}`;
|
||||
const newName = `更新后的剪辑计划 ${Date.now()}`
|
||||
const response = await request.patch(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
data: {
|
||||
name: newName,
|
||||
description: "更新后的描述",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新模板应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
const data = await response.json()
|
||||
expect(data.name).toBe(newName)
|
||||
|
||||
// 验证更新后的数据
|
||||
const verify = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
});
|
||||
})
|
||||
const verifyData = await verify.json()
|
||||
expect(verifyData.name).toBe(newName)
|
||||
})
|
||||
|
||||
test("删除剪辑计划 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-delete");
|
||||
const templateId = await createEditingTemplate(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "ep-delete")
|
||||
const templateId = await createEditingTemplate(request, headers, Date.now().toString(36))
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}`,
|
||||
{ headers },
|
||||
);
|
||||
const deleteResp = await request.delete(`${apiBase}/templates/${templateId}`, { headers })
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/templates/${templateId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
})
|
||||
|
||||
test("创建剪辑计划 - 无效 mode 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-badmode");
|
||||
const { headers } = await createAuthedUser(request, "ep-badmode")
|
||||
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -392,20 +358,17 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("获取不存在的剪辑计划 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "ep-404");
|
||||
const { headers } = await createAuthedUser(request, "ep-404")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/templates/nonexistent-template-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404);
|
||||
});
|
||||
const response = await request.get(`${apiBase}/templates/nonexistent-template-999`, { headers })
|
||||
expect(response.status(), "不存在的模板应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("未登录创建剪辑计划 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/templates`, {
|
||||
@@ -415,81 +378,77 @@ test.describe("剪辑计划 - API 操作", () => {
|
||||
estimated_duration: 30,
|
||||
segments: [],
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("剪辑策划页面 - 已模板数据加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("已创建的模板在页面中显示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "ep-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"ep-data",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await createEditingTemplate(request, headers, suffix);
|
||||
await createEditingTemplate(request, headers, suffix)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-data",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证状态栏存在
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".ep-status-bar")).toBeVisible()
|
||||
})
|
||||
|
||||
test("撤销/重做按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-undo",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-undo")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-undo",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证顶栏按钮存在(撤销、重做、保存、生成等)
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn");
|
||||
await expect(topBarBtns.first()).toBeVisible();
|
||||
const btnCount = await topBarBtns.count();
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const topBarBtns = page.locator(".ep-top-bar-right .ep-btn")
|
||||
await expect(topBarBtns.first()).toBeVisible()
|
||||
const btnCount = await topBarBtns.count()
|
||||
expect(btnCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("生成按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"ep-gen",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "ep-gen")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E ep-gen",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/editing-planner");
|
||||
await page.goto("/app/editing-planner")
|
||||
await expect(page.locator(".ep-v8-root")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证主操作按钮存在
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".ep-btn-primary")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
+240
-277
@@ -7,33 +7,29 @@
|
||||
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
|
||||
* 真实的生成流程测试见 core-generation.spec.ts。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "SmokePass123!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (
|
||||
page: import("@playwright/test").Page,
|
||||
) => {
|
||||
if (!apiOrigin) return;
|
||||
const routeBrowserApiToTestApi = async (page: import("@playwright/test").Page) => {
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -46,30 +42,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -77,14 +73,14 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock 产品数据 */
|
||||
function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
const products = [];
|
||||
const products = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const status = statuses[i % statuses.length];
|
||||
const status = statuses[i % statuses.length]
|
||||
products.push({
|
||||
id: `mock-prod-${Date.now()}-${i}`,
|
||||
title: `测试作品 ${i + 1}`,
|
||||
@@ -93,14 +89,13 @@ function mockProducts(count: number, statuses: string[] = ["completed"]) {
|
||||
resolution: "1080x1920",
|
||||
file_size: (5 + i) * 1024 * 1024,
|
||||
duplicate_rate: i * 5,
|
||||
video_url:
|
||||
status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
|
||||
thumbnail_url: undefined,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
})
|
||||
}
|
||||
return products;
|
||||
return products
|
||||
}
|
||||
|
||||
/** 在浏览器中设置登录态 */
|
||||
@@ -111,14 +106,14 @@ async function setupAuthInBrowser(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -132,17 +127,14 @@ async function setupAuthInBrowser(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/** Mock 产品列表 API */
|
||||
async function mockProductsApi(
|
||||
page: import("@playwright/test").Page,
|
||||
products: unknown[],
|
||||
) {
|
||||
async function mockProductsApi(page: import("@playwright/test").Page, products: unknown[]) {
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
const method = route.request().method()
|
||||
const url = route.request().url()
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
// 列表
|
||||
@@ -150,31 +142,29 @@ async function mockProductsApi(
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 单个产品详情
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = (products as Array<{ id: string }>).find(
|
||||
(p) => p.id === productId,
|
||||
);
|
||||
const productId = detailMatch[1]
|
||||
const product = (products as Array<{ id: string }>).find((p) => p.id === productId)
|
||||
if (product) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product),
|
||||
});
|
||||
})
|
||||
} else {
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Not found" }),
|
||||
});
|
||||
})
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 删除
|
||||
@@ -183,8 +173,8 @@ async function mockProductsApi(
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 下载链接
|
||||
@@ -196,67 +186,67 @@ async function mockProductsApi(
|
||||
url: "https://example.com/download.mp4",
|
||||
expires_at: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
route.continue()
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("作品库页面", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
// ─── 页面加载 ──────────────────────────────────────
|
||||
|
||||
test("作品库列表页面加载", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-load",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"]);
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(3, ["completed", "processing", "failed"])
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
|
||||
// 页面容器
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 页面标题
|
||||
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible()
|
||||
|
||||
// 筛选栏
|
||||
await expect(page.locator(".xx-products-filters")).toBeVisible();
|
||||
await expect(page.locator(".xx-products-filters")).toBeVisible()
|
||||
|
||||
// 卡片网格
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 作品卡片存在
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 状态展示 ──────────────────────────────────────
|
||||
|
||||
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-status",
|
||||
);
|
||||
)
|
||||
|
||||
const products = [
|
||||
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
|
||||
@@ -270,334 +260,311 @@ test.describe("作品库页面", () => {
|
||||
title: "失败作品",
|
||||
id: `mock-prod-${Date.now()}-f`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 等待卡片加载
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证各状态标签存在
|
||||
const completedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "已完成作品" });
|
||||
await expect(
|
||||
completedCard.locator(".xx-product-status.completed"),
|
||||
).toHaveText("已完成");
|
||||
const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" })
|
||||
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成")
|
||||
|
||||
const processingCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中作品" });
|
||||
await expect(
|
||||
processingCard.locator(".xx-product-status.processing"),
|
||||
).toHaveText("处理中");
|
||||
const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" })
|
||||
await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中")
|
||||
|
||||
const failedCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "失败作品" });
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText(
|
||||
"失败",
|
||||
);
|
||||
});
|
||||
const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" })
|
||||
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败")
|
||||
})
|
||||
|
||||
// ─── 作品详情页 ────────────────────────────────────
|
||||
|
||||
test("作品详情页打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-detail",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "详情页测试作品";
|
||||
const productId = products[0].id;
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "详情页测试作品"
|
||||
const productId = products[0].id
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
// 直接访问详情页
|
||||
await page.goto(`/app/products/${productId}`);
|
||||
await page.goto(`/app/products/${productId}`)
|
||||
|
||||
// 验证 URL
|
||||
await expect(page).toHaveURL(/\/app\/products\//);
|
||||
await expect(page).toHaveURL(/\/app\/products\//)
|
||||
|
||||
// 页面应正常渲染(无错误)
|
||||
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 视频播放 ──────────────────────────────────────
|
||||
|
||||
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-play",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "播放测试作品";
|
||||
products[0].video_url = "https://example.com/test-video.mp4";
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "播放测试作品"
|
||||
products[0].video_url = "https://example.com/test-video.mp4"
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击作品卡片打开播放
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "播放测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
|
||||
// 点击播放按钮
|
||||
await productCard.locator(".xx-product-play").click({ force: true });
|
||||
await productCard.locator(".xx-product-play").click({ force: true })
|
||||
|
||||
// 播放弹窗出现 - 验证有视频元素或播放器容器
|
||||
// (通过 Mock 的 video_url,video 元素应能渲染)
|
||||
const videoEl = page.locator("video");
|
||||
const videoEl = page.locator("video")
|
||||
const videoVisible = await videoEl
|
||||
.first()
|
||||
.isVisible({ timeout: 5000 })
|
||||
.catch(() => false);
|
||||
.catch(() => false)
|
||||
// 或弹窗容器可见
|
||||
const modalVisible = await page
|
||||
.locator(".ant-modal-content")
|
||||
.filter({ hasText: "播放测试作品" })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
.catch(() => false)
|
||||
|
||||
expect(videoVisible || modalVisible).toBeTruthy();
|
||||
});
|
||||
expect(videoVisible || modalVisible).toBeTruthy()
|
||||
})
|
||||
|
||||
// ─── 下载按钮 ──────────────────────────────────────
|
||||
|
||||
test("下载按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-download",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "下载测试作品";
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "下载测试作品"
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "下载测试作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
|
||||
// 下载按钮存在且可用(已完成状态)
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
await expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).not.toBeDisabled()
|
||||
})
|
||||
|
||||
test("处理中作品下载按钮禁用", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-disabled",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(1, ["processing"]);
|
||||
products[0].title = "处理中下载测试";
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(1, ["processing"])
|
||||
products[0].title = "处理中下载测试"
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "处理中下载测试" });
|
||||
await expect(productCard).toBeVisible();
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" })
|
||||
await expect(productCard).toBeVisible()
|
||||
|
||||
// 处理中的作品下载按钮应禁用
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" });
|
||||
await expect(downloadBtn).toBeVisible();
|
||||
const isDisabled = await downloadBtn.isDisabled();
|
||||
const downloadBtn = productCard.getByRole("button", { name: "下载" })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
const isDisabled = await downloadBtn.isDisabled()
|
||||
const hasDisabled = await downloadBtn.evaluate(
|
||||
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
|
||||
);
|
||||
expect(isDisabled || hasDisabled).toBeTruthy();
|
||||
});
|
||||
)
|
||||
expect(isDisabled || hasDisabled).toBeTruthy()
|
||||
})
|
||||
|
||||
// ─── 删除作品 ──────────────────────────────────────
|
||||
|
||||
test("删除作品 - API 调用正确", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-delete",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(1, ["completed"]);
|
||||
products[0].title = "待删除作品";
|
||||
let deleteCalled = false;
|
||||
let deletedId = "";
|
||||
const products = mockProducts(1, ["completed"])
|
||||
products[0].title = "待删除作品"
|
||||
let deleteCalled = false
|
||||
let deletedId = ""
|
||||
|
||||
await page.route("**/api/v1/products", (route) => {
|
||||
const method = route.request().method();
|
||||
const url = route.request().url();
|
||||
const method = route.request().method()
|
||||
const url = route.request().url()
|
||||
|
||||
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: products, total: products.length }),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/);
|
||||
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
|
||||
if (method === "DELETE" && detailMatch) {
|
||||
deleteCalled = true;
|
||||
deletedId = detailMatch[1];
|
||||
deleteCalled = true
|
||||
deletedId = detailMatch[1]
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "deleted" }),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (method === "GET" && detailMatch) {
|
||||
const productId = detailMatch[1];
|
||||
const product = products.find((p) => p.id === productId);
|
||||
const productId = detailMatch[1]
|
||||
const product = products.find((p) => p.id === productId)
|
||||
route.fulfill({
|
||||
status: product ? 200 : 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(product || { detail: "Not found" }),
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
route.continue();
|
||||
});
|
||||
route.continue()
|
||||
})
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const productCard = page
|
||||
.locator(".xx-product-card")
|
||||
.filter({ hasText: "待删除作品" });
|
||||
await expect(productCard).toBeVisible();
|
||||
const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" })
|
||||
await expect(productCard).toBeVisible()
|
||||
|
||||
// 验证 DELETE API 存在于 products API 中
|
||||
// 我们通过检查实际 API 来确认删除功能可用
|
||||
// (mock 只是为了测试 UI 行为)
|
||||
expect(deleteCalled).toBe(false); // 初始状态未调用
|
||||
expect(deletedId).toBe("");
|
||||
});
|
||||
expect(deleteCalled).toBe(false) // 初始状态未调用
|
||||
expect(deletedId).toBe("")
|
||||
})
|
||||
|
||||
test("删除作品 API 端点存在", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "products-del-api");
|
||||
const { headers } = await createAuthedUser(request, "products-del-api")
|
||||
|
||||
// 测试删除不存在的产品,验证 API 端点存在
|
||||
const resp = await request.delete(
|
||||
`${apiBase}/products/nonexistent-test-id`,
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
|
||||
headers,
|
||||
})
|
||||
|
||||
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
|
||||
// 404 表示资源不存在但端点存在
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405);
|
||||
expect([200, 204, 403, 404]).toContain(resp.status());
|
||||
});
|
||||
expect(resp.status(), "删除 API 端点应存在").not.toBe(405)
|
||||
expect([200, 204, 403, 404]).toContain(resp.status())
|
||||
})
|
||||
|
||||
// ─── 空状态 ────────────────────────────────────────
|
||||
|
||||
test("空状态展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-empty",
|
||||
);
|
||||
)
|
||||
|
||||
// Mock 空列表
|
||||
await mockProductsApi(page, []);
|
||||
await mockProductsApi(page, [])
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 空状态应显示
|
||||
await expect(page.locator(".xx-products-empty")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible();
|
||||
});
|
||||
})
|
||||
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 搜索筛选 ──────────────────────────────────────
|
||||
|
||||
test("作品搜索功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-search",
|
||||
);
|
||||
)
|
||||
|
||||
const products = [
|
||||
{
|
||||
@@ -610,44 +577,44 @@ test.describe("作品库页面", () => {
|
||||
title: "香蕉推广视频",
|
||||
id: `mock-prod-${Date.now()}-banana`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 两个作品都可见
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible();
|
||||
})
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible()
|
||||
|
||||
// 搜索"苹果"
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果");
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible();
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0);
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("苹果")
|
||||
await expect(page.getByText("苹果宣传视频")).toBeVisible()
|
||||
await expect(page.getByText("香蕉推广视频")).toHaveCount(0)
|
||||
|
||||
// 清空搜索
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("");
|
||||
await page.getByPlaceholder("搜索成片名称...").fill("")
|
||||
await expect(page.getByText("香蕉推广视频")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("作品状态筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-filter-status",
|
||||
);
|
||||
)
|
||||
|
||||
const products = [
|
||||
{
|
||||
@@ -660,94 +627,90 @@ test.describe("作品库页面", () => {
|
||||
title: "处理中筛选",
|
||||
id: `mock-prod-${Date.now()}-proc`,
|
||||
},
|
||||
];
|
||||
await mockProductsApi(page, products);
|
||||
]
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 两个都可见
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible();
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.getByText("处理中筛选")).toBeVisible()
|
||||
|
||||
// 状态筛选下拉存在
|
||||
const selects = page.locator(".xx-products-filters-left select");
|
||||
const count = await selects.count();
|
||||
const selects = page.locator(".xx-products-filters-left select")
|
||||
const count = await selects.count()
|
||||
if (count >= 2) {
|
||||
// 第2个 select 是状态筛选
|
||||
await selects.nth(1).selectOption({ label: "已完成" });
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible();
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0);
|
||||
await selects.nth(1).selectOption({ label: "已完成" })
|
||||
await expect(page.getByText("已完成筛选")).toBeVisible()
|
||||
await expect(page.getByText("处理中筛选")).toHaveCount(0)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// ─── 批量操作 ──────────────────────────────────────
|
||||
|
||||
test("批量选择和批量操作栏", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { userId, accessToken, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"products-batch",
|
||||
);
|
||||
)
|
||||
|
||||
const products = mockProducts(3, ["completed"]);
|
||||
products[0].title = "批量测试 1";
|
||||
products[1].title = "批量测试 2";
|
||||
products[2].title = "批量测试 3";
|
||||
await mockProductsApi(page, products);
|
||||
const products = mockProducts(3, ["completed"])
|
||||
products[0].title = "批量测试 1"
|
||||
products[1].title = "批量测试 2"
|
||||
products[2].title = "批量测试 3"
|
||||
await mockProductsApi(page, products)
|
||||
|
||||
await setupAuthInBrowser(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/products");
|
||||
await page.goto("/app/products")
|
||||
await expect(page.locator(".xx-products-grid")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 三张卡片
|
||||
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击第一张卡片的复选框
|
||||
const firstCard = page.locator(".xx-product-card").first();
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox");
|
||||
await expect(checkbox).toBeVisible();
|
||||
await checkbox.click();
|
||||
const firstCard = page.locator(".xx-product-card").first()
|
||||
const checkbox = firstCard.locator(".xx-product-card-checkbox")
|
||||
await expect(checkbox).toBeVisible()
|
||||
await checkbox.click()
|
||||
|
||||
// 批量操作栏应出现
|
||||
const batchBar = page.locator(".xx-products-batch-bar");
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 });
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible();
|
||||
const batchBar = page.locator(".xx-products-batch-bar")
|
||||
await expect(batchBar).toBeVisible({ timeout: 5_000 })
|
||||
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible()
|
||||
|
||||
// 批量按钮存在
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量下载" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
batchBar.getByRole("button", { name: "批量删除" }),
|
||||
).toBeVisible();
|
||||
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible()
|
||||
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible()
|
||||
|
||||
// 取消选择
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click();
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
await batchBar.getByRole("button", { name: "取消选择" }).click()
|
||||
await expect(batchBar).not.toBeVisible({ timeout: 3_000 })
|
||||
})
|
||||
|
||||
// ─── 未登录访问 ────────────────────────────────────
|
||||
|
||||
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/products");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/products")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
+157
-185
@@ -6,36 +6,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -48,30 +41,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -90,14 +83,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -111,210 +104,204 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("个人设置页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/profile");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/profile")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("设置页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("页面标题存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-title",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-title",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证页面包含"个人设置"标题
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ });
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
const heading = page.getByRole("heading", { name: /个人设置/ })
|
||||
await expect(heading.first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置 - 个人信息展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("个人信息卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-info",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-info")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-info",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证设置卡片存在
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".xx-settings-card")).toBeVisible()
|
||||
})
|
||||
|
||||
test("用户名、邮箱字段展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-fields",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-fields",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证表单字段存在
|
||||
const fields = page.locator(".xx-settings-field");
|
||||
await expect(fields.first()).toBeVisible();
|
||||
const fieldCount = await fields.count();
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const fields = page.locator(".xx-settings-field")
|
||||
await expect(fields.first()).toBeVisible()
|
||||
const fieldCount = await fields.count()
|
||||
expect(fieldCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("用户名标签和输入框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-username",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-username",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证用户名标签
|
||||
const usernameLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "用户名",
|
||||
});
|
||||
await expect(usernameLabel).toBeVisible();
|
||||
})
|
||||
await expect(usernameLabel).toBeVisible()
|
||||
|
||||
// 验证邮箱标签
|
||||
const emailLabel = page.locator(".xx-settings-label").filter({
|
||||
hasText: "邮箱",
|
||||
});
|
||||
await expect(emailLabel).toBeVisible();
|
||||
});
|
||||
})
|
||||
await expect(emailLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test("显示名称字段可编辑", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-dispname",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-dispname",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 查找显示名称输入框
|
||||
const displayNameField = page.locator(".xx-settings-field").filter({
|
||||
has: page.locator(".xx-settings-label", { hasText: "显示名称" }),
|
||||
});
|
||||
})
|
||||
if (await displayNameField.isVisible()) {
|
||||
const input = displayNameField.locator("input");
|
||||
const input = displayNameField.locator("input")
|
||||
if (await input.isVisible()) {
|
||||
// 验证输入框存在且可输入
|
||||
await expect(input).toBeVisible();
|
||||
const initialValue = await input.inputValue();
|
||||
await input.fill("新的显示名称");
|
||||
await expect(input).toHaveValue("新的显示名称");
|
||||
await expect(input).toBeVisible()
|
||||
const initialValue = await input.inputValue()
|
||||
await input.fill("新的显示名称")
|
||||
await expect(input).toHaveValue("新的显示名称")
|
||||
// 恢复原值
|
||||
await input.fill(initialValue);
|
||||
await input.fill(initialValue)
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置 - 修改密码", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("修改密码 API - 正向", async ({ request }) => {
|
||||
const { headers, email } = await createAuthedUser(request, "profile-chpwd");
|
||||
const { headers, email } = await createAuthedUser(request, "profile-chpwd")
|
||||
|
||||
const newPassword = "NewPass123456!";
|
||||
const newPassword = "NewPass123456!"
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
data: {
|
||||
old_password: PASSWORD,
|
||||
new_password: newPassword,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 修改密码可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`修改密码应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
// 如果成功,用新密码登录验证
|
||||
if (response.ok()) {
|
||||
const loginResp = await loginWithRetry(request, email, newPassword);
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy();
|
||||
const loginResp = await loginWithRetry(request, email, newPassword)
|
||||
expect(loginResp.ok(), "新密码应能登录").toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("修改密码 - 旧密码错误反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-badpwd");
|
||||
const { headers } = await createAuthedUser(request, "profile-badpwd")
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
@@ -322,17 +309,17 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: "WrongOldPass123!",
|
||||
new_password: "NewPass123456!",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 如果接口存在,应该返回 400/401
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 401]).toContain(response.status());
|
||||
expect([400, 401]).toContain(response.status())
|
||||
}
|
||||
// 接口不存在(404)也正常
|
||||
});
|
||||
})
|
||||
|
||||
test("修改密码 - 新密码太弱反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "profile-weakpwd");
|
||||
const { headers } = await createAuthedUser(request, "profile-weakpwd")
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
headers,
|
||||
@@ -340,12 +327,12 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: PASSWORD,
|
||||
new_password: "123",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (response.status() < 500 && response.status() >= 400) {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
expect([400, 422]).toContain(response.status())
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("未登录修改密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/change-password`, {
|
||||
@@ -353,130 +340,115 @@ test.describe("个人设置 - 修改密码", () => {
|
||||
old_password: "old",
|
||||
new_password: "new",
|
||||
},
|
||||
});
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
})
|
||||
expect([401, 403, 404]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置 - 账号安全", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const { headers, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-me",
|
||||
);
|
||||
const { headers, email, username } = await createAuthedUser(request, "profile-me")
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
const response = await request.get(`${apiBase}/auth/me`, { headers })
|
||||
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
|
||||
test("账号安全区域提示信息存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-security",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-security",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证通知区域存在
|
||||
const notice = page.locator(".xx-settings-notice");
|
||||
await expect(notice).toBeVisible();
|
||||
});
|
||||
});
|
||||
const notice = page.locator(".xx-settings-notice")
|
||||
await expect(notice).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置 - 退出登录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("登出 API - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout",
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "profile-logout")
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/logout`, {
|
||||
headers,
|
||||
});
|
||||
expect(
|
||||
response.ok(),
|
||||
`登出应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
})
|
||||
expect(response.ok(), `登出应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
// 登出后 token 应失效
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(meResp.status());
|
||||
});
|
||||
const meResp = await request.get(`${apiBase}/auth/me`, { headers })
|
||||
expect([401, 403]).toContain(meResp.status())
|
||||
})
|
||||
|
||||
test("登出后页面跳转登录页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-logout-ui",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-logout-ui",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 清除 localStorage 模拟登出
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("auth-storage");
|
||||
});
|
||||
localStorage.removeItem("access_token")
|
||||
localStorage.removeItem("auth-storage")
|
||||
})
|
||||
|
||||
// 刷新页面应该重定向到登录页
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
await page.reload()
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("个人设置 - 保存按钮", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("保存按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"profile-save",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "profile-save")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E profile-save",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/profile");
|
||||
await page.goto("/app/profile")
|
||||
await expect(page.locator(".xx-settings-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证按钮存在
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ });
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
const button = page.getByRole("button", { name: /保存|暂未开放/ })
|
||||
await expect(button.first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
|
||||
+121
-158
@@ -4,17 +4,17 @@
|
||||
* 覆盖:页面渲染、表单验证、成功注册、跳转链接
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
@@ -27,181 +27,154 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("注册页面", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
// ─── 页面渲染 ──────────────────────────────────────
|
||||
|
||||
test("页面正常渲染 - 标题、表单元素、提交按钮", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
// 品牌标识
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪");
|
||||
await expect(page.locator(".xx-auth-brand-name")).toHaveText("小虾智剪")
|
||||
|
||||
// 标题/描述
|
||||
await expect(
|
||||
page.getByText("创建账户,开启智能视频创作之旅"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("创建账户,开启智能视频创作之旅")).toBeVisible()
|
||||
|
||||
// 表单字段
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(page.getByLabel("用户名")).toBeVisible();
|
||||
await expect(page.getByLabel("密码")).toBeVisible();
|
||||
await expect(page.getByLabel("确认密码")).toBeVisible();
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
await expect(page.getByLabel("用户名")).toBeVisible()
|
||||
await expect(page.getByLabel("密码")).toBeVisible()
|
||||
await expect(page.getByLabel("确认密码")).toBeVisible()
|
||||
|
||||
// 提交按钮
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 表单验证 ──────────────────────────────────────
|
||||
|
||||
test("空提交 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
// 直接点击注册按钮
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
// 应显示必填错误
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible();
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
await expect(page.getByText("请输入密码")).toBeVisible();
|
||||
await expect(page.getByText("请确认密码")).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("请输入邮箱")).toBeVisible()
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible()
|
||||
await expect(page.getByText("请输入密码")).toBeVisible()
|
||||
await expect(page.getByText("请确认密码")).toBeVisible()
|
||||
})
|
||||
|
||||
test("无效邮箱格式 - 显示格式错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill("not-an-email");
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
await page.getByLabel("邮箱").fill("not-an-email")
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
// 应显示邮箱格式错误
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("请输入有效的邮箱地址")).toBeVisible()
|
||||
})
|
||||
|
||||
test("密码太短 - 显示长度错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill("123");
|
||||
await page.getByLabel("确认密码").fill("123");
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("short-pwd"))
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill("123")
|
||||
await page.getByLabel("确认密码").fill("123")
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
// 应显示密码长度错误
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("密码至少 8 个字符")).toBeVisible()
|
||||
})
|
||||
|
||||
test("确认密码不一致 - 显示不一致错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"));
|
||||
await page.getByLabel("用户名").fill("testuser");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill("Different123!");
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("pwd-mismatch"))
|
||||
await page.getByLabel("用户名").fill("testuser")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill("Different123!")
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
// 应显示密码不一致错误
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("两次输入的密码不一致")).toBeVisible()
|
||||
})
|
||||
|
||||
test("用户名为空 - 显示必填错误", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"));
|
||||
await page.getByLabel("用户名").fill("");
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
await page.getByLabel("邮箱").fill(uniqueEmail("empty-user"))
|
||||
await page.getByLabel("用户名").fill("")
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible();
|
||||
});
|
||||
await expect(page.getByText("请输入用户名")).toBeVisible()
|
||||
})
|
||||
|
||||
// ─── 成功注册 ──────────────────────────────────────
|
||||
|
||||
test("成功注册 - 提交有效表单", async ({ page, _request }) => {
|
||||
const email = uniqueEmail("reg-ui-ok");
|
||||
const username = uniqueUsername("reguiok");
|
||||
const email = uniqueEmail("reg-ui-ok")
|
||||
const username = uniqueUsername("reguiok")
|
||||
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
await page.getByLabel("邮箱").fill(email)
|
||||
await page.getByLabel("用户名").fill(username)
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
|
||||
// 监听注册请求
|
||||
const registerResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/auth/register") &&
|
||||
resp.request().method() === "POST",
|
||||
(resp) => resp.url().includes("/auth/register") && resp.request().method() === "POST",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
)
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
const resp = await registerResponse;
|
||||
expect(
|
||||
resp.ok(),
|
||||
`注册请求应返回 2xx,实际: ${resp.status()}`,
|
||||
).toBeTruthy();
|
||||
const resp = await registerResponse
|
||||
expect(resp.ok(), `注册请求应返回 2xx,实际: ${resp.status()}`).toBeTruthy()
|
||||
|
||||
// 注册成功后应跳转到登录页或显示成功消息
|
||||
// 页面应停留在可识别的状态(成功提示或跳转)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const url = page.url();
|
||||
const url = page.url()
|
||||
// 可能跳转到 login,也可能在当前页显示成功消息
|
||||
if (url.includes("/login")) return "redirected";
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible();
|
||||
return hasSuccess ? "success_msg" : url;
|
||||
if (url.includes("/login")) return "redirected"
|
||||
const hasSuccess = await page.getByText(/注册成功/).isVisible()
|
||||
return hasSuccess ? "success_msg" : url
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toMatch(/redirected|success_msg/);
|
||||
});
|
||||
.toMatch(/redirected|success_msg/)
|
||||
})
|
||||
|
||||
test("注册已存在邮箱 - UI 显示错误", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-ui-dup");
|
||||
const username1 = uniqueUsername("reguidup1");
|
||||
const username2 = uniqueUsername("reguidup2");
|
||||
const email = uniqueEmail("reg-ui-dup")
|
||||
const username1 = uniqueUsername("reguidup1")
|
||||
const username2 = uniqueUsername("reguidup2")
|
||||
|
||||
// 先通过 API 注册一个账号
|
||||
const firstReg = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -211,74 +184,66 @@ test.describe("注册页面", () => {
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
});
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy();
|
||||
})
|
||||
expect(firstReg.ok(), "第一次注册应成功").toBeTruthy()
|
||||
|
||||
// 再在 UI 上用相同邮箱注册
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByLabel("邮箱").fill(email);
|
||||
await page.getByLabel("用户名").fill(username2);
|
||||
await page.getByLabel("密码").fill(PASSWORD);
|
||||
await page.getByLabel("确认密码").fill(PASSWORD);
|
||||
await page.getByLabel("邮箱").fill(email)
|
||||
await page.getByLabel("用户名").fill(username2)
|
||||
await page.getByLabel("密码").fill(PASSWORD)
|
||||
await page.getByLabel("确认密码").fill(PASSWORD)
|
||||
|
||||
await page
|
||||
.locator("button[type='submit']")
|
||||
.filter({ hasText: "注册" })
|
||||
.click();
|
||||
await page.locator("button[type='submit']").filter({ hasText: "注册" }).click()
|
||||
|
||||
// 应显示错误提示(通过 antd message 或表单错误)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
// 检查是否有错误消息
|
||||
const hasError = await page
|
||||
.getByText(/注册失败|已注册|已存在|exists/)
|
||||
.isVisible();
|
||||
return hasError ? "error_shown" : "waiting";
|
||||
const hasError = await page.getByText(/注册失败|已注册|已存在|exists/).isVisible()
|
||||
return hasError ? "error_shown" : "waiting"
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe("error_shown");
|
||||
});
|
||||
.toBe("error_shown")
|
||||
})
|
||||
|
||||
// ─── 跳转链接 ──────────────────────────────────────
|
||||
|
||||
test("跳转到登录页的链接", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
await page.getByRole("link", { name: "立即登录" }).click();
|
||||
await page.getByRole("link", { name: "立即登录" }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
});
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
})
|
||||
|
||||
test("登录页有跳转到注册页的链接(反向验证)", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.goto("/login")
|
||||
|
||||
await page.getByRole("link", { name: "立即注册" }).click();
|
||||
await page.getByRole("link", { name: "立即注册" }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/register/);
|
||||
});
|
||||
await expect(page).toHaveURL(/\/register/)
|
||||
})
|
||||
|
||||
test("登录页有忘记密码链接", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.goto("/login")
|
||||
|
||||
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /忘记密码/ })).toBeVisible()
|
||||
|
||||
await page.getByRole("link", { name: /忘记密码/ }).click();
|
||||
await page.getByRole("link", { name: /忘记密码/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/\/forgot-password/);
|
||||
});
|
||||
await expect(page).toHaveURL(/\/forgot-password/)
|
||||
})
|
||||
|
||||
// ─── 路由守卫 - 已登录用户访问注册页 ──────────────
|
||||
|
||||
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const email = uniqueEmail("reg-auth");
|
||||
const username = uniqueUsername("regauth");
|
||||
test("已登录用户访问注册页 - 可正常访问(注册页无守卫)", async ({ page, request }) => {
|
||||
const email = uniqueEmail("reg-auth")
|
||||
const username = uniqueUsername("regauth")
|
||||
|
||||
// 注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -288,24 +253,24 @@ test.describe("注册页面", () => {
|
||||
username,
|
||||
display_name: "Reg Auth Test",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 登录
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), "登录应成功").toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), "登录应成功").toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
// 设置登录态
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token: loginData.access_token,
|
||||
@@ -319,15 +284,13 @@ test.describe("注册页面", () => {
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
await page.goto("/register");
|
||||
await page.goto("/register")
|
||||
|
||||
// 注册页对已登录用户也可访问(注册页是公开页面)
|
||||
// 验证页面正常渲染
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible();
|
||||
await expect(
|
||||
page.locator("button[type='submit']").filter({ hasText: "注册" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.getByLabel("邮箱")).toBeVisible()
|
||||
await expect(page.locator("button[type='submit']").filter({ hasText: "注册" })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,36 +9,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -51,30 +44,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -93,14 +86,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -114,138 +107,129 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("订阅套餐页 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("订阅套餐页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("套餐卡片网格展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cards",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cards")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cards",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证套餐卡片存在
|
||||
const planCards = page.locator(".xx-plan-card");
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 });
|
||||
const cardCount = await planCards.count();
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const planCards = page.locator(".xx-plan-card")
|
||||
await expect(planCards.first()).toBeVisible({ timeout: 10_000 })
|
||||
const cardCount = await planCards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("套餐卡片包含名称、价格、特性列表", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-cardinfo",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "sub-cardinfo")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-cardinfo",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-plan-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 });
|
||||
const firstCard = page.locator(".xx-plan-card").first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// 验证价格区域存在
|
||||
await expect(firstCard.locator(".xx-plan-price")).toBeVisible();
|
||||
await expect(firstCard.locator(".xx-plan-price")).toBeVisible()
|
||||
// 验证特性列表存在
|
||||
await expect(firstCard.locator(".xx-features")).toBeVisible();
|
||||
await expect(firstCard.locator(".xx-features")).toBeVisible()
|
||||
// 验证订阅按钮存在
|
||||
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible();
|
||||
});
|
||||
await expect(firstCard.locator(".xx-subscribe-btn")).toBeVisible()
|
||||
})
|
||||
|
||||
test("推荐套餐有特殊标识", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-recommended",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-recommended",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证有推荐标签
|
||||
const featuredCard = page.locator(".xx-plan-card.featured");
|
||||
const featuredCard = page.locator(".xx-plan-card.featured")
|
||||
if (await featuredCard.isVisible({ timeout: 5_000 })) {
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible();
|
||||
await expect(featuredCard.locator(".xx-badge")).toBeVisible()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅套餐页 - 升级交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("点击升级套餐按钮跳转升级页", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-btn",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-btn",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription");
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page.locator(".xx-plans-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击一个订阅按钮
|
||||
const subscribeBtn = page.locator(".xx-subscribe-btn").first();
|
||||
const subscribeBtn = page.locator(".xx-subscribe-btn").first()
|
||||
if (await subscribeBtn.isVisible({ timeout: 10_000 })) {
|
||||
await subscribeBtn.click();
|
||||
await subscribeBtn.click()
|
||||
// 可能跳转到升级页或打开支付弹窗
|
||||
const url = page.url();
|
||||
const url = page.url()
|
||||
// 验证页面有响应(跳转到支付或保持在订阅页但有弹窗)
|
||||
expect(
|
||||
url.includes("/subscription/upgrade") ||
|
||||
@@ -255,201 +239,180 @@ test.describe("订阅套餐页 - 升级交互", () => {
|
||||
.first()
|
||||
.isVisible()
|
||||
.catch(() => false)),
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("升级套餐升级页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-upgrade-page",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-upgrade-page",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription/upgrade");
|
||||
await page.goto("/app/subscription/upgrade")
|
||||
// 升级页面应该可访问(可能跳转到订阅页或显示升级内容)
|
||||
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
await expect(page).toHaveURL(/\/subscription/, { timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 账单列表页", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("账单页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-load",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await page.goto("/app/subscription/billing")
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("账单概览区域展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-billing-overview",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-billing-overview",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await page.goto("/app/subscription/billing")
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证概览区域存在
|
||||
const overview = page.locator(".xx-billing-overview");
|
||||
const overview = page.locator(".xx-billing-overview")
|
||||
if (await overview.isVisible({ timeout: 5_000 })) {
|
||||
await expect(overview).toBeVisible();
|
||||
await expect(overview).toBeVisible()
|
||||
// 验证套餐信息
|
||||
await expect(overview.locator(".xx-overview-item").first()).toBeVisible();
|
||||
await expect(overview.locator(".xx-overview-item").first()).toBeVisible()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("自动续费开关存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"sub-autorenew-ui",
|
||||
);
|
||||
)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E sub-autorenew-ui",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/subscription/billing");
|
||||
await page.goto("/app/subscription/billing")
|
||||
await expect(page.locator(".xx-billing-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证自动续费区域存在
|
||||
const autoRenew = page.locator(".xx-billing-auto-renew");
|
||||
const autoRenew = page.locator(".xx-billing-auto-renew")
|
||||
if (await autoRenew.isVisible({ timeout: 5_000 })) {
|
||||
await expect(autoRenew).toBeVisible();
|
||||
await expect(autoRenew).toBeVisible()
|
||||
// 验证开关组件存在
|
||||
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible();
|
||||
await expect(autoRenew.locator(".xx-toggle-switch")).toBeVisible()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("账单记录 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-bills-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-bills-api")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
{ headers },
|
||||
);
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`, { headers })
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取账单记录应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 自动续费切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("切换自动续费 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-api")
|
||||
|
||||
// 关闭自动续费
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
})
|
||||
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
})
|
||||
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
|
||||
})
|
||||
|
||||
test("切换自动续费 - 无效参数反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad");
|
||||
const { headers } = await createAuthedUser(request, "sub-toggle-bad")
|
||||
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 取消订阅", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("取消订阅 API - 免费用户反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel-api")
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
// 免费用户取消订阅可能返回错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy();
|
||||
const data = await response.json()
|
||||
expect(data.error?.message || data.detail || data.message).toBeTruthy()
|
||||
}
|
||||
// 如果成功了也没问题(某些实现可能允许)
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
expect(response.status() < 500).toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 套餐变更", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("升级到 Pro 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-upgrade-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-upgrade-api")
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
@@ -457,19 +420,16 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`升级套餐应成功: ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `升级套餐应成功: ${await response.text()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data).toBeTruthy()
|
||||
})
|
||||
|
||||
test("获取当前订阅信息 - 验证升级", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-current-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-current-api")
|
||||
|
||||
// 先升级
|
||||
await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -478,25 +438,22 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 获取当前订阅
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取订阅信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy()
|
||||
expect(data.status, "应返回 status").toBeTruthy()
|
||||
})
|
||||
|
||||
test("降级到 Standard 套餐 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade-api")
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -505,29 +462,26 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy();
|
||||
})
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功`).toBeTruthy()
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
expect(
|
||||
downgrade.status() < 500,
|
||||
`降级请求应返回 2xx 或 4xx,实际: ${downgrade.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test("切换到无效套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan-api")
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
@@ -535,82 +489,76 @@ test.describe("订阅 - 套餐变更", () => {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(response.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 支付流程", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("创建支付订单 - 正向 API", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-pay-api")
|
||||
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
headers,
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
// 创建支付订单可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建订单应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const data = await response.json()
|
||||
// 应返回订单 ID 或支付链接
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy();
|
||||
expect(data.order_id || data.payment_url || data).toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("未登录创建订单 - 反向", async ({ request }) => {
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/create-order`,
|
||||
{
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const response = await request.post(`${apiBase}/subscription/create-order`, {
|
||||
data: {
|
||||
plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
expect([401, 403, 404]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
})
|
||||
expect([401, 403, 404]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅 - 套餐列表 API", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("获取套餐列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-plans-api");
|
||||
const { headers } = await createAuthedUser(request, "sub-plans-api")
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/plans`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
// 套餐列表可能需要登录也可能公开
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items;
|
||||
const data = await response.json()
|
||||
const plans = Array.isArray(data) ? data : data.plans || data.items
|
||||
if (Array.isArray(plans)) {
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2);
|
||||
expect(plans.length).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
}
|
||||
// 如果需要登录也正常
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
expect(response.status() < 500).toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录获取套餐列表", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/plans`);
|
||||
const response = await request.get(`${apiBase}/subscription/plans`)
|
||||
// 套餐列表可能公开也可能需要登录
|
||||
expect(response.status() < 500).toBeTruthy();
|
||||
});
|
||||
});
|
||||
expect(response.status() < 500).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
+101
-133
@@ -3,17 +3,17 @@
|
||||
*
|
||||
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -26,74 +26,74 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Subscription route guard", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("redirects anonymous users to login", async ({ page }) => {
|
||||
await page.goto("/app/subscription");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/subscription")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅信息查看", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("获取当前订阅信息 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-info");
|
||||
const { headers } = await createAuthedUser(request, "sub-info")
|
||||
|
||||
const response = await request.get(`${apiBase}/subscription/current`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取订阅信息应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy();
|
||||
expect(data.status, "应返回 status").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.plan_id, "应返回 plan_id").toBeTruthy()
|
||||
expect(data.status, "应返回 status").toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录获取订阅信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/subscription/current`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
const response = await request.get(`${apiBase}/subscription/current`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅降级", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("Pro 用户降级到 Standard - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade");
|
||||
const { headers } = await createAuthedUser(request, "sub-downgrade")
|
||||
|
||||
// 先升级到 Pro
|
||||
const upgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -102,34 +102,28 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "pro",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
upgrade.ok(),
|
||||
`升级到 Pro 应成功: ${await upgrade.text()}`,
|
||||
).toBeTruthy();
|
||||
})
|
||||
expect(upgrade.ok(), `升级到 Pro 应成功: ${await upgrade.text()}`).toBeTruthy()
|
||||
|
||||
// 降级到 Standard
|
||||
const downgrade = await request.post(
|
||||
`${apiBase}/subscription/change-plan`,
|
||||
{
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
const downgrade = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
data: {
|
||||
target_plan_id: "standard",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
);
|
||||
})
|
||||
|
||||
// 降级应成功或返回提示信息(某些业务可能限制降级)
|
||||
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500);
|
||||
expect(downgrade.status(), "降级请求应返回 2xx 或 4xx").toBeLessThan(500)
|
||||
|
||||
const data = await downgrade.json();
|
||||
const data = await downgrade.json()
|
||||
// 成功或失败都应有明确响应
|
||||
expect(data).toBeTruthy();
|
||||
});
|
||||
expect(data).toBeTruthy()
|
||||
})
|
||||
|
||||
test("降级到相同套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-same");
|
||||
const { headers } = await createAuthedUser(request, "sub-same")
|
||||
|
||||
// 用户默认为 free,再次选择 free
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
@@ -138,19 +132,19 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "free",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 相同套餐应返回 200 + success=false,或者 400
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.success).toBe(false);
|
||||
const data = await response.json()
|
||||
expect(data.success).toBe(false)
|
||||
} else {
|
||||
expect([400, 422]).toContain(response.status());
|
||||
expect([400, 422]).toContain(response.status())
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("降级到无效套餐 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan");
|
||||
const { headers } = await createAuthedUser(request, "sub-badplan")
|
||||
|
||||
const response = await request.post(`${apiBase}/subscription/change-plan`, {
|
||||
headers,
|
||||
@@ -158,107 +152,81 @@ test.describe("订阅降级", () => {
|
||||
target_plan_id: "nonexistent_plan",
|
||||
billing_cycle: "monthly",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400);
|
||||
expect(response.status()).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
expect(response.status(), "无效套餐应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(response.status()).toBeLessThan(500)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("订阅过期处理", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("取消订阅 - 反向(免费用户)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel");
|
||||
const { headers } = await createAuthedUser(request, "sub-cancel")
|
||||
|
||||
// 免费用户取消订阅应返回错误
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
// 免费用户可能不需要取消,返回 400 或类似错误
|
||||
if (!response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(
|
||||
data.error?.message || data.detail || data.message,
|
||||
"应返回错误信息",
|
||||
).toBeTruthy();
|
||||
const data = await response.json()
|
||||
expect(data.error?.message || data.detail || data.message, "应返回错误信息").toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("未登录取消订阅 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
const response = await request.post(`${apiBase}/subscription/cancel`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("切换自动续费 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-autorenew");
|
||||
const { headers } = await createAuthedUser(request, "sub-autorenew")
|
||||
|
||||
// 关闭自动续费
|
||||
const disableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
disableResp.ok(),
|
||||
`关闭自动续费应成功: ${await disableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
const disableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: false },
|
||||
})
|
||||
expect(disableResp.ok(), `关闭自动续费应成功: ${await disableResp.text()}`).toBeTruthy()
|
||||
|
||||
// 重新开启自动续费
|
||||
const enableResp = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
},
|
||||
);
|
||||
expect(
|
||||
enableResp.ok(),
|
||||
`开启自动续费应成功: ${await enableResp.text()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
const enableResp = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: { enabled: true },
|
||||
})
|
||||
expect(enableResp.ok(), `开启自动续费应成功: ${await enableResp.text()}`).toBeTruthy()
|
||||
})
|
||||
|
||||
test("无效参数切换自动续费 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-autoren-bad");
|
||||
const { headers } = await createAuthedUser(request, "sub-autoren-bad")
|
||||
|
||||
// 缺少 enabled 字段
|
||||
const response = await request.post(
|
||||
`${apiBase}/subscription/toggle-auto-renew`,
|
||||
{
|
||||
headers,
|
||||
data: {},
|
||||
},
|
||||
);
|
||||
const response = await request.post(`${apiBase}/subscription/toggle-auto-renew`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("账单记录", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
test("获取账单记录 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "sub-bills");
|
||||
const { headers } = await createAuthedUser(request, "sub-bills")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
{ headers },
|
||||
);
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`, { headers })
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取账单记录应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取账单记录应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(Array.isArray(data), "账单记录应为数组").toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录获取账单记录 - 反向", async ({ request }) => {
|
||||
const response = await request.get(
|
||||
`${apiBase}/subscription/billing-records`,
|
||||
);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
const response = await request.get(`${apiBase}/subscription/billing-records`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
+206
-230
@@ -6,36 +6,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -48,30 +41,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -90,14 +83,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -111,95 +104,88 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("模板库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/templates");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/templates")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("模板库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("模板库头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-head",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-head")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-head",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证搜索框
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
const searchInput = page.locator(".xx-templates-search-input")
|
||||
await expect(searchInput).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test("分类切换按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cat",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-cat")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cat",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证分类按钮存在
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
|
||||
const count = await categoryBtns.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn")
|
||||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 })
|
||||
const count = await categoryBtns.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库 - 模板展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("模板卡片展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-cards");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-cards",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建一个模板
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
@@ -220,32 +206,34 @@ test.describe("模板库 - 模板展示", () => {
|
||||
tags: ["e2e", "展示"],
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-cards",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 等待模板卡片出现
|
||||
const cards = page.locator(".xx-template-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await cards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
const cards = page.locator(".xx-template-card")
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 })
|
||||
const count = await cards.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("模板卡片包含名称和类型", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-info");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-info",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -263,33 +251,35 @@ test.describe("模板库 - 模板展示", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-info",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
const firstCard = page.locator(".xx-template-card").first()
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证信息区域存在
|
||||
const info = firstCard.locator(".xx-template-info");
|
||||
await expect(info).toBeVisible();
|
||||
const info = firstCard.locator(".xx-template-info")
|
||||
await expect(info).toBeVisible()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("模板预览弹窗功能", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-preview");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-preview",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -308,78 +298,75 @@ test.describe("模板库 - 模板展示", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-preview",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 点击第一个模板卡片打开预览
|
||||
const firstCard = page.locator(".xx-template-card").first();
|
||||
const firstCard = page.locator(".xx-template-card").first()
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
await firstCard.click()
|
||||
// 预览弹窗应该出现
|
||||
const modal = page.locator(".xx-template-modal");
|
||||
const modal = page.locator(".xx-template-modal")
|
||||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modal).toBeVisible()
|
||||
// 验证预览内容存在
|
||||
await expect(
|
||||
modal.locator(".xx-template-modal-title-row"),
|
||||
).toBeVisible();
|
||||
await expect(modal.locator(".xx-template-modal-title-row")).toBeVisible()
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库 - 分类切换", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("切换分类筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-switch",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-switch")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-switch",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||||
const firstBtn = categoryBtns.first();
|
||||
const categoryBtns = page.locator(".xx-templates-cat-btn")
|
||||
const firstBtn = categoryBtns.first()
|
||||
|
||||
if (await firstBtn.isVisible({ timeout: 10_000 })) {
|
||||
await firstBtn.click();
|
||||
await firstBtn.click()
|
||||
// 验证按钮被选中
|
||||
await expect(firstBtn).toHaveClass(/active/);
|
||||
await expect(firstBtn).toHaveClass(/active/)
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库 - 搜索", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("搜索框可输入并筛选", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`;
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-search",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
const templateName = `E2E 搜索测试模板 ${suffix}`
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -397,35 +384,35 @@ test.describe("模板库 - 搜索", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-search",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/templates");
|
||||
await page.goto("/app/templates")
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const searchInput = page.locator(".xx-templates-search-input");
|
||||
const searchInput = page.locator(".xx-templates-search-input")
|
||||
if (await searchInput.isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.fill(suffix);
|
||||
await searchInput.fill(suffix)
|
||||
// 验证页面正常响应
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible();
|
||||
await expect(page.locator(".xx-templates-page")).toBeVisible()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("获取模板列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-list")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -442,23 +429,20 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取模板列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
const response = await request.get(`${apiBase}/templates`, { headers })
|
||||
expect(response.ok(), `获取模板列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.templates || [];
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
|
||||
expect(items.length).toBeGreaterThan(0);
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.templates || []
|
||||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy()
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "tpl-fav")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建模板
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
@@ -476,33 +460,26 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
const templateId = created.id;
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
const templateId = created.id
|
||||
|
||||
// 收藏
|
||||
const favResp = await request.post(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
const favResp = await request.post(`${apiBase}/templates/${templateId}/favorite`, { headers })
|
||||
// 收藏可能成功或接口不存在
|
||||
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||||
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy()
|
||||
|
||||
// 取消收藏
|
||||
const unfavResp = await request.delete(
|
||||
`${apiBase}/templates/${templateId}/favorite`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
unfavResp.status() < 500,
|
||||
"取消收藏请求应返回 2xx 或 4xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
const unfavResp = await request.delete(`${apiBase}/templates/${templateId}/favorite`, {
|
||||
headers,
|
||||
})
|
||||
expect(unfavResp.status() < 500, "取消收藏请求应返回 2xx 或 4xx").toBeTruthy()
|
||||
})
|
||||
|
||||
test("获取模板详情 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "tpl-api-detail")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -521,22 +498,22 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
|
||||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||||
headers,
|
||||
});
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(created.id);
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`);
|
||||
});
|
||||
})
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
|
||||
const detail = await detailResp.json()
|
||||
expect(detail.id).toBe(created.id)
|
||||
expect(detail.name).toBe(`详情测试 ${suffix}`)
|
||||
})
|
||||
|
||||
test("使用模板接口 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "tpl-use");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "tpl-use")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
const createResp = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -553,52 +530,51 @@ test.describe("模板库 - API 操作", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(createResp.ok()).toBeTruthy();
|
||||
const created = await createResp.json();
|
||||
})
|
||||
expect(createResp.ok()).toBeTruthy()
|
||||
const created = await createResp.json()
|
||||
|
||||
// 使用模板(生成)
|
||||
const genResp = await request.post(
|
||||
`${apiBase}/templates/${created.id}/generate`,
|
||||
{ headers, data: {} },
|
||||
);
|
||||
const genResp = await request.post(`${apiBase}/templates/${created.id}/generate`, {
|
||||
headers,
|
||||
data: {},
|
||||
})
|
||||
// 生成可能成功或返回业务错误
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
|
||||
});
|
||||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录获取模板列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/templates`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
const response = await request.get(`${apiBase}/templates`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("模板库 - 我的模板 Tab", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("我的模板页面可访问", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "tpl-my")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await page.goto("/app/my-templates")
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "tpl-my-data");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"tpl-my-data",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
@@ -616,22 +592,22 @@ test.describe("模板库 - 我的模板 Tab", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E tpl-my-data",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/my-templates");
|
||||
await page.goto("/app/my-templates")
|
||||
await expect(page.locator(".mt-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证卡片容器存在
|
||||
const cards = page.locator(".mt-card");
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
const cards = page.locator(".mt-card")
|
||||
await expect(cards.first()).toBeVisible({ timeout: 15_000 })
|
||||
})
|
||||
})
|
||||
|
||||
+86
-106
@@ -4,17 +4,17 @@
|
||||
* 覆盖:创建素材库、列出素材库、创建素材记录
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -27,37 +27,37 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建一个项目并返回 project id */
|
||||
@@ -69,23 +69,19 @@ async function createProject(
|
||||
const resp = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Asset Test Proj ${suffix}`, description: "E2E asset test" },
|
||||
});
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
test.describe("素材库流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("创建素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "lib-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -94,27 +90,23 @@ test.describe("素材库流程", () => {
|
||||
name: `视频素材库 ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回素材库 ID").toBeTruthy();
|
||||
expect(data.name).toContain("视频素材库");
|
||||
expect(data.kind).toBe("video");
|
||||
expect(data.project_id).toBe(projectId);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回素材库 ID").toBeTruthy()
|
||||
expect(data.name).toContain("视频素材库")
|
||||
expect(data.kind).toBe("video")
|
||||
expect(data.project_id).toBe(projectId)
|
||||
})
|
||||
|
||||
test("创建素材库 - 无效 kind 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-badkind");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "lib-badkind")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -123,14 +115,14 @@ test.describe("素材库流程", () => {
|
||||
name: "Bad Kind Library",
|
||||
kind: "invalid_kind",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// kind 有 pattern 校验 ^(video|voice|image)$,应返回 422
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("创建素材库 - 不存在的项目反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-nopj");
|
||||
const { headers } = await createAuthedUser(request, "lib-nopj")
|
||||
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
@@ -139,18 +131,14 @@ test.describe("素材库流程", () => {
|
||||
name: "Orphan Library",
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("列出素材库", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "lib-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "lib-list")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
// 创建 2 个不同类型的素材库
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -160,7 +148,7 @@ test.describe("素材库流程", () => {
|
||||
name: `Video Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
})
|
||||
await request.post(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -168,35 +156,31 @@ test.describe("素材库流程", () => {
|
||||
name: `Image Lib ${Date.now()}`,
|
||||
kind: "image",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 列出(按 project_id 过滤)
|
||||
const response = await request.get(`${apiBase}/asset-libraries`, {
|
||||
headers,
|
||||
params: { project_id: projectId },
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材库应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2)
|
||||
|
||||
const kinds = items.map((i: { kind: string }) => i.kind);
|
||||
expect(kinds).toContain("video");
|
||||
expect(kinds).toContain("image");
|
||||
});
|
||||
const kinds = items.map((i: { kind: string }) => i.kind)
|
||||
expect(kinds).toContain("video")
|
||||
expect(kinds).toContain("image")
|
||||
})
|
||||
|
||||
test("创建素材记录", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-create")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -206,9 +190,9 @@ test.describe("素材库流程", () => {
|
||||
name: `Asset Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(lib.ok()).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
})
|
||||
expect(lib.ok()).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 创建素材记录
|
||||
const response = await request.post(`${apiBase}/assets`, {
|
||||
@@ -224,27 +208,23 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy();
|
||||
expect(data.name).toContain("test_video");
|
||||
expect(data.mime_type).toBe("video/mp4");
|
||||
expect(data.library_id).toBe(libData.id);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回素材 ID").toBeTruthy()
|
||||
expect(data.name).toContain("test_video")
|
||||
expect(data.mime_type).toBe("video/mp4")
|
||||
expect(data.library_id).toBe(libData.id)
|
||||
})
|
||||
|
||||
test("列出素材", async ({ request }) => {
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-list");
|
||||
const projectId = await createProject(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(),
|
||||
);
|
||||
const { headers, userId } = await createAuthedUser(request, "asset-list")
|
||||
const projectId = await createProject(request, headers, Date.now().toString())
|
||||
|
||||
// 创建素材库
|
||||
const lib = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -254,9 +234,9 @@ test.describe("素材库流程", () => {
|
||||
name: `List Lib ${Date.now()}`,
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy();
|
||||
const libData = await lib.json();
|
||||
})
|
||||
expect(lib.ok(), `创建素材库应成功: ${await lib.text()}`).toBeTruthy()
|
||||
const libData = await lib.json()
|
||||
|
||||
// 创建 2 个素材
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
@@ -270,7 +250,7 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
})
|
||||
await request.post(`${apiBase}/assets`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -282,23 +262,23 @@ test.describe("素材库流程", () => {
|
||||
status: "ready",
|
||||
uploaded_by_user_id: userId,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 列出素材
|
||||
const response = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libData.id },
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出素材应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || []
|
||||
expect(items.length, "应至少有 2 个素材").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("未登录创建素材库 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/asset-libraries`, {
|
||||
@@ -307,8 +287,8 @@ test.describe("素材库流程", () => {
|
||||
name: "Unauthorized Library",
|
||||
kind: "video",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
+113
-127
@@ -4,29 +4,29 @@
|
||||
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 从错误响应中提取错误消息文本,兼容新老格式 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractErrorMessage(body: any): string {
|
||||
if (!body) return "";
|
||||
if (!body) return ""
|
||||
// 新格式: { error: { code: "...", message: "..." } }
|
||||
if (body.error && typeof body.error === "object" && body.error.message) {
|
||||
return String(body.error.message);
|
||||
return String(body.error.message)
|
||||
}
|
||||
// 老格式: { detail: "..." } 或 { message: "..." } 或 { error: "..." }
|
||||
return String(body.detail || body.message || body.error || "");
|
||||
return String(body.detail || body.message || body.error || "")
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
@@ -39,27 +39,27 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
// 被限流了,等窗口重置
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
// 最后一次直接返回
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
test.describe("认证流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
// ─── 注册 ────────────────────────────────────────────
|
||||
|
||||
test("注册新用户 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-ok");
|
||||
const username = uniqueUsername("regok");
|
||||
const email = uniqueEmail("reg-ok")
|
||||
const username = uniqueUsername("regok")
|
||||
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
data: {
|
||||
@@ -68,23 +68,23 @@ test.describe("认证流程", () => {
|
||||
username,
|
||||
display_name: "E2E 注册测试",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`注册应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.user_id, "应返回 user_id").toBeTruthy();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.user_id, "应返回 user_id").toBeTruthy()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
|
||||
test("注册已存在邮箱 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("reg-dup");
|
||||
const username1 = uniqueUsername("regdup1");
|
||||
const username2 = uniqueUsername("regdup2");
|
||||
const email = uniqueEmail("reg-dup")
|
||||
const username1 = uniqueUsername("regdup1")
|
||||
const username2 = uniqueUsername("regdup2")
|
||||
|
||||
// 第一次注册
|
||||
const first = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -94,8 +94,8 @@ test.describe("认证流程", () => {
|
||||
username: username1,
|
||||
display_name: "User 1",
|
||||
},
|
||||
});
|
||||
expect(first.ok(), "第一次注册应成功").toBeTruthy();
|
||||
})
|
||||
expect(first.ok(), "第一次注册应成功").toBeTruthy()
|
||||
|
||||
// 第二次使用相同邮箱
|
||||
const second = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -105,24 +105,22 @@ test.describe("认证流程", () => {
|
||||
username: username2,
|
||||
display_name: "User 2",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(
|
||||
400,
|
||||
);
|
||||
expect(second.status()).toBeLessThan(500);
|
||||
expect(second.status(), "重复邮箱注册应返回 4xx").toBeGreaterThanOrEqual(400)
|
||||
expect(second.status()).toBeLessThan(500)
|
||||
|
||||
const body = await second.json();
|
||||
const body = await second.json()
|
||||
// 错误信息应包含"已注册"或"exists"相关提示
|
||||
const detail = extractErrorMessage(body).toLowerCase();
|
||||
const detail = extractErrorMessage(body).toLowerCase()
|
||||
expect(
|
||||
detail.includes("已") ||
|
||||
detail.includes("exist") ||
|
||||
detail.includes("registered") ||
|
||||
detail.includes("duplicate"),
|
||||
`错误信息应提示邮箱已注册,实际: "${detail}"`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test("注册无效邮箱格式 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -132,11 +130,11 @@ test.describe("认证流程", () => {
|
||||
username: uniqueUsername("bademail"),
|
||||
display_name: "Bad Email",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 422 是 FastAPI 参数校验失败的标准状态码
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("注册弱密码 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -146,10 +144,10 @@ test.describe("认证流程", () => {
|
||||
username: uniqueUsername("weakpwd"),
|
||||
display_name: "Weak",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("注册用户名为空 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -159,12 +157,10 @@ test.describe("认证流程", () => {
|
||||
username: "",
|
||||
display_name: "Empty Username",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
expect([400, 422], "用户名为空应返回 4xx 校验错误").toContain(response.status())
|
||||
})
|
||||
|
||||
test("注册缺用户名字段 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -173,76 +169,72 @@ test.describe("认证流程", () => {
|
||||
password: PASSWORD,
|
||||
display_name: "No Username Field",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(
|
||||
response.status(),
|
||||
);
|
||||
});
|
||||
expect([400, 422], "缺少用户名字段应返回 4xx 校验错误").toContain(response.status())
|
||||
})
|
||||
|
||||
// ─── 登录 ────────────────────────────────────────────
|
||||
|
||||
test("登录成功 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-ok");
|
||||
const username = uniqueUsername("loginok");
|
||||
const email = uniqueEmail("login-ok")
|
||||
const username = uniqueUsername("loginok")
|
||||
|
||||
// 先注册
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Login Test" },
|
||||
});
|
||||
expect(reg.ok(), "注册应成功").toBeTruthy();
|
||||
})
|
||||
expect(reg.ok(), "注册应成功").toBeTruthy()
|
||||
|
||||
// 登录(带限流重试)
|
||||
const response = await loginWithRetry(request, email, PASSWORD);
|
||||
const response = await loginWithRetry(request, email, PASSWORD)
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`登录应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.access_token, "应返回 access_token").toBeTruthy();
|
||||
expect(data.token_type).toBe("bearer");
|
||||
expect(data.email).toBe(email);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.access_token, "应返回 access_token").toBeTruthy()
|
||||
expect(data.token_type).toBe("bearer")
|
||||
expect(data.email).toBe(email)
|
||||
})
|
||||
|
||||
test("登录错误密码 - 反向", async ({ request }) => {
|
||||
const email = uniqueEmail("login-bad");
|
||||
const username = uniqueUsername("loginbad");
|
||||
const email = uniqueEmail("login-bad")
|
||||
const username = uniqueUsername("loginbad")
|
||||
|
||||
// 先注册
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Bad Login" },
|
||||
});
|
||||
})
|
||||
|
||||
// 使用错误密码登录(带限流重试)
|
||||
const response = await loginWithRetry(request, email, "WrongPassword999!");
|
||||
const response = await loginWithRetry(request, email, "WrongPassword999!")
|
||||
|
||||
expect(response.status(), "错误密码应返回 401").toBe(401);
|
||||
});
|
||||
expect(response.status(), "错误密码应返回 401").toBe(401)
|
||||
})
|
||||
|
||||
test("登录不存在的邮箱 - 反向", async ({ request }) => {
|
||||
// 带限流重试的反向登录测试
|
||||
let response;
|
||||
let response
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email: `ghost_${Date.now()}@nonexist.com`, password: PASSWORD },
|
||||
});
|
||||
if (response.status() !== 429) break;
|
||||
console.log(
|
||||
`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`,
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 65_000));
|
||||
})
|
||||
if (response.status() !== 429) break
|
||||
console.log(`[反向登录测试] 触发限流,等待 65s 后重试 (${attempt + 1}/2)`)
|
||||
await new Promise((r) => setTimeout(r, 65_000))
|
||||
}
|
||||
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401);
|
||||
});
|
||||
expect(response.status(), "不存在的用户应返回 401").toBe(401)
|
||||
})
|
||||
|
||||
// ─── 登出 ────────────────────────────────────────────
|
||||
|
||||
test("登出成功", async ({ request }) => {
|
||||
const email = uniqueEmail("logout");
|
||||
const username = uniqueUsername("logout");
|
||||
const email = uniqueEmail("logout")
|
||||
const username = uniqueUsername("logout")
|
||||
|
||||
// 注册 & 登录
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
@@ -252,93 +244,87 @@ test.describe("认证流程", () => {
|
||||
username,
|
||||
display_name: "Logout Test",
|
||||
},
|
||||
});
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
const { access_token } = await login.json();
|
||||
const headers = { Authorization: `Bearer ${access_token}` };
|
||||
})
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
const { access_token } = await login.json()
|
||||
const headers = { Authorization: `Bearer ${access_token}` }
|
||||
|
||||
// 登出
|
||||
const logout = await request.post(`${apiBase}/auth/logout`, { headers });
|
||||
expect(
|
||||
logout.ok(),
|
||||
`登出应返回 2xx,实际: ${logout.status()}`,
|
||||
).toBeTruthy();
|
||||
const logout = await request.post(`${apiBase}/auth/logout`, { headers })
|
||||
expect(logout.ok(), `登出应返回 2xx,实际: ${logout.status()}`).toBeTruthy()
|
||||
|
||||
const body = await logout.json();
|
||||
expect(body.message).toBeTruthy();
|
||||
const body = await logout.json()
|
||||
expect(body.message).toBeTruthy()
|
||||
|
||||
// 登出后 token 应失效,尝试访问 /auth/me
|
||||
const me = await request.get(`${apiBase}/auth/me`, { headers });
|
||||
expect([401, 403]).toContain(me.status());
|
||||
});
|
||||
const me = await request.get(`${apiBase}/auth/me`, { headers })
|
||||
expect([401, 403]).toContain(me.status())
|
||||
})
|
||||
|
||||
// ─── 获取当前用户信息 ─────────────────────────────────
|
||||
|
||||
test("获取当前用户信息 - 正向", async ({ request }) => {
|
||||
const email = uniqueEmail("me-ok");
|
||||
const username = uniqueUsername("meok");
|
||||
const email = uniqueEmail("me-ok")
|
||||
const username = uniqueUsername("meok")
|
||||
|
||||
await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: "Me Test" },
|
||||
});
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
const { access_token } = await login.json();
|
||||
})
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
const { access_token } = await login.json()
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${access_token}` },
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取用户信息应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取用户信息应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.user_id).toBeTruthy();
|
||||
expect(data.email).toBe(email);
|
||||
expect(data.username).toBe(username);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.user_id).toBeTruthy()
|
||||
expect(data.email).toBe(email)
|
||||
expect(data.username).toBe(username)
|
||||
})
|
||||
|
||||
test("无 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`);
|
||||
const response = await request.get(`${apiBase}/auth/me`)
|
||||
// HTTPBearer 无凭证返回 403
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("无效 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer invalid.token.here" },
|
||||
});
|
||||
expect(response.status()).toBe(401);
|
||||
});
|
||||
})
|
||||
expect(response.status()).toBe(401)
|
||||
})
|
||||
|
||||
test("过期 token 获取用户信息 - 反向", async ({ request }) => {
|
||||
// 使用一个伪造的过期 JWT(header.payload.signature)
|
||||
// eyJhbGciOiJIUzI1NiJ9 = {"alg":"HS256"}
|
||||
// eyJleHAiOjF9 = {"exp":1} (1970-01-01 过期)
|
||||
const expiredToken =
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature";
|
||||
"eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjEsInN1YiI6InRlc3QtdXNlciJ9.expired_signature"
|
||||
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${expiredToken}` },
|
||||
});
|
||||
})
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("token 格式错误 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer not-a-jwt" },
|
||||
});
|
||||
})
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("空 Bearer token - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/auth/me`, {
|
||||
headers: { Authorization: "Bearer " },
|
||||
});
|
||||
})
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
* 覆盖:创建项目、列出项目、获取项目详情
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -27,46 +27,46 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
email,
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("项目流程", () => {
|
||||
// 登录限流 10次/60s,测试可能触发限流等待,给足够超时
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
test.describe.configure({ timeout: 180_000 })
|
||||
|
||||
test("创建项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-create");
|
||||
const projectName = `E2E 测试项目 ${Date.now()}`;
|
||||
const { headers } = await createAuthedUser(request, "proj-create")
|
||||
const projectName = `E2E 测试项目 ${Date.now()}`
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
@@ -74,156 +74,146 @@ test.describe("项目流程", () => {
|
||||
name: projectName,
|
||||
description: "Playwright E2E 回归测试创建",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回项目 ID").toBeTruthy();
|
||||
expect(data.name).toBe(projectName);
|
||||
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回项目 ID").toBeTruthy()
|
||||
expect(data.name).toBe(projectName)
|
||||
expect(data.owner_user_id, "应返回所有者 ID").toBeTruthy()
|
||||
})
|
||||
|
||||
test("创建项目名称为空 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-empty");
|
||||
const { headers } = await createAuthedUser(request, "proj-empty")
|
||||
|
||||
const response = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: "", description: "Should fail" },
|
||||
});
|
||||
})
|
||||
|
||||
// name 有 min_length=1 约束,应返回 422
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("列出项目", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-list");
|
||||
const { headers } = await createAuthedUser(request, "proj-list")
|
||||
|
||||
// 先创建 2 个项目
|
||||
await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `List Proj A ${Date.now()}` },
|
||||
});
|
||||
})
|
||||
await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `List Proj B ${Date.now()}` },
|
||||
});
|
||||
})
|
||||
|
||||
// 列出
|
||||
const response = await request.get(`${apiBase}/projects`, { headers });
|
||||
const response = await request.get(`${apiBase}/projects`, { headers })
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`列出项目应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.projects || data || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.projects || data || []
|
||||
expect(Array.isArray(items)).toBeTruthy()
|
||||
expect(items.length, "应至少有 2 个项目").toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("获取项目详情", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-detail");
|
||||
const { headers } = await createAuthedUser(request, "proj-detail")
|
||||
|
||||
// 先创建
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers,
|
||||
data: { name: `Detail Proj ${Date.now()}`, description: "Detail test" },
|
||||
});
|
||||
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
})
|
||||
expect(created.ok(), `创建应成功: ${await created.text()}`).toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
|
||||
// 获取详情
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取详情应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id).toBe(projectId);
|
||||
expect(data.name).toBeTruthy();
|
||||
expect(data.owner_user_id).toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id).toBe(projectId)
|
||||
expect(data.name).toBeTruthy()
|
||||
expect(data.owner_user_id).toBeTruthy()
|
||||
})
|
||||
|
||||
test("获取不存在的项目 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-404");
|
||||
const { headers } = await createAuthedUser(request, "proj-404")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/projects/nonexistent-project-id-999`,
|
||||
{ headers },
|
||||
);
|
||||
const response = await request.get(`${apiBase}/projects/nonexistent-project-id-999`, {
|
||||
headers,
|
||||
})
|
||||
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404);
|
||||
});
|
||||
expect(response.status(), "不存在的项目应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("未登录列出项目 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/projects`);
|
||||
const response = await request.get(`${apiBase}/projects`)
|
||||
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("未授权访问他人项目 - 反向", async ({ request }) => {
|
||||
// 用户 A 创建项目
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-owner");
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-owner")
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers: headersA,
|
||||
data: { name: `Owner Proj ${Date.now()}`, description: "Owner test" },
|
||||
});
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
})
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
|
||||
// 用户 B 尝试访问用户 A 的项目
|
||||
const { headers: headersB } = await createAuthedUser(
|
||||
request,
|
||||
"proj-intruder",
|
||||
);
|
||||
const { headers: headersB } = await createAuthedUser(request, "proj-intruder")
|
||||
const response = await request.get(`${apiBase}/projects/${projectId}`, {
|
||||
headers: headersB,
|
||||
});
|
||||
})
|
||||
|
||||
// 应返回 403 (Forbidden) 或 404 (Not Found) — 不应泄露资源存在性
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
expect([403, 404]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("未授权删除他人项目 - 反向", async ({ request }) => {
|
||||
// 用户 A 创建项目
|
||||
const { headers: headersA } = await createAuthedUser(
|
||||
request,
|
||||
"proj-del-owner",
|
||||
);
|
||||
const { headers: headersA } = await createAuthedUser(request, "proj-del-owner")
|
||||
const created = await request.post(`${apiBase}/projects`, {
|
||||
headers: headersA,
|
||||
data: { name: `Delete Test Proj ${Date.now()}` },
|
||||
});
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy();
|
||||
const { id: projectId } = await created.json();
|
||||
})
|
||||
expect(created.ok(), "用户 A 创建项目应成功").toBeTruthy()
|
||||
const { id: projectId } = await created.json()
|
||||
|
||||
// 用户 B 尝试删除用户 A 的项目
|
||||
const { headers: headersB } = await createAuthedUser(
|
||||
request,
|
||||
"proj-del-attempt",
|
||||
);
|
||||
const { headers: headersB } = await createAuthedUser(request, "proj-del-attempt")
|
||||
const response = await request.delete(`${apiBase}/projects/${projectId}`, {
|
||||
headers: headersB,
|
||||
});
|
||||
})
|
||||
|
||||
expect([403, 404]).toContain(response.status());
|
||||
});
|
||||
expect([403, 404]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("使用无效项目 ID 获取详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "proj-badid");
|
||||
const { headers } = await createAuthedUser(request, "proj-badid")
|
||||
|
||||
const response = await request.get(`${apiBase}/projects/`, { headers });
|
||||
const response = await request.get(`${apiBase}/projects/`, { headers })
|
||||
|
||||
// 空 ID 或无效格式应返回 404 或 422
|
||||
expect([400, 404, 422]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
expect([400, 404, 422]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
+191
-215
@@ -9,36 +9,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -51,30 +44,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -82,7 +75,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -93,14 +86,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -114,7 +107,7 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/** 创建一个标题并返回 id */
|
||||
@@ -133,77 +126,76 @@ async function createTitle(
|
||||
tags: ["e2e", "test"],
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy();
|
||||
const data = await resp.json();
|
||||
return data.id;
|
||||
})
|
||||
expect(resp.ok(), `创建标题应成功: ${await resp.text()}`).toBeTruthy()
|
||||
const data = await resp.json()
|
||||
return data.id
|
||||
}
|
||||
|
||||
test.describe("标题库 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("新用户标题页面显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"title-empty",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "title-empty")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-empty",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 新用户应该能看到页面主体
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("标题库 - 搜索功能", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-search");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-search",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await createTitle(request, headers, suffix)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-search",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 查找搜索框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索标题关键词'], input[placeholder*='搜索']",
|
||||
);
|
||||
)
|
||||
if (await searchInput.first().isVisible({ timeout: 10_000 })) {
|
||||
await searchInput.first().fill("测试搜索");
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索");
|
||||
await searchInput.first().fill("测试搜索")
|
||||
await expect(searchInput.first()).toHaveValue("测试搜索")
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("标题库 - API 完整操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("创建标题 - 完整参数", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-create-full");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "title-create-full")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -214,29 +206,25 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
tags: ["e2e", "完整测试", "种草"],
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`创建标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy();
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`);
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`);
|
||||
});
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回标题 ID").toBeTruthy()
|
||||
expect(data.name).toBe(`完整参数测试 ${suffix}`)
|
||||
expect(data.text).toBe(`这是一个完整参数的标题测试 ${suffix}`)
|
||||
})
|
||||
|
||||
test("编辑标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "title-update")
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36))
|
||||
|
||||
const newName = `更新后的标题 ${Date.now()}`;
|
||||
const newText = "这是更新后的标题内容";
|
||||
const newName = `更新后的标题 ${Date.now()}`
|
||||
const newText = "这是更新后的标题内容"
|
||||
const response = await request.patch(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -244,52 +232,48 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: newText,
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`更新标题应返回 2xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.name).toBe(newName);
|
||||
const data = await response.json()
|
||||
expect(data.name).toBe(newName)
|
||||
|
||||
// 验证更新
|
||||
const verify = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
const verifyData = await verify.json();
|
||||
expect(verifyData.name).toBe(newName);
|
||||
expect(verifyData.text).toBe(newText);
|
||||
});
|
||||
})
|
||||
const verifyData = await verify.json()
|
||||
expect(verifyData.name).toBe(newName)
|
||||
expect(verifyData.text).toBe(newText)
|
||||
})
|
||||
|
||||
test("删除标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-delete");
|
||||
const titleId = await createTitle(
|
||||
request,
|
||||
headers,
|
||||
Date.now().toString(36),
|
||||
);
|
||||
const { headers } = await createAuthedUser(request, "title-delete")
|
||||
const titleId = await createTitle(request, headers, Date.now().toString(36))
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/titles/${titleId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
});
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
})
|
||||
|
||||
test("批量导入标题 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-batch");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "title-batch")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
const titles = [
|
||||
{
|
||||
@@ -307,29 +291,27 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: `内容 3 ${suffix}`,
|
||||
category: "知识",
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const response = await request.post(`${apiBase}/titles/batch-import`, {
|
||||
headers,
|
||||
data: { titles },
|
||||
});
|
||||
})
|
||||
|
||||
// 批量导入可能成功或接口不存在
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`批量导入应返回 2xx 或 4xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(
|
||||
Array.isArray(data) || data.success_count !== undefined,
|
||||
).toBeTruthy();
|
||||
const data = await response.json()
|
||||
expect(Array.isArray(data) || data.success_count !== undefined).toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("创建标题 - 名称为空反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name");
|
||||
const { headers } = await createAuthedUser(request, "title-empty-name")
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -338,13 +320,13 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: "有内容但名称为空",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("创建标题 - 缺少必要字段反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-missing");
|
||||
const { headers } = await createAuthedUser(request, "title-missing")
|
||||
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
@@ -352,46 +334,37 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
name: "缺少 text 字段",
|
||||
// 缺少 text 字段
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
expect([400, 422]).toContain(response.status());
|
||||
});
|
||||
expect([400, 422]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("获取不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-404");
|
||||
const { headers } = await createAuthedUser(request, "title-404")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
const response = await request.get(`${apiBase}/titles/nonexistent-title-999`, { headers })
|
||||
expect(response.status(), "不存在的标题应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("更新不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-update-404");
|
||||
const { headers } = await createAuthedUser(request, "title-update-404")
|
||||
|
||||
const response = await request.patch(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
},
|
||||
);
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404);
|
||||
});
|
||||
const response = await request.patch(`${apiBase}/titles/nonexistent-title-999`, {
|
||||
headers,
|
||||
data: { name: "不存在的标题", text: "测试" },
|
||||
})
|
||||
expect(response.status(), "更新不存在的标题应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("删除不存在的标题 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-del-404");
|
||||
const { headers } = await createAuthedUser(request, "title-del-404")
|
||||
|
||||
const response = await request.delete(
|
||||
`${apiBase}/titles/nonexistent-title-999`,
|
||||
{ headers },
|
||||
);
|
||||
const response = await request.delete(`${apiBase}/titles/nonexistent-title-999`, { headers })
|
||||
expect(
|
||||
[404, 200, 204].includes(response.status()),
|
||||
"删除不存在的标题应返回 404 或幂等 2xx",
|
||||
).toBeTruthy();
|
||||
});
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
test("未登录创建标题 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/titles`, {
|
||||
@@ -400,39 +373,39 @@ test.describe("标题库 - API 完整操作", () => {
|
||||
text: "未登录创建标题",
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("未登录删除标题 - 反向", async ({ request }) => {
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
const response = await request.delete(`${apiBase}/titles/some-id`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("标题库 - 分类/标签筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("标题分类 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-cat");
|
||||
const { headers } = await createAuthedUser(request, "title-cat")
|
||||
|
||||
// 获取标题列表,检查分类字段
|
||||
const response = await request.get(`${apiBase}/titles`, { headers });
|
||||
expect(response.ok()).toBeTruthy();
|
||||
const response = await request.get(`${apiBase}/titles`, { headers })
|
||||
expect(response.ok()).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.titles || [];
|
||||
expect(Array.isArray(items)).toBeTruthy();
|
||||
const data = await response.json()
|
||||
const items = data.items || data.titles || []
|
||||
expect(Array.isArray(items)).toBeTruthy()
|
||||
|
||||
// 如果有标题,验证有分类字段
|
||||
if (items.length > 0) {
|
||||
expect(items[0].category !== undefined).toBeTruthy();
|
||||
expect(items[0].category !== undefined).toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("按分类筛选标题", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "title-filter-cat")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建不同分类的标题
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
@@ -442,7 +415,7 @@ test.describe("标题库 - 分类/标签筛选", () => {
|
||||
text: "种草内容",
|
||||
category: "种草",
|
||||
},
|
||||
});
|
||||
})
|
||||
await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
@@ -450,115 +423,118 @@ test.describe("标题库 - 分类/标签筛选", () => {
|
||||
text: "知识内容",
|
||||
category: "知识",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 按分类筛选
|
||||
const response = await request.get(`${apiBase}/titles`, {
|
||||
headers,
|
||||
params: { category: "种草" },
|
||||
});
|
||||
})
|
||||
|
||||
// 筛选可能支持也可能不支持
|
||||
expect(
|
||||
response.ok(),
|
||||
`筛选请求应成功,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
expect(response.ok(), `筛选请求应成功,实际: ${response.status()}`).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("标题库 - 页面交互", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("标题卡片展示完整信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-card");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-card",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await createTitle(request, headers, suffix)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-card",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
const firstCard = page.locator(".xx-title-card").first()
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证标题文本
|
||||
const titleText = firstCard.locator(".xx-title-card-text");
|
||||
const titleText = firstCard.locator(".xx-title-card-text")
|
||||
if (await titleText.isVisible()) {
|
||||
await expect(titleText).toBeVisible();
|
||||
await expect(titleText).toBeVisible()
|
||||
}
|
||||
// 验证统计信息
|
||||
const titleStat = firstCard.locator(".xx-title-card-stat");
|
||||
const titleStat = firstCard.locator(".xx-title-card-stat")
|
||||
if (await titleStat.isVisible()) {
|
||||
await expect(titleStat).toBeVisible();
|
||||
await expect(titleStat).toBeVisible()
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("标题卡片可点击查看详情", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-detail");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-detail",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
await createTitle(request, headers, suffix);
|
||||
await createTitle(request, headers, suffix)
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-detail",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-title-card").first();
|
||||
const firstCard = page.locator(".xx-title-card").first()
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
await firstCard.click();
|
||||
await firstCard.click()
|
||||
// 点击后页面应该有响应(可能是弹窗或跳转)
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("标题库 - 批量操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("多选复选框存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "title-batch-ui");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"title-batch-ui",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建多个标题
|
||||
await createTitle(request, headers, `${suffix}-1`);
|
||||
await createTitle(request, headers, `${suffix}-2`);
|
||||
await createTitle(request, headers, `${suffix}-3`);
|
||||
await createTitle(request, headers, `${suffix}-1`)
|
||||
await createTitle(request, headers, `${suffix}-2`)
|
||||
await createTitle(request, headers, `${suffix}-3`)
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E title-batch-ui",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/titles");
|
||||
await page.goto("/app/titles")
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 检查是否有批量操作相关 UI
|
||||
// 页面正常加载即可,批量操作是可选功能
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".xx-titles-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
+165
-208
@@ -6,36 +6,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -48,30 +41,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -90,14 +83,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -111,138 +104,123 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("声音克隆页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voice-clone");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("声音克隆页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("声音克隆页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("页面标题和描述存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-title",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-title")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-title",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证页面标题包含"克隆"或"音色"相关文字
|
||||
// 只要页面正常加载即可,标题可能在 PageHead 组件中
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".vc-page")).toBeVisible()
|
||||
})
|
||||
|
||||
test("克隆新音色按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-newbtn",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-newbtn")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-newbtn",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证克隆新音色按钮存在
|
||||
// 按钮可能在不同位置,只要页面加载成功即可
|
||||
await expect(page.locator(".vc-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".vc-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("声音克隆 - 空状态", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("无克隆音色时显示空状态", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-empty",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-empty")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-empty",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 新用户应该显示空状态
|
||||
const emptyState = page.locator(".vc-empty");
|
||||
const emptyState = page.locator(".vc-empty")
|
||||
if (await emptyState.isVisible({ timeout: 10_000 })) {
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible();
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible();
|
||||
await expect(emptyState.locator(".vc-empty-title")).toBeVisible()
|
||||
await expect(emptyState.locator(".vc-empty-desc")).toBeVisible()
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("声音克隆 - API 操作", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("获取克隆列表 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-list");
|
||||
const { headers } = await createAuthedUser(request, "vc-list")
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆列表应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取克隆列表应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voice_clones || []
|
||||
expect(Array.isArray(items), "克隆列表应为数组").toBeTruthy()
|
||||
})
|
||||
|
||||
test("创建音色克隆 - 正向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-create");
|
||||
const suffix = Date.now().toString(36);
|
||||
const { headers } = await createAuthedUser(request, "vc-create")
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建一个克隆任务(上传音频文件)
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -256,52 +234,49 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("fake audio data for e2e test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 克隆创建可能成功也可能因为缺少实际音频处理返回错误
|
||||
// 只要不是 500 错误即可
|
||||
expect(
|
||||
response.status() < 500,
|
||||
`创建克隆应返回 2xx 或 4xx,实际: ${response.status()} ${await response.text()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
if (response.ok()) {
|
||||
const data = await response.json();
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy();
|
||||
expect(data.status, "应返回状态").toBeTruthy();
|
||||
const data = await response.json()
|
||||
expect(data.id, "应返回克隆 ID").toBeTruthy()
|
||||
expect(data.status, "应返回状态").toBeTruthy()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("获取克隆详情 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-detail");
|
||||
const { headers } = await createAuthedUser(request, "vc-detail")
|
||||
|
||||
// 先获取列表看看有没有数据
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
})
|
||||
expect(listResp.ok()).toBeTruthy()
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
const listData = await listResp.json()
|
||||
const items = listData.items || listData.voice_clones || []
|
||||
|
||||
if (items.length > 0) {
|
||||
const cloneId = items[0].id;
|
||||
const detailResp = await request.get(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||||
const detail = await detailResp.json();
|
||||
expect(detail.id).toBe(cloneId);
|
||||
const cloneId = items[0].id
|
||||
const detailResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, { headers })
|
||||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy()
|
||||
const detail = await detailResp.json()
|
||||
expect(detail.id).toBe(cloneId)
|
||||
}
|
||||
// 如果没有数据,测试也通过(新用户正常情况)
|
||||
});
|
||||
})
|
||||
|
||||
test("删除克隆 - 正向(如存在克隆数据)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-del");
|
||||
const { headers } = await createAuthedUser(request, "vc-del")
|
||||
|
||||
// 先创建一个克隆
|
||||
const suffix = Date.now().toString(36);
|
||||
const suffix = Date.now().toString(36)
|
||||
const createResp = await request.post(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
multipart: {
|
||||
@@ -312,75 +287,63 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("delete me"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (createResp.ok()) {
|
||||
const created = await createResp.json();
|
||||
const cloneId = created.id;
|
||||
const created = await createResp.json()
|
||||
const cloneId = created.id
|
||||
|
||||
// 删除
|
||||
const deleteResp = await request.delete(
|
||||
`${apiBase}/voice-clones/${cloneId}`,
|
||||
{ headers },
|
||||
);
|
||||
const deleteResp = await request.delete(`${apiBase}/voice-clones/${cloneId}`, { headers })
|
||||
expect(
|
||||
[200, 204].includes(deleteResp.status()),
|
||||
`删除应返回 200 或 204,实际: ${deleteResp.status()}`,
|
||||
).toBeTruthy();
|
||||
).toBeTruthy()
|
||||
|
||||
// 验证已删除
|
||||
const getResp = await request.get(`${apiBase}/voice-clones/${cloneId}`, {
|
||||
headers,
|
||||
});
|
||||
expect([404, 410]).toContain(getResp.status());
|
||||
})
|
||||
expect([404, 410]).toContain(getResp.status())
|
||||
}
|
||||
// 如果创建失败(比如音频格式问题),测试也通过
|
||||
});
|
||||
})
|
||||
|
||||
test("重试克隆 - 正向(如存在失败的克隆)", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-retry");
|
||||
const { headers } = await createAuthedUser(request, "vc-retry")
|
||||
|
||||
// 先获取列表
|
||||
const listResp = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
expect(listResp.ok()).toBeTruthy();
|
||||
})
|
||||
expect(listResp.ok()).toBeTruthy()
|
||||
|
||||
const listData = await listResp.json();
|
||||
const items = listData.items || listData.voice_clones || [];
|
||||
const listData = await listResp.json()
|
||||
const items = listData.items || listData.voice_clones || []
|
||||
|
||||
// 找一个失败状态的克隆进行重试
|
||||
const failedClone = items.find(
|
||||
(item: { status: string }) => item.status === "failed",
|
||||
);
|
||||
const failedClone = items.find((item: { status: string }) => item.status === "failed")
|
||||
|
||||
if (failedClone) {
|
||||
const retryResp = await request.post(
|
||||
`${apiBase}/voice-clones/${failedClone.id}/retry`,
|
||||
{ headers },
|
||||
);
|
||||
expect(
|
||||
retryResp.ok(),
|
||||
`重试应返回 2xx,实际: ${retryResp.status()}`,
|
||||
).toBeTruthy();
|
||||
const retryResp = await request.post(`${apiBase}/voice-clones/${failedClone.id}/retry`, {
|
||||
headers,
|
||||
})
|
||||
expect(retryResp.ok(), `重试应返回 2xx,实际: ${retryResp.status()}`).toBeTruthy()
|
||||
}
|
||||
// 如果没有失败的克隆,测试通过
|
||||
});
|
||||
})
|
||||
|
||||
test("获取不存在的克隆详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "vc-404");
|
||||
const { headers } = await createAuthedUser(request, "vc-404")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-clone-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
const response = await request.get(`${apiBase}/voice-clones/nonexistent-clone-999`, { headers })
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404)
|
||||
})
|
||||
|
||||
test("未登录获取克隆列表 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
const response = await request.get(`${apiBase}/voice-clones`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("未登录创建克隆 - 反向", async ({ request }) => {
|
||||
const response = await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -392,47 +355,46 @@ test.describe("声音克隆 - API 操作", () => {
|
||||
buffer: Buffer.from("test"),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
});
|
||||
})
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("克隆卡片网格布局展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-grid",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-grid")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-grid",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证网格容器或空状态存在
|
||||
const grid = page.locator(".vc-grid");
|
||||
const empty = page.locator(".vc-empty");
|
||||
const grid = page.locator(".vc-grid")
|
||||
const empty = page.locator(".vc-empty")
|
||||
|
||||
// 至少一个应该可见
|
||||
const gridVisible = await grid.isVisible().catch(() => false);
|
||||
const emptyVisible = await empty.isVisible().catch(() => false);
|
||||
expect(gridVisible || emptyVisible).toBeTruthy();
|
||||
});
|
||||
const gridVisible = await grid.isVisible().catch(() => false)
|
||||
const emptyVisible = await empty.isVisible().catch(() => false)
|
||||
expect(gridVisible || emptyVisible).toBeTruthy()
|
||||
})
|
||||
|
||||
test("克隆状态标签展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username, headers } =
|
||||
await createAuthedUser(request, "vc-status");
|
||||
const suffix = Date.now().toString(36);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username, headers } = await createAuthedUser(
|
||||
request,
|
||||
"vc-status",
|
||||
)
|
||||
const suffix = Date.now().toString(36)
|
||||
|
||||
// 创建一个克隆任务
|
||||
await request.post(`${apiBase}/voice-clones`, {
|
||||
@@ -445,66 +407,61 @@ test.describe("声音克隆 - 克隆列表展示", () => {
|
||||
buffer: Buffer.from("status test data"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-status",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 如果有卡片,验证状态标签存在
|
||||
const cards = page.locator(".vc-card");
|
||||
const cards = page.locator(".vc-card")
|
||||
if ((await cards.count()) > 0) {
|
||||
const firstCard = cards.first();
|
||||
const statusPill = firstCard.locator(".vc-status-pill");
|
||||
const firstCard = cards.first()
|
||||
const statusPill = firstCard.locator(".vc-status-pill")
|
||||
if (await statusPill.isVisible()) {
|
||||
await expect(statusPill).toBeVisible();
|
||||
await expect(statusPill).toBeVisible()
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("声音克隆 - 上传区域", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("克隆弹窗上传区域可打开", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"vc-upload",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "vc-upload")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E vc-upload",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voice-clone");
|
||||
await page.goto("/app/voice-clone")
|
||||
await expect(page.locator(".vc-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 尝试点击克隆新音色按钮
|
||||
const cloneBtn = page.getByRole("button", {
|
||||
name: /克隆新音色|立即克隆|新建/,
|
||||
});
|
||||
})
|
||||
if (await cloneBtn.isVisible()) {
|
||||
await cloneBtn.click();
|
||||
await cloneBtn.click()
|
||||
// 弹窗应该出现
|
||||
const modal = page.locator(
|
||||
".ant-modal, .vc-edit-dialog, [role='dialog']",
|
||||
);
|
||||
const modal = page.locator(".ant-modal, .vc-edit-dialog, [role='dialog']")
|
||||
if (await modal.first().isVisible({ timeout: 5_000 })) {
|
||||
await expect(modal.first()).toBeVisible();
|
||||
await expect(modal.first()).toBeVisible()
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
+145
-188
@@ -6,36 +6,29 @@
|
||||
*
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import {
|
||||
expect,
|
||||
test,
|
||||
type APIRequestContext,
|
||||
type Page,
|
||||
} from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext, type Page } from "@playwright/test"
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
const apiOrigin = apiBase.endsWith("/api/v1")
|
||||
? apiBase.slice(0, -"/api/v1".length)
|
||||
: "";
|
||||
const PASSWORD = "Test123456!"
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1"
|
||||
const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""
|
||||
|
||||
const routeBrowserApiToTestApi = async (page: Page) => {
|
||||
if (!apiOrigin) return;
|
||||
if (!apiOrigin) return
|
||||
await page.route("**/api/v1/**", async (route) => {
|
||||
const sourceUrl = new URL(route.request().url());
|
||||
const sourceUrl = new URL(route.request().url())
|
||||
const response = await route.fetch({
|
||||
url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`,
|
||||
});
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
};
|
||||
})
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
}
|
||||
|
||||
function uniqueEmail(prefix: string): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`;
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`
|
||||
}
|
||||
|
||||
function uniqueUsername(prefix: string): string {
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
||||
return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`
|
||||
}
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
@@ -48,30 +41,30 @@ async function loginWithRetry(
|
||||
for (let i = 0; i <= maxRetries; i++) {
|
||||
const response = await request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
if (response.status() !== 429) return response;
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`);
|
||||
await new Promise((r) => setTimeout(r, 65000));
|
||||
})
|
||||
if (response.status() !== 429) return response
|
||||
console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`)
|
||||
await new Promise((r) => setTimeout(r, 65000))
|
||||
}
|
||||
return request.post(`${apiBase}/auth/login`, {
|
||||
data: { email, password },
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId, accessToken } */
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
const email = uniqueEmail(label)
|
||||
const username = uniqueUsername(label)
|
||||
|
||||
const reg = await request.post(`${apiBase}/auth/register`, {
|
||||
data: { email, password: PASSWORD, username, display_name: `E2E ${label}` },
|
||||
});
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy();
|
||||
const regData = await reg.json();
|
||||
})
|
||||
expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy()
|
||||
const regData = await reg.json()
|
||||
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy();
|
||||
const loginData = await login.json();
|
||||
const login = await loginWithRetry(request, email, PASSWORD)
|
||||
expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy()
|
||||
const loginData = await login.json()
|
||||
|
||||
return {
|
||||
headers: { Authorization: `Bearer ${loginData.access_token}` },
|
||||
@@ -79,7 +72,7 @@ async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
username,
|
||||
userId: regData.user_id,
|
||||
accessToken: loginData.access_token,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置页面认证状态(localStorage) */
|
||||
@@ -90,14 +83,14 @@ async function setupAuth(
|
||||
) {
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
localStorage.setItem("access_token", token);
|
||||
localStorage.setItem("access_token", token)
|
||||
localStorage.setItem(
|
||||
"auth-storage",
|
||||
JSON.stringify({
|
||||
state: { user, isAuthenticated: true },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
)
|
||||
},
|
||||
{
|
||||
token,
|
||||
@@ -111,325 +104,289 @@ async function setupAuth(
|
||||
email_verified: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
test.describe("音色库页面 - 未登录重定向", () => {
|
||||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||||
await page.goto("/app/voices");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
await page.goto("/app/voices")
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库页面 - 页面加载", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("音色库页面加载成功", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-load",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-load")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-load",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("页面头部和搜索栏存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-head",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-head")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-head",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证搜索框存在
|
||||
const searchInput = page.locator(
|
||||
"input[type='search'], .xx-voices-search input, input[placeholder*='搜索']",
|
||||
);
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
)
|
||||
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库 - 预设音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("预设音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-preset");
|
||||
const { headers } = await createAuthedUser(request, "voice-preset")
|
||||
|
||||
const response = await request.get(`${apiBase}/voices/preset`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
// 预设音色接口可能返回数组或包装对象
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取预设音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取预设音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voices || data;
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voices || data
|
||||
expect(Array.isArray(items), "预设音色应为数组").toBeTruthy()
|
||||
})
|
||||
|
||||
test("预设音色卡片在页面中展示", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-cards",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-cards")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-cards",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 等待音色卡片加载(预设音色应该有数据)
|
||||
const voiceCards = page.locator(".xx-voice-card");
|
||||
const voiceCards = page.locator(".xx-voice-card")
|
||||
// 等待至少一张卡片出现
|
||||
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 });
|
||||
const count = await voiceCards.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
await expect(voiceCards.first()).toBeVisible({ timeout: 15_000 })
|
||||
const count = await voiceCards.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("音色卡片包含名称和信息", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-info",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-info")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-info",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 });
|
||||
const firstCard = page.locator(".xx-voice-card").first()
|
||||
await expect(firstCard).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// 验证音色名称存在
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible();
|
||||
await expect(firstCard.locator(".xx-voice-name")).toBeVisible()
|
||||
// 验证头像存在
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(firstCard.locator(".xx-voice-avatar")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库 - 我的克隆音色", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("克隆音色列表 API 返回数据", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-cln-api");
|
||||
const { headers } = await createAuthedUser(request, "voice-cln-api")
|
||||
|
||||
const response = await request.get(`${apiBase}/voice-clones`, {
|
||||
headers,
|
||||
});
|
||||
})
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`获取克隆音色应返回 2xx,实际: ${response.status()}`,
|
||||
).toBeTruthy();
|
||||
expect(response.ok(), `获取克隆音色应返回 2xx,实际: ${response.status()}`).toBeTruthy()
|
||||
|
||||
const data = await response.json();
|
||||
const items = data.items || data.voice_clones || [];
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy();
|
||||
});
|
||||
const data = await response.json()
|
||||
const items = data.items || data.voice_clones || []
|
||||
expect(Array.isArray(items), "克隆音色应为数组").toBeTruthy()
|
||||
})
|
||||
|
||||
test("空状态展示 - 无克隆音色时", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-empty",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-empty")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-empty",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 切换到"我的克隆"tab(如果有tab的话)
|
||||
const clonedTab = page.getByText("我的克隆").first();
|
||||
const clonedTab = page.getByText("我的克隆").first()
|
||||
if (await clonedTab.isVisible()) {
|
||||
await clonedTab.click();
|
||||
await clonedTab.click()
|
||||
}
|
||||
|
||||
// 页面至少应该是可访问的
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible()
|
||||
})
|
||||
|
||||
test("创建克隆音色入口存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-create",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-create")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-create",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证创建克隆音色按钮存在(可能是"克隆音色"或"新建"按钮)
|
||||
// 不强制断言一定存在,因为不同页面结构可能不同
|
||||
// 只验证页面正常加载即可
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库 - 搜索和筛选", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("搜索框存在且可输入", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-search",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-search")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-search",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 查找搜索输入框
|
||||
const searchInput = page.locator(
|
||||
"input[placeholder*='搜索'], input[type='search'], .xx-voices-search input",
|
||||
);
|
||||
const firstInput = searchInput.first();
|
||||
)
|
||||
const firstInput = searchInput.first()
|
||||
|
||||
if (await firstInput.isVisible({ timeout: 5_000 })) {
|
||||
await firstInput.fill("测试搜索");
|
||||
await expect(firstInput).toHaveValue("测试搜索");
|
||||
await firstInput.fill("测试搜索")
|
||||
await expect(firstInput).toHaveValue("测试搜索")
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("性别/语言筛选选项存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-filter",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-filter")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-filter",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
// 验证筛选相关元素存在(可能是下拉选择器或标签)
|
||||
// 页面正常加载即通过
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible();
|
||||
});
|
||||
});
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库 - 播放试听", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("音色播放按钮存在", async ({ page, request }) => {
|
||||
await routeBrowserApiToTestApi(page);
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||||
request,
|
||||
"voice-play",
|
||||
);
|
||||
await routeBrowserApiToTestApi(page)
|
||||
const { accessToken, userId, email, username } = await createAuthedUser(request, "voice-play")
|
||||
await setupAuth(page, accessToken, {
|
||||
id: userId,
|
||||
email,
|
||||
username,
|
||||
display_name: "E2E voice-play",
|
||||
});
|
||||
})
|
||||
|
||||
await page.goto("/app/voices");
|
||||
await page.goto("/app/voices")
|
||||
await expect(page.locator(".xx-voices-page")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
})
|
||||
|
||||
const firstCard = page.locator(".xx-voice-card").first();
|
||||
const firstCard = page.locator(".xx-voice-card").first()
|
||||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||||
// 验证播放按钮存在
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn");
|
||||
const playBtn = firstCard.locator(".xx-voice-play-btn")
|
||||
if (await playBtn.isVisible()) {
|
||||
await expect(playBtn).toBeVisible();
|
||||
await expect(playBtn).toBeVisible()
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("音色库 - API 边界测试", () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
test.describe.configure({ timeout: 120_000 })
|
||||
|
||||
test("未登录获取预设音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voices/preset`);
|
||||
const response = await request.get(`${apiBase}/voices/preset`)
|
||||
// 预设音色可能不需要登录,也可能需要,两种情况都接受
|
||||
// 但如果需要登录,应返回 401/403
|
||||
if (!response.ok()) {
|
||||
expect([401, 403]).toContain(response.status());
|
||||
expect([401, 403]).toContain(response.status())
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("未登录获取克隆音色 - 反向", async ({ request }) => {
|
||||
const response = await request.get(`${apiBase}/voice-clones`);
|
||||
expect([401, 403]).toContain(response.status());
|
||||
});
|
||||
const response = await request.get(`${apiBase}/voice-clones`)
|
||||
expect([401, 403]).toContain(response.status())
|
||||
})
|
||||
|
||||
test("获取不存在的克隆音色详情 - 反向", async ({ request }) => {
|
||||
const { headers } = await createAuthedUser(request, "voice-404");
|
||||
const { headers } = await createAuthedUser(request, "voice-404")
|
||||
|
||||
const response = await request.get(
|
||||
`${apiBase}/voice-clones/nonexistent-999`,
|
||||
{ headers },
|
||||
);
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404);
|
||||
});
|
||||
});
|
||||
const response = await request.get(`${apiBase}/voice-clones/nonexistent-999`, { headers })
|
||||
expect(response.status(), "不存在的克隆应返回 404").toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
Generated
+18
-4
@@ -23,17 +23,18 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
@@ -1974,14 +1975,14 @@
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
@@ -3255,6 +3256,19 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-prettier": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.2.tgz",
|
||||
"integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz",
|
||||
|
||||
@@ -4,16 +4,18 @@
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ci": "npx playwright test --project=chromium --reporter=line",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"test:ui": "vitest --ui",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,17 +34,18 @@
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^20.14.9",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/ui": "^1.6.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"jsdom": "^24.1.0",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier": "^3.9.5",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.1",
|
||||
"vitest": "^1.6.0"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Playwright E2E 测试配置
|
||||
*/
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { defineConfig, devices } from "@playwright/test"
|
||||
|
||||
const externalBaseURL = process.env.E2E_BASE_URL;
|
||||
const externalBaseURL = process.env.E2E_BASE_URL
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -68,4 +68,4 @@ export default defineConfig({
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
+219
-242
@@ -2,163 +2,157 @@
|
||||
* 素材相关 API
|
||||
* Phase 1 重构:去掉 project_id,素材直接归属用户
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getOrCreateDefaultProject } from "./projects";
|
||||
import apiClient from "./client"
|
||||
import { getOrCreateDefaultProject } from "./projects"
|
||||
|
||||
/** 素材元数据 */
|
||||
export interface AssetMetadata {
|
||||
/** 时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 宽度(像素) */
|
||||
width?: number;
|
||||
width?: number
|
||||
/** 高度(像素) */
|
||||
height?: number;
|
||||
height?: number
|
||||
/** 比特率(bps) */
|
||||
bitrate?: number;
|
||||
bitrate?: number
|
||||
/** 编码格式 */
|
||||
codec?: string;
|
||||
codec?: string
|
||||
/** 帧率 */
|
||||
fps?: number;
|
||||
fps?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 声道数 */
|
||||
channels?: number;
|
||||
channels?: number
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 素材分类状态 */
|
||||
export type AssetClassificationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
export type AssetClassificationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/** 素材条目 */
|
||||
export interface AssetItem {
|
||||
id: string;
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata: AssetMetadata;
|
||||
file_size?: number;
|
||||
file_url?: string;
|
||||
thumbnail_url?: string;
|
||||
id: string
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata: AssetMetadata
|
||||
file_size?: number
|
||||
file_url?: string
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),视频/音频素材由后端从 metadata 提取到顶层 */
|
||||
duration?: number;
|
||||
status?: string;
|
||||
classification_status?: AssetClassificationStatus | null;
|
||||
quality_score?: number | null;
|
||||
tag_ids?: string[];
|
||||
created_at?: string;
|
||||
duration?: number
|
||||
status?: string
|
||||
classification_status?: AssetClassificationStatus | null
|
||||
quality_score?: number | null
|
||||
tag_ids?: string[]
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 素材库 */
|
||||
export interface AssetLibraryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
asset_count?: number;
|
||||
total_size?: number;
|
||||
created_at?: string;
|
||||
id: string
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
asset_count?: number
|
||||
total_size?: number
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 入库任务 */
|
||||
export interface IngestJob {
|
||||
id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
error_message: string;
|
||||
result_asset_id: string;
|
||||
id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
error_message: string
|
||||
result_asset_id: string
|
||||
}
|
||||
|
||||
/** 分类任务 */
|
||||
export interface ClassificationJob {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
classification: string;
|
||||
confidence: number;
|
||||
error_message: string;
|
||||
id: string
|
||||
asset_id: string
|
||||
status: "pending" | "processing" | "completed" | "failed"
|
||||
classification: string
|
||||
confidence: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
/** 素材诊断信息 */
|
||||
export interface AssetDiagnosis {
|
||||
readiness_score: number;
|
||||
readiness_label: string;
|
||||
total_assets: number;
|
||||
ready_assets: number;
|
||||
video_assets: number;
|
||||
image_assets: number;
|
||||
voice_assets: number;
|
||||
total_duration_seconds: number;
|
||||
estimated_video_count: number;
|
||||
used_assets: number;
|
||||
unused_assets: number;
|
||||
pending_review_assets: number;
|
||||
readiness_score: number
|
||||
readiness_label: string
|
||||
total_assets: number
|
||||
ready_assets: number
|
||||
video_assets: number
|
||||
image_assets: number
|
||||
voice_assets: number
|
||||
total_duration_seconds: number
|
||||
estimated_video_count: number
|
||||
used_assets: number
|
||||
unused_assets: number
|
||||
pending_review_assets: number
|
||||
smart_views: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
count: number;
|
||||
description: string;
|
||||
}>;
|
||||
key: string
|
||||
label: string
|
||||
count: number
|
||||
description: string
|
||||
}>
|
||||
gaps: Array<{
|
||||
key: string;
|
||||
severity: "critical" | "warning" | "info";
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}>;
|
||||
key: string
|
||||
severity: "critical" | "warning" | "info"
|
||||
message: string
|
||||
recommendation: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ─── 素材诊断 ──────────────────────────────────────────────
|
||||
|
||||
/** 获取素材诊断信息(可选 asset_id 查单素材,否则全局诊断) */
|
||||
export const getAssetDiagnosis = async (
|
||||
assetId?: string,
|
||||
): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {};
|
||||
if (assetId) params.asset_id = assetId;
|
||||
const response = await apiClient.get("/asset-diagnosis", { params });
|
||||
return response.data;
|
||||
};
|
||||
export const getAssetDiagnosis = async (assetId?: string): Promise<AssetDiagnosis> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (assetId) params.asset_id = assetId
|
||||
const response = await apiClient.get("/asset-diagnosis", { params })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ─── 素材库 ────────────────────────────────────────────────
|
||||
|
||||
/** 获取当前用户的所有素材库 */
|
||||
export const getAssetLibraries = async (): Promise<AssetLibraryItem[]> => {
|
||||
const response = await apiClient.get("/asset-libraries");
|
||||
return response.data.items || [];
|
||||
};
|
||||
const response = await apiClient.get("/asset-libraries")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材库(自动获取或创建默认项目以提供 project_id) */
|
||||
export const createAssetLibrary = async (data: {
|
||||
name: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
name: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
// 后端要求 project_id,前端自动管理默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const project = await getOrCreateDefaultProject()
|
||||
const response = await apiClient.post("/asset-libraries", {
|
||||
project_id: project.id,
|
||||
...data,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 确保项目下指定 kind 的默认素材库存在(不存在则自动创建) */
|
||||
export const ensureDefaultLibrary = async (data: {
|
||||
project_id: string;
|
||||
kind: "video" | "voice" | "image";
|
||||
project_id: string
|
||||
kind: "video" | "voice" | "image"
|
||||
}): Promise<AssetLibraryItem> => {
|
||||
const response = await apiClient.post(
|
||||
"/asset-libraries/ensure-default",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/asset-libraries/ensure-default", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材库 */
|
||||
export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`);
|
||||
};
|
||||
await apiClient.delete(`/asset-libraries/${libraryId}`)
|
||||
}
|
||||
|
||||
// ─── 素材 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -166,49 +160,49 @@ export const deleteAssetLibrary = async (libraryId: string): Promise<void> => {
|
||||
export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: { library_id: libraryId },
|
||||
});
|
||||
return response.data.items || [];
|
||||
};
|
||||
})
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 按类型获取素材(如 voice/video/image),支持可选筛选 */
|
||||
export const getAssetsByKind = async (
|
||||
kind: string,
|
||||
filters?: {
|
||||
keyword?: string;
|
||||
gender?: string;
|
||||
style?: string;
|
||||
tag_ids?: string[];
|
||||
keyword?: string
|
||||
gender?: string
|
||||
style?: string
|
||||
tag_ids?: string[]
|
||||
},
|
||||
): Promise<AssetItem[]> => {
|
||||
const params: Record<string, string> = { kind };
|
||||
if (filters?.keyword) params.keyword = filters.keyword;
|
||||
if (filters?.gender) params.gender = filters.gender;
|
||||
if (filters?.style) params.style = filters.style;
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",");
|
||||
const response = await apiClient.get("/assets", { params });
|
||||
return response.data.items || [];
|
||||
};
|
||||
const params: Record<string, string> = { kind }
|
||||
if (filters?.keyword) params.keyword = filters.keyword
|
||||
if (filters?.gender) params.gender = filters.gender
|
||||
if (filters?.style) params.style = filters.style
|
||||
if (filters?.tag_ids?.length) params.tag_ids = filters.tag_ids.join(",")
|
||||
const response = await apiClient.get("/assets", { params })
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建素材(上传文件后调用,附带 metadata) */
|
||||
export const createAsset = async (data: {
|
||||
library_id: string;
|
||||
name: string;
|
||||
storage_key: string;
|
||||
mime_type: string;
|
||||
metadata?: AssetMetadata;
|
||||
library_id: string
|
||||
name: string
|
||||
storage_key: string
|
||||
mime_type: string
|
||||
metadata?: AssetMetadata
|
||||
}): Promise<AssetItem> => {
|
||||
const response = await apiClient.post("/assets", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/assets", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材(名称、metadata 等) */
|
||||
export const updateAsset = async (
|
||||
assetId: string,
|
||||
data: { name?: string; metadata?: AssetMetadata },
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.put(`/assets/${assetId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新素材审核状态 */
|
||||
export const updateAssetReviewStatus = async (
|
||||
@@ -217,14 +211,14 @@ export const updateAssetReviewStatus = async (
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除素材 */
|
||||
export const deleteAsset = async (assetId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}`);
|
||||
};
|
||||
await apiClient.delete(`/assets/${assetId}`)
|
||||
}
|
||||
|
||||
// ─── 上传 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -235,47 +229,47 @@ export const uploadAsset = async (
|
||||
const response = await apiClient.post("/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
timeout: 30 * 60 * 1000,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 预签名直传准备 */
|
||||
export const prepareDirectUpload = async (data: {
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
filename: string;
|
||||
content_type: string;
|
||||
file_size: number;
|
||||
project_id: string
|
||||
library_id: string
|
||||
filename: string
|
||||
content_type: string
|
||||
file_size: number
|
||||
}): Promise<{
|
||||
upload_url: string;
|
||||
method: string;
|
||||
storage_key: string;
|
||||
expires_at: string;
|
||||
fields: Record<string, string>;
|
||||
max_size_bytes: number;
|
||||
upload_url: string
|
||||
method: string
|
||||
storage_key: string
|
||||
expires_at: string
|
||||
fields: Record<string, string>
|
||||
max_size_bytes: number
|
||||
}> => {
|
||||
const response = await apiClient.post("/upload/direct/prepare", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/upload/direct/prepare", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传完成确认 */
|
||||
export const completeDirectUpload = async (data: {
|
||||
project_id: string;
|
||||
library_id: string;
|
||||
storage_key: string;
|
||||
project_id: string
|
||||
library_id: string
|
||||
storage_key: string
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
const response = await apiClient.post("/upload/direct/complete", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/upload/direct/complete", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 直传上传(大文件推荐),支持可选进度回调 */
|
||||
export const uploadAssetDirect = async (data: {
|
||||
file: File;
|
||||
library_id: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
file: File
|
||||
library_id: string
|
||||
onProgress?: (percent: number) => void
|
||||
}): Promise<{ storage_key: string; ingest_job_id: string }> => {
|
||||
// 后端要求 project_id,前端自动获取默认项目
|
||||
const project = await getOrCreateDefaultProject();
|
||||
const project = await getOrCreateDefaultProject()
|
||||
|
||||
const prepared = await prepareDirectUpload({
|
||||
project_id: project.id,
|
||||
@@ -283,166 +277,149 @@ export const uploadAssetDirect = async (data: {
|
||||
filename: data.file.name,
|
||||
content_type: data.file.type || "application/octet-stream",
|
||||
file_size: data.file.size,
|
||||
});
|
||||
})
|
||||
|
||||
const directForm = new FormData();
|
||||
Object.entries(prepared.fields).forEach(([key, value]) =>
|
||||
directForm.append(key, value),
|
||||
);
|
||||
directForm.append("file", data.file);
|
||||
const directForm = new FormData()
|
||||
Object.entries(prepared.fields).forEach(([key, value]) => directForm.append(key, value))
|
||||
directForm.append("file", data.file)
|
||||
|
||||
// 使用 XMLHttpRequest 以获取上传进度 + 超时控制 + 详细错误诊断
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(prepared.method, prepared.upload_url);
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open(prepared.method, prepared.upload_url)
|
||||
|
||||
// 超时 10 分钟
|
||||
xhr.timeout = 10 * 60 * 1000;
|
||||
xhr.timeout = 10 * 60 * 1000
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && data.onProgress) {
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
data.onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
}
|
||||
};
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
resolve()
|
||||
} else {
|
||||
// 解析 OSS 返回的 XML 错误信息
|
||||
let ossError = "";
|
||||
let ossError = ""
|
||||
try {
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/);
|
||||
const msgMatch = xhr.responseText.match(
|
||||
/<Message>([^<]+)<\/Message>/,
|
||||
);
|
||||
const codeMatch = xhr.responseText.match(/<Code>([^<]+)<\/Code>/)
|
||||
const msgMatch = xhr.responseText.match(/<Message>([^<]+)<\/Message>/)
|
||||
if (codeMatch || msgMatch) {
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`;
|
||||
ossError = ` [OSS: ${codeMatch?.[1] || "unknown"} - ${msgMatch?.[1] || "unknown"}]`
|
||||
}
|
||||
} catch {
|
||||
// 无法解析响应体
|
||||
}
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`;
|
||||
const detail = `OSS 直传失败: HTTP ${xhr.status} ${xhr.statusText}${ossError}`
|
||||
console.error("[OSS Upload] 直传失败:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
});
|
||||
reject(new Error(detail));
|
||||
})
|
||||
reject(new Error(detail))
|
||||
}
|
||||
};
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
console.error("[OSS Upload] 网络错误:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"));
|
||||
};
|
||||
})
|
||||
reject(new Error("OSS 上传网络错误,请检查网络连接"))
|
||||
}
|
||||
xhr.ontimeout = () => {
|
||||
console.error("[OSS Upload] 上传超时:", {
|
||||
url: prepared.upload_url,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"));
|
||||
};
|
||||
xhr.send(directForm);
|
||||
});
|
||||
})
|
||||
reject(new Error("OSS 上传超时(10分钟),请检查网络或尝试更小的文件"))
|
||||
}
|
||||
xhr.send(directForm)
|
||||
})
|
||||
|
||||
return completeDirectUpload({
|
||||
project_id: project.id,
|
||||
library_id: data.library_id,
|
||||
storage_key: prepared.storage_key,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 入库 / 分类任务 ───────────────────────────────────────
|
||||
|
||||
/** 查询入库任务状态 */
|
||||
export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get(`/ingest-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 提交素材分类任务 */
|
||||
export const submitClassificationJob = async (data: {
|
||||
asset_id: string;
|
||||
asset_id: string
|
||||
}): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.post("/classification-jobs", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/classification-jobs", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 查询分类任务状态 */
|
||||
export const getClassificationJob = async (
|
||||
jobId: string,
|
||||
): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
|
||||
const response = await apiClient.get(`/classification-jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// ─── 批量操作 ───────────────────────────────────────────────
|
||||
|
||||
/** 批量操作结果 */
|
||||
export interface BatchOperationResult {
|
||||
succeeded: string[];
|
||||
failed: string[];
|
||||
total: number;
|
||||
success_count: number;
|
||||
failure_count: number;
|
||||
succeeded: string[]
|
||||
failed: string[]
|
||||
total: number
|
||||
success_count: number
|
||||
failure_count: number
|
||||
}
|
||||
|
||||
/** 统一批量操作结果归一化,防御后端字段缺失或格式不一致 */
|
||||
const normalizeBatchResult = (
|
||||
raw: Record<string, unknown>,
|
||||
): BatchOperationResult => {
|
||||
const succeeded = Array.isArray(raw.succeeded)
|
||||
? (raw.succeeded as string[])
|
||||
: [];
|
||||
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : [];
|
||||
const success_count =
|
||||
typeof raw.success_count === "number"
|
||||
? raw.success_count
|
||||
: succeeded.length;
|
||||
const failure_count =
|
||||
typeof raw.failure_count === "number" ? raw.failure_count : failed.length;
|
||||
const total =
|
||||
typeof raw.total === "number" ? raw.total : success_count + failure_count;
|
||||
return { succeeded, failed, total, success_count, failure_count };
|
||||
};
|
||||
const normalizeBatchResult = (raw: Record<string, unknown>): BatchOperationResult => {
|
||||
const succeeded = Array.isArray(raw.succeeded) ? (raw.succeeded as string[]) : []
|
||||
const failed = Array.isArray(raw.failed) ? (raw.failed as string[]) : []
|
||||
const success_count = typeof raw.success_count === "number" ? raw.success_count : succeeded.length
|
||||
const failure_count = typeof raw.failure_count === "number" ? raw.failure_count : failed.length
|
||||
const total = typeof raw.total === "number" ? raw.total : success_count + failure_count
|
||||
return { succeeded, failed, total, success_count, failure_count }
|
||||
}
|
||||
|
||||
/** 批量删除素材 */
|
||||
export const batchDeleteAssets = async (
|
||||
assetIds: string[],
|
||||
): Promise<BatchOperationResult> => {
|
||||
export const batchDeleteAssets = async (assetIds: string[]): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-delete", {
|
||||
asset_ids: assetIds,
|
||||
});
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>);
|
||||
};
|
||||
})
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量打标签 */
|
||||
export const batchTagAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
tags: string[];
|
||||
mode: "add" | "replace";
|
||||
asset_ids: string[]
|
||||
tags: string[]
|
||||
mode: "add" | "replace"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-tag", data);
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>);
|
||||
};
|
||||
const response = await apiClient.post("/assets/batch-tag", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量改分类 */
|
||||
export const batchClassifyAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
category: string;
|
||||
asset_ids: string[]
|
||||
category: string
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-classify", data);
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>);
|
||||
};
|
||||
const response = await apiClient.post("/assets/batch-classify", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
/** 批量智能标记 */
|
||||
export const batchMarkAssets = async (data: {
|
||||
asset_ids: string[];
|
||||
smart_view: "recommended" | "caution" | "high_risk";
|
||||
asset_ids: string[]
|
||||
smart_view: "recommended" | "caution" | "high_risk"
|
||||
}): Promise<BatchOperationResult> => {
|
||||
const response = await apiClient.post("/assets/batch-mark", data);
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>);
|
||||
};
|
||||
const response = await apiClient.post("/assets/batch-mark", data)
|
||||
return normalizeBatchResult((response.data || {}) as Record<string, unknown>)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeUser } from "./auth";
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { normalizeUser } from "./auth"
|
||||
|
||||
describe("normalizeUser", () => {
|
||||
it("normalizes canonical API current-user fields", () => {
|
||||
@@ -20,8 +20,8 @@ describe("normalizeUser", () => {
|
||||
is_email_verified: true,
|
||||
email_verified: true,
|
||||
created_at: undefined,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
it("keeps compatibility with legacy UI-shaped user fields", () => {
|
||||
expect(
|
||||
@@ -42,6 +42,6 @@ describe("normalizeUser", () => {
|
||||
is_email_verified: false,
|
||||
email_verified: false,
|
||||
created_at: "2026-06-22T00:00:00Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+64
-72
@@ -1,58 +1,58 @@
|
||||
/**
|
||||
* 认证相关 API
|
||||
*/
|
||||
import axios from "axios";
|
||||
import apiClient from "./client";
|
||||
import axios from "axios"
|
||||
import apiClient from "./client"
|
||||
|
||||
// 类型定义
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string | null;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
access_token: string
|
||||
refresh_token?: string | null
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
display_name?: string;
|
||||
email: string
|
||||
password: string
|
||||
username: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified: boolean;
|
||||
email_verified: boolean;
|
||||
created_at?: string;
|
||||
id: string
|
||||
user_id: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified: boolean
|
||||
email_verified: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id?: string;
|
||||
user_id?: string;
|
||||
email: string;
|
||||
username: string;
|
||||
display_name: string;
|
||||
is_email_verified?: boolean;
|
||||
email_verified?: boolean;
|
||||
created_at?: string;
|
||||
id?: string
|
||||
user_id?: string
|
||||
email: string
|
||||
username: string
|
||||
display_name: string
|
||||
is_email_verified?: boolean
|
||||
email_verified?: boolean
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export const normalizeUser = (data: UserResponse): User => {
|
||||
const userId = data.id ?? data.user_id ?? "";
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false;
|
||||
const userId = data.id ?? data.user_id ?? ""
|
||||
const emailVerified = data.is_email_verified ?? data.email_verified ?? false
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
@@ -63,52 +63,46 @@ export const normalizeUser = (data: UserResponse): User => {
|
||||
is_email_verified: emailVerified,
|
||||
email_verified: emailVerified,
|
||||
created_at: data.created_at,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 登录
|
||||
export const login = async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await apiClient.post("/auth/login", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/auth/login", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 刷新 access_token(使用裸 axios 避免拦截器递归)
|
||||
export const refreshAccessToken = async (
|
||||
refreshToken: string,
|
||||
): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? "";
|
||||
export const refreshAccessToken = async (refreshToken: string): Promise<LoginResponse> => {
|
||||
const baseURL = apiClient.defaults.baseURL ?? ""
|
||||
const response = await axios.post(`${baseURL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 注册
|
||||
export const register = async (
|
||||
data: RegisterRequest,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data);
|
||||
return response.data;
|
||||
};
|
||||
export const register = async (data: RegisterRequest): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/register", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export const logout = async (): Promise<void> => {
|
||||
await apiClient.post("/auth/logout");
|
||||
};
|
||||
await apiClient.post("/auth/logout")
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export const getCurrentUser = async (): Promise<User> => {
|
||||
const response = await apiClient.get<UserResponse>("/auth/me");
|
||||
return normalizeUser(response.data);
|
||||
};
|
||||
const response = await apiClient.get<UserResponse>("/auth/me")
|
||||
return normalizeUser(response.data)
|
||||
}
|
||||
|
||||
// 请求密码重置
|
||||
export const requestPasswordReset = async (
|
||||
email: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email });
|
||||
return response.data;
|
||||
};
|
||||
export const requestPasswordReset = async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/forgot-password", { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 重置密码
|
||||
export const resetPassword = async (
|
||||
@@ -118,14 +112,12 @@ export const resetPassword = async (
|
||||
const response = await apiClient.post("/auth/reset-password", {
|
||||
token,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
export const verifyEmail = async (
|
||||
token: string,
|
||||
): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token });
|
||||
return response.data;
|
||||
};
|
||||
export const verifyEmail = async (token: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post("/auth/verify-email", { token })
|
||||
return response.data
|
||||
}
|
||||
|
||||
+25
-27
@@ -2,48 +2,48 @@
|
||||
* BGM 预设音乐 API
|
||||
* 对接后端 BGM 混音能力:预设列表查询(按风格分类 + 关键词搜索)
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
/** BGM 风格分类 */
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商";
|
||||
export type BgmCategory = "轻快" | "治愈" | "科技" | "电商"
|
||||
|
||||
/** BGM 预设项 */
|
||||
export interface BgmPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: BgmCategory;
|
||||
id: string
|
||||
name: string
|
||||
category: BgmCategory
|
||||
/** 音频文件 URL */
|
||||
url: string;
|
||||
url: string
|
||||
/** 时长(秒) */
|
||||
duration: number;
|
||||
duration: number
|
||||
/** 关键词标签 */
|
||||
tags: string[];
|
||||
tags: string[]
|
||||
/** 封面图 URL */
|
||||
cover_url?: string;
|
||||
cover_url?: string
|
||||
}
|
||||
|
||||
/** BGM 预设列表查询参数 */
|
||||
export interface BgmPresetsQuery {
|
||||
category?: BgmCategory | string;
|
||||
keyword?: string;
|
||||
category?: BgmCategory | string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** BGM 混音配置(嵌入剪辑计划) */
|
||||
export interface BgmMixConfig {
|
||||
/** 是否启用 BGM */
|
||||
enabled: boolean;
|
||||
enabled: boolean
|
||||
/** 选中的 BGM ID */
|
||||
music_id: string;
|
||||
music_id: string
|
||||
/** BGM 音量 0-100 */
|
||||
volume: number;
|
||||
volume: number
|
||||
/** 淡入时长(秒) 0-3 */
|
||||
fade_in: number;
|
||||
fade_in: number
|
||||
/** 淡出时长(秒) 0-3 */
|
||||
fade_out: number;
|
||||
fade_out: number
|
||||
/** 人声闪避(sidechain) */
|
||||
voice_dodge: boolean;
|
||||
voice_dodge: boolean
|
||||
}
|
||||
|
||||
/** 默认 BGM 混音配置 */
|
||||
@@ -54,17 +54,15 @@ export const DEFAULT_BGM_MIX_CONFIG: BgmMixConfig = {
|
||||
fade_in: 0.5,
|
||||
fade_out: 0.5,
|
||||
voice_dodge: true,
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────── API ──────────── */
|
||||
|
||||
/** 获取 BGM 预设列表 */
|
||||
export const getBgmPresets = async (
|
||||
params?: BgmPresetsQuery,
|
||||
): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {};
|
||||
if (params?.category) searchParams.category = params.category;
|
||||
if (params?.keyword) searchParams.keyword = params.keyword;
|
||||
const res = await apiClient.get("/bgm/presets", { params: searchParams });
|
||||
return res.data?.data ?? res.data ?? [];
|
||||
};
|
||||
export const getBgmPresets = async (params?: BgmPresetsQuery): Promise<BgmPreset[]> => {
|
||||
const searchParams: Record<string, string> = {}
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
+78
-89
@@ -2,10 +2,10 @@
|
||||
* API 客户端配置
|
||||
* 封装 Axios 实例,配置拦截器和 Token 管理
|
||||
*/
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
import { message } from "antd";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { refreshAccessToken } from "./auth";
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"
|
||||
import { message } from "antd"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { refreshAccessToken } from "./auth"
|
||||
|
||||
// 创建 Axios 实例
|
||||
const apiClient = axios.create({
|
||||
@@ -14,161 +14,150 @@ const apiClient = axios.create({
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// ── Token 刷新队列 ─────────────────────────────────────────
|
||||
let isRefreshing = false;
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
}> = [];
|
||||
resolve: (value?: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}> = []
|
||||
|
||||
const processQueue = (error: unknown | null, token: string | null = null) => {
|
||||
failedQueue.forEach((prom) => {
|
||||
if (error) {
|
||||
prom.reject(error);
|
||||
prom.reject(error)
|
||||
} else {
|
||||
prom.resolve(token);
|
||||
prom.resolve(token)
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
};
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
// 请求拦截器:添加 Token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const token = localStorage.getItem("access_token")
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config;
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
// 响应拦截器:统一错误提示 + 401 自动刷新 Token
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (
|
||||
error: AxiosError<{ detail?: string; message?: string; msg?: string }>,
|
||||
) => {
|
||||
async (error: AxiosError<{ detail?: string; message?: string; msg?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
_retry?: boolean
|
||||
}
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
if (
|
||||
error.response?.status === 401 &&
|
||||
originalRequest &&
|
||||
!originalRequest._retry
|
||||
) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken;
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
const refreshToken = useAuthStore.getState().refreshToken
|
||||
|
||||
// 无 refresh_token → 直接登出
|
||||
if (!refreshToken) {
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(error);
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 已在刷新中 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
return apiClient(originalRequest)
|
||||
})
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const data = await refreshAccessToken(refreshToken);
|
||||
const newAccessToken = data.access_token;
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken;
|
||||
const data = await refreshAccessToken(refreshToken)
|
||||
const newAccessToken = data.access_token
|
||||
const newRefreshToken = data.refresh_token ?? refreshToken
|
||||
|
||||
// 更新 Zustand + localStorage
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setAuth(
|
||||
useAuthStore.getState().user!,
|
||||
newAccessToken,
|
||||
newRefreshToken,
|
||||
);
|
||||
.setAuth(useAuthStore.getState().user!, newAccessToken, newRefreshToken)
|
||||
|
||||
// 处理排队的请求
|
||||
processQueue(null, newAccessToken);
|
||||
processQueue(null, newAccessToken)
|
||||
|
||||
// 重试原始请求
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
return apiClient(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 刷新失败 → 登出
|
||||
processQueue(refreshError, null);
|
||||
useAuthStore.getState().clearAuth();
|
||||
window.location.href = "/";
|
||||
return Promise.reject(refreshError);
|
||||
processQueue(refreshError, null)
|
||||
useAuthStore.getState().clearAuth()
|
||||
window.location.href = "/"
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// 提取后端返回的错误信息(detail / message / msg)
|
||||
// 注意:后端返回的字段可能是对象 {code, message} 而非字符串,需要安全提取
|
||||
const data = error.response?.data;
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg;
|
||||
const data = error.response?.data
|
||||
const rawServerMsg = data?.detail || data?.message || data?.msg
|
||||
// 安全提取字符串:递归处理嵌套对象(后端可能返回 {code, message: {code, message}} 等)
|
||||
const safeExtractString = (val: unknown): string => {
|
||||
if (typeof val === "string") return val;
|
||||
if (typeof val === "string") return val
|
||||
if (typeof val === "object" && val !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 防御性错误提取,后端错误格式不固定
|
||||
const obj = val as Record<string, any>;
|
||||
if (typeof obj.message === "string") return obj.message;
|
||||
if (typeof obj.msg === "string") return obj.msg;
|
||||
if (typeof obj.detail === "string") return obj.detail;
|
||||
const obj = val as Record<string, any>
|
||||
if (typeof obj.message === "string") return obj.message
|
||||
if (typeof obj.msg === "string") return obj.msg
|
||||
if (typeof obj.detail === "string") return obj.detail
|
||||
// 嵌套对象:递归提取
|
||||
if (typeof obj.message === "object" && obj.message !== null)
|
||||
return safeExtractString(obj.message);
|
||||
if (typeof obj.msg === "object" && obj.msg !== null)
|
||||
return safeExtractString(obj.msg);
|
||||
return JSON.stringify(val);
|
||||
return safeExtractString(obj.message)
|
||||
if (typeof obj.msg === "object" && obj.msg !== null) return safeExtractString(obj.msg)
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const serverMsg = safeExtractString(rawServerMsg);
|
||||
let handled = false;
|
||||
return ""
|
||||
}
|
||||
const serverMsg = safeExtractString(rawServerMsg)
|
||||
let handled = false
|
||||
|
||||
if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
|
||||
message.error("请求超时,请检查网络后重试");
|
||||
handled = true;
|
||||
message.error("请求超时,请检查网络后重试")
|
||||
handled = true
|
||||
} else if (!error.response) {
|
||||
message.error("网络连接异常,请检查网络设置");
|
||||
handled = true;
|
||||
message.error("网络连接异常,请检查网络设置")
|
||||
handled = true
|
||||
} else if (serverMsg) {
|
||||
message.error(serverMsg);
|
||||
handled = true;
|
||||
message.error(serverMsg)
|
||||
handled = true
|
||||
} else {
|
||||
const status = error.response?.status;
|
||||
const status = error.response?.status
|
||||
if (status === 413) {
|
||||
message.error("文件过大,请缩小后重试");
|
||||
handled = true;
|
||||
message.error("文件过大,请缩小后重试")
|
||||
handled = true
|
||||
} else if (status === 415) {
|
||||
message.error("不支持的文件格式");
|
||||
handled = true;
|
||||
message.error("不支持的文件格式")
|
||||
handled = true
|
||||
} else if (status === 503) {
|
||||
message.error("服务暂不可用,请稍后再试");
|
||||
handled = true;
|
||||
message.error("服务暂不可用,请稍后再试")
|
||||
handled = true
|
||||
} else if (status && status >= 500) {
|
||||
message.error("服务器繁忙,请稍后再试");
|
||||
handled = true;
|
||||
message.error("服务器繁忙,请稍后再试")
|
||||
handled = true
|
||||
}
|
||||
// 其他 4xx 且无具体信息时不弹通用提示,由各组件自行处理
|
||||
}
|
||||
@@ -176,11 +165,11 @@ apiClient.interceptors.response.use(
|
||||
// 标记已展示过提示,组件 onError 可据此跳过重复 toast
|
||||
if (handled) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(error as any).__msgShown = true;
|
||||
;(error as any).__msgShown = true
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
return Promise.reject(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
export default apiClient;
|
||||
export default apiClient
|
||||
|
||||
@@ -2,109 +2,98 @@
|
||||
* 查重 API 模块
|
||||
* 提供视频查重相关接口
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 查重记录状态 */
|
||||
export type DuplicationStatus =
|
||||
"pending" | "processing" | "completed" | "failed";
|
||||
export type DuplicationStatus = "pending" | "processing" | "completed" | "failed"
|
||||
|
||||
/** 查重记录 */
|
||||
export interface DuplicationRecord {
|
||||
id: string;
|
||||
id: string
|
||||
/** 原始文件名 */
|
||||
filename: string;
|
||||
filename: string
|
||||
/** 文件大小(字节) */
|
||||
file_size: number;
|
||||
file_size: number
|
||||
/** 时长(秒) */
|
||||
duration_seconds?: number;
|
||||
duration_seconds?: number
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
status: DuplicationStatus
|
||||
/** 查重率(0-100) */
|
||||
duplicate_rate?: number;
|
||||
duplicate_rate?: number
|
||||
/** 重复片段数 */
|
||||
duplicate_count?: number;
|
||||
duplicate_count?: number
|
||||
/** 创建时间 */
|
||||
created_at: string;
|
||||
created_at: string
|
||||
/** 更新时间 */
|
||||
updated_at: string;
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 重复片段详情 */
|
||||
export interface DuplicateSegment {
|
||||
id: string;
|
||||
id: string
|
||||
/** 在原始视频中的起始时间(秒) */
|
||||
source_start: number;
|
||||
source_start: number
|
||||
/** 在原始视频中的结束时间(秒) */
|
||||
source_end: number;
|
||||
source_end: number
|
||||
/** 匹配到的已有视频 ID */
|
||||
matched_video_id: string;
|
||||
matched_video_id: string
|
||||
/** 匹配到的已有视频名称 */
|
||||
matched_video_name: string;
|
||||
matched_video_name: string
|
||||
/** 匹配片段在已有视频中的起始时间 */
|
||||
matched_start: number;
|
||||
matched_start: number
|
||||
/** 匹配片段在已有视频中的结束时间 */
|
||||
matched_end: number;
|
||||
matched_end: number
|
||||
/** 相似度(0-100) */
|
||||
similarity: number;
|
||||
similarity: number
|
||||
}
|
||||
|
||||
/** 查重详情 */
|
||||
export interface DuplicationDetail extends DuplicationRecord {
|
||||
/** 重复片段列表 */
|
||||
segments: DuplicateSegment[];
|
||||
segments: DuplicateSegment[]
|
||||
}
|
||||
|
||||
/** 上传查重响应 */
|
||||
export interface DuplicationUploadResponse {
|
||||
/** 查重记录 ID */
|
||||
id: string;
|
||||
id: string
|
||||
/** 状态 */
|
||||
status: DuplicationStatus;
|
||||
status: DuplicationStatus
|
||||
/** 消息 */
|
||||
message: string;
|
||||
message: string
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 上传视频进行查重 */
|
||||
export const uploadForDuplication = async (
|
||||
file: File,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
export const uploadForDuplication = async (file: File): Promise<DuplicationUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
const response = await apiClient.post("/duplication/upload", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重记录列表 */
|
||||
export const getDuplicationRecords = async (): Promise<DuplicationRecord[]> => {
|
||||
const response = await apiClient.get("/duplication/records");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/duplication/records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取查重详情 */
|
||||
export const getDuplicationDetail = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getDuplicationDetail = async (recordId: string): Promise<DuplicationDetail> => {
|
||||
const response = await apiClient.get(`/duplication/records/${recordId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除查重记录 */
|
||||
export const deleteDuplicationRecord = async (
|
||||
recordId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`);
|
||||
};
|
||||
export const deleteDuplicationRecord = async (recordId: string): Promise<void> => {
|
||||
await apiClient.delete(`/duplication/records/${recordId}`)
|
||||
}
|
||||
|
||||
/** 重新查重 */
|
||||
export const retryDuplication = async (
|
||||
recordId: string,
|
||||
): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(
|
||||
`/duplication/records/${recordId}/retry`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const retryDuplication = async (recordId: string): Promise<DuplicationUploadResponse> => {
|
||||
const response = await apiClient.post(`/duplication/records/${recordId}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+297
-342
@@ -2,8 +2,8 @@
|
||||
* 剪辑计划 API — 对接后端 Edit Plans Schema
|
||||
* 字段名严格匹配后端 API 响应
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { AssetItem } from "./assets";
|
||||
import apiClient from "./client"
|
||||
import type { AssetItem } from "./assets"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ============================================================
|
||||
* 后端 API 类型(严格匹配后端 Schema)
|
||||
@@ -20,203 +20,203 @@ import type {
|
||||
|
||||
/** 剪辑计划状态枚举 */
|
||||
export type EditPlanStatus =
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled";
|
||||
"draft" | "editing" | "rendering" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 标题配置(对齐后端 title_config) */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 片段 TTS 配置 */
|
||||
export interface SegmentTtsConfig {
|
||||
mode: string;
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed: number;
|
||||
pitch: number;
|
||||
volume: number;
|
||||
subtitle_sync: boolean;
|
||||
mode: string
|
||||
text: string
|
||||
voice_id: string
|
||||
speed: number
|
||||
pitch: number
|
||||
volume: number
|
||||
subtitle_sync: boolean
|
||||
}
|
||||
|
||||
/** 片段裁剪配置 */
|
||||
export interface SegmentTrimConfig {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
start_time: number
|
||||
end_time: number
|
||||
}
|
||||
|
||||
/** 片段转场配置 */
|
||||
export interface SegmentTransitionConfig {
|
||||
type: string;
|
||||
duration: number;
|
||||
type: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
/** 剪辑计划中的单个片段(config 内部 segments 项) */
|
||||
export interface EditPlanSegment {
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string;
|
||||
transition?: SegmentTransitionConfig;
|
||||
playback_speed?: number;
|
||||
tts_config?: SegmentTtsConfig;
|
||||
trim_config?: SegmentTrimConfig;
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string
|
||||
transition?: SegmentTransitionConfig
|
||||
playback_speed?: number
|
||||
tts_config?: SegmentTtsConfig
|
||||
trim_config?: SegmentTrimConfig
|
||||
}
|
||||
|
||||
/** 剪辑计划 config 完整类型(对齐后端 config JSON 结构) */
|
||||
export interface EditPlanConfig {
|
||||
title_config?: TitleConfig;
|
||||
subtitle_config?: SubtitleConfig;
|
||||
bgm_config?: BgmConfig;
|
||||
estimated_duration?: number;
|
||||
segments?: EditPlanSegment[];
|
||||
watermark_config?: WatermarkConfig;
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
pip_config?: PipConfig;
|
||||
filter_config?: FilterConfig;
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
sticker_config?: StickerConfig;
|
||||
cover_config?: CoverConfig;
|
||||
title_config?: TitleConfig
|
||||
subtitle_config?: SubtitleConfig
|
||||
bgm_config?: BgmConfig
|
||||
estimated_duration?: number
|
||||
segments?: EditPlanSegment[]
|
||||
watermark_config?: WatermarkConfig
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
pip_config?: PipConfig
|
||||
filter_config?: FilterConfig
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
sticker_config?: StickerConfig
|
||||
cover_config?: CoverConfig
|
||||
/** 前端扩展:关联的素材 ID 列表 */
|
||||
asset_ids?: string[];
|
||||
asset_ids?: string[]
|
||||
/** 配音 ID */
|
||||
voice_id?: string;
|
||||
voice_id?: string
|
||||
/** 克隆音色档案 ID */
|
||||
voice_clone_profile_id?: string;
|
||||
voice_clone_profile_id?: string
|
||||
/** 自定义配音音频 URL */
|
||||
custom_audio_url?: string;
|
||||
custom_audio_url?: string
|
||||
/** 自定义配音文本 */
|
||||
custom_text?: string;
|
||||
custom_text?: string
|
||||
/** 视频比例 */
|
||||
ratio?: string;
|
||||
ratio?: string
|
||||
/** 视频风格 */
|
||||
style?: string;
|
||||
style?: string
|
||||
/** 目标时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 是否自动生成字幕 */
|
||||
auto_subtitles?: boolean;
|
||||
auto_subtitles?: boolean
|
||||
/** 是否启用 BGM */
|
||||
bgm?: boolean;
|
||||
bgm?: boolean
|
||||
/** 生成数量 */
|
||||
generate_count?: number;
|
||||
generate_count?: number
|
||||
/** 素材模式 */
|
||||
material_mode?: string;
|
||||
material_mode?: string
|
||||
}
|
||||
|
||||
/** 剪辑计划(后端响应) */
|
||||
export interface EditPlan {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
status: EditPlanStatus;
|
||||
total_duration: number;
|
||||
id: string
|
||||
template_id: string
|
||||
name: string
|
||||
status: EditPlanStatus
|
||||
total_duration: number
|
||||
/** 生成视频数量(后端 EditPlanResponse.result_count) */
|
||||
result_count: number;
|
||||
config: EditPlanConfig;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
result_count: number
|
||||
config: EditPlanConfig
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
|
||||
export interface CreateEditPlanRequest {
|
||||
template_id: string;
|
||||
name: string;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
template_id: string
|
||||
name: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
/** 来源剪辑计划 ID(从剪辑计划跳转到一键生成时关联) */
|
||||
source_edit_plan_id?: string;
|
||||
source_edit_plan_id?: string
|
||||
}
|
||||
|
||||
/** 更新剪辑计划请求 */
|
||||
export interface UpdateEditPlanRequest {
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
total_duration?: number;
|
||||
status?: EditPlanStatus;
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
total_duration?: number
|
||||
status?: EditPlanStatus
|
||||
}
|
||||
|
||||
/** 生成响应 */
|
||||
export interface GenerateResponse {
|
||||
plan_id: string;
|
||||
plan_status: EditPlanStatus;
|
||||
generation_task_id: string;
|
||||
clip_count: number;
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id: string
|
||||
clip_count: number
|
||||
}
|
||||
|
||||
/** 剪辑计划关联的生成记录(实际是 GenerationTask 对象) */
|
||||
export interface EditPlanGeneration {
|
||||
id: string; // 即 generation_task_id
|
||||
source_edit_plan_id: string;
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
status: EditPlanStatus;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message: string;
|
||||
error_info: Record<string, unknown>;
|
||||
logs: Array<Record<string, unknown>>;
|
||||
retry_count: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string // 即 generation_task_id
|
||||
source_edit_plan_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
status: EditPlanStatus
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
error_info: Record<string, unknown>
|
||||
logs: Array<Record<string, unknown>>
|
||||
retry_count: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 片段生成状态 */
|
||||
export interface ClipStatusItem {
|
||||
clip_id: string;
|
||||
clip_type: string;
|
||||
order: number;
|
||||
status: string;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
duration?: number;
|
||||
clip_id: string
|
||||
clip_type: string
|
||||
order: number
|
||||
status: string
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** 生成状态轮询响应 */
|
||||
export interface GenerationStatusResponse {
|
||||
plan_id: string;
|
||||
plan_status: EditPlanStatus;
|
||||
generation_task_id?: string;
|
||||
clips: ClipStatusItem[];
|
||||
plan_id: string
|
||||
plan_status: EditPlanStatus
|
||||
generation_task_id?: string
|
||||
clips: ClipStatusItem[]
|
||||
}
|
||||
|
||||
/** 生成视频详情(对应后端 GeneratedVideoResponse) */
|
||||
export interface GeneratedVideo {
|
||||
id: string;
|
||||
project_id?: string;
|
||||
generation_task_id?: string;
|
||||
name: string;
|
||||
file_url: string;
|
||||
file_size?: number;
|
||||
duration?: number;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
status: string;
|
||||
review_status?: string;
|
||||
download_url?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
project_id?: string
|
||||
generation_task_id?: string
|
||||
name: string
|
||||
file_url: string
|
||||
file_size?: number
|
||||
duration?: number
|
||||
thumbnail_url?: string
|
||||
width?: number
|
||||
height?: number
|
||||
fps?: number
|
||||
status: string
|
||||
review_status?: string
|
||||
download_url?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -225,51 +225,51 @@ export interface GeneratedVideo {
|
||||
|
||||
/** AI 推荐请求 */
|
||||
export interface AIRecommendRequest {
|
||||
asset_ids: string[];
|
||||
editing_mode?: string;
|
||||
target_duration?: number;
|
||||
asset_ids: string[]
|
||||
editing_mode?: string
|
||||
target_duration?: number
|
||||
}
|
||||
|
||||
/** AI 推荐单个片段 */
|
||||
export interface AIRecommendClipItem {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
text_content: string;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
asset_id: string;
|
||||
start_time: number;
|
||||
config: EditPlanConfig;
|
||||
clip_type: string
|
||||
order: number
|
||||
text_content: string
|
||||
duration: number
|
||||
transition_effect: string
|
||||
asset_id: string
|
||||
start_time: number
|
||||
config: EditPlanConfig
|
||||
}
|
||||
|
||||
/** AI 推荐响应 */
|
||||
export interface AIRecommendResponse {
|
||||
plan_id: string;
|
||||
clips: AIRecommendClipItem[];
|
||||
config: EditPlanConfig;
|
||||
total_duration: number;
|
||||
confidence: number;
|
||||
plan_id: string
|
||||
clips: AIRecommendClipItem[]
|
||||
config: EditPlanConfig
|
||||
total_duration: number
|
||||
confidence: number
|
||||
}
|
||||
|
||||
/** AI 封面生成请求 */
|
||||
export interface GenerateCoverRequest {
|
||||
asset_ids: string[];
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate";
|
||||
frame_time?: number;
|
||||
asset_ids: string[]
|
||||
cover_type?: "ai_frame" | "manual" | "upload" | "ai_regenerate"
|
||||
frame_time?: number
|
||||
}
|
||||
|
||||
/** AI 封面生成响应 */
|
||||
export interface GenerateCoverResponse {
|
||||
plan_id: string;
|
||||
cover: CoverResult;
|
||||
plan_id: string
|
||||
cover: CoverResult
|
||||
}
|
||||
|
||||
/** 封面生成结果 */
|
||||
export interface CoverResult {
|
||||
scheme?: string;
|
||||
asset_id?: string;
|
||||
frame_time?: number;
|
||||
thumbnail_url?: string;
|
||||
scheme?: string
|
||||
asset_id?: string
|
||||
frame_time?: number
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -293,30 +293,30 @@ export interface TransitionEffect {
|
||||
| "wipe_up"
|
||||
| "wipe_down"
|
||||
| "circlecrop"
|
||||
| "rectcrop";
|
||||
duration: number; // 转场时长(秒)
|
||||
| "rectcrop"
|
||||
duration: number // 转场时长(秒)
|
||||
/** 播放速度倍率 */
|
||||
playback_speed?: number;
|
||||
playback_speed?: number
|
||||
}
|
||||
|
||||
/** 素材库资产(UI 层类型,映射自后端 AssetResponse) */
|
||||
export interface MediaAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "video" | "image" | "audio";
|
||||
id: string
|
||||
name: string
|
||||
type: "video" | "image" | "audio"
|
||||
/** 缩略图 URL */
|
||||
thumbnail_url?: string;
|
||||
thumbnail_url?: string
|
||||
/** 时长(秒),仅 video/audio */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 文件大小(字节) */
|
||||
size?: number;
|
||||
size?: number
|
||||
/** 标签 */
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
tags: string[]
|
||||
created_at: string
|
||||
/** 质量分 0-100 */
|
||||
quality_score?: number;
|
||||
quality_score?: number
|
||||
/** 分类状态 */
|
||||
classification_status?: "pending" | "processing" | "completed" | "failed";
|
||||
classification_status?: "pending" | "processing" | "completed" | "failed"
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -325,42 +325,38 @@ export interface MediaAsset {
|
||||
|
||||
/** 剪辑计划列表查询参数 */
|
||||
export interface EditPlanListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
template_id?: string;
|
||||
status?: string;
|
||||
page?: number
|
||||
page_size?: number
|
||||
template_id?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
/** 剪辑计划列表分页响应 */
|
||||
export interface EditPlanListResponse {
|
||||
items: EditPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: EditPlan[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 获取剪辑计划列表(支持分页和筛选) */
|
||||
export async function getEditPlans(
|
||||
params?: EditPlanListParams,
|
||||
): Promise<EditPlanListResponse> {
|
||||
export async function getEditPlans(params?: EditPlanListParams): Promise<EditPlanListResponse> {
|
||||
const response = await apiClient.get<EditPlanListResponse>("/edit-plans", {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个剪辑计划 */
|
||||
export async function getEditPlan(planId: string): Promise<EditPlan> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`);
|
||||
return response.data;
|
||||
const response = await apiClient.get(`/edit-plans/${planId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建剪辑计划 */
|
||||
export async function createEditPlan(
|
||||
data: CreateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data);
|
||||
return response.data;
|
||||
export async function createEditPlan(data: CreateEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post("/edit-plans", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新剪辑计划 */
|
||||
@@ -368,31 +364,25 @@ export async function updateEditPlan(
|
||||
planId: string,
|
||||
data: UpdateEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.put(`/edit-plans/${planId}`, data);
|
||||
return response.data;
|
||||
const response = await apiClient.put(`/edit-plans/${planId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除剪辑计划 */
|
||||
export async function deleteEditPlan(planId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}`);
|
||||
await apiClient.delete(`/edit-plans/${planId}`)
|
||||
}
|
||||
|
||||
/** 触发剪辑计划生成 */
|
||||
export async function generateEditPlan(
|
||||
planId: string,
|
||||
): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
|
||||
return response.data;
|
||||
export async function generateEditPlan(planId: string): Promise<GenerateResponse> {
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取剪辑计划生成状态(轮询用) */
|
||||
export async function getGenerationStatus(
|
||||
planId: string,
|
||||
): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(
|
||||
`/edit-plans/${planId}/generation-status`,
|
||||
);
|
||||
return response.data;
|
||||
export async function getGenerationStatus(planId: string): Promise<GenerationStatusResponse> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generation-status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 推荐片段方案 */
|
||||
@@ -400,11 +390,8 @@ export async function aiRecommendClips(
|
||||
planId: string,
|
||||
data: AIRecommendRequest,
|
||||
): Promise<AIRecommendResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/ai-recommend`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/ai-recommend`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** AI 生成封面 */
|
||||
@@ -412,32 +399,25 @@ export async function generateCover(
|
||||
planId: string,
|
||||
data: GenerateCoverRequest,
|
||||
): Promise<GenerateCoverResponse> {
|
||||
const response = await apiClient.post(
|
||||
`/edit-plans/${planId}/generate-cover`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post(`/edit-plans/${planId}/generate-cover`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取剪辑计划关联的生成记录 */
|
||||
export async function getEditPlanGenerations(
|
||||
planId: string,
|
||||
): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`);
|
||||
return response.data.items || [];
|
||||
export async function getEditPlanGenerations(planId: string): Promise<EditPlanGeneration[]> {
|
||||
const response = await apiClient.get(`/edit-plans/${planId}/generations`)
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 获取生成任务的视频结果列表 */
|
||||
export async function getGenerationTaskResults(
|
||||
taskId: string,
|
||||
): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`);
|
||||
return response.data.items || response.data || [];
|
||||
export async function getGenerationTaskResults(taskId: string): Promise<GeneratedVideo[]> {
|
||||
const response = await apiClient.get(`/generation/tasks/${taskId}/results`)
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 取消生成任务 */
|
||||
export async function cancelGeneration(planId: string): Promise<void> {
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`);
|
||||
await apiClient.post(`/edit-plans/${planId}/cancel`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -445,66 +425,66 @@ export async function cancelGeneration(planId: string): Promise<void> {
|
||||
* ============================================================ */
|
||||
|
||||
/** 片段状态 */
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed";
|
||||
export type EditPlanClipStatus = "pending" | "processing" | "ready" | "failed"
|
||||
|
||||
/** 剪辑片段(后端响应) */
|
||||
export interface EditPlanClip {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
clip_type: string; // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number;
|
||||
asset_id: string;
|
||||
text_content: string;
|
||||
start_time: number;
|
||||
duration: number;
|
||||
transition_effect: string;
|
||||
transition_duration: number;
|
||||
playback_speed: number;
|
||||
status: EditPlanClipStatus;
|
||||
config: Record<string, unknown>;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
plan_id: string
|
||||
clip_type: string // main / intro / outro / overlay / background / b_roll 等
|
||||
order: number
|
||||
asset_id: string
|
||||
text_content: string
|
||||
start_time: number
|
||||
duration: number
|
||||
transition_effect: string
|
||||
transition_duration: number
|
||||
playback_speed: number
|
||||
status: EditPlanClipStatus
|
||||
config: Record<string, unknown>
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 创建片段请求 */
|
||||
export interface CreateEditPlanClipRequest {
|
||||
clip_type: string;
|
||||
order: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
clip_type: string
|
||||
order: number
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
transition_effect?: string
|
||||
transition_duration?: number
|
||||
playback_speed?: number
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 更新片段请求 */
|
||||
export interface UpdateEditPlanClipRequest {
|
||||
clip_type?: string;
|
||||
order?: number;
|
||||
asset_id?: string;
|
||||
text_content?: string;
|
||||
start_time?: number;
|
||||
duration?: number;
|
||||
transition_effect?: string;
|
||||
transition_duration?: number;
|
||||
playback_speed?: number;
|
||||
config?: Record<string, unknown>;
|
||||
clip_type?: string
|
||||
order?: number
|
||||
asset_id?: string
|
||||
text_content?: string
|
||||
start_time?: number
|
||||
duration?: number
|
||||
transition_effect?: string
|
||||
transition_duration?: number
|
||||
playback_speed?: number
|
||||
config?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** 片段列表响应 */
|
||||
export interface EditPlanClipListResponse {
|
||||
items: EditPlanClip[];
|
||||
total: number;
|
||||
items: EditPlanClip[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 片段列表查询参数 */
|
||||
export interface EditPlanClipListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 获取片段列表 */
|
||||
@@ -512,22 +492,16 @@ export async function getEditPlanClips(
|
||||
planId: string,
|
||||
params?: EditPlanClipListParams,
|
||||
): Promise<EditPlanClipListResponse> {
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.get<EditPlanClipListResponse>(`/edit-plans/${planId}/clips`, {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个片段详情 */
|
||||
export async function getEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
);
|
||||
return response.data;
|
||||
export async function getEditPlanClip(planId: string, clipId: string): Promise<EditPlanClip> {
|
||||
const response = await apiClient.get<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建片段 */
|
||||
@@ -535,11 +509,8 @@ export async function createEditPlanClip(
|
||||
planId: string,
|
||||
data: CreateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.post<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.post<EditPlanClip>(`/edit-plans/${planId}/clips`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新片段 */
|
||||
@@ -548,19 +519,13 @@ export async function updateEditPlanClip(
|
||||
clipId: string,
|
||||
data: UpdateEditPlanClipRequest,
|
||||
): Promise<EditPlanClip> {
|
||||
const response = await apiClient.put<EditPlanClip>(
|
||||
`/edit-plans/${planId}/clips/${clipId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
const response = await apiClient.put<EditPlanClip>(`/edit-plans/${planId}/clips/${clipId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除片段 */
|
||||
export async function deleteEditPlanClip(
|
||||
planId: string,
|
||||
clipId: string,
|
||||
): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`);
|
||||
export async function deleteEditPlanClip(planId: string, clipId: string): Promise<void> {
|
||||
await apiClient.delete(`/edit-plans/${planId}/clips/${clipId}`)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -569,30 +534,30 @@ export async function deleteEditPlanClip(
|
||||
|
||||
/** 重排序条目 */
|
||||
export interface ClipReorderItem {
|
||||
clip_id: string;
|
||||
new_order: number;
|
||||
clip_id: string
|
||||
new_order: number
|
||||
}
|
||||
|
||||
/** 重排序响应 */
|
||||
export interface ClipReorderResponse {
|
||||
success: boolean;
|
||||
updated_count: number;
|
||||
message: string;
|
||||
success: boolean
|
||||
updated_count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 批量删除响应 */
|
||||
export interface ClipBatchDeleteResponse {
|
||||
success: boolean;
|
||||
deleted_count: number;
|
||||
message: string;
|
||||
success: boolean
|
||||
deleted_count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 从素材批量创建响应 */
|
||||
export interface ClipsFromAssetsResponse {
|
||||
success: boolean;
|
||||
created_count: number;
|
||||
message: string;
|
||||
clip_ids: string[];
|
||||
success: boolean
|
||||
created_count: number
|
||||
message: string
|
||||
clip_ids: string[]
|
||||
}
|
||||
|
||||
/** 片段重排序(拖拽排序后一次性提交) */
|
||||
@@ -603,8 +568,8 @@ export async function reorderEditPlanClips(
|
||||
const response = await apiClient.post<ClipReorderResponse>(
|
||||
`/edit-plans/${planId}/clips/reorder`,
|
||||
{ items },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 批量删除片段 */
|
||||
@@ -615,8 +580,8 @@ export async function batchDeleteEditPlanClips(
|
||||
const response = await apiClient.post<ClipBatchDeleteResponse>(
|
||||
`/edit-plans/${planId}/clips/batch-delete`,
|
||||
{ clip_ids: clipIds },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从素材批量创建片段(追加到时间线末尾) */
|
||||
@@ -628,8 +593,8 @@ export async function createClipsFromAssets(
|
||||
const response = await apiClient.post<ClipsFromAssetsResponse>(
|
||||
`/edit-plans/${planId}/clips/from-assets`,
|
||||
{ asset_ids: assetIds, clip_type: clipType },
|
||||
);
|
||||
return response.data;
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -638,40 +603,32 @@ export async function createClipsFromAssets(
|
||||
|
||||
/** 复制计划请求 */
|
||||
export interface CopyEditPlanRequest {
|
||||
name?: string;
|
||||
project_id?: string;
|
||||
name?: string
|
||||
project_id?: string
|
||||
}
|
||||
|
||||
/** 复制剪辑计划(含所有片段配置) */
|
||||
export async function copyEditPlan(
|
||||
planId: string,
|
||||
data?: CopyEditPlanRequest,
|
||||
): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(
|
||||
`/edit-plans/${planId}/copy`,
|
||||
data || {},
|
||||
);
|
||||
return response.data;
|
||||
export async function copyEditPlan(planId: string, data?: CopyEditPlanRequest): Promise<EditPlan> {
|
||||
const response = await apiClient.post<EditPlan>(`/edit-plans/${planId}/copy`, data || {})
|
||||
return response.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
|
||||
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
|
||||
*/
|
||||
export async function getMediaAssets(
|
||||
libraryId?: string,
|
||||
): Promise<MediaAsset[]> {
|
||||
export async function getMediaAssets(libraryId?: string): Promise<MediaAsset[]> {
|
||||
const response = await apiClient.get("/assets", {
|
||||
params: libraryId ? { library_id: libraryId } : undefined,
|
||||
});
|
||||
const items: AssetItem[] = response.data.items || [];
|
||||
return items.map(mapAssetToMediaAsset);
|
||||
})
|
||||
const items: AssetItem[] = response.data.items || []
|
||||
return items.map(mapAssetToMediaAsset)
|
||||
}
|
||||
|
||||
/** 获取单个素材 */
|
||||
export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
const response = await apiClient.get(`/assets/${id}`);
|
||||
return mapAssetToMediaAsset(response.data);
|
||||
const response = await apiClient.get(`/assets/${id}`)
|
||||
return mapAssetToMediaAsset(response.data)
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -679,17 +636,15 @@ export async function getMediaAsset(id: string): Promise<MediaAsset> {
|
||||
* ============================================================ */
|
||||
|
||||
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
return "audio";
|
||||
if (mimeType.startsWith("video/")) return "video"
|
||||
if (mimeType.startsWith("image/")) return "image"
|
||||
return "audio"
|
||||
}
|
||||
|
||||
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
// 优先取顶层 duration,其次从 metadata 回退
|
||||
const metaDuration =
|
||||
typeof asset.metadata?.duration === "number"
|
||||
? asset.metadata.duration
|
||||
: undefined;
|
||||
typeof asset.metadata?.duration === "number" ? asset.metadata.duration : undefined
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
@@ -701,7 +656,7 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
created_at: asset.created_at ?? "",
|
||||
quality_score: asset.quality_score ?? undefined,
|
||||
classification_status: asset.classification_status ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -710,9 +665,9 @@ function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
|
||||
|
||||
/** 转场效果选项(14 种预设) */
|
||||
export const TRANSITION_OPTIONS: {
|
||||
value: TransitionEffect["type"];
|
||||
label: string;
|
||||
icon: string;
|
||||
value: TransitionEffect["type"]
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ value: "none", label: "无转场", icon: "⊘" },
|
||||
{ value: "cut", label: "硬切", icon: "✂" },
|
||||
@@ -729,7 +684,7 @@ export const TRANSITION_OPTIONS: {
|
||||
{ value: "wipe_down", label: "下擦除", icon: "▾̄" },
|
||||
{ value: "circlecrop", label: "圆形裁切", icon: "●" },
|
||||
{ value: "rectcrop", label: "矩形裁切", icon: "■" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 素材类型标签 */
|
||||
export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
@@ -737,7 +692,7 @@ export const MATERIAL_TYPE_LABELS: Record<string, string> = {
|
||||
image: "图片",
|
||||
audio: "音频",
|
||||
voiceover: "配音",
|
||||
};
|
||||
}
|
||||
|
||||
/** 素材类型图标 */
|
||||
export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
@@ -745,7 +700,7 @@ export const MATERIAL_TYPE_ICONS: Record<string, string> = {
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
voiceover: "🎙️",
|
||||
};
|
||||
}
|
||||
|
||||
/** 计划状态标签 */
|
||||
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
@@ -755,17 +710,17 @@ export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
};
|
||||
}
|
||||
|
||||
/** 质量分筛选选项 */
|
||||
export const QUALITY_OPTIONS: {
|
||||
value: string;
|
||||
label: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
value: string
|
||||
label: string
|
||||
min?: number
|
||||
max?: number
|
||||
}[] = [
|
||||
{ value: "all", label: "全部质量" },
|
||||
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
|
||||
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
|
||||
{ value: "low", label: "低质量 (0-49)", min: 0, max: 49 },
|
||||
];
|
||||
]
|
||||
|
||||
+104
-112
@@ -2,7 +2,7 @@
|
||||
* 剪辑计划编辑器 API
|
||||
* 对接后端 /api/v1/templates 路由
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
import type {
|
||||
WatermarkConfig,
|
||||
IntroOutroConfig,
|
||||
@@ -11,12 +11,12 @@ import type {
|
||||
ChromaKeyConfig,
|
||||
StickerConfig,
|
||||
CoverConfig,
|
||||
} from "@/pages/editing-planner/types";
|
||||
} from "@/pages/editing-planner/types"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板模式(后端枚举值) */
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip";
|
||||
export type TemplateMode = "pip" | "voice_over" | "one_take" | "voice_pip"
|
||||
|
||||
/** 模式显示名称映射 */
|
||||
export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
@@ -24,7 +24,7 @@ export const MODE_LABELS: Record<TemplateMode, string> = {
|
||||
voice_over: "人物口播",
|
||||
one_take: "一镜到底",
|
||||
voice_pip: "口播+混剪",
|
||||
};
|
||||
}
|
||||
|
||||
/** 模式颜色映射 */
|
||||
export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
@@ -32,211 +32,203 @@ export const MODE_COLORS: Record<TemplateMode, string> = {
|
||||
voice_over: "green",
|
||||
one_take: "orange",
|
||||
voice_pip: "purple",
|
||||
};
|
||||
}
|
||||
|
||||
/** 标题配置 */
|
||||
export interface TitleConfig {
|
||||
ai_auto_select: boolean;
|
||||
content: string;
|
||||
font_preset: string;
|
||||
font_color: string;
|
||||
font_size: number;
|
||||
position: string;
|
||||
ai_auto_select: boolean
|
||||
content: string
|
||||
font_preset: string
|
||||
font_color: string
|
||||
font_size: number
|
||||
position: string
|
||||
}
|
||||
|
||||
/** 字幕配置 */
|
||||
export interface SubtitleConfig {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
color: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
color: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
/** BGM 配置 */
|
||||
export interface BgmConfig {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
}
|
||||
|
||||
/** 模板片段 */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
}
|
||||
|
||||
/** 剪辑模板 */
|
||||
export interface EditingTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: TemplateSegment[];
|
||||
id: string
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: TemplateSegment[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
is_active?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
cover_config?: CoverConfig
|
||||
is_active?: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模板分类 */
|
||||
export interface TemplateCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
/** 创建/更新模板请求体 */
|
||||
export interface SaveTemplatePayload {
|
||||
name: string;
|
||||
mode: TemplateMode;
|
||||
category: string;
|
||||
tags: string[];
|
||||
title_config: TitleConfig;
|
||||
subtitle_config: SubtitleConfig;
|
||||
bgm_config: BgmConfig;
|
||||
estimated_duration: number;
|
||||
segments: Omit<TemplateSegment, "id">[];
|
||||
name: string
|
||||
mode: TemplateMode
|
||||
category: string
|
||||
tags: string[]
|
||||
title_config: TitleConfig
|
||||
subtitle_config: SubtitleConfig
|
||||
bgm_config: BgmConfig
|
||||
estimated_duration: number
|
||||
segments: Omit<TemplateSegment, "id">[]
|
||||
/** 水印配置(后端就绪后启用) */
|
||||
watermark_config?: WatermarkConfig;
|
||||
watermark_config?: WatermarkConfig
|
||||
/** 片头片尾配置(后端就绪后启用) */
|
||||
intro_outro_config?: IntroOutroConfig;
|
||||
intro_outro_config?: IntroOutroConfig
|
||||
/** 画中画配置 */
|
||||
pip_config?: PipConfig;
|
||||
pip_config?: PipConfig
|
||||
/** 滤镜调色配置 */
|
||||
filter_config?: FilterConfig;
|
||||
filter_config?: FilterConfig
|
||||
/** 绿幕抠像配置 */
|
||||
green_screen_config?: ChromaKeyConfig;
|
||||
green_screen_config?: ChromaKeyConfig
|
||||
/** 贴纸配置 */
|
||||
sticker_config?: StickerConfig;
|
||||
sticker_config?: StickerConfig
|
||||
/** 封面配置 */
|
||||
cover_config?: CoverConfig;
|
||||
cover_config?: CoverConfig
|
||||
}
|
||||
|
||||
/** 使用模板生成请求体 */
|
||||
export interface GenerateFromTemplatePayload {
|
||||
voiceover_duration: number;
|
||||
voiceover_duration: number
|
||||
}
|
||||
|
||||
/** 验证警告详情 */
|
||||
export interface ValidationWarningDetails {
|
||||
/** 相关字段名 */
|
||||
field?: string;
|
||||
field?: string
|
||||
/** 期望值 */
|
||||
expected?: string | number;
|
||||
expected?: string | number
|
||||
/** 实际值 */
|
||||
actual?: string | number;
|
||||
actual?: string | number
|
||||
/** 建议值 */
|
||||
suggested?: string | number;
|
||||
suggested?: string | number
|
||||
}
|
||||
|
||||
/** 验证/生成响应 */
|
||||
export interface ValidateWarning {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: ValidationWarningDetails;
|
||||
code: string
|
||||
message: string
|
||||
details?: ValidationWarningDetails
|
||||
}
|
||||
|
||||
/** 使用模板生成响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
template: EditingTemplate;
|
||||
warnings: ValidateWarning[];
|
||||
template: EditingTemplate
|
||||
warnings: ValidateWarning[]
|
||||
}
|
||||
|
||||
/** 列表响应(带分页) */
|
||||
export interface ListTemplatesResponse {
|
||||
items: EditingTemplate[];
|
||||
total: number;
|
||||
items: EditingTemplate[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分类列表响应 */
|
||||
export interface ListCategoriesResponse {
|
||||
items: TemplateCategory[];
|
||||
items: TemplateCategory[]
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取模板列表 */
|
||||
export const getEditingTemplates = async (params?: {
|
||||
category?: string;
|
||||
tag?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
category?: string
|
||||
tag?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}): Promise<EditingTemplate[]> => {
|
||||
const response = await apiClient.get<ListTemplatesResponse>("/templates", {
|
||||
params: {
|
||||
skip: params?.skip ?? 0,
|
||||
limit: params?.limit ?? 50,
|
||||
},
|
||||
});
|
||||
let list = response.data.items;
|
||||
if (params?.category)
|
||||
list = list.filter((t) => t.category === params.category);
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!));
|
||||
return list;
|
||||
};
|
||||
})
|
||||
let list = response.data.items
|
||||
if (params?.category) list = list.filter((t) => t.category === params.category)
|
||||
if (params?.tag) list = list.filter((t) => t.tags.includes(params.tag!))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 获取模板详情 */
|
||||
export const getEditingTemplate = async (
|
||||
id: string,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getEditingTemplate = async (id: string): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.get<EditingTemplate>(`/templates/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建模板 */
|
||||
export const createEditingTemplate = async (
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post<EditingTemplate>("/templates", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新模板 */
|
||||
export const updateEditingTemplate = async (
|
||||
id: string,
|
||||
data: SaveTemplatePayload,
|
||||
): Promise<EditingTemplate> => {
|
||||
const response = await apiClient.patch<EditingTemplate>(
|
||||
`/templates/${id}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.patch<EditingTemplate>(`/templates/${id}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除模板 */
|
||||
export const deleteEditingTemplate = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/templates/${id}`);
|
||||
};
|
||||
await apiClient.delete(`/templates/${id}`)
|
||||
}
|
||||
|
||||
/** 获取模板分类列表 */
|
||||
export const getTemplateCategories = async (): Promise<TemplateCategory[]> => {
|
||||
const response = await apiClient.get<ListCategoriesResponse>(
|
||||
"/templates/categories/list",
|
||||
);
|
||||
return response.data.items;
|
||||
};
|
||||
const response = await apiClient.get<ListCategoriesResponse>("/templates/categories/list")
|
||||
return response.data.items
|
||||
}
|
||||
|
||||
/** 使用模板生成视频(调用 validate 端点) */
|
||||
export const generateFromTemplate = async (
|
||||
@@ -246,6 +238,6 @@ export const generateFromTemplate = async (
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/validate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
Executable → Regular
+58
-70
@@ -3,59 +3,59 @@
|
||||
* 包含:列表查询、复核状态、批量下载
|
||||
* 后端无 /products 路由,实际从 /generation/tasks 端点获取数据
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import { getGenerationTaskResults } from "./editPlans";
|
||||
import type { GeneratedVideo } from "./editPlans";
|
||||
import apiClient from "./client"
|
||||
import { getGenerationTaskResults } from "./editPlans"
|
||||
import type { GeneratedVideo } from "./editPlans"
|
||||
|
||||
/** 复核状态 */
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected";
|
||||
export type ReviewStatus = "pending_review" | "approved" | "rejected"
|
||||
|
||||
/** 成品条目 */
|
||||
export interface ProductItem {
|
||||
id: string;
|
||||
title: string;
|
||||
video_url?: string;
|
||||
thumbnail_url?: string;
|
||||
duration_seconds?: number;
|
||||
file_size?: number;
|
||||
resolution?: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
id: string
|
||||
title: string
|
||||
video_url?: string
|
||||
thumbnail_url?: string
|
||||
duration_seconds?: number
|
||||
file_size?: number
|
||||
resolution?: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 复核状态 */
|
||||
review_status?: ReviewStatus;
|
||||
review_status?: ReviewStatus
|
||||
/** 所属项目 ID */
|
||||
project_id?: string;
|
||||
project_id?: string
|
||||
/** 所属项目名称 */
|
||||
project_name?: string;
|
||||
project_name?: string
|
||||
/** 查重率(百分比) */
|
||||
duplicate_rate?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
duplicate_rate?: number
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 列表查询参数 */
|
||||
export interface ProductListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
project_id?: string;
|
||||
review_status?: ReviewStatus | "all";
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: string
|
||||
review_status?: ReviewStatus | "all"
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface ProductListResponse {
|
||||
items: ProductItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: ProductItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 批量下载任务状态 */
|
||||
export interface BatchDownloadStatus {
|
||||
job_id: string;
|
||||
status: "processing" | "completed" | "failed";
|
||||
job_id: string
|
||||
status: "processing" | "completed" | "failed"
|
||||
/** 完成后返回的下载 URL */
|
||||
download_url?: string;
|
||||
download_url?: string
|
||||
/** 进度百分比 */
|
||||
progress?: number;
|
||||
progress?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,8 +69,7 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
thumbnail_url: task.thumbnail_url,
|
||||
duration_seconds: task.duration,
|
||||
file_size: task.file_size,
|
||||
resolution:
|
||||
task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
resolution: task.width && task.height ? `${task.width}x${task.height}` : undefined,
|
||||
status:
|
||||
task.status === "completed"
|
||||
? "completed"
|
||||
@@ -81,43 +80,36 @@ function mapTaskToProductItem(task: GeneratedVideo): ProductItem {
|
||||
project_id: task.project_id,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取成品列表(支持分页和筛选)— 实际从 generation tasks 获取 */
|
||||
export const getProducts = async (
|
||||
params?: ProductListParams,
|
||||
): Promise<ProductItem[]> => {
|
||||
const response = await apiClient.get("/generation/tasks", { params });
|
||||
const data = response.data;
|
||||
const tasks = Array.isArray(data?.items)
|
||||
? data.items
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
return tasks.map(mapTaskToProductItem);
|
||||
};
|
||||
export const getProducts = async (params?: ProductListParams): Promise<ProductItem[]> => {
|
||||
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<ProductItem> => {
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`);
|
||||
return mapTaskToProductItem(response.data);
|
||||
};
|
||||
const response = await apiClient.get(`/generation/tasks/${productId}`)
|
||||
return mapTaskToProductItem(response.data)
|
||||
}
|
||||
|
||||
/** 删除成品 — 删除 generation task */
|
||||
export const deleteProduct = async (productId: string): Promise<void> => {
|
||||
await apiClient.delete(`/generation/tasks/${productId}`);
|
||||
};
|
||||
await apiClient.delete(`/generation/tasks/${productId}`)
|
||||
}
|
||||
|
||||
/** 获取成品下载链接 — 从 generation task results 获取 */
|
||||
export const getProductDownloadUrl = async (
|
||||
productId: string,
|
||||
): Promise<{ url: string; expires_at: string }> => {
|
||||
const videos = await getGenerationTaskResults(productId);
|
||||
const video = videos[0];
|
||||
if (!video?.download_url) throw new Error("下载链接不可用");
|
||||
return { url: video.download_url, expires_at: "" };
|
||||
};
|
||||
const videos = await getGenerationTaskResults(productId)
|
||||
const video = videos[0]
|
||||
if (!video?.download_url) throw new Error("下载链接不可用")
|
||||
return { url: video.download_url, expires_at: "" }
|
||||
}
|
||||
|
||||
/** 更新复核状态 — TODO: 后端暂无对应端点,暂存本地状态 */
|
||||
export const updateReviewStatus = async (
|
||||
@@ -126,26 +118,22 @@ export const updateReviewStatus = async (
|
||||
): Promise<ProductItem> => {
|
||||
// 后端暂无 /generation/tasks/{id}/review 端点
|
||||
// 暂时返回当前状态,后续可扩展
|
||||
const product = await getProduct(productId);
|
||||
return { ...product, review_status: status };
|
||||
};
|
||||
const product = await getProduct(productId)
|
||||
return { ...product, review_status: status }
|
||||
}
|
||||
|
||||
/** 发起批量下载 — TODO: 后端暂无对应端点 */
|
||||
export const batchDownload = async (
|
||||
videoIds: string[],
|
||||
): Promise<{ job_id: string }> => {
|
||||
export const batchDownload = async (videoIds: string[]): Promise<{ job_id: string }> => {
|
||||
// 后端暂无 /generation/tasks/batch-download 端点
|
||||
// 暂时返回模拟 job_id,后续可扩展
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds);
|
||||
return { job_id: `mock-${Date.now()}` };
|
||||
};
|
||||
console.warn("[batchDownload] 后端暂无批量下载端点", videoIds)
|
||||
return { job_id: `mock-${Date.now()}` }
|
||||
}
|
||||
|
||||
/** 查询批量下载状态 — TODO: 后端暂无对应端点 */
|
||||
export const getBatchDownloadStatus = async (
|
||||
jobId: string,
|
||||
): Promise<BatchDownloadStatus> => {
|
||||
export const getBatchDownloadStatus = async (jobId: string): Promise<BatchDownloadStatus> => {
|
||||
// 后端暂无 /generation/tasks/batch-download/{jobId} 端点
|
||||
// 暂时返回模拟状态,后续可扩展
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId);
|
||||
return { job_id: jobId, status: "processing", progress: 0 };
|
||||
};
|
||||
console.warn("[getBatchDownloadStatus] 后端暂无批量下载状态端点", jobId)
|
||||
return { job_id: jobId, status: "processing", progress: 0 }
|
||||
}
|
||||
|
||||
@@ -2,60 +2,59 @@
|
||||
* 项目相关 API
|
||||
* 素材库需要 project_id,前端自动管理默认项目
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
export interface ProjectItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 后端 ProjectResponse 只返回 id, name, description */
|
||||
interface BackendProjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/** 后端 ListProjectsResponse 返回 { items: [...] } */
|
||||
interface BackendListProjectsResponse {
|
||||
items: BackendProjectResponse[];
|
||||
items: BackendProjectResponse[]
|
||||
}
|
||||
|
||||
const toProjectItem = (item: BackendProjectResponse): ProjectItem => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
});
|
||||
})
|
||||
|
||||
/** 获取当前用户的项目列表 */
|
||||
export const getProjects = async (): Promise<ProjectItem[]> => {
|
||||
const response =
|
||||
await apiClient.get<BackendListProjectsResponse>("/projects");
|
||||
return (response.data.items || []).map(toProjectItem);
|
||||
};
|
||||
const response = await apiClient.get<BackendListProjectsResponse>("/projects")
|
||||
return (response.data.items || []).map(toProjectItem)
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
export const createProject = async (data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
name: string
|
||||
description?: string
|
||||
}): Promise<ProjectItem> => {
|
||||
const response = await apiClient.post<BackendProjectResponse>("/projects", {
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
});
|
||||
return toProjectItem(response.data);
|
||||
};
|
||||
})
|
||||
return toProjectItem(response.data)
|
||||
}
|
||||
|
||||
/** 获取或创建默认项目(素材库需要 project_id) */
|
||||
export const getOrCreateDefaultProject = async (): Promise<ProjectItem> => {
|
||||
const projects = await getProjects();
|
||||
const projects = await getProjects()
|
||||
if (projects.length > 0) {
|
||||
return projects[0];
|
||||
return projects[0]
|
||||
}
|
||||
// 没有项目时自动创建默认项目
|
||||
return createProject({
|
||||
name: "默认项目",
|
||||
description: "系统自动创建的默认项目",
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,100 +2,98 @@
|
||||
* 订阅 API 模块
|
||||
* 对接后端订阅管理接口
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 套餐类型 */
|
||||
export type PlanType = "free" | "standard" | "pro" | "enterprise";
|
||||
export type PlanType = "free" | "standard" | "pro" | "enterprise"
|
||||
|
||||
/** 订阅状态 */
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial";
|
||||
export type SubscriptionStatus = "active" | "expired" | "cancelled" | "trial"
|
||||
|
||||
/** 账单状态 */
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded";
|
||||
export type BillingStatus = "paid" | "pending" | "failed" | "refunded"
|
||||
|
||||
/** 计费周期 */
|
||||
export type BillingCycle = "monthly" | "yearly";
|
||||
export type BillingCycle = "monthly" | "yearly"
|
||||
|
||||
/** 套餐信息 */
|
||||
export interface Plan {
|
||||
id: PlanType;
|
||||
name: string;
|
||||
price: number | null;
|
||||
yearly_price?: number | null;
|
||||
description: string;
|
||||
recommended: boolean;
|
||||
features: string[];
|
||||
id: PlanType
|
||||
name: string
|
||||
price: number | null
|
||||
yearly_price?: number | null
|
||||
description: string
|
||||
recommended: boolean
|
||||
features: string[]
|
||||
}
|
||||
|
||||
/** 当前订阅信息 */
|
||||
export interface SubscriptionInfo {
|
||||
id: string;
|
||||
plan_id: PlanType;
|
||||
plan_name: string;
|
||||
status: SubscriptionStatus;
|
||||
billing_cycle: BillingCycle;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
amount: number;
|
||||
auto_renew: boolean;
|
||||
created_at: string;
|
||||
id: string
|
||||
plan_id: PlanType
|
||||
plan_name: string
|
||||
status: SubscriptionStatus
|
||||
billing_cycle: BillingCycle
|
||||
current_period_start: string
|
||||
current_period_end: string
|
||||
amount: number
|
||||
auto_renew: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 账单记录 */
|
||||
export interface BillingRecord {
|
||||
id: string;
|
||||
plan_name: string;
|
||||
amount: number;
|
||||
billing_cycle: BillingCycle;
|
||||
status: BillingStatus;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
invoice_url?: string;
|
||||
id: string
|
||||
plan_name: string
|
||||
amount: number
|
||||
billing_cycle: BillingCycle
|
||||
status: BillingStatus
|
||||
payment_method: string
|
||||
created_at: string
|
||||
invoice_url?: string
|
||||
}
|
||||
|
||||
/** 升级/降级请求 */
|
||||
export interface ChangePlanRequest {
|
||||
target_plan_id: PlanType;
|
||||
billing_cycle: BillingCycle;
|
||||
target_plan_id: PlanType
|
||||
billing_cycle: BillingCycle
|
||||
}
|
||||
|
||||
/** 升级/降级响应 */
|
||||
export interface ChangePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
new_subscription?: SubscriptionInfo;
|
||||
success: boolean
|
||||
message: string
|
||||
new_subscription?: SubscriptionInfo
|
||||
}
|
||||
|
||||
// ============ API 函数 ============
|
||||
|
||||
/** 获取当前订阅信息 */
|
||||
export const getCurrentSubscription = async (): Promise<SubscriptionInfo> => {
|
||||
const response = await apiClient.get("/subscription/current");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/subscription/current")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取账单记录列表 */
|
||||
export const getBillingRecords = async (): Promise<BillingRecord[]> => {
|
||||
const response = await apiClient.get("/subscription/billing-records");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get("/subscription/billing-records")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 升级/降级套餐 */
|
||||
export const changePlan = async (
|
||||
request: ChangePlanRequest,
|
||||
): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request);
|
||||
return response.data;
|
||||
};
|
||||
export const changePlan = async (request: ChangePlanRequest): Promise<ChangePlanResponse> => {
|
||||
const response = await apiClient.post("/subscription/change-plan", request)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 取消订阅 */
|
||||
export const cancelSubscription = async (): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
success: boolean
|
||||
message: string
|
||||
}> => {
|
||||
const response = await apiClient.post("/subscription/cancel");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/subscription/cancel")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 切换自动续费 */
|
||||
export const toggleAutoRenew = async (
|
||||
@@ -103,6 +101,6 @@ export const toggleAutoRenew = async (
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post("/subscription/toggle-auto-renew", {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
+20
-26
@@ -2,45 +2,39 @@
|
||||
* 标签 CRUD API
|
||||
* P3 标签体系:对接后端标签表
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
export interface TagItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at?: string;
|
||||
usage_count?: number;
|
||||
id: string
|
||||
name: string
|
||||
created_at?: string
|
||||
usage_count?: number
|
||||
}
|
||||
|
||||
/** 获取当前用户所有标签 */
|
||||
export const getTags = async (): Promise<TagItem[]> => {
|
||||
const response = await apiClient.get("/tags");
|
||||
return response.data.items || [];
|
||||
};
|
||||
const response = await apiClient.get("/tags")
|
||||
return response.data.items || []
|
||||
}
|
||||
|
||||
/** 创建标签(同名返回 409) */
|
||||
export const createTag = async (name: string): Promise<TagItem> => {
|
||||
const response = await apiClient.post("/tags", { name });
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/tags", { name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除标签(同时清理素材关联) */
|
||||
export const deleteTag = async (tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tags/${tagId}`);
|
||||
};
|
||||
await apiClient.delete(`/tags/${tagId}`)
|
||||
}
|
||||
|
||||
/** 为素材添加标签(最多 50 个) */
|
||||
export const tagAsset = async (
|
||||
assetId: string,
|
||||
tagIds: string[],
|
||||
): Promise<void> => {
|
||||
if (tagIds.length === 0) return;
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds });
|
||||
};
|
||||
export const tagAsset = async (assetId: string, tagIds: string[]): Promise<void> => {
|
||||
if (tagIds.length === 0) return
|
||||
await apiClient.post(`/assets/${assetId}/tags`, { tag_ids: tagIds })
|
||||
}
|
||||
|
||||
/** 移除素材的某个标签 */
|
||||
export const untagAsset = async (
|
||||
assetId: string,
|
||||
tagId: string,
|
||||
): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`);
|
||||
};
|
||||
export const untagAsset = async (assetId: string, tagId: string): Promise<void> => {
|
||||
await apiClient.delete(`/assets/${assetId}/tags/${tagId}`)
|
||||
}
|
||||
|
||||
+63
-69
@@ -6,85 +6,84 @@
|
||||
* - GET /api/v1/tasks/{task_id} — 任务详情(含 error_info)
|
||||
* - POST /api/v1/tasks/{task_id}/retry — 重试失败任务
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 任务状态 */
|
||||
export type TaskStatus =
|
||||
"pending" | "waiting" | "running" | "completed" | "failed" | "cancelled";
|
||||
export type TaskStatus = "pending" | "waiting" | "running" | "completed" | "failed" | "cancelled"
|
||||
|
||||
/** 任务类型 */
|
||||
export type TaskType = "ingest" | "generation" | string;
|
||||
export type TaskType = "ingest" | "generation" | string
|
||||
|
||||
/** 错误详情 */
|
||||
export interface TaskErrorInfo {
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
failed_step: string;
|
||||
stack_trace?: string;
|
||||
error_type: string
|
||||
error_message: string
|
||||
failed_step: string
|
||||
stack_trace?: string
|
||||
}
|
||||
|
||||
/** 任务条目(对应用户级 UserTaskResponse) */
|
||||
export interface TaskItem {
|
||||
id: string;
|
||||
task_type: TaskType;
|
||||
project_id: string;
|
||||
template_id?: string;
|
||||
status: TaskStatus;
|
||||
progress: number;
|
||||
current_step: string;
|
||||
error_message: string;
|
||||
user_message: string;
|
||||
retryable: boolean;
|
||||
source_id: string;
|
||||
id: string
|
||||
task_type: TaskType
|
||||
project_id: string
|
||||
template_id?: string
|
||||
status: TaskStatus
|
||||
progress: number
|
||||
current_step: string
|
||||
error_message: string
|
||||
user_message: string
|
||||
retryable: boolean
|
||||
source_id: string
|
||||
/** 错误详情(失败任务) */
|
||||
error_info?: TaskErrorInfo;
|
||||
error_info?: TaskErrorInfo
|
||||
/** 耗时(秒) */
|
||||
duration_seconds?: number;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
duration_seconds?: number
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
/** 任务列表查询参数 */
|
||||
export interface TaskListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
status?: TaskStatus | "all";
|
||||
task_type?: TaskType | "all";
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: TaskStatus | "all"
|
||||
task_type?: TaskType | "all"
|
||||
}
|
||||
|
||||
/** 任务列表分页响应 */
|
||||
export interface TaskListResponse {
|
||||
items: TaskItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TaskItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 创建生成任务请求参数 */
|
||||
export interface CreateGenerationTaskRequest {
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
title_ids: string[];
|
||||
voice_ids: string[];
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
}
|
||||
|
||||
/** 创建生成任务响应(对齐后端 GenerationTaskResponse) */
|
||||
export interface CreateGenerationTaskResponse {
|
||||
id: string;
|
||||
project_id: string;
|
||||
asset_library_id: string;
|
||||
strategy_id: string;
|
||||
voice_library_id: string;
|
||||
template_id: string;
|
||||
asset_ids: string[];
|
||||
title_ids: string[];
|
||||
voice_ids: string[];
|
||||
status: string;
|
||||
progress: number;
|
||||
result_count: number;
|
||||
error_message: string;
|
||||
id: string
|
||||
project_id: string
|
||||
asset_library_id: string
|
||||
strategy_id: string
|
||||
voice_library_id: string
|
||||
template_id: string
|
||||
asset_ids: string[]
|
||||
title_ids: string[]
|
||||
voice_ids: string[]
|
||||
status: string
|
||||
progress: number
|
||||
result_count: number
|
||||
error_message: string
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
@@ -93,37 +92,32 @@ export interface CreateGenerationTaskResponse {
|
||||
export const createGenerationTask = async (
|
||||
params: CreateGenerationTaskRequest,
|
||||
): Promise<CreateGenerationTaskResponse> => {
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>(
|
||||
"/generation/tasks",
|
||||
params,
|
||||
);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.post<CreateGenerationTaskResponse>("/generation/tasks", params)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取任务列表(支持分页和筛选) */
|
||||
export const getTasks = async (
|
||||
params?: TaskListParams,
|
||||
): Promise<TaskListResponse> => {
|
||||
export const getTasks = async (params?: TaskListParams): Promise<TaskListResponse> => {
|
||||
const { data } = await apiClient.get<TaskListResponse>("/tasks", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有任务(兼容旧接口,跨 project) */
|
||||
export const getUserTasks = async (): Promise<TaskItem[]> => {
|
||||
const { data } = await apiClient.get("/tasks");
|
||||
return data.items || data || [];
|
||||
};
|
||||
const { data } = await apiClient.get("/tasks")
|
||||
return data.items || data || []
|
||||
}
|
||||
|
||||
/** 获取单个任务详情(含 error_info) */
|
||||
export const getTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.get(`/tasks/${taskId}`)
|
||||
return data
|
||||
}
|
||||
|
||||
/** 重试失败的任务 */
|
||||
export const retryTask = async (taskId: string): Promise<TaskItem> => {
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`);
|
||||
return data;
|
||||
};
|
||||
const { data } = await apiClient.post(`/tasks/${taskId}/retry`)
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,142 +7,132 @@
|
||||
* - POST /api/v1/templates/{id}/generate — 从模板生成剪辑计划
|
||||
* - POST /api/v1/templates/{id}/toggle-favorite — 收藏/取消收藏
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner";
|
||||
import type { EditPlanConfig } from "./editPlans";
|
||||
import apiClient from "./client"
|
||||
import type { TitleConfig, SubtitleConfig, BgmConfig } from "./editingPlanner"
|
||||
import type { EditPlanConfig } from "./editPlans"
|
||||
|
||||
/* ──────────── 类型定义 ──────────── */
|
||||
|
||||
/** 模板条目(后端 TemplateResponse) */
|
||||
export interface TemplateItem {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
mode?: string;
|
||||
category: string;
|
||||
tags?: string[];
|
||||
id: string
|
||||
user_id?: string
|
||||
name: string
|
||||
description?: string
|
||||
mode?: string
|
||||
category: string
|
||||
tags?: string[]
|
||||
/** 预估时长(后端字段名 estimated_duration) */
|
||||
estimated_duration?: number;
|
||||
estimated_duration?: number
|
||||
/** @deprecated 后端已改名为 estimated_duration,保留兼容 */
|
||||
target_duration?: number;
|
||||
clip_count?: number;
|
||||
target_duration?: number
|
||||
clip_count?: number
|
||||
/** 使用次数 */
|
||||
usage_count?: number;
|
||||
thumbnail_url?: string;
|
||||
preview_url?: string;
|
||||
is_active?: boolean;
|
||||
is_favorite?: boolean;
|
||||
usage_count?: number
|
||||
thumbnail_url?: string
|
||||
preview_url?: string
|
||||
is_active?: boolean
|
||||
is_favorite?: boolean
|
||||
/** 素材规则(片段配置) */
|
||||
segments?: TemplateSegment[];
|
||||
segments?: TemplateSegment[]
|
||||
/** 字幕样式 */
|
||||
subtitle_config?: SubtitleConfig;
|
||||
subtitle_config?: SubtitleConfig
|
||||
/** BGM 配置 */
|
||||
bgm_config?: BgmConfig;
|
||||
bgm_config?: BgmConfig
|
||||
/** 标题配置 */
|
||||
title_config?: TitleConfig;
|
||||
title_config?: TitleConfig
|
||||
/** 视频比例 */
|
||||
aspect_ratio?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
aspect_ratio?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 模板片段(素材规则) */
|
||||
export interface TemplateSegment {
|
||||
id?: string;
|
||||
segment_order: number;
|
||||
duration_min: number;
|
||||
duration_max: number;
|
||||
material_type: string | null;
|
||||
description?: string;
|
||||
id?: string
|
||||
segment_order: number
|
||||
duration_min: number
|
||||
duration_max: number
|
||||
material_type: string | null
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 模板列表查询参数 */
|
||||
export interface TemplateListParams {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category?: string;
|
||||
tags?: string;
|
||||
keyword?: string;
|
||||
page?: number
|
||||
page_size?: number
|
||||
category?: string
|
||||
tags?: string
|
||||
keyword?: string
|
||||
/** 时长筛选(秒):short < 30, medium 30-120, long > 120 */
|
||||
duration_range?: "short" | "medium" | "long";
|
||||
duration_range?: "short" | "medium" | "long"
|
||||
}
|
||||
|
||||
/** 模板列表分页响应 */
|
||||
export interface TemplateListResponse {
|
||||
items: TemplateItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
items: TemplateItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划请求 */
|
||||
export interface GenerateFromTemplateRequest {
|
||||
asset_ids?: string[];
|
||||
name?: string;
|
||||
config?: EditPlanConfig;
|
||||
asset_ids?: string[]
|
||||
name?: string
|
||||
config?: EditPlanConfig
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划响应 */
|
||||
export interface GenerateFromTemplateResponse {
|
||||
plan_id: string;
|
||||
template_id: string;
|
||||
status: string;
|
||||
name: string;
|
||||
plan_id: string
|
||||
template_id: string
|
||||
status: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 复制模板响应 */
|
||||
export interface CopyTemplateResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
source_template_id: string;
|
||||
id: string
|
||||
name: string
|
||||
source_template_id: string
|
||||
}
|
||||
|
||||
/* ──────────── API 函数 ──────────── */
|
||||
|
||||
/** 获取模板列表(支持分页和筛选) */
|
||||
export const getTemplates = async (
|
||||
params?: TemplateListParams,
|
||||
): Promise<TemplateListResponse> => {
|
||||
export const getTemplates = async (params?: TemplateListParams): Promise<TemplateListResponse> => {
|
||||
const { data } = await apiClient.get<TemplateListResponse>("/templates", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/** 获取模板列表(兼容旧接口,返回数组) */
|
||||
export const getTemplatesList = async (): Promise<TemplateItem[]> => {
|
||||
const response = await apiClient.get("/templates");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
const response = await apiClient.get("/templates")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 获取单个模板详情 */
|
||||
export const getTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`);
|
||||
return response.data;
|
||||
};
|
||||
export const getTemplate = async (templateId: string): Promise<TemplateItem> => {
|
||||
const response = await apiClient.get(`/templates/${templateId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 收藏 / 取消收藏模板 */
|
||||
export const toggleFavoriteTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<{ is_favorite: boolean }> => {
|
||||
const response = await apiClient.post(
|
||||
`/templates/${templateId}/toggle-favorite`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post(`/templates/${templateId}/toggle-favorite`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 复制模板(创建副本到我的模板) */
|
||||
export const copyTemplate = async (
|
||||
templateId: string,
|
||||
): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(
|
||||
`/templates/${templateId}/copy`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const copyTemplate = async (templateId: string): Promise<CopyTemplateResponse> => {
|
||||
const response = await apiClient.post<CopyTemplateResponse>(`/templates/${templateId}/copy`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 从模板生成剪辑计划 */
|
||||
export const generateFromTemplate = async (
|
||||
@@ -152,9 +142,9 @@ export const generateFromTemplate = async (
|
||||
const response = await apiClient.post<GenerateFromTemplateResponse>(
|
||||
`/templates/${templateId}/generate`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -167,7 +157,7 @@ export const TEMPLATE_CATEGORY_OPTIONS = [
|
||||
{ value: "品牌", label: "品牌" },
|
||||
{ value: "混剪", label: "混剪" },
|
||||
{ value: "Vlog", label: "Vlog" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 时长筛选选项 */
|
||||
export const TEMPLATE_DURATION_OPTIONS = [
|
||||
@@ -175,4 +165,4 @@ export const TEMPLATE_DURATION_OPTIONS = [
|
||||
{ value: "short", label: "30秒以内" },
|
||||
{ value: "medium", label: "30秒-2分钟" },
|
||||
{ value: "long", label: "2分钟以上" },
|
||||
];
|
||||
]
|
||||
|
||||
+57
-69
@@ -3,51 +3,51 @@
|
||||
* Phase 1 新增:全局标题库
|
||||
* 注意:后端 schema 使用 name + text 字段,前端 UI 用 content 展示
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/** 标题条目(前端展示用) */
|
||||
export interface TitleItem {
|
||||
id: string;
|
||||
content: string;
|
||||
category?: string;
|
||||
source?: string;
|
||||
word_count?: number;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
content: string
|
||||
category?: string
|
||||
source?: string
|
||||
word_count?: number
|
||||
is_favorite?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 后端标题响应格式 */
|
||||
interface BackendTitleResponse {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
usage_count: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description: string
|
||||
tags: string[]
|
||||
usage_count: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端创建标题请求格式 */
|
||||
interface BackendCreateTitleRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
name: string
|
||||
text: string
|
||||
category: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 后端更新标题请求格式 */
|
||||
interface BackendUpdateTitleRequest {
|
||||
name?: string;
|
||||
text?: string;
|
||||
category?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
name?: string
|
||||
text?: string
|
||||
category?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
/** 将后端响应映射为前端 TitleItem */
|
||||
@@ -58,73 +58,61 @@ const toTitleItem = (item: BackendTitleResponse): TitleItem => ({
|
||||
word_count: item.text?.length || 0,
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
});
|
||||
})
|
||||
|
||||
/** 创建标题请求(前端接口,保持向后兼容) */
|
||||
export interface CreateTitleRequest {
|
||||
content: string;
|
||||
category?: string;
|
||||
content: string
|
||||
category?: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有标题 */
|
||||
export const getTitles = async (): Promise<TitleItem[]> => {
|
||||
const response = await apiClient.get<
|
||||
{ items: BackendTitleResponse[] } | BackendTitleResponse[]
|
||||
>("/titles");
|
||||
const response = await apiClient.get<{ items: BackendTitleResponse[] } | BackendTitleResponse[]>(
|
||||
"/titles",
|
||||
)
|
||||
// 兼容两种后端返回格式:{ items: [...] } 或直接 [...]
|
||||
const items = Array.isArray(response.data)
|
||||
? response.data
|
||||
: response.data.items || [];
|
||||
return items.map(toTitleItem);
|
||||
};
|
||||
const items = Array.isArray(response.data) ? response.data : response.data.items || []
|
||||
return items.map(toTitleItem)
|
||||
}
|
||||
|
||||
/** 创建标题 */
|
||||
export const createTitle = async (
|
||||
data: CreateTitleRequest,
|
||||
): Promise<TitleItem> => {
|
||||
export const createTitle = async (data: CreateTitleRequest): Promise<TitleItem> => {
|
||||
// 后端要求 name(≤255)和 text(≤500),name 从 content 截取
|
||||
const payload: BackendCreateTitleRequest = {
|
||||
name: data.content.slice(0, 255),
|
||||
text: data.content.slice(0, 500),
|
||||
category: data.category || "default",
|
||||
};
|
||||
const response = await apiClient.post<BackendTitleResponse>(
|
||||
"/titles",
|
||||
payload,
|
||||
);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post<BackendTitleResponse>("/titles", payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 更新标题 */
|
||||
export const updateTitle = async (
|
||||
titleId: string,
|
||||
data: Partial<CreateTitleRequest>,
|
||||
): Promise<TitleItem> => {
|
||||
const payload: BackendUpdateTitleRequest = {};
|
||||
const payload: BackendUpdateTitleRequest = {}
|
||||
if (data.content !== undefined) {
|
||||
payload.name = data.content.slice(0, 255);
|
||||
payload.text = data.content.slice(0, 500);
|
||||
payload.name = data.content.slice(0, 255)
|
||||
payload.text = data.content.slice(0, 500)
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
payload.category = data.category;
|
||||
payload.category = data.category
|
||||
}
|
||||
// 后端用 PUT,非 PATCH
|
||||
const response = await apiClient.put<BackendTitleResponse>(
|
||||
`/titles/${titleId}`,
|
||||
payload,
|
||||
);
|
||||
return toTitleItem(response.data);
|
||||
};
|
||||
const response = await apiClient.put<BackendTitleResponse>(`/titles/${titleId}`, payload)
|
||||
return toTitleItem(response.data)
|
||||
}
|
||||
|
||||
/** 删除标题 */
|
||||
export const deleteTitle = async (titleId: string): Promise<void> => {
|
||||
await apiClient.delete(`/titles/${titleId}`);
|
||||
};
|
||||
await apiClient.delete(`/titles/${titleId}`)
|
||||
}
|
||||
|
||||
/** 批量导入标题 */
|
||||
export const batchImportTitles = async (
|
||||
titles: string[],
|
||||
): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post("/titles/batch-import", { titles });
|
||||
return response.data;
|
||||
};
|
||||
export const batchImportTitles = async (titles: string[]): Promise<{ imported_count: number }> => {
|
||||
const response = await apiClient.post("/titles/batch-import", { titles })
|
||||
return response.data
|
||||
}
|
||||
|
||||
+95
-110
@@ -4,91 +4,91 @@
|
||||
*
|
||||
* 任务 3.14 新增
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 类型定义 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 元数据(合成时附带的扩展信息) */
|
||||
export interface TTSMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
language?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** TTS 合成请求参数 */
|
||||
export interface TTSSynthesizeRequest {
|
||||
text: string;
|
||||
voice_id?: string;
|
||||
output_name?: string;
|
||||
language?: string;
|
||||
speed?: number;
|
||||
voice_model?: string;
|
||||
voice_clone_profile_id?: string;
|
||||
format?: string;
|
||||
metadata?: TTSMetadata;
|
||||
text: string
|
||||
voice_id?: string
|
||||
output_name?: string
|
||||
language?: string
|
||||
speed?: number
|
||||
voice_model?: string
|
||||
voice_clone_profile_id?: string
|
||||
format?: string
|
||||
metadata?: TTSMetadata
|
||||
}
|
||||
|
||||
/** TTS 合成创建响应 */
|
||||
export interface TTSSynthesizeResponse {
|
||||
job_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
job_id: string
|
||||
status: string
|
||||
message: string
|
||||
}
|
||||
|
||||
/** TTS 任务详情 */
|
||||
export interface TTSJob {
|
||||
id: string;
|
||||
user_id: string;
|
||||
project_id: string | null;
|
||||
text: string;
|
||||
voice_id: string | null;
|
||||
voice_model: string | null;
|
||||
voice_clone_profile_id: string | null;
|
||||
language: string;
|
||||
speed: number;
|
||||
output_name: string | null;
|
||||
output_audio_url: string | null;
|
||||
output_format: string;
|
||||
duration_seconds: number | null;
|
||||
file_size_bytes: number | null;
|
||||
sample_rate: number | null;
|
||||
status: string;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: TTSMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
project_id: string | null
|
||||
text: string
|
||||
voice_id: string | null
|
||||
voice_model: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
language: string
|
||||
speed: number
|
||||
output_name: string | null
|
||||
output_audio_url: string | null
|
||||
output_format: string
|
||||
duration_seconds: number | null
|
||||
file_size_bytes: number | null
|
||||
sample_rate: number | null
|
||||
status: string
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: TTSMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** TTS 任务状态(轻量轮询用) */
|
||||
export interface TTSJobStatus {
|
||||
id: string;
|
||||
status: string;
|
||||
output_audio_url: string | null;
|
||||
error_message: string | null;
|
||||
duration_seconds: number | null;
|
||||
retry_count: number;
|
||||
id: string
|
||||
status: string
|
||||
output_audio_url: string | null
|
||||
error_message: string | null
|
||||
duration_seconds: number | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表响应 */
|
||||
export interface TTSJobListResponse {
|
||||
items: TTSJob[];
|
||||
total: number;
|
||||
skip: number;
|
||||
limit: number;
|
||||
items: TTSJob[]
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/** TTS 任务列表查询参数 */
|
||||
export interface TTSJobListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ──────────────────────────────────── */
|
||||
@@ -97,47 +97,37 @@ export interface TTSJobListParams {
|
||||
export const synthesizeSpeech = async (
|
||||
data: TTSSynthesizeRequest,
|
||||
): Promise<TTSSynthesizeResponse> => {
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>(
|
||||
"/tts/synthesize",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post<TTSSynthesizeResponse>("/tts/synthesize", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务详情 */
|
||||
export const getTTSJob = async (jobId: string): Promise<TTSJob> => {
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSJob>(`/tts/jobs/${jobId}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务状态(轻量轮询) */
|
||||
export const getTTSJobStatus = async (jobId: string): Promise<TTSJobStatus> => {
|
||||
const response = await apiClient.get<TTSJobStatus>(
|
||||
`/tts/jobs/${jobId}/status`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSJobStatus>(`/tts/jobs/${jobId}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取 TTS 任务列表 */
|
||||
export const getTTSJobs = async (
|
||||
params?: TTSJobListParams,
|
||||
): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<TTSJobListResponse>(
|
||||
`/tts/jobs${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getTTSJobs = async (params?: TTSJobListParams): Promise<TTSJobListResponse> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<TTSJobListResponse>(`/tts/jobs${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 存为素材请求参数 */
|
||||
export interface SaveTtsToLibraryRequest {
|
||||
name?: string;
|
||||
tag_ids?: string[];
|
||||
name?: string
|
||||
tag_ids?: string[]
|
||||
}
|
||||
|
||||
/** 将 TTS 合成结果保存到配音素材库 */
|
||||
@@ -145,59 +135,54 @@ export const saveTtsToLibrary = async (
|
||||
jobId: string,
|
||||
data?: SaveTtsToLibraryRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {});
|
||||
};
|
||||
await apiClient.post(`/tts/jobs/${jobId}/save-to-library`, data ?? {})
|
||||
}
|
||||
|
||||
/** 删除 TTS 任务 */
|
||||
export const deleteTTSJob = async (jobId: string): Promise<void> => {
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`);
|
||||
};
|
||||
await apiClient.delete(`/tts/jobs/${jobId}`)
|
||||
}
|
||||
|
||||
/* ── 音色列表 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 音色 */
|
||||
export interface TTSVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
id: string
|
||||
name: string
|
||||
/** 音色分类标签:male/female/young/service/news/emotion */
|
||||
category?: string;
|
||||
category?: string
|
||||
/** 语言 */
|
||||
language?: string;
|
||||
language?: string
|
||||
/** 试听 URL */
|
||||
preview_url?: string;
|
||||
preview_url?: string
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** 获取 TTS 音色列表 */
|
||||
export const getTtsVoices = async (): Promise<TTSVoice[]> => {
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<TTSVoice[]>("/tts/voices")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── TTS 试听 ──────────────────────────────────── */
|
||||
|
||||
/** TTS 试听请求参数 */
|
||||
export interface TTSPreviewRequest {
|
||||
text: string;
|
||||
voice_id: string;
|
||||
speed?: number;
|
||||
pitch?: number;
|
||||
text: string
|
||||
voice_id: string
|
||||
speed?: number
|
||||
pitch?: number
|
||||
}
|
||||
|
||||
/** TTS 试听响应 */
|
||||
export interface TTSPreviewResponse {
|
||||
audio_url: string;
|
||||
duration?: number;
|
||||
audio_url: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** TTS 试听 */
|
||||
export const previewTts = async (
|
||||
data: TTSPreviewRequest,
|
||||
): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>(
|
||||
"/tts/preview",
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const previewTts = async (data: TTSPreviewRequest): Promise<TTSPreviewResponse> => {
|
||||
const response = await apiClient.post<TTSPreviewResponse>("/tts/preview", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+98
-123
@@ -3,35 +3,35 @@
|
||||
* 任务 3.11:替换 Mock 数据,对接后端真实 API(3.05)
|
||||
* 任务 3.15:新增 progress 字段用于进度展示
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 前端兼容类型 ─────────────────────────────────────── */
|
||||
|
||||
/** 克隆音色状态(前端展示用) */
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed";
|
||||
export type VoiceCloneStatus = "ready" | "processing" | "failed"
|
||||
|
||||
/** 克隆音色条目(前端展示用) */
|
||||
export interface VoiceClone {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
duration_seconds: number;
|
||||
status: VoiceCloneStatus;
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
duration_seconds: number
|
||||
status: VoiceCloneStatus
|
||||
/** 克隆进度 0-100,仅 processing 状态时有值 */
|
||||
progress: number;
|
||||
sample_url?: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
error_message: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
progress: number
|
||||
sample_url?: string
|
||||
language: string
|
||||
gender: string
|
||||
error_message: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 创建克隆请求(前端简化版) */
|
||||
export interface CreateVoiceCloneRequest {
|
||||
name: string;
|
||||
audio_url: string;
|
||||
description?: string;
|
||||
name: string
|
||||
audio_url: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
/* ── 后端 API 类型 ────────────────────────────────────── */
|
||||
@@ -39,60 +39,60 @@ export interface CreateVoiceCloneRequest {
|
||||
/** 音色克隆元数据(克隆时附带的扩展信息) */
|
||||
export interface VoiceCloneMetadata {
|
||||
/** 语音时长(秒) */
|
||||
duration?: number;
|
||||
duration?: number
|
||||
/** 采样率(Hz) */
|
||||
sample_rate?: number;
|
||||
sample_rate?: number
|
||||
/** 音色 ID(克隆完成后分配) */
|
||||
voice_id?: string;
|
||||
voice_id?: string
|
||||
/** 其他扩展字段 */
|
||||
[key: string]: unknown;
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 后端克隆档案响应 */
|
||||
export interface VoiceCloneProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source_audio_url: string;
|
||||
voice_id: string | null;
|
||||
voice_model: string;
|
||||
language: string;
|
||||
gender: string;
|
||||
status: "pending" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
max_retries: number;
|
||||
metadata_: VoiceCloneMetadata | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
id: string
|
||||
user_id: string
|
||||
name: string
|
||||
description: string
|
||||
source_audio_url: string
|
||||
voice_id: string | null
|
||||
voice_model: string
|
||||
language: string
|
||||
gender: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
retry_count: number
|
||||
max_retries: number
|
||||
metadata_: VoiceCloneMetadata | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 后端克隆列表响应 */
|
||||
export interface ListVoiceCloneResponse {
|
||||
items: VoiceCloneProfile[];
|
||||
total: number;
|
||||
items: VoiceCloneProfile[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 后端克隆状态响应 */
|
||||
export interface VoiceCloneStatusResponse {
|
||||
id: string;
|
||||
status: "pending" | "processing" | "ready" | "failed";
|
||||
error_message: string | null;
|
||||
voice_id: string | null;
|
||||
retry_count: number;
|
||||
id: string
|
||||
status: "pending" | "processing" | "ready" | "failed"
|
||||
error_message: string | null
|
||||
voice_id: string | null
|
||||
retry_count: number
|
||||
}
|
||||
|
||||
/** 后端创建克隆请求(完整版) */
|
||||
export interface CreateVoiceCloneRequestFull {
|
||||
name: string;
|
||||
description?: string;
|
||||
source_audio_url: string;
|
||||
voice_model?: string;
|
||||
language?: string;
|
||||
gender?: string;
|
||||
max_retries?: number;
|
||||
metadata_?: VoiceCloneMetadata;
|
||||
name: string
|
||||
description?: string
|
||||
source_audio_url: string
|
||||
voice_model?: string
|
||||
language?: string
|
||||
gender?: string
|
||||
max_retries?: number
|
||||
metadata_?: VoiceCloneMetadata
|
||||
}
|
||||
|
||||
/* ── 辅助函数 ─────────────────────────────────────────── */
|
||||
@@ -114,66 +114,54 @@ export const toVoiceClone = (profile: VoiceCloneProfile): VoiceClone => ({
|
||||
error_message: profile.error_message || null,
|
||||
created_at: profile.created_at,
|
||||
updated_at: profile.updated_at,
|
||||
});
|
||||
})
|
||||
|
||||
/** 格式化时长 */
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${String(s).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 查询参数 ─────────────────────────────────────────── */
|
||||
|
||||
export interface VoiceCloneListParams {
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/* ── API 函数 ─────────────────────────────────────────── */
|
||||
|
||||
/** 获取克隆音色列表(返回前端兼容数组) */
|
||||
export const getVoiceClones = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data.items.map(toVoiceClone);
|
||||
};
|
||||
export const getVoiceClones = async (params?: VoiceCloneListParams): Promise<VoiceClone[]> => {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data.items.map(toVoiceClone)
|
||||
}
|
||||
|
||||
/** 获取克隆音色列表(返回完整响应含 total) */
|
||||
export const getVoiceClonesWithTotal = async (
|
||||
params?: VoiceCloneListParams,
|
||||
): Promise<ListVoiceCloneResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(
|
||||
`/voice-clones${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<ListVoiceCloneResponse>(`/voice-clones${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取单个克隆音色详情 */
|
||||
export const getVoiceCloneDetail = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getVoiceCloneDetail = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 创建克隆音色 */
|
||||
export const createVoiceClone = async (
|
||||
@@ -183,18 +171,15 @@ export const createVoiceClone = async (
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
source_audio_url: data.audio_url,
|
||||
};
|
||||
const response = await apiClient.post<VoiceCloneProfile>(
|
||||
"/voice-clones",
|
||||
payload,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
}
|
||||
const response = await apiClient.post<VoiceCloneProfile>("/voice-clones", payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除克隆音色 */
|
||||
export const deleteVoiceClone = async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/voice-clones/${id}`);
|
||||
};
|
||||
await apiClient.delete(`/voice-clones/${id}`)
|
||||
}
|
||||
|
||||
/** 更新克隆音色名称(stub — 后端暂无 PATCH 端点) */
|
||||
export const updateVoiceClone = async (
|
||||
@@ -202,32 +187,22 @@ export const updateVoiceClone = async (
|
||||
data: Partial<Pick<VoiceClone, "name">>,
|
||||
): Promise<VoiceClone> => {
|
||||
// 后端暂未提供更新端点,暂用详情接口模拟
|
||||
const response = await apiClient.get<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}`,
|
||||
);
|
||||
const response = await apiClient.get<VoiceCloneProfile>(`/voice-clones/${id}`)
|
||||
return toVoiceClone({
|
||||
...response.data,
|
||||
...data,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取克隆状态 */
|
||||
export const getVoiceCloneStatus = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(
|
||||
`/voice-clones/${id}/status`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const getVoiceCloneStatus = async (id: string): Promise<VoiceCloneStatusResponse> => {
|
||||
const response = await apiClient.get<VoiceCloneStatusResponse>(`/voice-clones/${id}/status`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 重试克隆 */
|
||||
export const retryVoiceClone = async (
|
||||
id: string,
|
||||
): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(
|
||||
`/voice-clones/${id}/retry`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
export const retryVoiceClone = async (id: string): Promise<VoiceCloneProfile> => {
|
||||
const response = await apiClient.post<VoiceCloneProfile>(`/voice-clones/${id}/retry`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
+81
-87
@@ -4,148 +4,142 @@
|
||||
*
|
||||
* 任务 3.11:新增统一音色 API(对接后端 3.04),保留旧接口向后兼容
|
||||
*/
|
||||
import apiClient from "./client";
|
||||
import apiClient from "./client"
|
||||
|
||||
/* ── 统一音色 API(后端 3.04) ─────────────────────────── */
|
||||
|
||||
/** 统一音色条目(preset + clone 混合) */
|
||||
export interface UnifiedVoiceItem {
|
||||
id: string;
|
||||
type: "preset" | "clone";
|
||||
name: string;
|
||||
description: string;
|
||||
gender: string;
|
||||
language: string;
|
||||
voice_id: string;
|
||||
voice_provider: string;
|
||||
audio_url: string | null;
|
||||
preview_url: string | null;
|
||||
duration: number | null;
|
||||
file_size: number | null;
|
||||
status: string;
|
||||
tags: string[];
|
||||
user_id: string | null;
|
||||
project_id: string | null;
|
||||
voice_clone_profile_id: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
id: string
|
||||
type: "preset" | "clone"
|
||||
name: string
|
||||
description: string
|
||||
gender: string
|
||||
language: string
|
||||
voice_id: string
|
||||
voice_provider: string
|
||||
audio_url: string | null
|
||||
preview_url: string | null
|
||||
duration: number | null
|
||||
file_size: number | null
|
||||
status: string
|
||||
tags: string[]
|
||||
user_id: string | null
|
||||
project_id: string | null
|
||||
voice_clone_profile_id: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/** 统一音色列表响应 */
|
||||
export interface UnifiedVoiceListResponse {
|
||||
items: UnifiedVoiceItem[];
|
||||
total: number;
|
||||
preset_count: number;
|
||||
clone_count: number;
|
||||
items: UnifiedVoiceItem[]
|
||||
total: number
|
||||
preset_count: number
|
||||
clone_count: number
|
||||
}
|
||||
|
||||
/** 预设音色条目 */
|
||||
export interface PresetVoiceItem {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
gender: string;
|
||||
language: string;
|
||||
preview_url: string | null;
|
||||
tags: string[];
|
||||
voice_id: string
|
||||
name: string
|
||||
description: string
|
||||
gender: string
|
||||
language: string
|
||||
preview_url: string | null
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
/** 预设音色列表响应 */
|
||||
export interface PresetVoiceListResponse {
|
||||
items: PresetVoiceItem[];
|
||||
total: number;
|
||||
items: PresetVoiceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 统一列表查询参数 */
|
||||
export interface UnifiedVoiceListParams {
|
||||
type?: "preset" | "clone";
|
||||
status?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
type?: "preset" | "clone"
|
||||
status?: string
|
||||
skip?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/** 获取统一音色列表(推荐) */
|
||||
export const fetchVoices = async (
|
||||
params?: UnifiedVoiceListParams,
|
||||
): Promise<UnifiedVoiceListResponse> => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.type) searchParams.set("type", params.type);
|
||||
if (params?.status) searchParams.set("status", params.status);
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip));
|
||||
if (params?.limit !== undefined)
|
||||
searchParams.set("limit", String(params.limit));
|
||||
const qs = searchParams.toString();
|
||||
const response = await apiClient.get<UnifiedVoiceListResponse>(
|
||||
`/voices${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.type) searchParams.set("type", params.type)
|
||||
if (params?.status) searchParams.set("status", params.status)
|
||||
if (params?.skip !== undefined) searchParams.set("skip", String(params.skip))
|
||||
if (params?.limit !== undefined) searchParams.set("limit", String(params.limit))
|
||||
const qs = searchParams.toString()
|
||||
const response = await apiClient.get<UnifiedVoiceListResponse>(`/voices${qs ? `?${qs}` : ""}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 获取预设音色列表(无需鉴权) */
|
||||
export const fetchPresetVoices = async (): Promise<PresetVoiceListResponse> => {
|
||||
const response =
|
||||
await apiClient.get<PresetVoiceListResponse>("/voices/presets");
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.get<PresetVoiceListResponse>("/voices/presets")
|
||||
return response.data
|
||||
}
|
||||
|
||||
/* ── 向后兼容(旧接口) ────────────────────────────────── */
|
||||
|
||||
/** 配音条目(旧) */
|
||||
export interface VoiceItem {
|
||||
id: string;
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
duration_seconds?: number;
|
||||
storage_key?: string;
|
||||
audio_url?: string;
|
||||
status?: string;
|
||||
is_favorite?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
id: string
|
||||
name: string
|
||||
text: string
|
||||
voice_type?: string
|
||||
duration_seconds?: number
|
||||
storage_key?: string
|
||||
audio_url?: string
|
||||
status?: string
|
||||
is_favorite?: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
/** 创建配音请求(旧) */
|
||||
export interface CreateVoiceRequest {
|
||||
name: string;
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
name: string
|
||||
text: string
|
||||
voice_type?: string
|
||||
}
|
||||
|
||||
/** 获取当前用户的所有配音(旧 → /voices/legacy) */
|
||||
export const getVoices = async (): Promise<VoiceItem[]> => {
|
||||
const response = await apiClient.get("/voices/legacy");
|
||||
return response.data.items || response.data || [];
|
||||
};
|
||||
const response = await apiClient.get("/voices/legacy")
|
||||
return response.data.items || response.data || []
|
||||
}
|
||||
|
||||
/** 创建配音 */
|
||||
export const createVoice = async (
|
||||
data: CreateVoiceRequest,
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices", data);
|
||||
return response.data;
|
||||
};
|
||||
export const createVoice = async (data: CreateVoiceRequest): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 更新配音 */
|
||||
export const updateVoice = async (
|
||||
voiceId: string,
|
||||
data: Partial<CreateVoiceRequest>,
|
||||
): Promise<VoiceItem> => {
|
||||
const response = await apiClient.put(`/voices/${voiceId}`, data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.put(`/voices/${voiceId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
/** 删除配音 */
|
||||
export const deleteVoice = async (voiceId: string): Promise<void> => {
|
||||
await apiClient.delete(`/voices/${voiceId}`);
|
||||
};
|
||||
await apiClient.delete(`/voices/${voiceId}`)
|
||||
}
|
||||
|
||||
/** AI 生成配音 */
|
||||
export const generateAIVoice = async (data: {
|
||||
text: string;
|
||||
voice_type?: string;
|
||||
speed?: number;
|
||||
text: string
|
||||
voice_type?: string
|
||||
speed?: number
|
||||
}): Promise<VoiceItem> => {
|
||||
const response = await apiClient.post("/voices/generate", data);
|
||||
return response.data;
|
||||
};
|
||||
const response = await apiClient.post("/voices/generate", data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -9,72 +9,62 @@
|
||||
* - 筛选增强:类型筛选 + 质量分筛选
|
||||
* - 视图切换:网格视图 / 列表视图
|
||||
*/
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import "./AssetSelector.css";
|
||||
import { Input, Select, Button } from "@/components/ui";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import {
|
||||
MATERIAL_TYPE_LABELS,
|
||||
MATERIAL_TYPE_ICONS,
|
||||
QUALITY_OPTIONS,
|
||||
} from "@/api/editPlans";
|
||||
import React, { useState, useMemo, useCallback, useRef, useEffect } from "react"
|
||||
import "./AssetSelector.css"
|
||||
import { Input, Select, Button } from "@/components/ui"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import { MATERIAL_TYPE_LABELS, MATERIAL_TYPE_ICONS, QUALITY_OPTIONS } from "@/api/editPlans"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export interface AssetSelectorProps {
|
||||
assets: MediaAsset[];
|
||||
selectedIds?: string[];
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
onAssetDragStart?: (asset: MediaAsset) => void;
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void;
|
||||
showQualityFilter?: boolean;
|
||||
showBatchSelect?: boolean;
|
||||
compact?: boolean;
|
||||
assets: MediaAsset[]
|
||||
selectedIds?: string[]
|
||||
onSelectionChange?: (ids: string[]) => void
|
||||
onAssetDragStart?: (asset: MediaAsset) => void
|
||||
onReorder?: (fromIdx: number, toIdx: number) => void
|
||||
showQualityFilter?: boolean
|
||||
showBatchSelect?: boolean
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
type ViewMode = "grid" | "list";
|
||||
type ViewMode = "grid" | "list"
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes?: number): string => {
|
||||
if (!bytes) return "";
|
||||
if (bytes < 1024) return `${bytes}B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
};
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return "";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`;
|
||||
};
|
||||
if (!seconds) return ""
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, "0")}` : `${s}s`
|
||||
}
|
||||
|
||||
/** 获取质量分等级 */
|
||||
const getQualityLevel = (score?: number): string => {
|
||||
if (score == null) return "none";
|
||||
if (score >= 90) return "excellent";
|
||||
if (score >= 70) return "good";
|
||||
if (score >= 50) return "fair";
|
||||
return "poor";
|
||||
};
|
||||
if (score == null) return "none"
|
||||
if (score >= 90) return "excellent"
|
||||
if (score >= 70) return "good"
|
||||
if (score >= 50) return "fair"
|
||||
return "poor"
|
||||
}
|
||||
|
||||
/** 质量分颜色 */
|
||||
const getQualityColor = (score?: number): string => {
|
||||
if (score == null) return "var(--text-secondary)";
|
||||
if (score >= 90) return "var(--success-color, #10b981)";
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)";
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)";
|
||||
return "var(--error-color, #ef4444)";
|
||||
};
|
||||
if (score == null) return "var(--text-secondary)"
|
||||
if (score >= 90) return "var(--success-color, #10b981)"
|
||||
if (score >= 70) return "var(--primary-color, #6366f1)"
|
||||
if (score >= 50) return "var(--warning-color, #f59e0b)"
|
||||
return "var(--error-color, #ef4444)"
|
||||
}
|
||||
|
||||
/* ──────────── 类型筛选选项 ──────────── */
|
||||
|
||||
@@ -83,7 +73,7 @@ const TYPE_OPTIONS = [
|
||||
{ value: "video", label: "🎬 视频" },
|
||||
{ value: "image", label: "🖼️ 图片" },
|
||||
{ value: "audio", label: "🎵 音频" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
@@ -98,189 +88,171 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
compact = false,
|
||||
}) => {
|
||||
/* ── 搜索 & 筛选 ── */
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [filterType, setFilterType] = useState("");
|
||||
const [filterQuality, setFilterQuality] = useState("");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid");
|
||||
const [searchText, setSearchText] = useState("")
|
||||
const [filterType, setFilterType] = useState("")
|
||||
const [filterQuality, setFilterQuality] = useState("")
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("grid")
|
||||
|
||||
/* ── 拖拽状态 ── */
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null)
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null)
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null);
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 });
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [previewAsset, setPreviewAsset] = useState<MediaAsset | null>(null)
|
||||
const [previewPos, setPreviewPos] = useState({ x: 0, y: 0 })
|
||||
const previewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
/* ── Shift 连选 ── */
|
||||
const lastClickedIdx = useRef<number | null>(null);
|
||||
const lastClickedIdx = useRef<number | null>(null)
|
||||
|
||||
/* ── 过滤后的素材列表 ── */
|
||||
const filteredAssets = useMemo(() => {
|
||||
let list = assets;
|
||||
let list = assets
|
||||
if (searchText) {
|
||||
const q = searchText.toLowerCase();
|
||||
const q = searchText.toLowerCase()
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.name.toLowerCase().includes(q) ||
|
||||
a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
);
|
||||
(a) => a.name.toLowerCase().includes(q) || a.tags.some((t) => t.toLowerCase().includes(q)),
|
||||
)
|
||||
}
|
||||
if (filterType) {
|
||||
list = list.filter((a) => a.type === filterType);
|
||||
list = list.filter((a) => a.type === filterType)
|
||||
}
|
||||
if (filterQuality) {
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality);
|
||||
const opt = QUALITY_OPTIONS.find((o) => o.value === filterQuality)
|
||||
if (opt?.min != null && opt?.max != null) {
|
||||
list = list.filter(
|
||||
(a) =>
|
||||
a.quality_score != null &&
|
||||
a.quality_score >= opt.min! &&
|
||||
a.quality_score <= opt.max!,
|
||||
);
|
||||
a.quality_score != null && a.quality_score >= opt.min! && a.quality_score <= opt.max!,
|
||||
)
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [assets, searchText, filterType, filterQuality]);
|
||||
return list
|
||||
}, [assets, searchText, filterType, filterQuality])
|
||||
|
||||
/* ── 选中状态 ── */
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
|
||||
/* ── 选择操作 ── */
|
||||
const toggleSelect = useCallback(
|
||||
(asset: MediaAsset, idx: number, shiftKey: boolean) => {
|
||||
if (!onSelectionChange) return;
|
||||
if (!onSelectionChange) return
|
||||
|
||||
if (shiftKey && lastClickedIdx.current !== null) {
|
||||
// Shift 连选
|
||||
const start = Math.min(lastClickedIdx.current, idx);
|
||||
const end = Math.max(lastClickedIdx.current, idx);
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id);
|
||||
const newSet = new Set(selectedIds);
|
||||
rangeIds.forEach((id) => newSet.add(id));
|
||||
onSelectionChange(Array.from(newSet));
|
||||
const start = Math.min(lastClickedIdx.current, idx)
|
||||
const end = Math.max(lastClickedIdx.current, idx)
|
||||
const rangeIds = filteredAssets.slice(start, end + 1).map((a) => a.id)
|
||||
const newSet = new Set(selectedIds)
|
||||
rangeIds.forEach((id) => newSet.add(id))
|
||||
onSelectionChange(Array.from(newSet))
|
||||
} else {
|
||||
const newSet = new Set(selectedIds);
|
||||
const newSet = new Set(selectedIds)
|
||||
if (newSet.has(asset.id)) {
|
||||
newSet.delete(asset.id);
|
||||
newSet.delete(asset.id)
|
||||
} else {
|
||||
newSet.add(asset.id);
|
||||
newSet.add(asset.id)
|
||||
}
|
||||
onSelectionChange(Array.from(newSet));
|
||||
onSelectionChange(Array.from(newSet))
|
||||
}
|
||||
lastClickedIdx.current = idx;
|
||||
lastClickedIdx.current = idx
|
||||
},
|
||||
[onSelectionChange, selectedIds, filteredAssets],
|
||||
);
|
||||
)
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
onSelectionChange?.([]);
|
||||
}, [onSelectionChange]);
|
||||
onSelectionChange?.([])
|
||||
}, [onSelectionChange])
|
||||
|
||||
/* ── 拖拽排序 ── */
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
setDragIdx(idx);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(idx));
|
||||
setDragIdx(idx)
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
e.dataTransfer.setData("text/plain", String(idx))
|
||||
// 设置素材数据,供 TimelinePanel 接收(P1-2 修复)
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-asset",
|
||||
JSON.stringify(filteredAssets[idx]),
|
||||
);
|
||||
e.dataTransfer.setData("application/x-media-asset", JSON.stringify(filteredAssets[idx]))
|
||||
// 批量拖拽:如果有多个选中素材,一起携带
|
||||
if (
|
||||
selectedIds.length > 1 &&
|
||||
selectedIds.includes(filteredAssets[idx].id)
|
||||
) {
|
||||
const batchAssets = filteredAssets.filter((a) =>
|
||||
selectedIds.includes(a.id),
|
||||
);
|
||||
e.dataTransfer.setData(
|
||||
"application/x-media-assets",
|
||||
JSON.stringify(batchAssets),
|
||||
);
|
||||
if (selectedIds.length > 1 && selectedIds.includes(filteredAssets[idx].id)) {
|
||||
const batchAssets = filteredAssets.filter((a) => selectedIds.includes(a.id))
|
||||
e.dataTransfer.setData("application/x-media-assets", JSON.stringify(batchAssets))
|
||||
}
|
||||
// 通知父组件素材拖拽开始
|
||||
if (onAssetDragStart) {
|
||||
onAssetDragStart(filteredAssets[idx]);
|
||||
onAssetDragStart(filteredAssets[idx])
|
||||
}
|
||||
},
|
||||
[filteredAssets, selectedIds, onAssetDragStart],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
setDragOverIdx(idx);
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
if (dragIdx === null || dragIdx === idx) return
|
||||
setDragOverIdx(idx)
|
||||
},
|
||||
[dragIdx],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, toIdx: number) => {
|
||||
e.preventDefault();
|
||||
e.preventDefault()
|
||||
if (dragIdx !== null && dragIdx !== toIdx && onReorder) {
|
||||
onReorder(dragIdx, toIdx);
|
||||
onReorder(dragIdx, toIdx)
|
||||
}
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
},
|
||||
[dragIdx, onReorder],
|
||||
);
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragIdx(null);
|
||||
setDragOverIdx(null);
|
||||
}, []);
|
||||
setDragIdx(null)
|
||||
setDragOverIdx(null)
|
||||
}, [])
|
||||
|
||||
/* ── 悬浮预览 ── */
|
||||
const handleMouseEnter = useCallback(
|
||||
(asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setPreviewAsset(asset);
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
});
|
||||
}, 400);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleMouseEnter = useCallback((asset: MediaAsset, e: React.MouseEvent) => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
previewTimer.current = setTimeout(() => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setPreviewAsset(asset)
|
||||
setPreviewPos({
|
||||
x: rect.right + 12,
|
||||
y: Math.max(8, rect.top - 20),
|
||||
})
|
||||
}, 400)
|
||||
}, [])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (previewTimer.current) {
|
||||
clearTimeout(previewTimer.current);
|
||||
previewTimer.current = null;
|
||||
clearTimeout(previewTimer.current)
|
||||
previewTimer.current = null
|
||||
}
|
||||
setPreviewAsset(null);
|
||||
}, []);
|
||||
setPreviewAsset(null)
|
||||
}, [])
|
||||
|
||||
// 清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
};
|
||||
}, []);
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── 点击卡片 ── */
|
||||
const handleCardClick = useCallback(
|
||||
(asset: MediaAsset, idx: number, e: React.MouseEvent) => {
|
||||
// 如果点击的是 checkbox 区域,不触发卡片点击
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("[data-checkbox]")) return;
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest("[data-checkbox]")) return
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
},
|
||||
[toggleSelect],
|
||||
);
|
||||
)
|
||||
|
||||
/* ──────────── 渲染 ──────────── */
|
||||
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
const hasSelection = selectedIds.length > 0
|
||||
|
||||
return (
|
||||
<div className="as-container">
|
||||
@@ -329,9 +301,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{/* ═══ 批量操作栏 ═══ */}
|
||||
{showBatchSelect && hasSelection && (
|
||||
<div className="as-batch-bar">
|
||||
<span className="as-batch-bar-count">
|
||||
已选 {selectedIds.length} 项
|
||||
</span>
|
||||
<span className="as-batch-bar-count">已选 {selectedIds.length} 项</span>
|
||||
<div className="as-batch-bar-actions">
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
|
||||
取消选择
|
||||
@@ -351,10 +321,10 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
/* ── 网格视图 ── */
|
||||
<div className={`as-grid${compact ? " compact" : ""}`}>
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
const qualityLevel = getQualityLevel(asset.quality_score);
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
const qualityLevel = getQualityLevel(asset.quality_score)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -380,15 +350,9 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{/* 缩略图 */}
|
||||
<div className="as-card-thumb">
|
||||
{asset.thumbnail_url ? (
|
||||
<img
|
||||
src={asset.thumbnail_url}
|
||||
alt={asset.name}
|
||||
loading="lazy"
|
||||
/>
|
||||
<img src={asset.thumbnail_url} alt={asset.name} loading="lazy" />
|
||||
) : (
|
||||
<span className="as-card-thumb-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
<span className="as-card-thumb-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
)}
|
||||
|
||||
{/* Checkbox */}
|
||||
@@ -397,22 +361,18 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
data-checkbox
|
||||
className={`as-card-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 类型角标 */}
|
||||
<span className="as-card-type-badge">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
</span>
|
||||
<span className="as-card-type-badge">{MATERIAL_TYPE_LABELS[asset.type]}</span>
|
||||
|
||||
{/* 时长角标 */}
|
||||
{asset.duration != null && (
|
||||
<span className="as-card-duration">
|
||||
{formatDuration(asset.duration)}
|
||||
</span>
|
||||
<span className="as-card-duration">{formatDuration(asset.duration)}</span>
|
||||
)}
|
||||
|
||||
{/* 质量分角标 */}
|
||||
@@ -434,16 +394,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
<div className="as-card-meta">{formatSize(asset.size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* ── 列表视图 ── */
|
||||
<div className="as-list">
|
||||
{filteredAssets.map((asset, idx) => {
|
||||
const isSelected = selectedSet.has(asset.id);
|
||||
const isDragging = dragIdx === idx;
|
||||
const isDragOver = dragOverIdx === idx;
|
||||
const isSelected = selectedSet.has(asset.id)
|
||||
const isDragging = dragIdx === idx
|
||||
const isDragOver = dragOverIdx === idx
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -476,24 +436,21 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
data-checkbox
|
||||
className={`as-list-item-checkbox${isSelected ? " checked" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelect(asset, idx, e.shiftKey);
|
||||
e.stopPropagation()
|
||||
toggleSelect(asset, idx, e.shiftKey)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<span className="as-list-item-icon">
|
||||
{MATERIAL_TYPE_ICONS[asset.type]}
|
||||
</span>
|
||||
<span className="as-list-item-icon">{MATERIAL_TYPE_ICONS[asset.type]}</span>
|
||||
|
||||
{/* 信息 */}
|
||||
<div className="as-list-item-info">
|
||||
<div className="as-list-item-name">{asset.name}</div>
|
||||
<div className="as-list-item-meta">
|
||||
{MATERIAL_TYPE_LABELS[asset.type]}
|
||||
{asset.duration != null &&
|
||||
` · ${formatDuration(asset.duration)}`}
|
||||
{asset.duration != null && ` · ${formatDuration(asset.duration)}`}
|
||||
{asset.size != null && ` · ${formatSize(asset.size)}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -508,7 +465,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
@@ -516,10 +473,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
|
||||
{/* ═══ 悬浮预览 ═══ */}
|
||||
{previewAsset && (
|
||||
<div
|
||||
className="as-preview-overlay"
|
||||
style={{ left: previewPos.x, top: previewPos.y }}
|
||||
>
|
||||
<div className="as-preview-overlay" style={{ left: previewPos.x, top: previewPos.y }}>
|
||||
<div className="as-preview-overlay-thumb">
|
||||
{previewAsset.thumbnail_url ? (
|
||||
<img src={previewAsset.thumbnail_url} alt={previewAsset.name} />
|
||||
@@ -535,20 +489,16 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
|
||||
{previewAsset.duration != null && (
|
||||
<span>时长: {formatDuration(previewAsset.duration)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && (
|
||||
<span>大小: {formatSize(previewAsset.size)}</span>
|
||||
)}
|
||||
{previewAsset.size != null && <span>大小: {formatSize(previewAsset.size)}</span>}
|
||||
{previewAsset.quality_score != null && (
|
||||
<span>质量分: {previewAsset.quality_score}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && (
|
||||
<span>标签: {previewAsset.tags.join(", ")}</span>
|
||||
)}
|
||||
{previewAsset.tags.length > 0 && <span>标签: {previewAsset.tags.join(", ")}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AssetSelector;
|
||||
export default AssetSelector
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as AssetSelector } from "./AssetSelector";
|
||||
export type { AssetSelectorProps } from "./AssetSelector";
|
||||
export { default as AssetSelector } from "./AssetSelector"
|
||||
export type { AssetSelectorProps } from "./AssetSelector"
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
* 语义化 HTML 结构:header + aside + main 三栏布局
|
||||
* 由 MainLayout 管理侧边栏状态,AppLayout 负责渲染结构
|
||||
*/
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import Header from "./Header";
|
||||
import React from "react"
|
||||
import { Outlet } from "react-router-dom"
|
||||
import Header from "./Header"
|
||||
|
||||
interface AppLayoutProps {
|
||||
sidebar: React.ReactNode;
|
||||
sidebar: React.ReactNode
|
||||
}
|
||||
|
||||
const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
|
||||
@@ -23,7 +23,7 @@ const AppLayout: React.FC<AppLayoutProps> = ({ sidebar }) => {
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AppLayout;
|
||||
export default AppLayout
|
||||
|
||||
@@ -2,27 +2,22 @@
|
||||
* Phase 1 Header 重构
|
||||
* 扁平化导航菜单 + 手机端汉堡菜单
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd";
|
||||
import {
|
||||
LogoutOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
MenuOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useLogout } from "@/hooks/useAuth";
|
||||
import type { MenuProps } from "antd";
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import "./Header.css";
|
||||
import React, { useState } from "react"
|
||||
import { Avatar, Dropdown, Drawer, Space } from "antd"
|
||||
import { LogoutOutlined, SettingOutlined, UserOutlined, MenuOutlined } from "@ant-design/icons"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
import { useLogout } from "@/hooks/useAuth"
|
||||
import type { MenuProps } from "antd"
|
||||
import { NAV_ITEMS } from "@/config/navigation"
|
||||
import "./Header.css"
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useAuthStore((state) => state.user);
|
||||
const logoutMutation = useLogout();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const logoutMutation = useLogout()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
/** 用户下拉菜单 */
|
||||
const menuItems: MenuProps["items"] = [
|
||||
@@ -45,7 +40,7 @@ const Header: React.FC = () => {
|
||||
label: "退出登录",
|
||||
onClick: () => logoutMutation.mutateAsync(),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/** 判断导航项是否激活 */
|
||||
const isActive = (path: string) => {
|
||||
@@ -55,19 +50,15 @@ const Header: React.FC = () => {
|
||||
location.pathname === "/" ||
|
||||
location.pathname === "/app" ||
|
||||
location.pathname === "/app/dashboard"
|
||||
);
|
||||
)
|
||||
}
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
return location.pathname.startsWith(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="xx-top-nav">
|
||||
<div className="xx-top-nav-inner">
|
||||
<button
|
||||
className="xx-brand"
|
||||
type="button"
|
||||
onClick={() => navigate("/app/dashboard")}
|
||||
>
|
||||
<button className="xx-brand" type="button" onClick={() => navigate("/app/dashboard")}>
|
||||
<span className="xx-logo">🦐</span>
|
||||
<span className="xx-brand-text">小虾自动剪辑</span>
|
||||
</button>
|
||||
@@ -88,11 +79,7 @@ const Header: React.FC = () => {
|
||||
|
||||
<div className="xx-right-section">
|
||||
{/* 手机端汉堡菜单按钮 */}
|
||||
<button
|
||||
className="xx-hamburger"
|
||||
type="button"
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
>
|
||||
<button className="xx-hamburger" type="button" onClick={() => setMobileMenuOpen(true)}>
|
||||
<MenuOutlined />
|
||||
</button>
|
||||
|
||||
@@ -123,8 +110,8 @@ const Header: React.FC = () => {
|
||||
className={`xx-mobile-nav-item ${isActive(item.path) ? "active" : ""}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
navigate(item.path)
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<span className="xx-mobile-nav-icon">{item.icon}</span>
|
||||
@@ -134,7 +121,7 @@ const Header: React.FC = () => {
|
||||
</div>
|
||||
</Drawer>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default Header
|
||||
|
||||
@@ -9,39 +9,32 @@
|
||||
*
|
||||
* 侧边栏/导航具体内容 deferred to Tasks 1.3, 1.4
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons";
|
||||
import AppLayout from "./AppLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
import "./MainLayout.css";
|
||||
import React, { useState } from "react"
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"
|
||||
import AppLayout from "./AppLayout"
|
||||
import Sidebar from "./Sidebar"
|
||||
import "./MainLayout.css"
|
||||
|
||||
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
|
||||
export interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
});
|
||||
})
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
// 移动端默认折叠,桌面端默认展开
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768);
|
||||
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 768)
|
||||
|
||||
const sidebar = (
|
||||
<>
|
||||
{/* 移动端遮罩层 —— 仅在侧边栏展开时由 CSS 显示 */}
|
||||
<div
|
||||
className="xx-sidebar-overlay"
|
||||
onClick={() => setCollapsed(true)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="xx-sidebar-overlay" onClick={() => setCollapsed(true)} aria-hidden="true" />
|
||||
|
||||
<aside
|
||||
className={`xx-app-sidebar${collapsed ? " xx-collapsed" : ""}`}
|
||||
aria-label="侧边栏"
|
||||
>
|
||||
<aside className={`xx-app-sidebar${collapsed ? " xx-collapsed" : ""}`} aria-label="侧边栏">
|
||||
<div className="xx-sidebar-toggle">
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,9 +42,7 @@ const MainLayout: React.FC = () => {
|
||||
aria-label={collapsed ? "展开侧边栏" : "收起侧边栏"}
|
||||
>
|
||||
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
||||
<span className="xx-sidebar-toggle-label">
|
||||
{collapsed ? "展开" : "收起"}
|
||||
</span>
|
||||
<span className="xx-sidebar-toggle-label">{collapsed ? "展开" : "收起"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -61,13 +52,13 @@ const MainLayout: React.FC = () => {
|
||||
</nav>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={{ collapsed }}>
|
||||
<AppLayout sidebar={sidebar} />
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default MainLayout;
|
||||
export default MainLayout
|
||||
|
||||
@@ -10,33 +10,33 @@
|
||||
* 复用 global.css 中已有的 .xx-page-head 基础样式,
|
||||
* 补充面包屑、操作区等扩展样式。
|
||||
*/
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom";
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons";
|
||||
import "./PageHead.css";
|
||||
import React from "react"
|
||||
import { useLocation, useNavigate, Link } from "react-router-dom"
|
||||
import { RightOutlined, HomeOutlined } from "@ant-design/icons"
|
||||
import "./PageHead.css"
|
||||
|
||||
/* ── 类型定义 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 面包屑项 */
|
||||
export interface BreadcrumbItem {
|
||||
/** 显示文字 */
|
||||
label: string;
|
||||
label: string
|
||||
/** 路由路径,不传则为当前页(不可点击) */
|
||||
path?: string;
|
||||
path?: string
|
||||
}
|
||||
|
||||
/** PageHead 组件属性 */
|
||||
export interface PageHeadProps {
|
||||
/** 页面标题 */
|
||||
title: string;
|
||||
title: string
|
||||
/** 页面描述(可选,显示在标题下方) */
|
||||
description?: React.ReactNode;
|
||||
description?: React.ReactNode
|
||||
/** 面包屑项(可选,不传则自动根据路由生成) */
|
||||
breadcrumb?: BreadcrumbItem[];
|
||||
breadcrumb?: BreadcrumbItem[]
|
||||
/** 右侧操作区内容(按钮等) */
|
||||
actions?: React.ReactNode;
|
||||
actions?: React.ReactNode
|
||||
/** 是否隐藏面包屑 */
|
||||
hideBreadcrumb?: boolean;
|
||||
hideBreadcrumb?: boolean
|
||||
}
|
||||
|
||||
/* ── 路由 → 标题映射(用于自动生成面包屑) ────────────────── */
|
||||
@@ -66,46 +66,46 @@ const ROUTE_TITLE_MAP: Record<string, string> = {
|
||||
"/app/accounts": "账号管理",
|
||||
"/app/duplication": "查重",
|
||||
"/app/duplication/results": "查重结果",
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 自动生成面包屑 ─────────────────────────────────────── */
|
||||
|
||||
/** 根据当前路径生成面包屑 */
|
||||
const generateBreadcrumb = (pathname: string): BreadcrumbItem[] => {
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }];
|
||||
const items: BreadcrumbItem[] = [{ label: "首页", path: "/app/dashboard" }]
|
||||
|
||||
// 首页本身不需要面包屑
|
||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
||||
return items;
|
||||
return items
|
||||
}
|
||||
|
||||
// 逐级拆分路径,生成中间层级
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
let currentPath = "";
|
||||
const segments = pathname.split("/").filter(Boolean)
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
currentPath += `/${segments[i]}`;
|
||||
const title = ROUTE_TITLE_MAP[currentPath];
|
||||
currentPath += `/${segments[i]}`
|
||||
const title = ROUTE_TITLE_MAP[currentPath]
|
||||
|
||||
if (title) {
|
||||
// 最后一级不带 path(当前页面,不可点击)
|
||||
const isLast = i === segments.length - 1;
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: title,
|
||||
path: isLast ? undefined : currentPath,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
// 动态路由段(如 :id),用路径片段做 label
|
||||
const isLast = i === segments.length - 1;
|
||||
const isLast = i === segments.length - 1
|
||||
items.push({
|
||||
label: segments[i],
|
||||
path: isLast ? undefined : currentPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
return items
|
||||
}
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -116,18 +116,18 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
actions,
|
||||
hideBreadcrumb = false,
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 使用传入的面包屑或自动生成
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname);
|
||||
const breadcrumbItems = breadcrumb ?? generateBreadcrumb(location.pathname)
|
||||
|
||||
// 首页不显示面包屑
|
||||
const showBreadcrumb =
|
||||
!hideBreadcrumb &&
|
||||
breadcrumbItems.length > 1 &&
|
||||
location.pathname !== "/app" &&
|
||||
location.pathname !== "/app/dashboard";
|
||||
location.pathname !== "/app/dashboard"
|
||||
|
||||
return (
|
||||
<header className="xx-page-head">
|
||||
@@ -137,42 +137,30 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
<nav className="xx-page-breadcrumb" aria-label="面包屑导航">
|
||||
<ol>
|
||||
{breadcrumbItems.map((item, index) => {
|
||||
const isLast = index === breadcrumbItems.length - 1;
|
||||
const isLast = index === breadcrumbItems.length - 1
|
||||
return (
|
||||
<li
|
||||
key={`${item.label}-${index}`}
|
||||
className="xx-page-breadcrumb-item"
|
||||
>
|
||||
{index > 0 && (
|
||||
<RightOutlined className="xx-page-breadcrumb-separator" />
|
||||
)}
|
||||
<li key={`${item.label}-${index}`} className="xx-page-breadcrumb-item">
|
||||
{index > 0 && <RightOutlined className="xx-page-breadcrumb-separator" />}
|
||||
{item.path && !isLast ? (
|
||||
<Link
|
||||
to={item.path}
|
||||
className="xx-page-breadcrumb-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(item.path!);
|
||||
e.preventDefault()
|
||||
navigate(item.path!)
|
||||
}}
|
||||
>
|
||||
{index === 0 ? (
|
||||
<HomeOutlined className="xx-page-breadcrumb-home" />
|
||||
) : null}
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="xx-page-breadcrumb-current"
|
||||
aria-current="page"
|
||||
>
|
||||
{index === 0 ? (
|
||||
<HomeOutlined className="xx-page-breadcrumb-home" />
|
||||
) : null}
|
||||
<span className="xx-page-breadcrumb-current" aria-current="page">
|
||||
{index === 0 ? <HomeOutlined className="xx-page-breadcrumb-home" /> : null}
|
||||
<span>{item.label}</span>
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -188,7 +176,7 @@ const PageHead: React.FC<PageHeadProps> = ({
|
||||
{/* 右侧操作区 */}
|
||||
{actions && <div className="xx-page-head-actions">{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PageHead;
|
||||
export default PageHead
|
||||
|
||||
@@ -9,44 +9,38 @@
|
||||
* - 折叠态:仅显示图标,隐藏文字(通过 SidebarContext 读取 collapsed 状态)
|
||||
* - 响应式:移动端自动折叠
|
||||
*/
|
||||
import React, { useContext } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { SidebarContext } from "./MainLayout";
|
||||
import { NAV_GROUPS } from "@/config/navigation";
|
||||
import "./Sidebar.css";
|
||||
import React, { useContext } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { SidebarContext } from "./MainLayout"
|
||||
import { NAV_GROUPS } from "@/config/navigation"
|
||||
import "./Sidebar.css"
|
||||
|
||||
/** 判断菜单项是否激活 */
|
||||
const isMenuItemActive = (pathname: string, path: string): boolean => {
|
||||
// 首页特殊处理:/ 和 /app/dashboard 都算激活
|
||||
if (path === "/app/dashboard") {
|
||||
return (
|
||||
pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
);
|
||||
return pathname === "/" || pathname === "/app" || pathname === "/app/dashboard"
|
||||
}
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
return pathname.startsWith(path)
|
||||
}
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { collapsed } = useContext(SidebarContext);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { collapsed } = useContext(SidebarContext)
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
navigate(path);
|
||||
};
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}
|
||||
>
|
||||
<div className={`xx-sidebar-nav${collapsed ? " xx-sidebar-nav--collapsed" : ""}`}>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div className="xx-sidebar-group" key={group.title}>
|
||||
{!collapsed && (
|
||||
<div className="xx-sidebar-group-title">{group.title}</div>
|
||||
)}
|
||||
{!collapsed && <div className="xx-sidebar-group-title">{group.title}</div>}
|
||||
<ul className="xx-sidebar-menu" role="menu">
|
||||
{group.items.map((item) => {
|
||||
const active = isMenuItemActive(location.pathname, item.path);
|
||||
const active = isMenuItemActive(location.pathname, item.path)
|
||||
return (
|
||||
<li
|
||||
key={item.key}
|
||||
@@ -56,17 +50,15 @@ const Sidebar: React.FC = () => {
|
||||
onClick={() => handleNavigate(item.path)}
|
||||
>
|
||||
<span className="xx-sidebar-menu-icon">{item.icon}</span>
|
||||
{!collapsed && (
|
||||
<span className="xx-sidebar-menu-label">{item.label}</span>
|
||||
)}
|
||||
{!collapsed && <span className="xx-sidebar-menu-label">{item.label}</span>}
|
||||
</li>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
export default Sidebar
|
||||
|
||||
@@ -2,26 +2,25 @@
|
||||
* V21 Button 按钮
|
||||
* 封装 Ant Design Button,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button as AntButton } from "antd";
|
||||
import type { ButtonProps as AntButtonProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Button as AntButton } from "antd"
|
||||
import type { ButtonProps as AntButtonProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type ButtonType =
|
||||
"primary" | "secondary" | "ghost" | "text" | "danger" | "link";
|
||||
export type ButtonType = "primary" | "secondary" | "ghost" | "text" | "danger" | "link"
|
||||
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
export type ButtonSize = "sm" | "md" | "lg"
|
||||
|
||||
export interface ButtonProps extends Omit<AntButtonProps, "type" | "size"> {
|
||||
/** 按钮类型 */
|
||||
buttonType?: ButtonType;
|
||||
buttonType?: ButtonType
|
||||
/** 按钮尺寸 */
|
||||
buttonSize?: ButtonSize;
|
||||
buttonSize?: ButtonSize
|
||||
/** 兼容 antd type(用于直接替换) */
|
||||
type?: AntButtonProps["type"];
|
||||
type?: AntButtonProps["type"]
|
||||
/** 兼容 antd size */
|
||||
size?: AntButtonProps["size"];
|
||||
size?: AntButtonProps["size"]
|
||||
}
|
||||
|
||||
const TYPE_CLASS_MAP: Record<ButtonType, string> = {
|
||||
@@ -31,29 +30,29 @@ const TYPE_CLASS_MAP: Record<ButtonType, string> = {
|
||||
text: "xx-btn-text",
|
||||
danger: "xx-btn-danger",
|
||||
link: "xx-btn-link",
|
||||
};
|
||||
}
|
||||
|
||||
const SIZE_CLASS_MAP: Record<ButtonSize, string> = {
|
||||
sm: "xx-btn-sm",
|
||||
md: "xx-btn-md",
|
||||
lg: "xx-btn-lg",
|
||||
};
|
||||
}
|
||||
|
||||
/** 将 buttonType 映射到 antd type */
|
||||
const toAntdType = (bt: ButtonType): AntButtonProps["type"] => {
|
||||
switch (bt) {
|
||||
case "primary":
|
||||
return "primary";
|
||||
return "primary"
|
||||
case "danger":
|
||||
return "primary";
|
||||
return "primary"
|
||||
case "link":
|
||||
return "link";
|
||||
return "link"
|
||||
case "text":
|
||||
return "text";
|
||||
return "text"
|
||||
default:
|
||||
return "default";
|
||||
return "default"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
buttonType = "ghost",
|
||||
@@ -64,17 +63,16 @@ const Button: React.FC<ButtonProps> = ({
|
||||
size,
|
||||
...rest
|
||||
}) => {
|
||||
const antdType = type ?? toAntdType(buttonType);
|
||||
const antdType = type ?? toAntdType(buttonType)
|
||||
const antdSize =
|
||||
size ??
|
||||
(buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle");
|
||||
size ?? (buttonSize === "sm" ? "small" : buttonSize === "lg" ? "large" : "middle")
|
||||
|
||||
const v21Class = classNames(
|
||||
"xx-btn",
|
||||
TYPE_CLASS_MAP[buttonType],
|
||||
SIZE_CLASS_MAP[buttonSize],
|
||||
className,
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<AntButton
|
||||
@@ -86,7 +84,7 @@ const Button: React.FC<ButtonProps> = ({
|
||||
>
|
||||
{children}
|
||||
</AntButton>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Button;
|
||||
export default Button
|
||||
|
||||
@@ -2,24 +2,20 @@
|
||||
* V21 Card 卡片
|
||||
* 封装 Ant Design Card,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Card as AntCard } from "antd";
|
||||
import type { CardProps as AntCardProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Card as AntCard } from "antd"
|
||||
import type { CardProps as AntCardProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface CardProps extends AntCardProps {
|
||||
/** 是否启用悬浮效果 */
|
||||
hover?: boolean;
|
||||
hover?: boolean
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({ className, hover, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-card-v21",
|
||||
hover && "xx-card-hover",
|
||||
className,
|
||||
);
|
||||
return <AntCard className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-card-v21", hover && "xx-card-hover", className)
|
||||
return <AntCard className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export default Card;
|
||||
export default Card
|
||||
|
||||
@@ -3,70 +3,59 @@
|
||||
* 封装 Ant Design Input,应用 V21 设计系统样式
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from "react";
|
||||
import { Input as AntInput } from "antd";
|
||||
import type { InputProps as AntInputProps } from "antd";
|
||||
import type { InputRef } from "antd/es/input";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Input as AntInput } from "antd"
|
||||
import type { InputProps as AntInputProps } from "antd"
|
||||
import type { InputRef } from "antd/es/input"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
const {
|
||||
Search: AntSearch,
|
||||
TextArea: AntTextArea,
|
||||
Password: AntPassword,
|
||||
} = AntInput;
|
||||
const { Search: AntSearch, TextArea: AntTextArea, Password: AntPassword } = AntInput
|
||||
|
||||
export interface InputProps extends AntInputProps {
|
||||
/** 是否处于错误状态 */
|
||||
error?: boolean;
|
||||
error?: boolean
|
||||
}
|
||||
|
||||
/** 基础 Input */
|
||||
const Input = React.forwardRef<InputRef, InputProps>(
|
||||
({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
return <AntInput ref={ref} className={v21Class} {...rest} />;
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
const Input = React.forwardRef<InputRef, InputProps>(({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntInput ref={ref} className={v21Class} {...rest} />
|
||||
})
|
||||
Input.displayName = "Input"
|
||||
|
||||
/** Search 搜索框 */
|
||||
const Search: React.FC<
|
||||
React.ComponentProps<typeof AntSearch> & { error?: boolean }
|
||||
> = ({ className, error, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-input",
|
||||
"xx-input-search",
|
||||
error && "xx-input-error",
|
||||
className,
|
||||
);
|
||||
return <AntSearch className={v21Class} {...rest} />;
|
||||
};
|
||||
const Search: React.FC<React.ComponentProps<typeof AntSearch> & { error?: boolean }> = ({
|
||||
className,
|
||||
error,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-input", "xx-input-search", error && "xx-input-error", className)
|
||||
return <AntSearch className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** TextArea 文本域 */
|
||||
const TextArea: React.FC<
|
||||
React.ComponentProps<typeof AntTextArea> & { error?: boolean }
|
||||
> = ({ className, error, ...rest }) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className);
|
||||
return <AntTextArea className={v21Class} {...rest} />;
|
||||
};
|
||||
const TextArea: React.FC<React.ComponentProps<typeof AntTextArea> & { error?: boolean }> = ({
|
||||
className,
|
||||
error,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntTextArea className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** Password 密码框 */
|
||||
const Password = React.forwardRef<
|
||||
InputRef,
|
||||
React.ComponentProps<typeof AntPassword> & { error?: boolean }
|
||||
>(({ className, error, ...rest }, ref) => {
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className);
|
||||
return <AntPassword ref={ref} className={v21Class} {...rest} />;
|
||||
});
|
||||
Password.displayName = "Password";
|
||||
const v21Class = classNames("xx-input", error && "xx-input-error", className)
|
||||
return <AntPassword ref={ref} className={v21Class} {...rest} />
|
||||
})
|
||||
Password.displayName = "Password"
|
||||
|
||||
export default Object.assign(Input, {
|
||||
Search,
|
||||
TextArea,
|
||||
Password,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -2,74 +2,69 @@
|
||||
* V21 Modal 对话框
|
||||
* 封装 Ant Design Modal,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Modal as AntModal } from "antd";
|
||||
import type { ModalProps as AntModalProps } from "antd";
|
||||
import type { ModalFuncProps } from "antd/es/modal";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Modal as AntModal } from "antd"
|
||||
import type { ModalProps as AntModalProps } from "antd"
|
||||
import type { ModalFuncProps } from "antd/es/modal"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface ModalProps extends AntModalProps {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
interface ModalComponent extends React.FC<ModalProps> {
|
||||
confirm: (config: ModalFuncProps) => ReturnType<typeof AntModal.confirm>;
|
||||
info: (config: ModalFuncProps) => ReturnType<typeof AntModal.info>;
|
||||
success: (config: ModalFuncProps) => ReturnType<typeof AntModal.success>;
|
||||
error: (config: ModalFuncProps) => ReturnType<typeof AntModal.error>;
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>;
|
||||
confirm: (config: ModalFuncProps) => ReturnType<typeof AntModal.confirm>
|
||||
info: (config: ModalFuncProps) => ReturnType<typeof AntModal.info>
|
||||
success: (config: ModalFuncProps) => ReturnType<typeof AntModal.success>
|
||||
error: (config: ModalFuncProps) => ReturnType<typeof AntModal.error>
|
||||
warning: (config: ModalFuncProps) => ReturnType<typeof AntModal.warning>
|
||||
}
|
||||
|
||||
const Modal: ModalComponent = ({
|
||||
className,
|
||||
v21 = true,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className);
|
||||
const Modal: ModalComponent = ({ className, v21 = true, children, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-modal", className)
|
||||
return (
|
||||
<AntModal className={v21Class} {...rest}>
|
||||
{children}
|
||||
</AntModal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
/** 确认对话框快捷方法 */
|
||||
Modal.confirm = (config: ModalFuncProps) => {
|
||||
return AntModal.confirm({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.info = (config: ModalFuncProps) => {
|
||||
return AntModal.info({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.success = (config: ModalFuncProps) => {
|
||||
return AntModal.success({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.error = (config: ModalFuncProps) => {
|
||||
return AntModal.error({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
Modal.warning = (config: ModalFuncProps) => {
|
||||
return AntModal.warning({
|
||||
...config,
|
||||
className: classNames("xx-modal-confirm", config.className),
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
export default Modal
|
||||
|
||||
@@ -2,31 +2,21 @@
|
||||
* V21 Select 选择器
|
||||
* 封装 Ant Design Select,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Select as AntSelect } from "antd";
|
||||
import type { SelectProps as AntSelectProps } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Select as AntSelect } from "antd"
|
||||
import type { SelectProps as AntSelectProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export interface SelectProps extends AntSelectProps {
|
||||
/** 自定义弹出层类名 */
|
||||
dropdownClassName?: string;
|
||||
dropdownClassName?: string
|
||||
}
|
||||
|
||||
const Select: React.FC<SelectProps> = ({
|
||||
className,
|
||||
dropdownClassName,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames("xx-select", className);
|
||||
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName);
|
||||
return (
|
||||
<AntSelect
|
||||
className={v21Class}
|
||||
popupClassName={v21DropdownClass}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
const Select: React.FC<SelectProps> = ({ className, dropdownClassName, ...rest }) => {
|
||||
const v21Class = classNames("xx-select", className)
|
||||
const v21DropdownClass = classNames("xx-select-dropdown", dropdownClassName)
|
||||
return <AntSelect className={v21Class} popupClassName={v21DropdownClass} {...rest} />
|
||||
}
|
||||
|
||||
export default Select;
|
||||
export default Select
|
||||
|
||||
@@ -2,20 +2,17 @@
|
||||
* V21 Tag 标签 / Badge 徽标
|
||||
* 封装 Ant Design Tag 和 Badge,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Tag as AntTag, Badge as AntBadge } from "antd";
|
||||
import type {
|
||||
TagProps as AntTagProps,
|
||||
BadgeProps as AntBadgeProps,
|
||||
} from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Tag as AntTag, Badge as AntBadge } from "antd"
|
||||
import type { TagProps as AntTagProps, BadgeProps as AntBadgeProps } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type TagVariant = "primary" | "success" | "warning" | "error" | "info";
|
||||
export type TagVariant = "primary" | "success" | "warning" | "error" | "info"
|
||||
|
||||
export interface TagProps extends AntTagProps {
|
||||
/** V21 语义变体 */
|
||||
variant?: TagVariant;
|
||||
variant?: TagVariant
|
||||
}
|
||||
|
||||
const VARIANT_CLASS_MAP: Record<TagVariant, string> = {
|
||||
@@ -24,23 +21,19 @@ const VARIANT_CLASS_MAP: Record<TagVariant, string> = {
|
||||
warning: "xx-tag-warning",
|
||||
error: "xx-tag-error",
|
||||
info: "xx-tag-info",
|
||||
};
|
||||
}
|
||||
|
||||
/** Tag 标签 */
|
||||
const Tag: React.FC<TagProps> = ({ className, variant, ...rest }) => {
|
||||
const v21Class = classNames(
|
||||
"xx-tag",
|
||||
variant && VARIANT_CLASS_MAP[variant],
|
||||
className,
|
||||
);
|
||||
return <AntTag className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-tag", variant && VARIANT_CLASS_MAP[variant], className)
|
||||
return <AntTag className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
/** Badge 徽标 */
|
||||
const Badge: React.FC<AntBadgeProps> = ({ className, ...rest }) => {
|
||||
const v21Class = classNames("xx-badge", className);
|
||||
return <AntBadge className={v21Class} {...rest} />;
|
||||
};
|
||||
const v21Class = classNames("xx-badge", className)
|
||||
return <AntBadge className={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export { Tag, Badge };
|
||||
export default Tag;
|
||||
export { Tag, Badge }
|
||||
export default Tag
|
||||
|
||||
@@ -2,40 +2,32 @@
|
||||
* V21 Tooltip 文字提示 / Popover 气泡卡片
|
||||
* 封装 Ant Design Tooltip 和 Popover,应用 V21 设计系统样式
|
||||
*/
|
||||
import React from "react";
|
||||
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd";
|
||||
import classNames from "classnames";
|
||||
import "./ui.css";
|
||||
import React from "react"
|
||||
import { Tooltip as AntTooltip, Popover as AntPopover } from "antd"
|
||||
import classNames from "classnames"
|
||||
import "./ui.css"
|
||||
|
||||
export type TooltipProps = React.ComponentProps<typeof AntTooltip> & {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
};
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
/** Tooltip 文字提示 */
|
||||
const Tooltip: React.FC<TooltipProps> = ({
|
||||
overlayClassName,
|
||||
v21 = true,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-tooltip", overlayClassName);
|
||||
return <AntTooltip overlayClassName={v21Class} {...rest} />;
|
||||
};
|
||||
const Tooltip: React.FC<TooltipProps> = ({ overlayClassName, v21 = true, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-tooltip", overlayClassName)
|
||||
return <AntTooltip overlayClassName={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export type PopoverProps = React.ComponentProps<typeof AntPopover> & {
|
||||
/** 使用 V21 样式 */
|
||||
v21?: boolean;
|
||||
};
|
||||
v21?: boolean
|
||||
}
|
||||
|
||||
/** Popover 气泡卡片 */
|
||||
const Popover: React.FC<PopoverProps> = ({
|
||||
overlayClassName,
|
||||
v21 = true,
|
||||
...rest
|
||||
}) => {
|
||||
const v21Class = classNames(v21 && "xx-popover", overlayClassName);
|
||||
return <AntPopover overlayClassName={v21Class} {...rest} />;
|
||||
};
|
||||
const Popover: React.FC<PopoverProps> = ({ overlayClassName, v21 = true, ...rest }) => {
|
||||
const v21Class = classNames(v21 && "xx-popover", overlayClassName)
|
||||
return <AntPopover overlayClassName={v21Class} {...rest} />
|
||||
}
|
||||
|
||||
export { Tooltip, Popover };
|
||||
export default Tooltip;
|
||||
export { Tooltip, Popover }
|
||||
export default Tooltip
|
||||
|
||||
@@ -3,23 +3,23 @@
|
||||
*/
|
||||
|
||||
// 组件导出
|
||||
export { default as Button } from "./Button";
|
||||
export type { ButtonProps, ButtonType, ButtonSize } from "./Button";
|
||||
export { default as Button } from "./Button"
|
||||
export type { ButtonProps, ButtonType, ButtonSize } from "./Button"
|
||||
|
||||
export { default as Input } from "./Input";
|
||||
export type { InputProps } from "./Input";
|
||||
export { default as Input } from "./Input"
|
||||
export type { InputProps } from "./Input"
|
||||
|
||||
export { default as Select } from "./Select";
|
||||
export type { SelectProps } from "./Select";
|
||||
export { default as Select } from "./Select"
|
||||
export type { SelectProps } from "./Select"
|
||||
|
||||
export { default as Modal } from "./Modal";
|
||||
export type { ModalProps } from "./Modal";
|
||||
export { default as Modal } from "./Modal"
|
||||
export type { ModalProps } from "./Modal"
|
||||
|
||||
export { default as Card } from "./Card";
|
||||
export type { CardProps } from "./Card";
|
||||
export { default as Card } from "./Card"
|
||||
export type { CardProps } from "./Card"
|
||||
|
||||
export { Tag, Badge } from "./Tag";
|
||||
export type { TagProps, TagVariant } from "./Tag";
|
||||
export { Tag, Badge } from "./Tag"
|
||||
export type { TagProps, TagVariant } from "./Tag"
|
||||
|
||||
export { Tooltip, Popover } from "./Tooltip";
|
||||
export type { TooltipProps, PopoverProps } from "./Tooltip";
|
||||
export { Tooltip, Popover } from "./Tooltip"
|
||||
export type { TooltipProps, PopoverProps } from "./Tooltip"
|
||||
|
||||
@@ -10,24 +10,24 @@
|
||||
*
|
||||
* V21 Design System — 零 antd 直接导入
|
||||
*/
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { uploadAsset } from "@/api/assets";
|
||||
import "./clone-modal.css";
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import { createVoiceClone, toVoiceClone } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
import { uploadAsset } from "@/api/assets"
|
||||
import "./clone-modal.css"
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done";
|
||||
type ModalPhase = "input" | "uploading" | "cloning" | "done"
|
||||
|
||||
export interface CloneModalProps {
|
||||
/** 弹窗是否可见 */
|
||||
open: boolean;
|
||||
open: boolean
|
||||
/** 关闭弹窗回调 */
|
||||
onClose: () => void;
|
||||
onClose: () => void
|
||||
/** 克隆成功回调(返回新创建的音色) */
|
||||
onSuccess?: (voice: VoiceClone) => void;
|
||||
onSuccess?: (voice: VoiceClone) => void
|
||||
}
|
||||
|
||||
/* ── 进度阶段配置 ─────────────────────────────────────────── */
|
||||
@@ -36,352 +36,331 @@ const PROGRESS_STEPS: { key: string; label: string; icon: string }[] = [
|
||||
{ key: "uploading", label: "上传中", icon: "📤" },
|
||||
{ key: "cloning", label: "克隆中", icon: "🧬" },
|
||||
{ key: "done", label: "完成", icon: "✅" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ── 常量 ───────────────────────────────────────────────── */
|
||||
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"];
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4";
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ACCEPTED_EXTENSIONS = ["mp3", "wav", "m4a"]
|
||||
const ACCEPTED_MIME = ".mp3,.wav,.m4a,audio/mpeg,audio/wav,audio/mp4"
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
/** 最长录制时长:5 分钟(秒) */
|
||||
const MAX_RECORD_SECONDS = 5 * 60;
|
||||
const MAX_RECORD_SECONDS = 5 * 60
|
||||
|
||||
/* ── 组件 ───────────────────────────────────────────────── */
|
||||
|
||||
const CloneModal: React.FC<CloneModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input");
|
||||
const [voiceName, setVoiceName] = useState("");
|
||||
const [voiceDescription, setVoiceDescription] = useState("");
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const CloneModal: React.FC<CloneModalProps> = ({ open, onClose, onSuccess }) => {
|
||||
const [phase, setPhase] = useState<ModalPhase>("input")
|
||||
const [voiceName, setVoiceName] = useState("")
|
||||
const [voiceDescription, setVoiceDescription] = useState("")
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState("")
|
||||
|
||||
// 录音状态
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordTime, setRecordTime] = useState(0);
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [recordTime, setRecordTime] = useState(0)
|
||||
const [recordedBlob, setRecordedBlob] = useState<Blob | null>(null)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const recordTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
/** 默认音色名称计数器(组件级 ref,避免多实例串号) */
|
||||
const cloneCounterRef = useRef(1);
|
||||
const cloneCounterRef = useRef(1)
|
||||
|
||||
/** 生成下一个默认音色名称 */
|
||||
const getNextDefaultName = useCallback((): string => {
|
||||
const name = `我的声音 ${cloneCounterRef.current}`;
|
||||
cloneCounterRef.current += 1;
|
||||
return name;
|
||||
}, []);
|
||||
const name = `我的声音 ${cloneCounterRef.current}`
|
||||
cloneCounterRef.current += 1
|
||||
return name
|
||||
}, [])
|
||||
|
||||
/** 组件卸载时清理定时器和 MediaRecorder */
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current);
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
if (recordTimerRef.current) clearInterval(recordTimerRef.current)
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 重置弹窗状态 */
|
||||
const resetState = useCallback(() => {
|
||||
setPhase("input");
|
||||
setVoiceName(getNextDefaultName());
|
||||
setVoiceDescription("");
|
||||
setSelectedFile(null);
|
||||
setDragActive(false);
|
||||
setErrorMessage("");
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
audioChunksRef.current = [];
|
||||
setPhase("input")
|
||||
setVoiceName(getNextDefaultName())
|
||||
setVoiceDescription("")
|
||||
setSelectedFile(null)
|
||||
setDragActive(false)
|
||||
setErrorMessage("")
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
audioChunksRef.current = []
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
}, [getNextDefaultName]);
|
||||
mediaRecorderRef.current = null
|
||||
}, [getNextDefaultName])
|
||||
|
||||
/** 关闭弹窗 */
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
resetState()
|
||||
onClose()
|
||||
}, [resetState, onClose])
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
resetState()
|
||||
}
|
||||
}, [open, resetState]);
|
||||
}, [open, resetState])
|
||||
|
||||
/* ── 文件验证 ──────────────────────────────────────── */
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
const ext = file.name.split(".").pop()?.toLowerCase()
|
||||
if (!ext || !ACCEPTED_EXTENSIONS.includes(ext)) {
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件";
|
||||
return "不支持的音频格式,请上传 MP3、WAV 或 M4A 文件"
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return "文件大小超过 10MB,请压缩后重试";
|
||||
return "文件大小超过 10MB,请压缩后重试"
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 文件上传 ──────────────────────────────────────── */
|
||||
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file);
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
setSelectedFile(null);
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
// 清除录音
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/* ── 拖拽 ──────────────────────────────────────────── */
|
||||
|
||||
const handleDrag = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive(true);
|
||||
setDragActive(true)
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive(false);
|
||||
setDragActive(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragActive(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) {
|
||||
const error = validateFile(file);
|
||||
const error = validateFile(file)
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
setSelectedFile(null);
|
||||
setErrorMessage(error)
|
||||
setSelectedFile(null)
|
||||
} else {
|
||||
setErrorMessage("");
|
||||
setSelectedFile(file);
|
||||
setRecordedBlob(null);
|
||||
setIsRecording(false);
|
||||
setRecordTime(0);
|
||||
setErrorMessage("")
|
||||
setSelectedFile(file)
|
||||
setRecordedBlob(null)
|
||||
setIsRecording(false)
|
||||
setRecordTime(0)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 录音(真实 MediaRecorder) ───────────────────── */
|
||||
|
||||
const handleRecord = async () => {
|
||||
if (isRecording) {
|
||||
// 停止录制
|
||||
setIsRecording(false);
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
} else {
|
||||
// 开始录制
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
})
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" });
|
||||
setRecordedBlob(blob);
|
||||
const blob = new Blob(audioChunksRef.current, { type: "audio/webm" })
|
||||
setRecordedBlob(blob)
|
||||
// 清除上传的文件
|
||||
setSelectedFile(null);
|
||||
setSelectedFile(null)
|
||||
// 停止音轨
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
};
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
setRecordTime(0);
|
||||
setRecordedBlob(null);
|
||||
setErrorMessage("");
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
setRecordTime(0)
|
||||
setRecordedBlob(null)
|
||||
setErrorMessage("")
|
||||
|
||||
recordTimerRef.current = setInterval(() => {
|
||||
setRecordTime((prev) => {
|
||||
const next = prev + 1;
|
||||
const next = prev + 1
|
||||
if (next >= MAX_RECORD_SECONDS) {
|
||||
// 达到 5 分钟上限,自动停止录制
|
||||
setTimeout(() => {
|
||||
setIsRecording(false);
|
||||
setIsRecording(false)
|
||||
if (recordTimerRef.current) {
|
||||
clearInterval(recordTimerRef.current);
|
||||
recordTimerRef.current = null;
|
||||
clearInterval(recordTimerRef.current)
|
||||
recordTimerRef.current = null
|
||||
}
|
||||
if (
|
||||
mediaRecorderRef.current &&
|
||||
mediaRecorderRef.current.state !== "inactive"
|
||||
) {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== "inactive") {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止");
|
||||
}, 0);
|
||||
return MAX_RECORD_SECONDS;
|
||||
setErrorMessage("已达最长录制时长(5分钟),已自动停止")
|
||||
}, 0)
|
||||
return MAX_RECORD_SECONDS
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
return next
|
||||
})
|
||||
}, 1000)
|
||||
} catch {
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置");
|
||||
setErrorMessage("无法访问麦克风,请检查浏览器权限设置")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化录制时间 mm:ss */
|
||||
const formatRecordTime = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/* ── 表单验证 ──────────────────────────────────────── */
|
||||
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null;
|
||||
const hasAudio = selectedFile !== null || recordedBlob !== null
|
||||
|
||||
const validateForm = (): string | null => {
|
||||
const name = voiceName.trim();
|
||||
const name = voiceName.trim()
|
||||
if (!name) {
|
||||
return "请输入音色名称";
|
||||
return "请输入音色名称"
|
||||
}
|
||||
if (name.length < 2 || name.length > 20) {
|
||||
return "音色名称需在 2-20 个字符之间";
|
||||
return "音色名称需在 2-20 个字符之间"
|
||||
}
|
||||
if (!hasAudio) {
|
||||
return "请上传音频文件或录制一段声音";
|
||||
return "请上传音频文件或录制一段声音"
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
/* ── 提交克隆 ──────────────────────────────────────── */
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const formError = validateForm();
|
||||
const formError = validateForm()
|
||||
if (formError) {
|
||||
setErrorMessage(formError);
|
||||
return;
|
||||
setErrorMessage(formError)
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage("");
|
||||
setErrorMessage("")
|
||||
|
||||
try {
|
||||
// 阶段 1:上传音频
|
||||
setPhase("uploading");
|
||||
setPhase("uploading")
|
||||
|
||||
let fileToUpload: File;
|
||||
let fileToUpload: File
|
||||
if (selectedFile) {
|
||||
fileToUpload = selectedFile;
|
||||
fileToUpload = selectedFile
|
||||
} else {
|
||||
// 将录音 Blob 转为 File
|
||||
fileToUpload = new File(
|
||||
[recordedBlob!],
|
||||
`recorded-${Date.now()}.webm`,
|
||||
{
|
||||
type: "audio/webm",
|
||||
},
|
||||
);
|
||||
fileToUpload = new File([recordedBlob!], `recorded-${Date.now()}.webm`, {
|
||||
type: "audio/webm",
|
||||
})
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileToUpload);
|
||||
const uploadResult = await uploadAsset(formData);
|
||||
const formData = new FormData()
|
||||
formData.append("file", fileToUpload)
|
||||
const uploadResult = await uploadAsset(formData)
|
||||
|
||||
// 阶段 2:克隆
|
||||
setPhase("cloning");
|
||||
setPhase("cloning")
|
||||
const result = await createVoiceClone({
|
||||
name: voiceName.trim(),
|
||||
description: voiceDescription.trim() || undefined,
|
||||
audio_url: uploadResult.url,
|
||||
});
|
||||
})
|
||||
|
||||
// 阶段 3:完成
|
||||
setPhase("done");
|
||||
setPhase("done")
|
||||
|
||||
// 2秒后自动关闭
|
||||
timerRef.current = setTimeout(() => {
|
||||
onSuccess?.(toVoiceClone(result));
|
||||
handleClose();
|
||||
}, 2000);
|
||||
onSuccess?.(toVoiceClone(result))
|
||||
handleClose()
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
setPhase("input");
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试");
|
||||
setPhase("input")
|
||||
setErrorMessage(err instanceof Error ? err.message : "克隆失败,请重试")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 计算属性 ──────────────────────────────────────── */
|
||||
|
||||
const canSubmit =
|
||||
voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio;
|
||||
const canSubmit = voiceName.trim().length >= 2 && voiceName.trim().length <= 20 && hasAudio
|
||||
|
||||
const isProcessing = phase === "uploading" || phase === "cloning";
|
||||
const isProcessing = phase === "uploading" || phase === "cloning"
|
||||
|
||||
/** 当前进度索引 */
|
||||
const getProgressIndex = (): number => {
|
||||
switch (phase) {
|
||||
case "uploading":
|
||||
return 0;
|
||||
return 0
|
||||
case "cloning":
|
||||
return 1;
|
||||
return 1
|
||||
case "done":
|
||||
return 2;
|
||||
return 2
|
||||
default:
|
||||
return -1;
|
||||
return -1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const progressIndex = getProgressIndex();
|
||||
const progressIndex = getProgressIndex()
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -428,9 +407,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
placeholder="输入音色名称(2-20字符)"
|
||||
maxLength={20}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceName.length}/20
|
||||
</div>
|
||||
<div className="xx-clonemodal-char-count">{voiceName.length}/20</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
@@ -444,17 +421,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
onDragLeave={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="xx-clonemodal-upload-icon">
|
||||
{selectedFile ? "📄" : "🎵"}
|
||||
</div>
|
||||
<div className="xx-clonemodal-upload-icon">{selectedFile ? "📄" : "🎵"}</div>
|
||||
<p className="xx-clonemodal-upload-title">
|
||||
{selectedFile
|
||||
? selectedFile.name
|
||||
: "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">
|
||||
支持 MP3、WAV、M4A 格式,最大 10MB
|
||||
{selectedFile ? selectedFile.name : "拖拽音频文件到此处,或点击上传"}
|
||||
</p>
|
||||
<p className="xx-clonemodal-upload-hint">支持 MP3、WAV、M4A 格式,最大 10MB</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -516,9 +487,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
maxLength={100}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="xx-clonemodal-char-count">
|
||||
{voiceDescription.length}/100
|
||||
</div>
|
||||
<div className="xx-clonemodal-char-count">{voiceDescription.length}/100</div>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
@@ -532,9 +501,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{/* 提示 */}
|
||||
<div className="xx-clonemodal-tip">
|
||||
<span className="xx-clonemodal-tip-icon">💡</span>
|
||||
<span>
|
||||
建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳
|
||||
</span>
|
||||
<span>建议上传 10 秒 ~ 3 分钟的清晰语音,环境安静、语速均匀效果最佳</span>
|
||||
</div>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
@@ -542,11 +509,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<Button buttonType="ghost" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="primary"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
<Button buttonType="primary" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
🎤 开始克隆
|
||||
</Button>
|
||||
</div>
|
||||
@@ -559,15 +522,15 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
{/* 步骤指示器 */}
|
||||
<div className="xx-clonemodal-steps-progress">
|
||||
{PROGRESS_STEPS.map((step, idx) => {
|
||||
const isActive = idx === progressIndex;
|
||||
const isDone = idx < progressIndex;
|
||||
const isActive = idx === progressIndex
|
||||
const isDone = idx < progressIndex
|
||||
const stepClass = [
|
||||
"xx-clonemodal-step-progress",
|
||||
isActive ? "xx-clonemodal-step-progress--active" : "",
|
||||
isDone ? "xx-clonemodal-step-progress--done" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
.join(" ")
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.key}>
|
||||
@@ -577,15 +540,11 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
/>
|
||||
)}
|
||||
<div className={stepClass}>
|
||||
<div className="xx-clonemodal-step-icon">
|
||||
{isDone ? "✓" : step.icon}
|
||||
</div>
|
||||
<span className="xx-clonemodal-step-label">
|
||||
{step.label}
|
||||
</span>
|
||||
<div className="xx-clonemodal-step-icon">{isDone ? "✓" : step.icon}</div>
|
||||
<span className="xx-clonemodal-step-label">{step.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -595,20 +554,14 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner" />
|
||||
<p className="xx-clonemodal-progress-text">正在上传音频文件…</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
请稍候,正在将音频上传至服务器
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">请稍候,正在将音频上传至服务器</p>
|
||||
</>
|
||||
)}
|
||||
{phase === "cloning" && (
|
||||
<>
|
||||
<div className="xx-clonemodal-progress-spinner xx-clonemodal-progress-spinner--cloning" />
|
||||
<p className="xx-clonemodal-progress-text">
|
||||
AI 正在克隆你的声音…
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-sub">
|
||||
正在分析声音特征,生成专属音色模型
|
||||
</p>
|
||||
<p className="xx-clonemodal-progress-text">AI 正在克隆你的声音…</p>
|
||||
<p className="xx-clonemodal-progress-sub">正在分析声音特征,生成专属音色模型</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -643,7 +596,7 @@ const CloneModal: React.FC<CloneModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CloneModal;
|
||||
export default CloneModal
|
||||
|
||||
@@ -49,8 +49,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active)
|
||||
.xx-clonemodal-step-number {
|
||||
.xx-clonemodal-step:not(.xx-clonemodal-step--active) .xx-clonemodal-step-number {
|
||||
background: var(--xx-color-border, #e5e7eb);
|
||||
color: var(--xx-color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 统一导航配置
|
||||
* Header.tsx 和 Sidebar.tsx 共享此数据源,避免路由配置重复
|
||||
*/
|
||||
import React from "react";
|
||||
import React from "react"
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileOutlined,
|
||||
@@ -18,20 +18,20 @@ import {
|
||||
ControlOutlined,
|
||||
CrownOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons";
|
||||
} from "@ant-design/icons"
|
||||
|
||||
/** 导航项类型 */
|
||||
export interface NavItem {
|
||||
key: string;
|
||||
label: string;
|
||||
path: string;
|
||||
icon: React.ReactNode;
|
||||
key: string
|
||||
label: string
|
||||
path: string
|
||||
icon: React.ReactNode
|
||||
}
|
||||
|
||||
/** 导航分组类型 */
|
||||
export interface NavGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,7 +117,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
path: "/app/tasks",
|
||||
icon: React.createElement(UnorderedListOutlined),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/** 侧边栏导航分组(Sidebar 分组列表使用) */
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
@@ -226,4 +226,4 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
@@ -1,94 +1,94 @@
|
||||
/**
|
||||
* 认证相关 Hooks
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import * as authApi from "@/api/auth";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import * as authApi from "@/api/auth"
|
||||
import { useAuthStore } from "@/store/authStore"
|
||||
|
||||
// 登录 Hook
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((state) => state.setAuth)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.login,
|
||||
});
|
||||
})
|
||||
|
||||
// 手动处理成功后的逻辑
|
||||
const login = async (credentials: Parameters<typeof authApi.login>[0]) => {
|
||||
const data = await mutation.mutateAsync(credentials);
|
||||
const refreshToken = data.refresh_token ?? null;
|
||||
const data = await mutation.mutateAsync(credentials)
|
||||
const refreshToken = data.refresh_token ?? null
|
||||
|
||||
// 先保存 token
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("access_token", data.access_token)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem("refresh_token", refreshToken);
|
||||
localStorage.setItem("refresh_token", refreshToken)
|
||||
} else {
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("refresh_token")
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
const user = await authApi.getCurrentUser();
|
||||
setAuth(user, data.access_token, refreshToken);
|
||||
navigate("/");
|
||||
return data;
|
||||
};
|
||||
const user = await authApi.getCurrentUser()
|
||||
setAuth(user, data.access_token, refreshToken)
|
||||
navigate("/")
|
||||
return data
|
||||
}
|
||||
|
||||
return { ...mutation, mutateAsync: login };
|
||||
};
|
||||
return { ...mutation, mutateAsync: login }
|
||||
}
|
||||
|
||||
// 注册 Hook
|
||||
export const useRegister = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.register,
|
||||
});
|
||||
})
|
||||
|
||||
const register = async (data: Parameters<typeof authApi.register>[0]) => {
|
||||
const result = await mutation.mutateAsync(data);
|
||||
const result = await mutation.mutateAsync(data)
|
||||
navigate("/login", {
|
||||
state: { message: "注册成功!请查收验证邮件。" },
|
||||
});
|
||||
return result;
|
||||
};
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
return { ...mutation, mutateAsync: register };
|
||||
};
|
||||
return { ...mutation, mutateAsync: register }
|
||||
}
|
||||
|
||||
// 登出 Hook
|
||||
export const useLogout = () => {
|
||||
const navigate = useNavigate();
|
||||
const clearAuth = useAuthStore((state) => state.clearAuth);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate()
|
||||
const clearAuth = useAuthStore((state) => state.clearAuth)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: authApi.logout,
|
||||
});
|
||||
})
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await mutation.mutateAsync();
|
||||
await mutation.mutateAsync()
|
||||
} catch (error) {
|
||||
// 即使登出失败也清除本地状态
|
||||
} finally {
|
||||
clearAuth();
|
||||
queryClient.clear();
|
||||
navigate("/");
|
||||
clearAuth()
|
||||
queryClient.clear()
|
||||
navigate("/")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return { ...mutation, mutateAsync: logout };
|
||||
};
|
||||
return { ...mutation, mutateAsync: logout }
|
||||
}
|
||||
|
||||
// 获取当前用户 Hook
|
||||
export const useCurrentUser = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated)
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["currentUser"],
|
||||
queryFn: authApi.getCurrentUser,
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,76 +4,76 @@
|
||||
* 当存在 processing 状态的克隆条目时,每 3 秒自动拉取最新列表;
|
||||
* 全部完成(ready / failed)后停止轮询。
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getVoiceClones } from "@/api/voiceClone";
|
||||
import type { VoiceClone } from "@/api/voiceClone";
|
||||
import { useState, useEffect, useCallback, useRef } from "react"
|
||||
import { getVoiceClones } from "@/api/voiceClone"
|
||||
import type { VoiceClone } from "@/api/voiceClone"
|
||||
|
||||
const POLL_INTERVAL = 3000; // 3 秒
|
||||
const POLL_INTERVAL = 3000 // 3 秒
|
||||
|
||||
export const useCloneProgress = () => {
|
||||
const [clones, setClones] = useState<VoiceClone[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const [clones, setClones] = useState<VoiceClone[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined)
|
||||
|
||||
const fetchClones = useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true);
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const data = await getVoiceClones();
|
||||
setClones(data);
|
||||
const data = await getVoiceClones()
|
||||
setClones(data)
|
||||
} catch {
|
||||
// 静默失败,下次轮询重试
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
/* 初始加载 */
|
||||
useEffect(() => {
|
||||
fetchClones();
|
||||
}, [fetchClones]);
|
||||
fetchClones()
|
||||
}, [fetchClones])
|
||||
|
||||
/* 轮询:有 processing 条目时启动,全部结束时停止 */
|
||||
useEffect(() => {
|
||||
const hasProcessing = clones.some((c) => c.status === "processing");
|
||||
const hasProcessing = clones.some((c) => c.status === "processing")
|
||||
|
||||
if (hasProcessing) {
|
||||
if (!timerRef.current) {
|
||||
timerRef.current = setInterval(() => {
|
||||
fetchClones(true);
|
||||
}, POLL_INTERVAL);
|
||||
fetchClones(true)
|
||||
}, POLL_INTERVAL)
|
||||
}
|
||||
} else {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = undefined
|
||||
}
|
||||
};
|
||||
}, [clones, fetchClones]);
|
||||
}
|
||||
}, [clones, fetchClones])
|
||||
|
||||
/** 手动刷新 */
|
||||
const refresh = useCallback(() => fetchClones(), [fetchClones]);
|
||||
const refresh = useCallback(() => fetchClones(), [fetchClones])
|
||||
|
||||
/** 克隆成功后追加到列表 */
|
||||
const addClone = useCallback((voice: VoiceClone) => {
|
||||
setClones((prev) => [voice, ...prev]);
|
||||
}, []);
|
||||
setClones((prev) => [voice, ...prev])
|
||||
}, [])
|
||||
|
||||
/** 删除后从列表移除 */
|
||||
const removeClone = useCallback((id: string) => {
|
||||
setClones((prev) => prev.filter((c) => c.id !== id));
|
||||
}, []);
|
||||
setClones((prev) => prev.filter((c) => c.id !== id))
|
||||
}, [])
|
||||
|
||||
/** 更新某条克隆(如改名) */
|
||||
const updateClone = useCallback((updated: VoiceClone) => {
|
||||
setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c)));
|
||||
}, []);
|
||||
setClones((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
clones,
|
||||
@@ -84,5 +84,5 @@ export const useCloneProgress = () => {
|
||||
updateClone,
|
||||
/** 是否有正在处理中的克隆 */
|
||||
hasProcessing: clones.some((c) => c.status === "processing"),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ body,
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB",
|
||||
"Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
color: var(--text-primary);
|
||||
|
||||
+12
-12
@@ -2,15 +2,15 @@
|
||||
* 更新主入口,引入全局样式
|
||||
* V21 设计系统配置
|
||||
*/
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ConfigProvider, App as AntApp } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import router from "./router";
|
||||
import "./index.css";
|
||||
import "./styles/global.css";
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import { RouterProvider } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { ConfigProvider, App as AntApp } from "antd"
|
||||
import zhCN from "antd/locale/zh_CN"
|
||||
import router from "./router"
|
||||
import "./index.css"
|
||||
import "./styles/global.css"
|
||||
|
||||
// 创建 React Query 客户端
|
||||
const queryClient = new QueryClient({
|
||||
@@ -22,7 +22,7 @@ const queryClient = new QueryClient({
|
||||
gcTime: 10 * 60 * 1000, // 10 分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// V21 Ant Design 主题配置 - Indigo 主色 / 大圆角
|
||||
const theme = {
|
||||
@@ -85,7 +85,7 @@ const theme = {
|
||||
borderRadius: 14,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
@@ -97,4 +97,4 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
</ConfigProvider>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
)
|
||||
|
||||
@@ -6,21 +6,21 @@
|
||||
*
|
||||
* 零 antd 直接导入,全部使用 CSS 变量
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import "./accounts.css";
|
||||
import React from "react"
|
||||
import { Button } from "@/components/ui"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
import "./accounts.css"
|
||||
|
||||
/* ── 类型定义 ───────────────────────────────────────────── */
|
||||
|
||||
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat";
|
||||
export type PlatformId = "douyin" | "kuaishou" | "xiaohongshu" | "wechat"
|
||||
|
||||
export interface Platform {
|
||||
id: PlatformId;
|
||||
name: string;
|
||||
subName: string;
|
||||
icon: string;
|
||||
gradient: string;
|
||||
id: PlatformId
|
||||
name: string
|
||||
subName: string
|
||||
icon: string
|
||||
gradient: string
|
||||
}
|
||||
|
||||
/** 支持的平台列表 */
|
||||
@@ -53,27 +53,21 @@ const PLATFORMS: Platform[] = [
|
||||
icon: "💬",
|
||||
gradient: "linear-gradient(135deg, #07c160 0%, #38d97a 100%)",
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/* ── 主页面 ─────────────────────────────────────────────── */
|
||||
|
||||
const Accounts: React.FC = () => {
|
||||
return (
|
||||
<div className="acc-page">
|
||||
<PageHead
|
||||
title="🔑 账号管理"
|
||||
description="绑定您的社交平台账号,用于视频一键发布到各平台"
|
||||
/>
|
||||
<PageHead title="🔑 账号管理" description="绑定您的社交平台账号,用于视频一键发布到各平台" />
|
||||
|
||||
{/* 平台卡片网格 — 占位状态 */}
|
||||
<div className="acc-grid">
|
||||
{PLATFORMS.map((platform) => (
|
||||
<div key={platform.id} className="acc-card">
|
||||
<div className="acc-card-header">
|
||||
<div
|
||||
className="acc-card-icon"
|
||||
style={{ background: platform.gradient }}
|
||||
>
|
||||
<div className="acc-card-icon" style={{ background: platform.gradient }}>
|
||||
{platform.icon}
|
||||
</div>
|
||||
<div>
|
||||
@@ -100,12 +94,11 @@ const Accounts: React.FC = () => {
|
||||
<div className="acc-stats-bar">
|
||||
<span className="acc-stats-icon">📊</span>
|
||||
<span className="acc-stats-text">
|
||||
支持 <span className="acc-stats-highlight">{PLATFORMS.length}</span>{" "}
|
||||
个平台,功能即将上线
|
||||
支持 <span className="acc-stats-highlight">{PLATFORMS.length}</span> 个平台,功能即将上线
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Accounts;
|
||||
export default Accounts
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { Button, Card, Result } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./Admin.css";
|
||||
import React from "react"
|
||||
import { Button, Card, Result } from "antd"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./Admin.css"
|
||||
|
||||
const AdminComingSoon: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="admin-coming-soon-page">
|
||||
@@ -27,9 +27,9 @@ const AdminComingSoon: React.FC = () => {
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminComingSoon;
|
||||
export default AdminComingSoon
|
||||
|
||||
export const Component = AdminComingSoon;
|
||||
export const Component = AdminComingSoon
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,11 +3,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
);
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -86,11 +82,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
);
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
/**
|
||||
* 忘记密码页面 - V21 完全对标
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import { Form, Input, Button, Result, message } from "antd";
|
||||
import { MailOutlined } from "@ant-design/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { requestPasswordReset } from "@/api/auth";
|
||||
import "./ForgotPassword.css";
|
||||
import React, { useState } from "react"
|
||||
import { Form, Input, Button, Result, message } from "antd"
|
||||
import { MailOutlined } from "@ant-design/icons"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { requestPasswordReset } from "@/api/auth"
|
||||
import "./ForgotPassword.css"
|
||||
|
||||
const ForgotPassword: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const [form] = Form.useForm()
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (email: string) => requestPasswordReset(email),
|
||||
onSuccess: () => {
|
||||
setEmailSent(true);
|
||||
message.success("重置邮件已发送!");
|
||||
setEmailSent(true)
|
||||
message.success("重置邮件已发送!")
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("发送失败,请重试");
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error("发送失败,请重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onFinish = (values: { email: string }) => {
|
||||
resetMutation.mutate(values.email);
|
||||
};
|
||||
resetMutation.mutate(values.email)
|
||||
}
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
@@ -45,7 +44,7 @@ const ForgotPassword: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -68,12 +67,7 @@ const ForgotPassword: React.FC = () => {
|
||||
请输入您的邮箱地址,我们将发送重置密码的链接到您的邮箱。
|
||||
</p>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="forgot-password"
|
||||
onFinish={onFinish}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form form={form} name="forgot-password" onFinish={onFinish} layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
@@ -82,11 +76,7 @@ const ForgotPassword: React.FC = () => {
|
||||
{ type: "email", message: "请输入有效的邮箱地址" },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="name@example.com"
|
||||
size="large"
|
||||
/>
|
||||
<Input prefix={<MailOutlined />} placeholder="name@example.com" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
@@ -106,7 +96,7 @@ const ForgotPassword: React.FC = () => {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ForgotPassword;
|
||||
export default ForgotPassword
|
||||
|
||||
@@ -31,21 +31,15 @@
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
/* ── 忘记密码链接 ───────────────────────────────────────── */
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
/**
|
||||
* 登录页面 - V21 完全对标
|
||||
*/
|
||||
import React from "react";
|
||||
import { Form, Input, Checkbox, message } from "antd";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useLogin } from "@/hooks/useAuth";
|
||||
import Button from "@/components/ui/Button";
|
||||
import "./Login.css";
|
||||
import React from "react"
|
||||
import { Form, Input, Checkbox, message } from "antd"
|
||||
import { Link, useNavigate } from "react-router-dom"
|
||||
import { useLogin } from "@/hooks/useAuth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Login.css"
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
remember: boolean;
|
||||
email: string
|
||||
password: string
|
||||
remember: boolean
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const loginMutation = useLogin();
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate()
|
||||
const loginMutation = useLogin()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
await loginMutation.mutateAsync({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
message.success("登录成功!");
|
||||
navigate("/app/dashboard");
|
||||
})
|
||||
message.success("登录成功!")
|
||||
navigate("/app/dashboard")
|
||||
} catch (error: unknown) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("登录失败,请检查邮箱和密码");
|
||||
message.error("登录失败,请检查邮箱和密码")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-auth-page">
|
||||
@@ -44,13 +44,7 @@ const Login: React.FC = () => {
|
||||
<p>登录到您的账户,开始智能视频创作</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<Form form={form} name="login" onFinish={onFinish} autoComplete="off" layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
@@ -118,7 +112,7 @@ const Login: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login
|
||||
|
||||
@@ -31,19 +31,13 @@
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
box-shadow: 0 4px 14px color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.xx-auth-card .xx-btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px
|
||||
color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
box-shadow: 0 6px 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
/**
|
||||
* 注册页面 - V21 完全对标
|
||||
*/
|
||||
import React from "react";
|
||||
import { Form, Input, message } from "antd";
|
||||
import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useRegister } from "@/hooks/useAuth";
|
||||
import Button from "@/components/ui/Button";
|
||||
import "./Register.css";
|
||||
import React from "react"
|
||||
import { Form, Input, message } from "antd"
|
||||
import { UserOutlined, LockOutlined, MailOutlined } from "@ant-design/icons"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useRegister } from "@/hooks/useAuth"
|
||||
import Button from "@/components/ui/Button"
|
||||
import "./Register.css"
|
||||
|
||||
interface RegisterFormValues {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
email: string
|
||||
username: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
const Register: React.FC = () => {
|
||||
const registerMutation = useRegister();
|
||||
const [form] = Form.useForm();
|
||||
const registerMutation = useRegister()
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const onFinish = async (values: RegisterFormValues) => {
|
||||
try {
|
||||
@@ -27,13 +27,12 @@ const Register: React.FC = () => {
|
||||
username: values.username,
|
||||
password: values.password,
|
||||
display_name: values.username,
|
||||
});
|
||||
message.success("注册成功!请查收验证邮件。");
|
||||
})
|
||||
message.success("注册成功!请查收验证邮件。")
|
||||
} catch (error: unknown) {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("注册失败,请重试");
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error("注册失败,请重试")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="xx-auth-page">
|
||||
@@ -46,13 +45,7 @@ const Register: React.FC = () => {
|
||||
<p>创建账户,开启智能视频创作之旅</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="register"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
layout="vertical"
|
||||
>
|
||||
<Form form={form} name="register" onFinish={onFinish} autoComplete="off" layout="vertical">
|
||||
<Form.Item
|
||||
name="email"
|
||||
label="邮箱"
|
||||
@@ -61,11 +54,7 @@ const Register: React.FC = () => {
|
||||
{ type: "email", message: "请输入有效的邮箱地址" },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder="name@example.com"
|
||||
size="large"
|
||||
/>
|
||||
<Input prefix={<MailOutlined />} placeholder="name@example.com" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -81,11 +70,7 @@ const Register: React.FC = () => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder="用户名"
|
||||
size="large"
|
||||
/>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -100,11 +85,7 @@ const Register: React.FC = () => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="•••••••••"
|
||||
size="large"
|
||||
/>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="•••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -116,18 +97,14 @@ const Register: React.FC = () => {
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("password") === value) {
|
||||
return Promise.resolve();
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error("两次输入的密码不一致"));
|
||||
return Promise.reject(new Error("两次输入的密码不一致"))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="•••••••••"
|
||||
size="large"
|
||||
/>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="•••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
@@ -148,7 +125,7 @@ const Register: React.FC = () => {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Register;
|
||||
export default Register
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
);
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -86,11 +82,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(79, 70, 229, 0.08),
|
||||
rgba(16, 185, 129, 0.06)
|
||||
);
|
||||
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(16, 185, 129, 0.06));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,35 +1,34 @@
|
||||
/**
|
||||
* 重置密码页面 - V21 完全对标
|
||||
*/
|
||||
import React from "react";
|
||||
import { Form, Input, Button, Result, message } from "antd";
|
||||
import { LockOutlined } from "@ant-design/icons";
|
||||
import { Link, useSearchParams, useNavigate } from "react-router-dom";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { resetPassword } from "@/api/auth";
|
||||
import "./ResetPassword.css";
|
||||
import React from "react"
|
||||
import { Form, Input, Button, Result, message } from "antd"
|
||||
import { LockOutlined } from "@ant-design/icons"
|
||||
import { Link, useSearchParams, useNavigate } from "react-router-dom"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { resetPassword } from "@/api/auth"
|
||||
import "./ResetPassword.css"
|
||||
|
||||
const ResetPassword: React.FC = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get("token");
|
||||
const [form] = Form.useForm()
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const token = searchParams.get("token")
|
||||
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: (password: string) => resetPassword(token!, password),
|
||||
onSuccess: () => {
|
||||
message.success("密码重置成功!");
|
||||
setTimeout(() => navigate("/login"), 2000);
|
||||
message.success("密码重置成功!")
|
||||
setTimeout(() => navigate("/login"), 2000)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown)
|
||||
message.error("重置失败,请重试");
|
||||
if (!(error as { __msgShown?: boolean })?.__msgShown) message.error("重置失败,请重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const onFinish = (values: { password: string }) => {
|
||||
resetMutation.mutate(values.password);
|
||||
};
|
||||
resetMutation.mutate(values.password)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
@@ -47,7 +46,7 @@ const ResetPassword: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (resetMutation.isSuccess) {
|
||||
@@ -66,7 +65,7 @@ const ResetPassword: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -78,12 +77,7 @@ const ResetPassword: React.FC = () => {
|
||||
<p>设置新密码</p>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="reset-password"
|
||||
onFinish={onFinish}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form form={form} name="reset-password" onFinish={onFinish} layout="vertical">
|
||||
<Form.Item
|
||||
name="password"
|
||||
label="新密码"
|
||||
@@ -96,11 +90,7 @@ const ResetPassword: React.FC = () => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="•••••••••"
|
||||
size="large"
|
||||
/>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="•••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -112,18 +102,14 @@ const ResetPassword: React.FC = () => {
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue("password") === value) {
|
||||
return Promise.resolve();
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error("两次输入的密码不一致"));
|
||||
return Promise.reject(new Error("两次输入的密码不一致"))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder="•••••••••"
|
||||
size="large"
|
||||
/>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="•••••••••" size="large" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
@@ -143,7 +129,7 @@ const ResetPassword: React.FC = () => {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
export default ResetPassword
|
||||
|
||||
@@ -3,16 +3,16 @@
|
||||
* KPI 卡片网格 + 快速入口 + 最近任务卡片列表 + 使用统计图表 + 公告
|
||||
* CSS 变量,V21 组件
|
||||
*/
|
||||
import React from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui";
|
||||
import { DatabaseOutlined } from "@ant-design/icons";
|
||||
import "./dashboard.css";
|
||||
import React from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Button } from "@/components/ui"
|
||||
import { DatabaseOutlined } from "@ant-design/icons"
|
||||
import "./dashboard.css"
|
||||
|
||||
/* ── 主组件 ─────────────────────────────────────────────── */
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div className="xx-dashboard-page">
|
||||
@@ -39,11 +39,7 @@ const Dashboard: React.FC = () => {
|
||||
<section className="xx-dashboard-section">
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>最近任务</h3>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={() => navigate("/app/history")}
|
||||
>
|
||||
<Button buttonType="ghost" buttonSize="sm" onClick={() => navigate("/app/history")}>
|
||||
查看全部
|
||||
</Button>
|
||||
</div>
|
||||
@@ -55,10 +51,7 @@ const Dashboard: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* 使用统计 */}
|
||||
<section
|
||||
className="xx-dashboard-section"
|
||||
style={{ marginTop: "var(--space-md)" }}
|
||||
>
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>使用统计</h3>
|
||||
</div>
|
||||
@@ -73,18 +66,13 @@ const Dashboard: React.FC = () => {
|
||||
</section>
|
||||
|
||||
{/* 公告 */}
|
||||
<section
|
||||
className="xx-dashboard-section"
|
||||
style={{ marginTop: "var(--space-md)" }}
|
||||
>
|
||||
<section className="xx-dashboard-section" style={{ marginTop: "var(--space-md)" }}>
|
||||
<div className="xx-dashboard-section-header">
|
||||
<h3>公告</h3>
|
||||
</div>
|
||||
<div className="xx-announcement-list">
|
||||
<div className="xx-announcement-item">
|
||||
<span className="xx-announcement-tag xx-announcement-tag--notice">
|
||||
官方
|
||||
</span>
|
||||
<span className="xx-announcement-tag xx-announcement-tag--notice">官方</span>
|
||||
<div className="xx-announcement-content">
|
||||
<h4>欢迎使用小应 SaaS 平台</h4>
|
||||
<time>当前为演示版本,部分功能正在开发中。</time>
|
||||
@@ -93,7 +81,7 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard
|
||||
|
||||
@@ -3,75 +3,70 @@
|
||||
* 风险评估 + 基本信息 + 检测项列表 + 匹配片段
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Tag, Tooltip } from "@/components/ui";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
getDuplicationDetail,
|
||||
retryDuplication,
|
||||
type DuplicateSegment,
|
||||
} from "@/api/duplication";
|
||||
import "./duplication.css";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import React, { useState, useCallback } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { getDuplicationDetail, retryDuplication, type DuplicateSegment } from "@/api/duplication"
|
||||
import "./duplication.css"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 格式化时间(秒 → mm:ss) */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return "-";
|
||||
const totalSec = Math.round(seconds);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
||||
};
|
||||
if (!seconds) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
/** 根据查重率获取风险等级 */
|
||||
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
||||
if (rate === undefined) return "low";
|
||||
if (rate <= 10) return "low";
|
||||
if (rate <= 30) return "medium";
|
||||
return "high";
|
||||
};
|
||||
if (rate === undefined) return "low"
|
||||
if (rate <= 10) return "low"
|
||||
if (rate <= 30) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
/** 风险等级描述 */
|
||||
const RISK_DESC: Record<string, string> = {
|
||||
low: "查重率较低,内容原创度高",
|
||||
medium: "存在一定重复,建议修改部分片段",
|
||||
high: "重复率较高,建议大幅修改或替换",
|
||||
};
|
||||
}
|
||||
|
||||
/** 风险等级标签变体 */
|
||||
const RISK_TAG_VARIANT: Record<string, "success" | "warning" | "error"> = {
|
||||
low: "success",
|
||||
medium: "warning",
|
||||
high: "error",
|
||||
};
|
||||
}
|
||||
|
||||
/** 风险等级文字 */
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
};
|
||||
}
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error" | "warning";
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
/** 单个重复片段卡片 */
|
||||
@@ -79,14 +74,9 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
||||
segment,
|
||||
index,
|
||||
}) => {
|
||||
const sourceDuration = segment.source_end - segment.source_start;
|
||||
const matchedDuration = segment.matched_end - segment.matched_start;
|
||||
const riskLevel =
|
||||
segment.similarity >= 90
|
||||
? "high"
|
||||
: segment.similarity >= 70
|
||||
? "medium"
|
||||
: "low";
|
||||
const sourceDuration = segment.source_end - segment.source_start
|
||||
const matchedDuration = segment.matched_end - segment.matched_start
|
||||
const riskLevel = segment.similarity >= 90 ? "high" : segment.similarity >= 70 ? "medium" : "low"
|
||||
|
||||
return (
|
||||
<div className="dup-check-item">
|
||||
@@ -96,9 +86,8 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
||||
片段 {index + 1}:{segment.matched_video_name}
|
||||
</h4>
|
||||
<p>
|
||||
原始 {formatTime(segment.source_start)} -{" "}
|
||||
{formatTime(segment.source_end)}({sourceDuration.toFixed(0)}s)→ 匹配{" "}
|
||||
{formatTime(segment.matched_start)} -{" "}
|
||||
原始 {formatTime(segment.source_start)} - {formatTime(segment.source_end)}(
|
||||
{sourceDuration.toFixed(0)}s)→ 匹配 {formatTime(segment.matched_start)} -{" "}
|
||||
{formatTime(segment.matched_end)}({matchedDuration.toFixed(0)}s)
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,26 +97,21 @@ const SegmentCard: React.FC<{ segment: DuplicateSegment; index: number }> = ({
|
||||
style={{ width: `${Math.min(segment.similarity, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`dup-check-value ${riskLevel}`}>
|
||||
{segment.similarity.toFixed(1)}%
|
||||
</span>
|
||||
<span className={`dup-check-value ${riskLevel}`}>{segment.similarity.toFixed(1)}%</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const DuplicationDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
const {
|
||||
data: detail,
|
||||
@@ -137,19 +121,19 @@ const DuplicationDetail: React.FC = () => {
|
||||
queryKey: ["duplication-detail", id],
|
||||
queryFn: () => getDuplicationDetail(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
})
|
||||
|
||||
// 重新查重
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryDuplication,
|
||||
onSuccess: () => {
|
||||
showToast("已重新提交查重", "success");
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] });
|
||||
showToast("已重新提交查重", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-detail", id] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重新查重失败", "error");
|
||||
showToast("重新查重失败", "error")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -159,7 +143,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !detail) {
|
||||
@@ -178,23 +162,17 @@ const DuplicationDetail: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const riskLevel = getRiskLevel(detail.duplicate_rate);
|
||||
const riskLevel = getRiskLevel(detail.duplicate_rate)
|
||||
const similarityPercent =
|
||||
detail.duplicate_rate !== undefined
|
||||
? detail.duplicate_rate.toFixed(1)
|
||||
: "—";
|
||||
detail.duplicate_rate !== undefined ? detail.duplicate_rate.toFixed(1) : "—"
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`dup-toast dup-toast--${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className={`dup-toast dup-toast--${toast.type}`}>{toast.message}</div>}
|
||||
|
||||
<PageHead
|
||||
title={`🎬 ${detail.filename}`}
|
||||
@@ -208,26 +186,19 @@ const DuplicationDetail: React.FC = () => {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Tag variant={RISK_TAG_VARIANT[riskLevel]}>
|
||||
{RISK_LABEL[riskLevel]}
|
||||
</Tag>
|
||||
<Tag variant={RISK_TAG_VARIANT[riskLevel]}>{RISK_LABEL[riskLevel]}</Tag>
|
||||
<span>{formatSize(detail.file_size)}</span>
|
||||
<span>{formatDuration(detail.duration_seconds)}</span>
|
||||
<span>
|
||||
提交于 {new Date(detail.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<span>提交于 {new Date(detail.created_at).toLocaleString("zh-CN")}</span>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div
|
||||
className="dup-detail-actions"
|
||||
style={{ display: "flex", gap: 8 }}
|
||||
>
|
||||
<div className="dup-detail-actions" style={{ display: "flex", gap: 8 }}>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="md"
|
||||
onClick={() => {
|
||||
showToast("报告下载功能开发中", "warning");
|
||||
showToast("报告下载功能开发中", "warning")
|
||||
}}
|
||||
>
|
||||
📥 下载报告
|
||||
@@ -279,15 +250,11 @@ const DuplicationDetail: React.FC = () => {
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">文件大小</span>
|
||||
<span className="dup-info-row-value">
|
||||
{formatSize(detail.file_size)}
|
||||
</span>
|
||||
<span className="dup-info-row-value">{formatSize(detail.file_size)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">视频时长</span>
|
||||
<span className="dup-info-row-value">
|
||||
{formatDuration(detail.duration_seconds)}
|
||||
</span>
|
||||
<span className="dup-info-row-value">{formatDuration(detail.duration_seconds)}</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">查重状态</span>
|
||||
@@ -315,9 +282,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">重复片段数</span>
|
||||
<span className="dup-info-row-value">
|
||||
{detail.duplicate_count ?? 0} 个
|
||||
</span>
|
||||
<span className="dup-info-row-value">{detail.duplicate_count ?? 0} 个</span>
|
||||
</div>
|
||||
<div className="dup-info-row">
|
||||
<span className="dup-info-row-label">提交时间</span>
|
||||
@@ -352,7 +317,7 @@ const DuplicationDetail: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DuplicationDetail;
|
||||
export default DuplicationDetail
|
||||
|
||||
@@ -2,73 +2,72 @@
|
||||
* 查重结果列表页面 — V21 设计系统
|
||||
* 胶囊筛选 + 卡片列表,零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button, Tag, Tooltip } from "@/components/ui";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import React, { useState, useMemo } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Button, Tag, Tooltip } from "@/components/ui"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import {
|
||||
getDuplicationRecords,
|
||||
deleteDuplicationRecord,
|
||||
retryDuplication,
|
||||
type DuplicationStatus,
|
||||
} from "@/api/duplication";
|
||||
import "./duplication.css";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
} from "@/api/duplication"
|
||||
import "./duplication.css"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 风险等级分类 */
|
||||
type RiskFilter = "all" | "high" | "medium" | "low";
|
||||
type RiskFilter = "all" | "high" | "medium" | "low"
|
||||
|
||||
/** 状态配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
DuplicationStatus,
|
||||
{
|
||||
variant: "primary" | "warning" | "success" | "error";
|
||||
text: string;
|
||||
icon: string;
|
||||
variant: "primary" | "warning" | "success" | "error"
|
||||
text: string
|
||||
icon: string
|
||||
}
|
||||
> = {
|
||||
pending: { variant: "primary", text: "等待中", icon: "⏳" },
|
||||
processing: { variant: "warning", text: "查重中", icon: "🔄" },
|
||||
completed: { variant: "success", text: "已完成", icon: "✅" },
|
||||
failed: { variant: "error", text: "失败", icon: "❌" },
|
||||
};
|
||||
}
|
||||
|
||||
/** 根据查重率获取风险等级 */
|
||||
const getRiskLevel = (rate?: number): "low" | "medium" | "high" => {
|
||||
if (rate === undefined) return "low";
|
||||
if (rate <= 10) return "low";
|
||||
if (rate <= 30) return "medium";
|
||||
return "high";
|
||||
};
|
||||
if (rate === undefined) return "low"
|
||||
if (rate <= 10) return "low"
|
||||
if (rate <= 30) return "medium"
|
||||
return "high"
|
||||
}
|
||||
|
||||
/** 风险等级标签 */
|
||||
const RISK_LABELS: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
};
|
||||
}
|
||||
|
||||
/** 格式化文件大小 */
|
||||
const formatSize = (bytes: number) => {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
|
||||
}
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return "-";
|
||||
const totalSec = Math.round(seconds);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`;
|
||||
};
|
||||
if (!seconds) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
return m > 0 ? `${m}分${s}秒` : `${s}秒`
|
||||
}
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error" | "warning";
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
@@ -76,69 +75,62 @@ const FILTER_OPTIONS: { key: RiskFilter; label: string }[] = [
|
||||
{ key: "low", label: "低风险" },
|
||||
{ key: "medium", label: "中风险" },
|
||||
{ key: "high", label: "高风险" },
|
||||
];
|
||||
]
|
||||
|
||||
const DuplicationResults: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all");
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [riskFilter, setRiskFilter] = useState<RiskFilter>("all")
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
const showToast = (
|
||||
message: string,
|
||||
type: "success" | "error" | "warning",
|
||||
) => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
const showToast = (message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}
|
||||
|
||||
// 获取查重记录
|
||||
const { data: records = [], isLoading } = useQuery({
|
||||
queryKey: ["duplication-records"],
|
||||
queryFn: getDuplicationRecords,
|
||||
});
|
||||
})
|
||||
|
||||
// 删除
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteDuplicationRecord,
|
||||
onSuccess: () => {
|
||||
showToast("已删除", "success");
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
|
||||
showToast("已删除", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("删除失败", "error");
|
||||
showToast("删除失败", "error")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 重新查重
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: retryDuplication,
|
||||
onSuccess: () => {
|
||||
showToast("已重新提交查重", "success");
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] });
|
||||
showToast("已重新提交查重", "success")
|
||||
queryClient.invalidateQueries({ queryKey: ["duplication-records"] })
|
||||
},
|
||||
onError: () => {
|
||||
showToast("重新查重失败", "error");
|
||||
showToast("重新查重失败", "error")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/** 按风险等级筛选 */
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (riskFilter === "all") return records;
|
||||
if (riskFilter === "all") return records
|
||||
return records.filter((r) => {
|
||||
if (r.status !== "completed") return riskFilter === "low";
|
||||
return getRiskLevel(r.duplicate_rate) === riskFilter;
|
||||
});
|
||||
}, [records, riskFilter]);
|
||||
if (r.status !== "completed") return riskFilter === "low"
|
||||
return getRiskLevel(r.duplicate_rate) === riskFilter
|
||||
})
|
||||
}, [records, riskFilter])
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`dup-toast dup-toast--${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className={`dup-toast dup-toast--${toast.type}`}>{toast.message}</div>}
|
||||
|
||||
<PageHead
|
||||
title="查重记录"
|
||||
@@ -191,9 +183,9 @@ const DuplicationResults: React.FC = () => {
|
||||
{!isLoading && filteredRecords.length > 0 && (
|
||||
<div className="dup-results-list">
|
||||
{filteredRecords.map((record) => {
|
||||
const statusCfg = STATUS_CONFIG[record.status];
|
||||
const riskLevel = getRiskLevel(record.duplicate_rate);
|
||||
const rateValue = record.duplicate_rate;
|
||||
const statusCfg = STATUS_CONFIG[record.status]
|
||||
const riskLevel = getRiskLevel(record.duplicate_rate)
|
||||
const rateValue = record.duplicate_rate
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -201,7 +193,7 @@ const DuplicationResults: React.FC = () => {
|
||||
className="dup-result-card"
|
||||
onClick={() => {
|
||||
if (record.status === "completed") {
|
||||
navigate(`/duplication/${record.id}`);
|
||||
navigate(`/duplication/${record.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -217,13 +209,10 @@ const DuplicationResults: React.FC = () => {
|
||||
</Tag>
|
||||
<span>{formatSize(record.file_size)}</span>
|
||||
<span>{formatDuration(record.duration_seconds)}</span>
|
||||
<span>
|
||||
{new Date(record.created_at).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
{record.status === "completed" &&
|
||||
record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
<span>{new Date(record.created_at).toLocaleDateString("zh-CN")}</span>
|
||||
{record.status === "completed" && record.duplicate_count !== undefined && (
|
||||
<span>{record.duplicate_count} 个重复片段</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,17 +236,15 @@ const DuplicationResults: React.FC = () => {
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
retryMutation.mutate(record.id);
|
||||
e.stopPropagation()
|
||||
retryMutation.mutate(record.id)
|
||||
}}
|
||||
>
|
||||
🔄 重试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
style={{ color: "var(--text-secondary)", fontSize: 12 }}
|
||||
>
|
||||
<span style={{ color: "var(--text-secondary)", fontSize: 12 }}>
|
||||
{record.status === "processing" ? "分析中..." : "—"}
|
||||
</span>
|
||||
)}
|
||||
@@ -268,21 +255,21 @@ const DuplicationResults: React.FC = () => {
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.stopPropagation()
|
||||
if (window.confirm("确定删除此记录?")) {
|
||||
deleteMutation.mutate(record.id);
|
||||
deleteMutation.mutate(record.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
🗑️
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DuplicationResults;
|
||||
export default DuplicationResults
|
||||
|
||||
@@ -3,126 +3,119 @@
|
||||
* 左右分栏:拖拽上传区 + 格式说明
|
||||
* 零 antd 依赖
|
||||
*/
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Button, Card, Tag } from "@/components/ui";
|
||||
import { uploadForDuplication } from "@/api/duplication";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import "./duplication.css";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import React, { useState, useRef, useCallback } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Button, Card, Tag } from "@/components/ui"
|
||||
import { uploadForDuplication } from "@/api/duplication"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import "./duplication.css"
|
||||
import PageHead from "@/components/layout/PageHead"
|
||||
|
||||
/** 支持的视频格式 */
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm";
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"];
|
||||
const ACCEPT_FORMATS = ".mp4,.avi,.mov,.mkv,.wmv,.flv,.webm"
|
||||
const FORMAT_LIST = ["MP4", "AVI", "MOV", "MKV", "WMV", "FLV", "WebM"]
|
||||
/** 最大文件大小:2GB */
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024;
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024
|
||||
|
||||
/** 简易 toast */
|
||||
interface ToastState {
|
||||
message: string;
|
||||
type: "success" | "error" | "warning";
|
||||
message: string
|
||||
type: "success" | "error" | "warning"
|
||||
}
|
||||
|
||||
const DuplicationUpload: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResult, setUploadResult] = useState<{
|
||||
id: string;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
id: string
|
||||
message: string
|
||||
} | null>(null)
|
||||
const [toast, setToast] = useState<ToastState | null>(null)
|
||||
|
||||
/** 显示 toast */
|
||||
const showToast = useCallback(
|
||||
(message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const showToast = useCallback((message: string, type: "success" | "error" | "warning") => {
|
||||
setToast({ message, type })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
|
||||
// 上传查重 mutation
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: (file: File) => uploadForDuplication(file),
|
||||
onSuccess: (data) => {
|
||||
setUploading(false);
|
||||
setUploadResult({ id: data.id, message: data.message });
|
||||
showToast("查重任务已提交", "success");
|
||||
setUploading(false)
|
||||
setUploadResult({ id: data.id, message: data.message })
|
||||
showToast("查重任务已提交", "success")
|
||||
},
|
||||
onError: () => {
|
||||
setUploading(false);
|
||||
showToast("上传失败,请重试", "error");
|
||||
setUploading(false)
|
||||
showToast("上传失败,请重试", "error")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
/** 校验并上传文件 */
|
||||
const handleFile = useCallback(
|
||||
(file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
showToast("文件大小不能超过 2GB", "error");
|
||||
return;
|
||||
showToast("文件大小不能超过 2GB", "error")
|
||||
return
|
||||
}
|
||||
const ext = file.name.toLowerCase().split(".").pop();
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",");
|
||||
const ext = file.name.toLowerCase().split(".").pop()
|
||||
const allowedExts = ACCEPT_FORMATS.replace(/\./g, "").split(",")
|
||||
if (!allowedExts.includes(ext || "")) {
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error");
|
||||
return;
|
||||
showToast(`不支持的文件格式,支持:${FORMAT_LIST.join("、")}`, "error")
|
||||
return
|
||||
}
|
||||
setUploading(true);
|
||||
setUploadResult(null);
|
||||
uploadMutation.mutate(file);
|
||||
setUploading(true)
|
||||
setUploadResult(null)
|
||||
uploadMutation.mutate(file)
|
||||
},
|
||||
[uploadMutation, showToast],
|
||||
);
|
||||
)
|
||||
|
||||
/** 拖拽事件 */
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}, []);
|
||||
e.preventDefault()
|
||||
setDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
setDragging(false);
|
||||
}, []);
|
||||
setDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
},
|
||||
[handleFile],
|
||||
);
|
||||
)
|
||||
|
||||
/** 点击选择文件 */
|
||||
const handleSelectFile = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
e.target.value = "";
|
||||
};
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
/** 重置状态 */
|
||||
const handleReset = () => {
|
||||
setUploadResult(null);
|
||||
setUploading(false);
|
||||
};
|
||||
setUploadResult(null)
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dup-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`dup-toast dup-toast--${toast.type}`}>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
{toast && <div className={`dup-toast dup-toast--${toast.type}`}>{toast.message}</div>}
|
||||
|
||||
<PageHead
|
||||
title="视频查重"
|
||||
@@ -141,9 +134,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
onClick={uploading ? undefined : handleSelectFile}
|
||||
>
|
||||
<div className="dup-upload-icon">{uploading ? "⏳" : "📁"}</div>
|
||||
<h3>
|
||||
{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}
|
||||
</h3>
|
||||
<h3>{uploading ? "正在上传并查重..." : "点击或拖拽视频文件到此区域"}</h3>
|
||||
<p>支持 MP4、AVI、MOV、MKV 等格式,单个文件不超过 2GB</p>
|
||||
<div className="dup-upload-formats">
|
||||
{FORMAT_LIST.map((fmt) => (
|
||||
@@ -200,11 +191,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
>
|
||||
查看结果
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="secondary"
|
||||
buttonSize="md"
|
||||
onClick={handleReset}
|
||||
>
|
||||
<Button buttonType="secondary" buttonSize="md" onClick={handleReset}>
|
||||
继续上传
|
||||
</Button>
|
||||
</div>
|
||||
@@ -240,7 +227,7 @@ const DuplicationUpload: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default DuplicationUpload;
|
||||
export default DuplicationUpload
|
||||
|
||||
@@ -2,19 +2,10 @@
|
||||
* 剪辑计划管理页面
|
||||
* 展示用户的所有剪辑计划,支持状态筛选、模板筛选、分页、一键重新生成
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Table,
|
||||
Tabs,
|
||||
Select,
|
||||
Tag,
|
||||
Button,
|
||||
message,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
} from "antd";
|
||||
import { useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Table, Tabs, Select, Tag, Button, message, Popconfirm, Tooltip } from "antd"
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
@@ -27,8 +18,8 @@ import {
|
||||
CopyOutlined,
|
||||
UnorderedListOutlined,
|
||||
StopOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
} from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import {
|
||||
getEditPlans,
|
||||
deleteEditPlan,
|
||||
@@ -38,9 +29,9 @@ import {
|
||||
type EditPlan,
|
||||
type EditPlanStatus,
|
||||
type EditPlanListParams,
|
||||
} from "@/api/editPlans";
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates";
|
||||
import "./edit-plans.css";
|
||||
} from "@/api/editPlans"
|
||||
import { getTemplatesList, type TemplateItem } from "@/api/templates"
|
||||
import "./edit-plans.css"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -53,7 +44,7 @@ const STATUS_TABS: { key: EditPlanStatus | "all"; label: string }[] = [
|
||||
{ key: "completed", label: "已完成" },
|
||||
{ key: "failed", label: "失败" },
|
||||
{ key: "cancelled", label: "已取消" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 状态标签配置 */
|
||||
const STATUS_CONFIG: Record<
|
||||
@@ -90,45 +81,43 @@ const STATUS_CONFIG: Record<
|
||||
color: "default",
|
||||
icon: <StopOutlined />,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────── 工具函数 ──────────── */
|
||||
|
||||
/** 格式化时长 */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (seconds <= 0) return "-";
|
||||
const totalSec = Math.round(seconds);
|
||||
const m = Math.floor(totalSec / 60);
|
||||
const s = totalSec % 60;
|
||||
if (m === 0) return `${s}秒`;
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`;
|
||||
};
|
||||
if (seconds <= 0) return "-"
|
||||
const totalSec = Math.round(seconds)
|
||||
const m = Math.floor(totalSec / 60)
|
||||
const s = totalSec % 60
|
||||
if (m === 0) return `${s}秒`
|
||||
return `${m}分${s > 0 ? `${s}秒` : ""}`
|
||||
}
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (dateStr?: string | null): string => {
|
||||
if (!dateStr) return "-";
|
||||
const date = new Date(dateStr);
|
||||
if (!dateStr) return "-"
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
})
|
||||
}
|
||||
|
||||
/* ──────────── 主组件 ──────────── */
|
||||
|
||||
export default function EditPlans() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 筛选状态
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">(
|
||||
"all",
|
||||
);
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [statusFilter, setStatusFilter] = useState<EditPlanStatus | "all">("all")
|
||||
const [templateFilter, setTemplateFilter] = useState<string>("all")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
// 查询参数
|
||||
const queryParams: EditPlanListParams = {
|
||||
@@ -136,7 +125,7 @@ export default function EditPlans() {
|
||||
page_size: pageSize,
|
||||
...(statusFilter !== "all" && { status: statusFilter }),
|
||||
...(templateFilter !== "all" && { template_id: templateFilter }),
|
||||
};
|
||||
}
|
||||
|
||||
// 获取剪辑计划列表
|
||||
const {
|
||||
@@ -148,87 +137,85 @@ export default function EditPlans() {
|
||||
queryFn: () => getEditPlans(queryParams),
|
||||
refetchInterval: (query) => {
|
||||
// 有进行中的计划时自动刷新
|
||||
const plans = query.state.data?.items ?? [];
|
||||
const hasRunning = plans.some(
|
||||
(p) => p.status === "rendering" || p.status === "editing",
|
||||
);
|
||||
return hasRunning ? 5000 : false;
|
||||
const plans = query.state.data?.items ?? []
|
||||
const hasRunning = plans.some((p) => p.status === "rendering" || p.status === "editing")
|
||||
return hasRunning ? 5000 : false
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 获取模板列表(用于筛选下拉)
|
||||
const { data: templates } = useQuery({
|
||||
queryKey: ["templates-list-simple"],
|
||||
queryFn: getTemplatesList,
|
||||
});
|
||||
})
|
||||
|
||||
const plans = planData?.items ?? [];
|
||||
const total = planData?.total ?? 0;
|
||||
const plans = planData?.items ?? []
|
||||
const total = planData?.total ?? 0
|
||||
|
||||
// 模板名称映射
|
||||
const templateNameMap = new Map<string, string>();
|
||||
(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name);
|
||||
});
|
||||
const templateNameMap = new Map<string, string>()
|
||||
;(templates ?? []).forEach((t: TemplateItem) => {
|
||||
templateNameMap.set(t.id, t.name)
|
||||
})
|
||||
|
||||
// 删除计划
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("剪辑计划已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
message.success("剪辑计划已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("删除失败,请稍后重试");
|
||||
message.error("删除失败,请稍后重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 重新生成
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: generateEditPlan,
|
||||
onSuccess: () => {
|
||||
message.success("已重新提交生成");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
message.success("已重新提交生成")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("重新生成失败,请稍后重试");
|
||||
message.error("重新生成失败,请稍后重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 取消生成
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelGeneration,
|
||||
onSuccess: () => {
|
||||
message.success("已提交取消请求");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
message.success("已提交取消请求")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
},
|
||||
onError: () => {
|
||||
message.error("取消失败,请稍后重试");
|
||||
message.error("取消失败,请稍后重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 复制计划
|
||||
const copyMutation = useMutation({
|
||||
mutationFn: ({ planId, name }: { planId: string; name?: string }) =>
|
||||
copyEditPlan(planId, name ? { name } : undefined),
|
||||
onSuccess: (newPlan) => {
|
||||
message.success("计划已复制");
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] });
|
||||
message.success("计划已复制")
|
||||
queryClient.invalidateQueries({ queryKey: ["edit-plans"] })
|
||||
// 自动跳转到新计划的编辑器
|
||||
navigate(`/app/editing-planner?planId=${newPlan.id}`);
|
||||
navigate(`/app/editing-planner?planId=${newPlan.id}`)
|
||||
},
|
||||
onError: () => {
|
||||
message.error("复制失败,请稍后重试");
|
||||
message.error("复制失败,请稍后重试")
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// 跳转到剪辑编辑器
|
||||
const handleEdit = useCallback(
|
||||
(plan: EditPlan) => {
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`);
|
||||
navigate(`/app/editing-planner?planId=${plan.id}`)
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
)
|
||||
|
||||
// 表格列定义
|
||||
const columns: ColumnsType<EditPlan> = [
|
||||
@@ -253,12 +240,12 @@ export default function EditPlans() {
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (templateId: string) => {
|
||||
const name = templateNameMap.get(templateId);
|
||||
const name = templateNameMap.get(templateId)
|
||||
return (
|
||||
<Tag color="blue" className="plan-template-tag">
|
||||
{name || templateId.slice(0, 8)}
|
||||
</Tag>
|
||||
);
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -271,16 +258,12 @@ export default function EditPlans() {
|
||||
label: status,
|
||||
color: "default",
|
||||
icon: null,
|
||||
};
|
||||
}
|
||||
return (
|
||||
<Tag
|
||||
color={config.color}
|
||||
icon={config.icon}
|
||||
className="plan-status-tag"
|
||||
>
|
||||
<Tag color={config.color} icon={config.icon} className="plan-status-tag">
|
||||
{config.label}
|
||||
</Tag>
|
||||
);
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -288,9 +271,7 @@ export default function EditPlans() {
|
||||
dataIndex: "total_duration",
|
||||
key: "total_duration",
|
||||
width: 100,
|
||||
render: (seconds: number) => (
|
||||
<span className="plan-duration">{formatDuration(seconds)}</span>
|
||||
),
|
||||
render: (seconds: number) => <span className="plan-duration">{formatDuration(seconds)}</span>,
|
||||
},
|
||||
{
|
||||
title: "视频数",
|
||||
@@ -307,18 +288,14 @@ export default function EditPlans() {
|
||||
dataIndex: "created_at",
|
||||
key: "created_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "更新时间",
|
||||
dataIndex: "updated_at",
|
||||
key: "updated_at",
|
||||
width: 130,
|
||||
render: (time: string) => (
|
||||
<span className="plan-time">{formatTime(time)}</span>
|
||||
),
|
||||
render: (time: string) => <span className="plan-time">{formatTime(time)}</span>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
@@ -433,7 +410,7 @@ export default function EditPlans() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
// 错误处理
|
||||
if (error) {
|
||||
@@ -445,7 +422,7 @@ export default function EditPlans() {
|
||||
<Button onClick={() => window.location.reload()}>刷新页面</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -467,8 +444,8 @@ export default function EditPlans() {
|
||||
<Tabs
|
||||
activeKey={statusFilter}
|
||||
onChange={(key) => {
|
||||
setStatusFilter(key as EditPlanStatus | "all");
|
||||
setPage(1);
|
||||
setStatusFilter(key as EditPlanStatus | "all")
|
||||
setPage(1)
|
||||
}}
|
||||
items={STATUS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
@@ -481,8 +458,8 @@ export default function EditPlans() {
|
||||
<Select
|
||||
value={templateFilter}
|
||||
onChange={(value) => {
|
||||
setTemplateFilter(value);
|
||||
setPage(1);
|
||||
setTemplateFilter(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: "all", label: "全部模板" },
|
||||
@@ -511,8 +488,8 @@ export default function EditPlans() {
|
||||
showQuickJumper: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
@@ -534,5 +511,5 @@ export default function EditPlans() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 对接后端 PR#389 片段 CRUD API
|
||||
* 功能:列表查看、创建、编辑、删除、批量删除、拖拽排序、从素材导入
|
||||
*/
|
||||
import { useState, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useCallback } from "react"
|
||||
import { useParams, useNavigate } from "react-router-dom"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Drawer,
|
||||
Empty,
|
||||
Card,
|
||||
} from "antd";
|
||||
} from "antd"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
PlusOutlined,
|
||||
@@ -30,8 +30,8 @@ import {
|
||||
UploadOutlined,
|
||||
OrderedListOutlined,
|
||||
SaveOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
} from "@ant-design/icons"
|
||||
import type { ColumnsType } from "antd/es/table"
|
||||
import {
|
||||
getEditPlan,
|
||||
getEditPlanClips,
|
||||
@@ -44,8 +44,8 @@ import {
|
||||
getMediaAssets,
|
||||
type EditPlanClip,
|
||||
type EditPlanClipStatus,
|
||||
} from "@/api/editPlans";
|
||||
import "./plan-clips.css";
|
||||
} from "@/api/editPlans"
|
||||
import "./plan-clips.css"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -56,21 +56,21 @@ const CLIP_TYPE_OPTIONS = [
|
||||
{ value: "overlay", label: "叠加层" },
|
||||
{ value: "background", label: "背景" },
|
||||
{ value: "b_roll", label: "B-roll" },
|
||||
];
|
||||
]
|
||||
|
||||
const STATUS_COLORS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "default",
|
||||
processing: "processing",
|
||||
ready: "success",
|
||||
failed: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<EditPlanClipStatus, string> = {
|
||||
pending: "待处理",
|
||||
processing: "处理中",
|
||||
ready: "就绪",
|
||||
failed: "失败",
|
||||
};
|
||||
}
|
||||
|
||||
const TRANSITION_OPTIONS = [
|
||||
{ value: "cut", label: "硬切" },
|
||||
@@ -87,54 +87,54 @@ const TRANSITION_OPTIONS = [
|
||||
{ value: "wipe_down", label: "下擦除" },
|
||||
{ value: "circlecrop", label: "圆形裁切" },
|
||||
{ value: "rectcrop", label: "矩形裁切" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
const PlanClipsManager: React.FC = () => {
|
||||
const { planId } = useParams<{ planId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { planId } = useParams<{ planId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
/* ── 计划信息 ── */
|
||||
const { data: plan, isLoading: planLoading } = useQuery({
|
||||
queryKey: ["editPlan", planId],
|
||||
queryFn: () => getEditPlan(planId!),
|
||||
enabled: !!planId,
|
||||
});
|
||||
})
|
||||
|
||||
/* ── 片段列表 ── */
|
||||
const { data: clipsData, isLoading: clipsLoading } = useQuery({
|
||||
queryKey: ["editPlanClips", planId],
|
||||
queryFn: () => getEditPlanClips(planId!, { limit: 500 }),
|
||||
enabled: !!planId,
|
||||
});
|
||||
})
|
||||
|
||||
const clips = clipsData?.items ?? [];
|
||||
const clips = clipsData?.items ?? []
|
||||
|
||||
/* ── 选中的片段(批量操作) ── */
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
|
||||
|
||||
/* ── 编辑弹窗 ── */
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
const [editLoading, setEditLoading] = useState(false);
|
||||
const [editModalOpen, setEditModalOpen] = useState(false)
|
||||
const [editingClip, setEditingClip] = useState<EditPlanClip | null>(null)
|
||||
const [editForm] = Form.useForm()
|
||||
const [editLoading, setEditLoading] = useState(false)
|
||||
|
||||
/* ── 素材导入抽屉 ── */
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [importLoading, setImportLoading] = useState(false);
|
||||
const [importDrawerOpen, setImportDrawerOpen] = useState(false)
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([])
|
||||
const [importLoading, setImportLoading] = useState(false)
|
||||
|
||||
const { data: assets } = useQuery({
|
||||
queryKey: ["mediaAssets"],
|
||||
queryFn: () => getMediaAssets(),
|
||||
enabled: importDrawerOpen,
|
||||
});
|
||||
})
|
||||
|
||||
/* ── 重新排序模式 ── */
|
||||
const [reorderMode, setReorderMode] = useState(false);
|
||||
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([]);
|
||||
const [reorderMode, setReorderMode] = useState(false)
|
||||
const [reorderItems, setReorderItems] = useState<EditPlanClip[]>([])
|
||||
|
||||
/* ── 列定义 ── */
|
||||
const columns: ColumnsType<EditPlanClip> = [
|
||||
@@ -149,8 +149,8 @@ const PlanClipsManager: React.FC = () => {
|
||||
dataIndex: "clip_type",
|
||||
width: 100,
|
||||
render: (type: string) => {
|
||||
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type);
|
||||
return <Tag>{opt?.label || type}</Tag>;
|
||||
const opt = CLIP_TYPE_OPTIONS.find((o) => o.value === type)
|
||||
return <Tag>{opt?.label || type}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -169,8 +169,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
title: "文本内容",
|
||||
dataIndex: "text_content",
|
||||
ellipsis: true,
|
||||
render: (text: string) =>
|
||||
text || <span style={{ color: "#999" }}>-</span>,
|
||||
render: (text: string) => text || <span style={{ color: "#999" }}>-</span>,
|
||||
},
|
||||
{
|
||||
title: "时长",
|
||||
@@ -183,8 +182,8 @@ const PlanClipsManager: React.FC = () => {
|
||||
dataIndex: "transition_effect",
|
||||
width: 100,
|
||||
render: (effect: string) => {
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect);
|
||||
return opt?.label || effect || "硬切";
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === effect)
|
||||
return opt?.label || effect || "硬切"
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -198,9 +197,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (status: EditPlanClipStatus) => (
|
||||
<Tag color={STATUS_COLORS[status] || "default"}>
|
||||
{STATUS_LABELS[status] || status}
|
||||
</Tag>
|
||||
<Tag color={STATUS_COLORS[status] || "default"}>{STATUS_LABELS[status] || status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -233,12 +230,12 @@ const PlanClipsManager: React.FC = () => {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/* ── 编辑片段 ── */
|
||||
const handleEditClip = useCallback(
|
||||
(clip: EditPlanClip) => {
|
||||
setEditingClip(clip);
|
||||
setEditingClip(clip)
|
||||
editForm.setFieldsValue({
|
||||
clip_type: clip.clip_type,
|
||||
asset_id: clip.asset_id,
|
||||
@@ -248,141 +245,138 @@ const PlanClipsManager: React.FC = () => {
|
||||
transition_effect: clip.transition_effect,
|
||||
transition_duration: clip.transition_duration,
|
||||
playback_speed: clip.playback_speed,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
},
|
||||
[editForm],
|
||||
);
|
||||
)
|
||||
|
||||
const handleNewClip = useCallback(() => {
|
||||
setEditingClip(null);
|
||||
editForm.resetFields();
|
||||
setEditingClip(null)
|
||||
editForm.resetFields()
|
||||
editForm.setFieldsValue({
|
||||
clip_type: "main",
|
||||
duration: 5,
|
||||
transition_effect: "cut",
|
||||
transition_duration: 0,
|
||||
playback_speed: 1.0,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
}, [editForm]);
|
||||
})
|
||||
setEditModalOpen(true)
|
||||
}, [editForm])
|
||||
|
||||
const handleSaveClip = async () => {
|
||||
if (!planId) return;
|
||||
if (!planId) return
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
setEditLoading(true);
|
||||
const values = await editForm.validateFields()
|
||||
setEditLoading(true)
|
||||
|
||||
if (editingClip) {
|
||||
// 更新
|
||||
await updateEditPlanClip(planId, editingClip.id, values);
|
||||
message.success("片段已更新");
|
||||
await updateEditPlanClip(planId, editingClip.id, values)
|
||||
message.success("片段已更新")
|
||||
} else {
|
||||
// 新建
|
||||
const maxOrder =
|
||||
clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1;
|
||||
const maxOrder = clips.length > 0 ? Math.max(...clips.map((c) => c.order)) : -1
|
||||
await createEditPlanClip(planId, {
|
||||
...values,
|
||||
order: maxOrder + 1,
|
||||
});
|
||||
message.success("片段已创建");
|
||||
})
|
||||
message.success("片段已创建")
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setEditModalOpen(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setEditModalOpen(false)
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
message.error(editingClip ? "更新失败" : "创建失败");
|
||||
console.error(err)
|
||||
message.error(editingClip ? "更新失败" : "创建失败")
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
setEditLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 删除片段 ── */
|
||||
const handleDeleteClip = async (clipId: string) => {
|
||||
if (!planId) return;
|
||||
if (!planId) return
|
||||
try {
|
||||
await deleteEditPlanClip(planId, clipId);
|
||||
message.success("已删除");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId));
|
||||
await deleteEditPlanClip(planId, clipId)
|
||||
message.success("已删除")
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setSelectedRowKeys((prev) => prev.filter((k) => k !== clipId))
|
||||
} catch {
|
||||
message.error("删除失败");
|
||||
message.error("删除失败")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 批量删除 ── */
|
||||
const handleBatchDelete = async () => {
|
||||
if (!planId || selectedRowKeys.length === 0) return;
|
||||
if (!planId || selectedRowKeys.length === 0) return
|
||||
try {
|
||||
await batchDeleteEditPlanClips(
|
||||
planId,
|
||||
selectedRowKeys.map((k) => String(k)),
|
||||
);
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setSelectedRowKeys([]);
|
||||
)
|
||||
message.success(`已删除 ${selectedRowKeys.length} 个片段`)
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setSelectedRowKeys([])
|
||||
} catch {
|
||||
message.error("批量删除失败");
|
||||
message.error("批量删除失败")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 从素材导入 ── */
|
||||
const handleImportFromAssets = async () => {
|
||||
if (!planId || selectedAssetIds.length === 0) return;
|
||||
if (!planId || selectedAssetIds.length === 0) return
|
||||
try {
|
||||
setImportLoading(true);
|
||||
const res = await createClipsFromAssets(planId, selectedAssetIds);
|
||||
message.success(`已导入 ${res.created_count} 个片段`);
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setImportDrawerOpen(false);
|
||||
setSelectedAssetIds([]);
|
||||
setImportLoading(true)
|
||||
const res = await createClipsFromAssets(planId, selectedAssetIds)
|
||||
message.success(`已导入 ${res.created_count} 个片段`)
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setImportDrawerOpen(false)
|
||||
setSelectedAssetIds([])
|
||||
} catch {
|
||||
message.error("导入失败");
|
||||
message.error("导入失败")
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
setImportLoading(false)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 排序模式 ── */
|
||||
const enterReorderMode = () => {
|
||||
setReorderItems([...clips].sort((a, b) => a.order - b.order));
|
||||
setReorderMode(true);
|
||||
};
|
||||
setReorderItems([...clips].sort((a, b) => a.order - b.order))
|
||||
setReorderMode(true)
|
||||
}
|
||||
|
||||
const moveClip = (fromIndex: number, toIndex: number) => {
|
||||
if (toIndex < 0 || toIndex >= reorderItems.length) return;
|
||||
const newItems = [...reorderItems];
|
||||
const [moved] = newItems.splice(fromIndex, 1);
|
||||
newItems.splice(toIndex, 0, moved);
|
||||
setReorderItems(newItems);
|
||||
};
|
||||
if (toIndex < 0 || toIndex >= reorderItems.length) return
|
||||
const newItems = [...reorderItems]
|
||||
const [moved] = newItems.splice(fromIndex, 1)
|
||||
newItems.splice(toIndex, 0, moved)
|
||||
setReorderItems(newItems)
|
||||
}
|
||||
|
||||
const saveReorder = async () => {
|
||||
if (!planId) return;
|
||||
if (!planId) return
|
||||
const items = reorderItems.map((clip, index) => ({
|
||||
clip_id: clip.id,
|
||||
new_order: index,
|
||||
}));
|
||||
}))
|
||||
try {
|
||||
await reorderEditPlanClips(planId, items);
|
||||
message.success("排序已保存");
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] });
|
||||
setReorderMode(false);
|
||||
await reorderEditPlanClips(planId, items)
|
||||
message.success("排序已保存")
|
||||
queryClient.invalidateQueries({ queryKey: ["editPlanClips", planId] })
|
||||
setReorderMode(false)
|
||||
} catch {
|
||||
message.error("排序保存失败");
|
||||
message.error("排序保存失败")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const cancelReorder = () => {
|
||||
setReorderMode(false);
|
||||
setReorderItems([]);
|
||||
};
|
||||
setReorderMode(false)
|
||||
setReorderItems([])
|
||||
}
|
||||
|
||||
/* ── 渲染 ── */
|
||||
const displayClips = reorderMode
|
||||
? reorderItems
|
||||
: [...clips].sort((a, b) => a.order - b.order);
|
||||
const displayClips = reorderMode ? reorderItems : [...clips].sort((a, b) => a.order - b.order)
|
||||
|
||||
return (
|
||||
<div className="plan-clips-page">
|
||||
@@ -407,20 +401,13 @@ const PlanClipsManager: React.FC = () => {
|
||||
</div>
|
||||
<div className="plan-clips-header-right">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setImportDrawerOpen(true)}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} onClick={() => setImportDrawerOpen(true)}>
|
||||
从素材导入
|
||||
</Button>
|
||||
{reorderMode ? (
|
||||
<>
|
||||
<Button onClick={cancelReorder}>取消排序</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={saveReorder}
|
||||
>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={saveReorder}>
|
||||
保存排序
|
||||
</Button>
|
||||
</>
|
||||
@@ -433,11 +420,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
>
|
||||
调整顺序
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleNewClip}
|
||||
>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleNewClip}>
|
||||
添加片段
|
||||
</Button>
|
||||
</>
|
||||
@@ -473,15 +456,13 @@ const PlanClipsManager: React.FC = () => {
|
||||
<div key={clip.id} className="plan-clips-reorder-item">
|
||||
<span className="reorder-index">{index + 1}</span>
|
||||
<span className="reorder-type">
|
||||
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)
|
||||
?.label || clip.clip_type}
|
||||
{CLIP_TYPE_OPTIONS.find((o) => o.value === clip.clip_type)?.label ||
|
||||
clip.clip_type}
|
||||
</span>
|
||||
<span className="reorder-content">
|
||||
{clip.text_content || clip.asset_id || "无内容"}
|
||||
</span>
|
||||
<span className="reorder-duration">
|
||||
{clip.duration.toFixed(1)}s
|
||||
</span>
|
||||
<span className="reorder-duration">{clip.duration.toFixed(1)}s</span>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -556,11 +537,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
<Input.TextArea rows={3} placeholder="字幕/配音文案等" />
|
||||
</Form.Item>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="起始时间(秒)"
|
||||
name="start_time"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Form.Item label="起始时间(秒)" name="start_time" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="时长(秒)" name="duration" style={{ flex: 1 }}>
|
||||
@@ -568,28 +545,15 @@ const PlanClipsManager: React.FC = () => {
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 16 }}>
|
||||
<Form.Item
|
||||
label="转场效果"
|
||||
name="transition_effect"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Form.Item label="转场效果" name="transition_effect" style={{ flex: 1 }}>
|
||||
<Select options={TRANSITION_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="转场时长"
|
||||
name="transition_duration"
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<Form.Item label="转场时长" name="transition_duration" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="播放速度" name="playback_speed">
|
||||
<InputNumber
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<InputNumber min={0.1} max={10} step={0.1} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -607,8 +571,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
loading={importLoading}
|
||||
disabled={selectedAssetIds.length === 0}
|
||||
>
|
||||
导入{" "}
|
||||
{selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
||||
导入 {selectedAssetIds.length > 0 ? `(${selectedAssetIds.length})` : ""}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
@@ -625,7 +588,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
prev.includes(asset.id)
|
||||
? prev.filter((id) => id !== asset.id)
|
||||
: [...prev, asset.id],
|
||||
);
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="asset-thumb">
|
||||
@@ -652,7 +615,7 @@ const PlanClipsManager: React.FC = () => {
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanClipsManager;
|
||||
export default PlanClipsManager
|
||||
|
||||
@@ -651,11 +651,7 @@
|
||||
.ep-cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ep-bg-card-hover, #1a1a2e),
|
||||
var(--ep-bg-card, #13131f)
|
||||
);
|
||||
background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-card, #13131f));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -903,11 +899,7 @@
|
||||
|
||||
.ep-clip-thumbnail {
|
||||
height: 34px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--bg-secondary, #f8fafc),
|
||||
var(--border-light, #f1f5f9)
|
||||
);
|
||||
background: linear-gradient(135deg, var(--bg-secondary, #f8fafc), var(--border-light, #f1f5f9));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -6144,8 +6136,13 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══ 右侧栏 Tab ═══ */
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,92 +2,85 @@
|
||||
* BGM 选择器 — Drawer 形式
|
||||
* 预设 BGM 列表(按风格分类)、搜索、试听、音量/淡入淡出/人声闪避配置
|
||||
*/
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd";
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react"
|
||||
import { Drawer, Slider, Input, Tag, message } from "antd"
|
||||
import {
|
||||
getBgmPresets,
|
||||
type BgmPreset,
|
||||
type BgmCategory,
|
||||
type BgmMixConfig,
|
||||
DEFAULT_BGM_MIX_CONFIG,
|
||||
} from "@/api/bgm";
|
||||
} from "@/api/bgm"
|
||||
|
||||
const { Search } = Input;
|
||||
const { Search } = Input
|
||||
|
||||
/* ──────────── 分类标签 ──────────── */
|
||||
const CATEGORY_LIST: {
|
||||
key: BgmCategory | "all";
|
||||
label: string;
|
||||
icon: string;
|
||||
key: BgmCategory | "all"
|
||||
label: string
|
||||
icon: string
|
||||
}[] = [
|
||||
{ key: "all", label: "全部", icon: "🎶" },
|
||||
{ key: "轻快", label: "轻快", icon: "🎉" },
|
||||
{ key: "治愈", label: "治愈", icon: "🌿" },
|
||||
{ key: "科技", label: "科技", icon: "🔬" },
|
||||
{ key: "电商", label: "电商", icon: "🛒" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface BgmSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: BgmMixConfig;
|
||||
onChange: (config: BgmMixConfig) => void;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: BgmMixConfig
|
||||
onChange: (config: BgmMixConfig) => void
|
||||
}
|
||||
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">(
|
||||
"all",
|
||||
);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const BgmSelector: React.FC<BgmSelectorProps> = ({ open, onClose, config, onChange }) => {
|
||||
const [presets, setPresets] = useState<BgmPreset[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [activeCategory, setActiveCategory] = useState<BgmCategory | "all">("all")
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
/* ── 加载 BGM 列表 ── */
|
||||
const loadPresets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: { category?: string; keyword?: string } = {};
|
||||
if (activeCategory !== "all") params.category = activeCategory;
|
||||
if (keyword.trim()) params.keyword = keyword.trim();
|
||||
const data = await getBgmPresets(params);
|
||||
setPresets(data);
|
||||
const params: { category?: string; keyword?: string } = {}
|
||||
if (activeCategory !== "all") params.category = activeCategory
|
||||
if (keyword.trim()) params.keyword = keyword.trim()
|
||||
const data = await getBgmPresets(params)
|
||||
setPresets(data)
|
||||
} catch {
|
||||
message.error("加载 BGM 列表失败");
|
||||
message.error("加载 BGM 列表失败")
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}, [activeCategory, keyword]);
|
||||
}, [activeCategory, keyword])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadPresets();
|
||||
}, [open, loadPresets]);
|
||||
if (open) loadPresets()
|
||||
}, [open, loadPresets])
|
||||
|
||||
/* ── 试听 ── */
|
||||
const handlePreview = useCallback(
|
||||
(bgm: BgmPreset) => {
|
||||
if (previewingId === bgm.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
audioRef.current?.pause();
|
||||
const audio = new Audio(bgm.url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(bgm.id);
|
||||
audioRef.current?.pause()
|
||||
const audio = new Audio(bgm.url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
audio.onended = () => setPreviewingId(null)
|
||||
setPreviewingId(bgm.id)
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 选中 BGM ── */
|
||||
const handleSelect = useCallback(
|
||||
@@ -96,27 +89,27 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
...config,
|
||||
enabled: true,
|
||||
music_id: bgm.id,
|
||||
});
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 关闭时停止播放 ── */
|
||||
const handleClose = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
/* ── 移除 BGM ── */
|
||||
const handleClear = useCallback(() => {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG });
|
||||
}, [onChange]);
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
onChange({ ...DEFAULT_BGM_MIX_CONFIG })
|
||||
}, [onChange])
|
||||
|
||||
/* ── 当前选中的 BGM ── */
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id);
|
||||
const selectedBgm = presets.find((p) => p.id === config.music_id)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -154,12 +147,10 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
{/* ── BGM 列表 ── */}
|
||||
<div className="bgm-list">
|
||||
{loading && <div className="bgm-loading">加载中...</div>}
|
||||
{!loading && presets.length === 0 && (
|
||||
<div className="bgm-empty">暂无 BGM 数据</div>
|
||||
)}
|
||||
{!loading && presets.length === 0 && <div className="bgm-empty">暂无 BGM 数据</div>}
|
||||
{presets.map((bgm) => {
|
||||
const isSelected = config.music_id === bgm.id;
|
||||
const isPlaying = previewingId === bgm.id;
|
||||
const isSelected = config.music_id === bgm.id
|
||||
const isPlaying = previewingId === bgm.id
|
||||
return (
|
||||
<div
|
||||
key={bgm.id}
|
||||
@@ -195,8 +186,8 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
<button
|
||||
className={`bgm-item-preview-btn${isPlaying ? " playing" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreview(bgm);
|
||||
e.stopPropagation()
|
||||
handlePreview(bgm)
|
||||
}}
|
||||
title={isPlaying ? "暂停" : "试听"}
|
||||
>
|
||||
@@ -204,7 +195,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
</button>
|
||||
{isSelected && <span className="bgm-item-check">✓</span>}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -219,9 +210,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bgm-mix-selected">
|
||||
{selectedBgm
|
||||
? `当前:${selectedBgm.name}`
|
||||
: `当前:${config.music_id}`}
|
||||
{selectedBgm ? `当前:${selectedBgm.name}` : `当前:${config.music_id}`}
|
||||
</div>
|
||||
|
||||
{/* 音量 */}
|
||||
@@ -240,10 +229,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
{/* 淡入 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡入{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_in.toFixed(1)}s
|
||||
</span>
|
||||
淡入 <span className="bgm-mix-value">{config.fade_in.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
@@ -257,10 +243,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
{/* 淡出 */}
|
||||
<div className="bgm-mix-field">
|
||||
<label className="bgm-mix-label">
|
||||
淡出{" "}
|
||||
<span className="bgm-mix-value">
|
||||
{config.fade_out.toFixed(1)}s
|
||||
</span>
|
||||
淡出 <span className="bgm-mix-value">{config.fade_out.toFixed(1)}s</span>
|
||||
</label>
|
||||
<Slider
|
||||
min={0}
|
||||
@@ -276,9 +259,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
<label className="bgm-mix-label">人声闪避(sidechain)</label>
|
||||
<div
|
||||
className={`ep-toggle${config.voice_dodge ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
onChange({ ...config, voice_dodge: !config.voice_dodge })
|
||||
}
|
||||
onClick={() => onChange({ ...config, voice_dodge: !config.voice_dodge })}
|
||||
>
|
||||
<div className="ep-toggle-knob" />
|
||||
</div>
|
||||
@@ -286,7 +267,7 @@ const BgmSelector: React.FC<BgmSelectorProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default BgmSelector;
|
||||
export default BgmSelector
|
||||
|
||||
@@ -2,103 +2,95 @@
|
||||
* 右栏设置面板 — V8 原型 1:1 还原
|
||||
* 标题设置(AI toggle) + 字幕设置 + BGM设置 + 片段详情
|
||||
*/
|
||||
import React, { useRef, useState, useCallback } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { TemplateMode } from "@/api/editingPlanner";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import React, { useRef, useState, useCallback } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import type { TemplateMode } from "@/api/editingPlanner"
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
import type { AssetItem } from "@/api/assets"
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
animation: string;
|
||||
mode?: string;
|
||||
stroke?: boolean;
|
||||
shadow?: boolean;
|
||||
asrLanguage?: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
fontSize: number
|
||||
fontColor: string
|
||||
animation: string
|
||||
mode?: string
|
||||
stroke?: boolean
|
||||
shadow?: boolean
|
||||
asrLanguage?: string
|
||||
}
|
||||
|
||||
interface BgmSettings {
|
||||
enabled: boolean;
|
||||
music_id: string;
|
||||
volume?: number;
|
||||
fade_in?: number;
|
||||
fade_out?: number;
|
||||
voice_dodge?: boolean;
|
||||
enabled: boolean
|
||||
music_id: string
|
||||
volume?: number
|
||||
fade_in?: number
|
||||
fade_out?: number
|
||||
voice_dodge?: boolean
|
||||
}
|
||||
|
||||
interface ClipPropertiesPanelProps {
|
||||
selectedClip: ClipData | null;
|
||||
titleSettings: TitleSettings;
|
||||
subtitleSettings: SubtitleSettings;
|
||||
bgmSettings: BgmSettings;
|
||||
clipsCount: number;
|
||||
totalDuration: number;
|
||||
currentMode: TemplateMode;
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void;
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void;
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void;
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void;
|
||||
selectedClip: ClipData | null
|
||||
titleSettings: TitleSettings
|
||||
subtitleSettings: SubtitleSettings
|
||||
bgmSettings: BgmSettings
|
||||
clipsCount: number
|
||||
totalDuration: number
|
||||
currentMode: TemplateMode
|
||||
onTitleSettingsChange: (partial: Partial<TitleSettings>) => void
|
||||
onSubtitleSettingsChange: (partial: Partial<SubtitleSettings>) => void
|
||||
onBgmSettingsChange: (partial: Partial<BgmSettings>) => void
|
||||
onClipUpdate: (clipId: string, data: Partial<ClipData>) => void
|
||||
/** 打开 BGM 选择器 Drawer */
|
||||
onOpenBgmDrawer?: () => void;
|
||||
onOpenBgmDrawer?: () => void
|
||||
/** 打开字幕样式配置 Drawer */
|
||||
onOpenSubtitleDrawer?: () => void;
|
||||
onOpenSubtitleDrawer?: () => void
|
||||
/** 配音素材列表(从配音素材库 API 获取) */
|
||||
voiceMaterials?: AssetItem[];
|
||||
voiceMaterials?: AssetItem[]
|
||||
/** 配音素材加载中 */
|
||||
voiceMaterialsLoading?: boolean;
|
||||
voiceMaterialsLoading?: boolean
|
||||
/** 刷新配音素材列表 */
|
||||
onRefreshVoiceMaterials?: () => void;
|
||||
onRefreshVoiceMaterials?: () => void
|
||||
/** 为片段选择配音素材 */
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void;
|
||||
onClipVoiceSelect?: (clipId: string, asset: AssetItem | null) => void
|
||||
/** 打开转场特效选择器 Drawer */
|
||||
onOpenTransitionDrawer?: (clipId: string) => void;
|
||||
onOpenTransitionDrawer?: (clipId: string) => void
|
||||
/** 打开片段调速面板 Drawer */
|
||||
onOpenSpeedDrawer?: (clipId: string) => void;
|
||||
onOpenSpeedDrawer?: (clipId: string) => void
|
||||
/** 打开 TTS 配音面板 Drawer */
|
||||
onOpenTtsDrawer?: (clipId: string) => void;
|
||||
onOpenTtsDrawer?: (clipId: string) => void
|
||||
/** 打开水印设置面板 Drawer */
|
||||
onOpenWatermarkDrawer?: () => void;
|
||||
onOpenWatermarkDrawer?: () => void
|
||||
/** 打开片头片尾设置面板 Drawer */
|
||||
onOpenIntroOutroDrawer?: () => void;
|
||||
onOpenIntroOutroDrawer?: () => void
|
||||
/** 打开画中画设置面板 Drawer */
|
||||
onOpenPipDrawer?: () => void;
|
||||
onOpenPipDrawer?: () => void
|
||||
/** 打开滤镜调色面板 Drawer */
|
||||
onOpenFilterDrawer?: () => void;
|
||||
onOpenFilterDrawer?: () => void
|
||||
/** 打开绿幕抠像面板 Drawer */
|
||||
onOpenGreenScreenDrawer?: () => void;
|
||||
onOpenGreenScreenDrawer?: () => void
|
||||
/** 打开贴纸面板 Drawer */
|
||||
onOpenStickerDrawer?: () => void;
|
||||
onOpenStickerDrawer?: () => void
|
||||
/** 打开封面选择器 Drawer */
|
||||
onOpenCoverDrawer?: () => void;
|
||||
onOpenCoverDrawer?: () => void
|
||||
}
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{ value: "top", label: "顶部" },
|
||||
{ value: "center", label: "居中" },
|
||||
{ value: "bottom", label: "底部" },
|
||||
];
|
||||
]
|
||||
|
||||
const FONT_OPTIONS = [
|
||||
"思源黑体",
|
||||
"思源宋体",
|
||||
"苹方",
|
||||
"PingFang",
|
||||
"微软雅黑",
|
||||
"楷体",
|
||||
"华康俪金黑",
|
||||
];
|
||||
const FONT_OPTIONS = ["思源黑体", "思源宋体", "苹方", "PingFang", "微软雅黑", "楷体", "华康俪金黑"]
|
||||
|
||||
const ANIMATION_OPTIONS = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade", label: "淡入淡出" },
|
||||
{ value: "slide", label: "滑动" },
|
||||
{ value: "typewriter", label: "打字机" },
|
||||
];
|
||||
]
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 纯样式组合(颜色+描边+阴影+字重+字号)
|
||||
@@ -227,8 +219,7 @@ const TITLE_PRESETS = [
|
||||
previewStyle: {
|
||||
fontWeight: 700,
|
||||
color: "#00e5ff",
|
||||
textShadow:
|
||||
"0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
textShadow: "0 0 4px #00e5ff, 0 0 8px #00e5ff, 0 0 16px rgba(0,229,255,0.5)",
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
@@ -250,7 +241,7 @@ const TITLE_PRESETS = [
|
||||
fontSize: "20px",
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
/** 判断当前设置匹配哪个预设(比较 size + color + bold/italic/stroke/shadow,不比较字体) */
|
||||
function getActivePreset(settings: TitleSettings): string | null {
|
||||
@@ -263,18 +254,18 @@ function getActivePreset(settings: TitleSettings): string | null {
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key;
|
||||
return p.key
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
/** 片段类型图标/标签 */
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" };
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = { voice: "🎙️", pip: "🖼️" }
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
}
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
@@ -305,41 +296,41 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
onOpenStickerDrawer,
|
||||
onOpenCoverDrawer,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const navigate = useNavigate()
|
||||
|
||||
/* ── 配音试听 ── */
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [previewingId, setPreviewingId] = useState<string | null>(null)
|
||||
|
||||
/** 试听配音素材 */
|
||||
const handlePreviewVoice = useCallback(
|
||||
(asset: AssetItem) => {
|
||||
// 点击同一个 → 暂停
|
||||
if (previewingId === asset.id) {
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
return;
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
return
|
||||
}
|
||||
// 停止上一个
|
||||
audioRef.current?.pause();
|
||||
const url = asset.file_url || (asset.metadata?.preview_url as string);
|
||||
if (!url) return;
|
||||
const audio = new Audio(url);
|
||||
audioRef.current = audio;
|
||||
audio.play().catch(() => {});
|
||||
audio.onended = () => setPreviewingId(null);
|
||||
setPreviewingId(asset.id);
|
||||
audioRef.current?.pause()
|
||||
const url = asset.file_url || (asset.metadata?.preview_url as string)
|
||||
if (!url) return
|
||||
const audio = new Audio(url)
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
audio.onended = () => setPreviewingId(null)
|
||||
setPreviewingId(asset.id)
|
||||
},
|
||||
[previewingId],
|
||||
);
|
||||
)
|
||||
|
||||
/** 从 metadata 取性别标签 */
|
||||
const getGenderLabel = (m: AssetItem): string => {
|
||||
const g = (m.metadata?.gender as string) || "";
|
||||
if (g === "male") return "男";
|
||||
if (g === "female") return "女";
|
||||
return "";
|
||||
};
|
||||
const g = (m.metadata?.gender as string) || ""
|
||||
if (g === "male") return "男"
|
||||
if (g === "female") return "女"
|
||||
return ""
|
||||
}
|
||||
return (
|
||||
<div className="ep-right-panel">
|
||||
{/* ═══ 标题设置 ═══ */}
|
||||
@@ -370,9 +361,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.position}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
onChange={(e) => onTitleSettingsChange({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -387,9 +376,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={titleSettings.font}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
onChange={(e) => onTitleSettingsChange({ font: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
@@ -408,9 +395,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
min={12}
|
||||
max={48}
|
||||
value={titleSettings.size}
|
||||
onChange={(e) =>
|
||||
onTitleSettingsChange({ size: Number(e.target.value) })
|
||||
}
|
||||
onChange={(e) => onTitleSettingsChange({ size: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="ep-slider-value">{titleSettings.size}px</span>
|
||||
</div>
|
||||
@@ -420,7 +405,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<label className="ep-field-label">预设样式</label>
|
||||
<div className="ep-title-presets-grid">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = getActivePreset(titleSettings) === p.key;
|
||||
const isActive = getActivePreset(titleSettings) === p.key
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
@@ -437,17 +422,12 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="ep-title-preset-preview-text"
|
||||
style={p.previewStyle}
|
||||
>
|
||||
<span className="ep-title-preset-preview-text" style={p.previewStyle}>
|
||||
标题
|
||||
</span>
|
||||
<span className="ep-title-preset-card-label">
|
||||
{p.label}
|
||||
</span>
|
||||
<span className="ep-title-preset-card-label">{p.label}</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -457,36 +437,28 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<div className="ep-style-btns">
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.bold ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ bold: !titleSettings.bold })
|
||||
}
|
||||
onClick={() => onTitleSettingsChange({ bold: !titleSettings.bold })}
|
||||
title="粗体"
|
||||
>
|
||||
<b>B</b>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.italic ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ italic: !titleSettings.italic })
|
||||
}
|
||||
onClick={() => onTitleSettingsChange({ italic: !titleSettings.italic })}
|
||||
title="斜体"
|
||||
>
|
||||
<i>I</i>
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.stroke ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ stroke: !titleSettings.stroke })
|
||||
}
|
||||
onClick={() => onTitleSettingsChange({ stroke: !titleSettings.stroke })}
|
||||
title="描边"
|
||||
>
|
||||
S
|
||||
</button>
|
||||
<button
|
||||
className={`ep-style-btn ${titleSettings.shadow ? "active" : ""}`}
|
||||
onClick={() =>
|
||||
onTitleSettingsChange({ shadow: !titleSettings.shadow })
|
||||
}
|
||||
onClick={() => onTitleSettingsChange({ shadow: !titleSettings.shadow })}
|
||||
title="阴影"
|
||||
>
|
||||
☁
|
||||
@@ -525,9 +497,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.position}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ position: e.target.value })
|
||||
}
|
||||
onChange={(e) => onSubtitleSettingsChange({ position: e.target.value })}
|
||||
>
|
||||
{POSITION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -542,9 +512,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.font}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ font: e.target.value })
|
||||
}
|
||||
onChange={(e) => onSubtitleSettingsChange({ font: e.target.value })}
|
||||
>
|
||||
{FONT_OPTIONS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
@@ -569,9 +537,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ep-slider-value">
|
||||
{subtitleSettings.fontSize}px
|
||||
</span>
|
||||
<span className="ep-slider-value">{subtitleSettings.fontSize}px</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -580,9 +546,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<select
|
||||
className="ep-form-select"
|
||||
value={subtitleSettings.animation}
|
||||
onChange={(e) =>
|
||||
onSubtitleSettingsChange({ animation: e.target.value })
|
||||
}
|
||||
onChange={(e) => onSubtitleSettingsChange({ animation: e.target.value })}
|
||||
>
|
||||
{ANIMATION_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
@@ -594,10 +558,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
|
||||
{/* 高级配置按钮 */}
|
||||
{onOpenSubtitleDrawer && (
|
||||
<button
|
||||
className="ep-advanced-btn"
|
||||
onClick={onOpenSubtitleDrawer}
|
||||
>
|
||||
<button className="ep-advanced-btn" onClick={onOpenSubtitleDrawer}>
|
||||
🎨 高级字幕样式配置
|
||||
</button>
|
||||
)}
|
||||
@@ -617,9 +578,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<span className="ep-bgm-current-label">🎵 已选择 BGM</span>
|
||||
<span className="ep-bgm-current-id">{bgmSettings.music_id}</span>
|
||||
{bgmSettings.volume !== undefined && (
|
||||
<span className="ep-bgm-current-vol">
|
||||
音量 {bgmSettings.volume}%
|
||||
</span>
|
||||
<span className="ep-bgm-current-vol">音量 {bgmSettings.volume}%</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -757,19 +716,17 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
? t !== "pip"
|
||||
: currentMode === "voice_over"
|
||||
? t !== "voice"
|
||||
: false; // voice_pip 可切换
|
||||
: false // voice_pip 可切换
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
className={`ep-clip-type-btn${selectedClip.type === t ? " active" : ""}${disabled ? " disabled" : ""}`}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
!disabled && onClipUpdate(selectedClip.id, { type: t })
|
||||
}
|
||||
onClick={() => !disabled && onClipUpdate(selectedClip.id, { type: t })}
|
||||
>
|
||||
{CLIP_TYPE_ICONS[t]} {CLIP_TYPE_LABELS[t]}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -786,10 +743,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
value={selectedClip.duration}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
duration: Math.max(
|
||||
1,
|
||||
Math.min(120, Number(e.target.value) || 1),
|
||||
),
|
||||
duration: Math.max(1, Math.min(120, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -808,12 +762,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<span className="ep-advanced-btn-label">转场效果</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const t = selectedClip.transition;
|
||||
if (!t || t.type === "none") return "无转场";
|
||||
const opt = TRANSITION_OPTIONS.find(
|
||||
(o) => o.value === t.type,
|
||||
);
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`;
|
||||
const t = selectedClip.transition
|
||||
if (!t || t.type === "none") return "无转场"
|
||||
const opt = TRANSITION_OPTIONS.find((o) => o.value === t.type)
|
||||
return `${opt?.label ?? t.type} · ${t.duration.toFixed(1)}s`
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
@@ -831,9 +783,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<span className="ep-advanced-btn-icon">⚡</span>
|
||||
<span className="ep-advanced-btn-label">播放速度</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{selectedClip.speed
|
||||
? `${selectedClip.speed.rate.toFixed(2)}x`
|
||||
: "1.00x"}
|
||||
{selectedClip.speed ? `${selectedClip.speed.rate.toFixed(2)}x` : "1.00x"}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
</button>
|
||||
@@ -851,10 +801,10 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<span className="ep-advanced-btn-label">TTS 配音</span>
|
||||
<span className="ep-advanced-btn-value">
|
||||
{(() => {
|
||||
const tts = selectedClip.tts_config;
|
||||
if (!tts || tts.mode === "none") return "无配音";
|
||||
if (tts.mode === "upload") return "上传配音";
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`;
|
||||
const tts = selectedClip.tts_config
|
||||
if (!tts || tts.mode === "none") return "无配音"
|
||||
if (tts.mode === "upload") return "上传配音"
|
||||
return `TTS · ${tts.voice_id ? "已选音色" : "未选音色"}`
|
||||
})()}
|
||||
</span>
|
||||
<span className="ep-advanced-btn-arrow">›</span>
|
||||
@@ -876,10 +826,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
value={selectedClip.startOffset}
|
||||
onChange={(e) =>
|
||||
onClipUpdate(selectedClip.id, {
|
||||
startOffset: Math.max(
|
||||
0,
|
||||
Math.min(9999, Number(e.target.value) || 0),
|
||||
),
|
||||
startOffset: Math.max(0, Math.min(9999, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -915,33 +862,28 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
className="ep-clip-detail-select"
|
||||
value={selectedClip.voice_asset_id ?? ""}
|
||||
onChange={(e) => {
|
||||
const assetId = e.target.value;
|
||||
if (!onClipVoiceSelect) return;
|
||||
const assetId = e.target.value
|
||||
if (!onClipVoiceSelect) return
|
||||
if (!assetId) {
|
||||
onClipVoiceSelect(selectedClip.id, null);
|
||||
onClipVoiceSelect(selectedClip.id, null)
|
||||
} else {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === assetId,
|
||||
);
|
||||
if (asset)
|
||||
onClipVoiceSelect(selectedClip.id, asset);
|
||||
const asset = voiceMaterials.find((m) => m.id === assetId)
|
||||
if (asset) onClipVoiceSelect(selectedClip.id, asset)
|
||||
}
|
||||
// 切换选择时停止试听
|
||||
audioRef.current?.pause();
|
||||
setPreviewingId(null);
|
||||
audioRef.current?.pause()
|
||||
setPreviewingId(null)
|
||||
}}
|
||||
>
|
||||
<option value="">未选择</option>
|
||||
{voiceMaterials.map((m) => {
|
||||
const gender = getGenderLabel(m);
|
||||
const label = gender
|
||||
? `${m.name}(${gender})`
|
||||
: m.name;
|
||||
const gender = getGenderLabel(m)
|
||||
const label = gender ? `${m.name}(${gender})` : m.name
|
||||
return (
|
||||
<option key={m.id} value={m.id}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{/* 试听按钮 */}
|
||||
@@ -949,28 +891,20 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
className="ep-voice-preview-btn"
|
||||
title={
|
||||
previewingId === selectedClip.voice_asset_id
|
||||
? "暂停"
|
||||
: "试听"
|
||||
}
|
||||
title={previewingId === selectedClip.voice_asset_id ? "暂停" : "试听"}
|
||||
onClick={() => {
|
||||
const asset = voiceMaterials.find(
|
||||
(m) => m.id === selectedClip.voice_asset_id,
|
||||
);
|
||||
if (asset) handlePreviewVoice(asset);
|
||||
)
|
||||
if (asset) handlePreviewVoice(asset)
|
||||
}}
|
||||
>
|
||||
{previewingId === selectedClip.voice_asset_id
|
||||
? "⏸"
|
||||
: "▶️"}
|
||||
{previewingId === selectedClip.voice_asset_id ? "⏸" : "▶️"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiceMaterials.length === 0 && (
|
||||
<div className="ep-voice-empty">
|
||||
暂无配音素材,请先上传
|
||||
</div>
|
||||
<div className="ep-voice-empty">暂无配音素材,请先上传</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -1001,9 +935,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">总时长</div>
|
||||
<div className="ep-clip-detail-value">
|
||||
{totalDuration.toFixed(1)}s
|
||||
</div>
|
||||
<div className="ep-clip-detail-value">{totalDuration.toFixed(1)}s</div>
|
||||
</div>
|
||||
<div className="ep-clip-detail-field">
|
||||
<div className="ep-clip-detail-label">当前模式</div>
|
||||
@@ -1021,7 +953,7 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ClipPropertiesPanel;
|
||||
export default ClipPropertiesPanel
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
* 封面选择器
|
||||
* 抽帧选封面 + 上传自定义封面 + 智能封面推荐
|
||||
*/
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type { CoverConfig, CoverMode } from "../types";
|
||||
import { DEFAULT_COVER_CONFIG } from "../types";
|
||||
import React, { useCallback, useRef, useState } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { CoverConfig, CoverMode } from "../types"
|
||||
import { DEFAULT_COVER_CONFIG } from "../types"
|
||||
|
||||
interface CoverSelectorProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: CoverConfig;
|
||||
onChange: (config: CoverConfig) => void;
|
||||
totalDuration: number;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: CoverConfig
|
||||
onChange: (config: CoverConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/** 封面模式标签 */
|
||||
@@ -20,14 +20,14 @@ const MODE_LABELS: Record<CoverMode, string> = {
|
||||
auto: "智能封面",
|
||||
frame: "抽帧选封面",
|
||||
upload: "上传封面",
|
||||
};
|
||||
}
|
||||
|
||||
/** 封面模式图标 */
|
||||
const MODE_ICONS: Record<CoverMode, string> = {
|
||||
auto: "🤖",
|
||||
frame: "🎞️",
|
||||
upload: "📤",
|
||||
};
|
||||
}
|
||||
|
||||
const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
open,
|
||||
@@ -36,67 +36,67 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
onChange,
|
||||
totalDuration,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const update = useCallback(
|
||||
(partial: Partial<CoverConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
onChange({ ...DEFAULT_COVER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 切换模式 */
|
||||
const handleModeChange = useCallback(
|
||||
(mode: CoverMode) => {
|
||||
update({ mode });
|
||||
update({ mode })
|
||||
},
|
||||
[update],
|
||||
);
|
||||
)
|
||||
|
||||
/** 处理文件上传 */
|
||||
const handleFileUpload = useCallback(
|
||||
(file: File) => {
|
||||
if (!file.type.startsWith("image/")) return;
|
||||
const reader = new FileReader();
|
||||
if (!file.type.startsWith("image/")) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const url = e.target?.result as string;
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" });
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
const url = e.target?.result as string
|
||||
update({ upload_url: url, thumbnail_url: url, mode: "upload" })
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
},
|
||||
[update],
|
||||
);
|
||||
)
|
||||
|
||||
/** 拖拽上传 */
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFileUpload(file);
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFileUpload(file)
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
)
|
||||
|
||||
/** 使用 AI 推荐时间 */
|
||||
const handleUseAiSuggestion = useCallback(() => {
|
||||
if (config.ai_suggested_time !== null) {
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" });
|
||||
update({ frame_time: config.ai_suggested_time, mode: "frame" })
|
||||
}
|
||||
}, [config.ai_suggested_time, update]);
|
||||
}, [config.ai_suggested_time, update])
|
||||
|
||||
/** 格式化时间 */
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
const ms = Math.floor((seconds % 1) * 10);
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`;
|
||||
};
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const ms = Math.floor((seconds % 1) * 10)
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -151,10 +151,7 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
<div className="cover-auto-time">
|
||||
推荐时间点:{formatTime(config.ai_suggested_time)}
|
||||
</div>
|
||||
<button
|
||||
className="cover-auto-use-btn"
|
||||
onClick={handleUseAiSuggestion}
|
||||
>
|
||||
<button className="cover-auto-use-btn" onClick={handleUseAiSuggestion}>
|
||||
使用此时间点
|
||||
</button>
|
||||
</div>
|
||||
@@ -173,17 +170,13 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
<div className="cover-frame-preview">
|
||||
<div className="cover-frame-placeholder">
|
||||
<span className="cover-frame-icon">🎞️</span>
|
||||
<span className="cover-frame-time">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
<span className="cover-frame-time">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="cover-frame-timeline">
|
||||
<div className="cover-frame-slider-header">
|
||||
<span className="cover-frame-slider-label">拖动选择封面帧</span>
|
||||
<span className="cover-frame-slider-value">
|
||||
{formatTime(config.frame_time)}
|
||||
</span>
|
||||
<span className="cover-frame-slider-value">{formatTime(config.frame_time)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
@@ -203,7 +196,7 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
<div className="cover-frame-quick">
|
||||
<span className="cover-quick-label">快捷选帧:</span>
|
||||
{[0, 0.25, 0.5, 0.75].map((ratio) => {
|
||||
const t = totalDuration * ratio;
|
||||
const t = totalDuration * ratio
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
@@ -212,7 +205,7 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
>
|
||||
{formatTime(t)}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,8 +217,8 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
<div
|
||||
className={`cover-upload-area${isDragging ? " dragging" : ""}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
@@ -239,12 +232,8 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
) : (
|
||||
<div className="cover-upload-placeholder">
|
||||
<span className="cover-upload-icon">📤</span>
|
||||
<span className="cover-upload-text">
|
||||
点击或拖拽上传封面图片
|
||||
</span>
|
||||
<span className="cover-upload-hint">
|
||||
支持 JPG / PNG,建议 16:9 比例
|
||||
</span>
|
||||
<span className="cover-upload-text">点击或拖拽上传封面图片</span>
|
||||
<span className="cover-upload-hint">支持 JPG / PNG,建议 16:9 比例</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
@@ -253,8 +242,8 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileUpload(file);
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileUpload(file)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -267,11 +256,7 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
<div className="cover-section-title">封面预览</div>
|
||||
<div className="cover-preview-box">
|
||||
{config.upload_url ? (
|
||||
<img
|
||||
src={config.upload_url}
|
||||
alt="封面预览"
|
||||
className="cover-preview-img"
|
||||
/>
|
||||
<img src={config.upload_url} alt="封面预览" className="cover-preview-img" />
|
||||
) : (
|
||||
<div className="cover-preview-placeholder">
|
||||
<span className="cover-preview-icon">🖼️</span>
|
||||
@@ -295,7 +280,7 @@ const CoverSelector: React.FC<CoverSelectorProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverSelector;
|
||||
export default CoverSelector
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* 编辑器右侧栏 — 片段列表 Tab
|
||||
* 紧凑版片段管理:选中、上下移动、删除、添加
|
||||
*/
|
||||
import React from "react";
|
||||
import { Button, Tooltip, Empty } from "antd";
|
||||
import React from "react"
|
||||
import { Button, Tooltip, Empty } from "antd"
|
||||
import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
@@ -13,17 +13,17 @@ import {
|
||||
SoundOutlined,
|
||||
PictureOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { ClipData, ClipType } from "../types";
|
||||
} from "@ant-design/icons"
|
||||
import type { ClipData, ClipType } from "../types"
|
||||
|
||||
interface EditorClipListProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
onSelect: (clipId: string) => void;
|
||||
onMoveUp: (clipId: string) => void;
|
||||
onMoveDown: (clipId: string) => void;
|
||||
onRemove: (clipId: string) => void;
|
||||
onAdd: () => void;
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
onSelect: (clipId: string) => void
|
||||
onMoveUp: (clipId: string) => void
|
||||
onMoveDown: (clipId: string) => void
|
||||
onRemove: (clipId: string) => void
|
||||
onAdd: () => void
|
||||
}
|
||||
|
||||
const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
@@ -31,21 +31,21 @@ const clipTypeIcon: Record<ClipType | string, React.ReactNode> = {
|
||||
image: <PictureOutlined />,
|
||||
voice: <SoundOutlined />,
|
||||
pip: <ScissorOutlined />,
|
||||
};
|
||||
}
|
||||
|
||||
const clipTypeLabel: Record<ClipType | string, string> = {
|
||||
video: "视频",
|
||||
image: "图片",
|
||||
voice: "配音",
|
||||
pip: "画中画",
|
||||
};
|
||||
}
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = (sec % 60).toFixed(0);
|
||||
return `${m}m${s.padStart(2, "0")}s`;
|
||||
};
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = (sec % 60).toFixed(0)
|
||||
return `${m}m${s.padStart(2, "0")}s`
|
||||
}
|
||||
|
||||
const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
clips,
|
||||
@@ -68,7 +68,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
添加片段
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -79,12 +79,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
共 <b>{clips.length}</b> 个片段
|
||||
</span>
|
||||
<Tooltip title="添加片段">
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={onAdd}
|
||||
>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={onAdd}>
|
||||
添加
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -95,9 +90,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
{clips.map((clip, index) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className={`ep-clip-list-item${
|
||||
selectedClipId === clip.id ? " selected" : ""
|
||||
}`}
|
||||
className={`ep-clip-list-item${selectedClipId === clip.id ? " selected" : ""}`}
|
||||
onClick={() => onSelect(clip.id)}
|
||||
>
|
||||
{/* 序号 + 类型图标 */}
|
||||
@@ -109,9 +102,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
{clipTypeLabel[clip.type] || "片段"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">
|
||||
{formatDuration(clip.duration)}
|
||||
</span>
|
||||
<span className="ep-clip-item-duration">{formatDuration(clip.duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* 文案预览 */}
|
||||
@@ -123,10 +114,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div
|
||||
className="ep-clip-item-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ep-clip-item-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<Tooltip title="上移">
|
||||
<Button
|
||||
type="text"
|
||||
@@ -162,7 +150,7 @@ const EditorClipList: React.FC<EditorClipListProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default EditorClipList;
|
||||
export default EditorClipList
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
* 滤镜调色配置面板
|
||||
* 预设滤镜 + 手动调节(亮度/对比度/饱和度/色温/色调/锐度)
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { FilterConfig, FilterPreset } from "../types";
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types";
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { FilterConfig, FilterPreset } from "../types"
|
||||
import { DEFAULT_FILTER_CONFIG, FILTER_PRESET_LABELS } from "../types"
|
||||
|
||||
interface FilterPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: FilterConfig;
|
||||
onChange: (config: FilterConfig) => void;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: FilterConfig
|
||||
onChange: (config: FilterConfig) => void
|
||||
}
|
||||
|
||||
/** 所有预设列表 */
|
||||
@@ -26,7 +26,7 @@ const PRESET_LIST: FilterPreset[] = [
|
||||
"bw",
|
||||
"sunshine",
|
||||
"film",
|
||||
];
|
||||
]
|
||||
|
||||
/** 预设对应的示例渐变色(用于视觉预览) */
|
||||
const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
@@ -40,40 +40,35 @@ const PRESET_GRADIENTS: Record<FilterPreset, string> = {
|
||||
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<FilterPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const FilterPanel: React.FC<FilterPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<FilterConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 选择预设时重置手动参数 */
|
||||
const handlePresetSelect = useCallback(
|
||||
(preset: FilterPreset) => {
|
||||
if (preset === "none") {
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled });
|
||||
onChange({ ...DEFAULT_FILTER_CONFIG, enabled: config.enabled })
|
||||
} else {
|
||||
onChange({
|
||||
...DEFAULT_FILTER_CONFIG,
|
||||
enabled: config.enabled,
|
||||
preset,
|
||||
});
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.enabled, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -104,13 +99,8 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
className={`filter-preset-item${config.preset === p ? " active" : ""}`}
|
||||
onClick={() => handlePresetSelect(p)}
|
||||
>
|
||||
<div
|
||||
className="filter-preset-preview"
|
||||
style={{ background: PRESET_GRADIENTS[p] }}
|
||||
/>
|
||||
<span className="filter-preset-label">
|
||||
{FILTER_PRESET_LABELS[p]}
|
||||
</span>
|
||||
<div className="filter-preset-preview" style={{ background: PRESET_GRADIENTS[p] }} />
|
||||
<span className="filter-preset-label">{FILTER_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -228,7 +218,7 @@ const FilterPanel: React.FC<FilterPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default FilterPanel;
|
||||
export default FilterPanel
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
* 生成历史弹窗 — 展示当前剪辑计划的生成任务记录
|
||||
* 从 EditingPlanner 拆分,避免主文件过大
|
||||
*/
|
||||
import React from "react";
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons";
|
||||
import type { EditPlanGeneration } from "@/api/editPlans";
|
||||
import { PLAN_STATUS_LABELS } from "@/api/editPlans";
|
||||
import React from "react"
|
||||
import { CloseOutlined, InboxOutlined } from "@ant-design/icons"
|
||||
import type { EditPlanGeneration } from "@/api/editPlans"
|
||||
import { PLAN_STATUS_LABELS } from "@/api/editPlans"
|
||||
|
||||
interface GenerationHistoryModalProps {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
history: EditPlanGeneration[];
|
||||
onClose: () => void;
|
||||
onCancel?: (taskId: string) => void;
|
||||
cancelLoading?: boolean;
|
||||
open: boolean
|
||||
loading: boolean
|
||||
history: EditPlanGeneration[]
|
||||
onClose: () => void
|
||||
onCancel?: (taskId: string) => void
|
||||
cancelLoading?: boolean
|
||||
}
|
||||
|
||||
const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
@@ -24,14 +24,11 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
onCancel,
|
||||
cancelLoading,
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="ep-modal-overlay" onClick={onClose}>
|
||||
<div
|
||||
className="ep-modal ep-gh-modal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="ep-modal ep-gh-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="ep-modal-header">
|
||||
<h3>生成历史</h3>
|
||||
<button className="ep-modal-close" onClick={onClose}>
|
||||
@@ -48,7 +45,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
<div className="ep-skeleton-item" />
|
||||
</div>
|
||||
</div>
|
||||
) : (history?.length ?? 0) === 0 ? (
|
||||
) : history.length === 0 ? (
|
||||
<div className="ep-gh-empty">
|
||||
<InboxOutlined style={{ fontSize: 32, opacity: 0.4 }} />
|
||||
<span>暂无生成记录</span>
|
||||
@@ -65,10 +62,9 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(history ?? []).map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`;
|
||||
const canCancel =
|
||||
gen.status === "rendering" || gen.status === "editing";
|
||||
{history.map((gen) => {
|
||||
const statusClass = `ep-gh-status-tag--${gen.status}`
|
||||
const canCancel = gen.status === "rendering" || gen.status === "editing"
|
||||
return (
|
||||
<tr key={gen.id} className="ep-gh-table-row">
|
||||
<td className="ep-gh-td ep-gh-td-id">
|
||||
@@ -80,14 +76,10 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</span>
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.created_at
|
||||
? new Date(gen.created_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
{gen.created_at ? new Date(gen.created_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
<td className="ep-gh-td ep-gh-td-time">
|
||||
{gen.updated_at
|
||||
? new Date(gen.updated_at).toLocaleString("zh-CN")
|
||||
: "—"}
|
||||
{gen.updated_at ? new Date(gen.updated_at).toLocaleString("zh-CN") : "—"}
|
||||
</td>
|
||||
{onCancel && (
|
||||
<td className="ep-gh-td ep-gh-td-action">
|
||||
@@ -105,7 +97,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -118,7 +110,7 @@ const GenerationHistoryModal: React.FC<GenerationHistoryModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationHistoryModal;
|
||||
export default GenerationHistoryModal
|
||||
|
||||
@@ -3,32 +3,32 @@
|
||||
* 三阶段 UI:setup(配置)→ progress(进度轮询)→ completed / failed(结果)
|
||||
* V21 设计系统,CSS 类名前缀 ep-gen-
|
||||
*/
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Modal, Button } from "@/components/ui";
|
||||
import type { TaskItem } from "@/api/tasks";
|
||||
import React, { useEffect, useRef } from "react"
|
||||
import { Modal, Button } from "@/components/ui"
|
||||
import type { TaskItem } from "@/api/tasks"
|
||||
|
||||
/* ──────────── 类型 ──────────── */
|
||||
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed";
|
||||
export type GenPhase = "setup" | "progress" | "completed" | "failed"
|
||||
|
||||
export interface GenerationProgressModalProps {
|
||||
open: boolean;
|
||||
phase: GenPhase;
|
||||
open: boolean
|
||||
phase: GenPhase
|
||||
|
||||
/* setup 阶段 */
|
||||
voiceoverDuration: number | null;
|
||||
estimatedDuration: number;
|
||||
onDurationChange: (v: number | null) => void;
|
||||
onGenerate: () => void;
|
||||
voiceoverDuration: number | null
|
||||
estimatedDuration: number
|
||||
onDurationChange: (v: number | null) => void
|
||||
onGenerate: () => void
|
||||
|
||||
/* progress / 结果阶段 */
|
||||
task: TaskItem | null;
|
||||
task: TaskItem | null
|
||||
|
||||
/* 通用 */
|
||||
submitting: boolean;
|
||||
onCancel: () => void;
|
||||
onRetry?: () => void;
|
||||
onClose?: () => void;
|
||||
submitting: boolean
|
||||
onCancel: () => void
|
||||
onRetry?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
/* ──────────── 步骤文案映射 ──────────── */
|
||||
@@ -42,10 +42,9 @@ const STEP_LABELS: Record<string, string> = {
|
||||
encoding: "编码输出中…",
|
||||
completed: "生成完成!",
|
||||
failed: "生成失败",
|
||||
};
|
||||
}
|
||||
|
||||
const getStepLabel = (step: string) =>
|
||||
STEP_LABELS[step] || step.replace(/_/g, " ");
|
||||
const getStepLabel = (step: string) => STEP_LABELS[step] || step.replace(/_/g, " ")
|
||||
|
||||
/* ──────────── 状态徽标颜色 ──────────── */
|
||||
|
||||
@@ -59,7 +58,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
encoding: "#0891b2",
|
||||
completed: "#10b981",
|
||||
failed: "#ef4444",
|
||||
};
|
||||
}
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
@@ -77,20 +76,20 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
onClose,
|
||||
}) => {
|
||||
/* 关闭弹窗时重置(避免下次打开残留旧状态) */
|
||||
const prevOpen = useRef(false);
|
||||
const prevOpen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (prevOpen.current && !open) {
|
||||
/* modal just closed — parent handles reset */
|
||||
}
|
||||
prevOpen.current = open;
|
||||
}, [open]);
|
||||
prevOpen.current = open
|
||||
}, [open])
|
||||
|
||||
const progress = task?.progress ?? 0;
|
||||
const status = task?.status ?? "";
|
||||
const currentStep = task?.current_step ?? "";
|
||||
const userMessage = task?.user_message ?? "";
|
||||
const errorMessage = task?.error_message ?? "";
|
||||
const retryable = task?.retryable ?? false;
|
||||
const progress = task?.progress ?? 0
|
||||
const status = task?.status ?? ""
|
||||
const currentStep = task?.current_step ?? ""
|
||||
const userMessage = task?.user_message ?? ""
|
||||
const errorMessage = task?.error_message ?? ""
|
||||
const retryable = task?.retryable ?? false
|
||||
|
||||
/* ── setup 阶段 ── */
|
||||
if (phase === "setup") {
|
||||
@@ -113,8 +112,8 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
placeholder="请输入配音时长"
|
||||
value={voiceoverDuration ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value ? Number(e.target.value) : null;
|
||||
onDurationChange(v);
|
||||
const v = e.target.value ? Number(e.target.value) : null
|
||||
onDurationChange(v)
|
||||
}}
|
||||
min={1}
|
||||
max={600}
|
||||
@@ -124,33 +123,20 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/* ── progress 阶段 ── */
|
||||
if (phase === "progress") {
|
||||
const stepColor =
|
||||
STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5";
|
||||
const stepColor = STATUS_COLOR[status] || STATUS_COLOR[currentStep] || "#4f46e5"
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="视频生成中"
|
||||
footer={null}
|
||||
onCancel={onCancel}
|
||||
closable
|
||||
width={480}
|
||||
>
|
||||
<Modal open={open} title="视频生成中" footer={null} onCancel={onCancel} closable width={480}>
|
||||
<div className="ep-gen-progress">
|
||||
{/* 进度环 */}
|
||||
<div className="ep-gen-progress-ring-wrap">
|
||||
<svg className="ep-gen-progress-ring" viewBox="0 0 120 120">
|
||||
<circle
|
||||
className="ep-gen-progress-ring-bg"
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="52"
|
||||
/>
|
||||
<circle className="ep-gen-progress-ring-bg" cx="60" cy="60" r="52" />
|
||||
<circle
|
||||
className="ep-gen-progress-ring-fill"
|
||||
cx="60"
|
||||
@@ -188,7 +174,7 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
{task?.id && <div className="ep-gen-task-id">任务 ID: {task.id}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/* ── completed 阶段 ── */
|
||||
@@ -205,9 +191,7 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
<div className="ep-gen-result">
|
||||
<div className="ep-gen-result-icon">🎉</div>
|
||||
<div className="ep-gen-result-title">视频生成完成!</div>
|
||||
{userMessage && (
|
||||
<div className="ep-gen-result-msg">{userMessage}</div>
|
||||
)}
|
||||
{userMessage && <div className="ep-gen-result-msg">{userMessage}</div>}
|
||||
<div className="ep-gen-result-actions">
|
||||
<Button buttonType="primary" onClick={onClose || onCancel}>
|
||||
查看结果
|
||||
@@ -215,7 +199,7 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/* ── failed 阶段 ── */
|
||||
@@ -248,7 +232,7 @@ const GenerationProgressModal: React.FC<GenerationProgressModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GenerationProgressModal;
|
||||
export default GenerationProgressModal
|
||||
|
||||
@@ -2,47 +2,36 @@
|
||||
* 绿幕抠像配置面板
|
||||
* 5 种颜色预设 + 自定义颜色 + 相似度/边缘平滑/溢色抑制
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types";
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { ChromaKeyConfig, ChromaKeyColorPreset } from "../types"
|
||||
import {
|
||||
DEFAULT_CHROMA_KEY_CONFIG,
|
||||
CHROMA_KEY_PRESET_LABELS,
|
||||
CHROMA_KEY_PRESET_COLORS,
|
||||
} from "../types";
|
||||
} from "../types"
|
||||
|
||||
interface GreenScreenPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: ChromaKeyConfig;
|
||||
onChange: (config: ChromaKeyConfig) => void;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: ChromaKeyConfig
|
||||
onChange: (config: ChromaKeyConfig) => void
|
||||
}
|
||||
|
||||
/** 预设列表 */
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = [
|
||||
"green",
|
||||
"blue",
|
||||
"red",
|
||||
"pure_green",
|
||||
"soft_green",
|
||||
];
|
||||
const PRESET_LIST: ChromaKeyColorPreset[] = ["green", "blue", "red", "pure_green", "soft_green"]
|
||||
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
const update = useCallback(
|
||||
(partial: Partial<ChromaKeyConfig>) => {
|
||||
onChange({ ...config, ...partial });
|
||||
onChange({ ...config, ...partial })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled });
|
||||
}, [config.enabled, onChange]);
|
||||
onChange({ ...DEFAULT_CHROMA_KEY_CONFIG, enabled: config.enabled })
|
||||
}, [config.enabled, onChange])
|
||||
|
||||
/** 选择颜色预设时同步更新 color 字段 */
|
||||
const handlePresetSelect = useCallback(
|
||||
@@ -50,18 +39,18 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
update({
|
||||
color_preset: preset,
|
||||
color: CHROMA_KEY_PRESET_COLORS[preset],
|
||||
});
|
||||
})
|
||||
},
|
||||
[update],
|
||||
);
|
||||
)
|
||||
|
||||
/** 自定义颜色变化时清除预设标记 */
|
||||
const handleColorChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
update({ color: e.target.value });
|
||||
update({ color: e.target.value })
|
||||
},
|
||||
[update],
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -96,9 +85,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
className="green-preset-dot"
|
||||
style={{ background: CHROMA_KEY_PRESET_COLORS[p] }}
|
||||
/>
|
||||
<span className="green-preset-label">
|
||||
{CHROMA_KEY_PRESET_LABELS[p]}
|
||||
</span>
|
||||
<span className="green-preset-label">{CHROMA_KEY_PRESET_LABELS[p]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,10 +108,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
onChange={handleColorChange}
|
||||
placeholder="#00FF00"
|
||||
/>
|
||||
<div
|
||||
className="green-color-swatch"
|
||||
style={{ background: config.color }}
|
||||
/>
|
||||
<div className="green-color-swatch" style={{ background: config.color }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -188,10 +172,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
<div className="green-section">
|
||||
<div className="green-section-title">效果预览</div>
|
||||
<div className="green-preview-box">
|
||||
<div
|
||||
className="green-preview-bg"
|
||||
style={{ background: config.color, opacity: 0.3 }}
|
||||
/>
|
||||
<div className="green-preview-bg" style={{ background: config.color, opacity: 0.3 }} />
|
||||
<div className="green-preview-subject">
|
||||
<div className="green-preview-circle" />
|
||||
<div className="green-preview-text">主体</div>
|
||||
@@ -213,7 +194,7 @@ const GreenScreenPanel: React.FC<GreenScreenPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default GreenScreenPanel;
|
||||
export default GreenScreenPanel
|
||||
|
||||
@@ -3,16 +3,11 @@
|
||||
* 两个区块:片头(Intro)/ 片尾(Outro)
|
||||
* 每个区块支持:类型选择(无/视频/图片)、素材 URL、时长、过渡动画
|
||||
*/
|
||||
import React, { useCallback } from "react";
|
||||
import { Drawer } from "antd";
|
||||
import type {
|
||||
IntroOutroConfig,
|
||||
IntroOutroItem,
|
||||
IntroOutroKind,
|
||||
TransitionType,
|
||||
} from "../types";
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types";
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans";
|
||||
import React, { useCallback } from "react"
|
||||
import { Drawer } from "antd"
|
||||
import type { IntroOutroConfig, IntroOutroItem, IntroOutroKind, TransitionType } from "../types"
|
||||
import { DEFAULT_INTRO_OUTRO } from "../types"
|
||||
import { TRANSITION_OPTIONS } from "@/api/editPlans"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -20,58 +15,53 @@ const KIND_OPTIONS: { value: IntroOutroKind; label: string; icon: string }[] = [
|
||||
{ value: "none", label: "无", icon: "🚫" },
|
||||
{ value: "video", label: "视频", icon: "🎬" },
|
||||
{ value: "image", label: "图片", icon: "🖼️" },
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
interface IntroOutroPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: IntroOutroConfig;
|
||||
onChange: (config: IntroOutroConfig) => void;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: IntroOutroConfig
|
||||
onChange: (config: IntroOutroConfig) => void
|
||||
}
|
||||
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({ open, onClose, config, onChange }) => {
|
||||
/* ── 更新片头 ── */
|
||||
const handleIntroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } });
|
||||
onChange({ ...config, intro: { ...config.intro, ...partial } })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 更新片尾 ── */
|
||||
const handleOutroChange = useCallback(
|
||||
(partial: Partial<IntroOutroItem>) => {
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } });
|
||||
onChange({ ...config, outro: { ...config.outro, ...partial } })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 切换片头类型 ── */
|
||||
const handleIntroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
handleIntroChange({ kind, url: kind === "none" ? undefined : "" })
|
||||
},
|
||||
[handleIntroChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 切换片尾类型 ── */
|
||||
const handleOutroKindChange = useCallback(
|
||||
(kind: IntroOutroKind) => {
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" });
|
||||
handleOutroChange({ kind, url: kind === "none" ? undefined : "" })
|
||||
},
|
||||
[handleOutroChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO });
|
||||
}, [onChange]);
|
||||
onChange({ ...DEFAULT_INTRO_OUTRO })
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -133,13 +123,9 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.intro.duration}
|
||||
onChange={(e) =>
|
||||
handleIntroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
onChange={(e) => handleIntroChange({ duration: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.intro.duration}s
|
||||
</span>
|
||||
<span className="iop-slider-value">{config.intro.duration}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,13 +226,9 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
max={15}
|
||||
step={0.5}
|
||||
value={config.outro.duration}
|
||||
onChange={(e) =>
|
||||
handleOutroChange({ duration: Number(e.target.value) })
|
||||
}
|
||||
onChange={(e) => handleOutroChange({ duration: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="iop-slider-value">
|
||||
{config.outro.duration}s
|
||||
</span>
|
||||
<span className="iop-slider-value">{config.outro.duration}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -303,7 +285,7 @@ const IntroOutroPanel: React.FC<IntroOutroPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default IntroOutroPanel;
|
||||
export default IntroOutroPanel
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React, { useState } from "react";
|
||||
import type { EditingTemplate } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS } from "@/api/editingPlanner";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
import React, { useState } from "react"
|
||||
import type { EditingTemplate } from "@/api/editingPlanner"
|
||||
import { MODE_LABELS } from "@/api/editingPlanner"
|
||||
import type { MediaAsset } from "@/api/editPlans"
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector"
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[];
|
||||
loading: boolean;
|
||||
searchQuery: string;
|
||||
currentFilter: string;
|
||||
filterCategories: string[];
|
||||
loadedTemplateId: string | null;
|
||||
onLoadTemplate: (id: string) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onFilterChange: (f: string) => void;
|
||||
templates: EditingTemplate[]
|
||||
loading: boolean
|
||||
searchQuery: string
|
||||
currentFilter: string
|
||||
filterCategories: string[]
|
||||
loadedTemplateId: string | null
|
||||
onLoadTemplate: (id: string) => void
|
||||
onSearchChange: (q: string) => void
|
||||
onFilterChange: (f: string) => void
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[];
|
||||
onAssetSelect?: (ids: string[]) => void;
|
||||
selectedAssetIds?: string[];
|
||||
mediaAssets?: MediaAsset[]
|
||||
onAssetSelect?: (ids: string[]) => void
|
||||
selectedAssetIds?: string[]
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -38,9 +38,7 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">(
|
||||
"templates",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">("templates")
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
@@ -108,9 +106,7 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
>
|
||||
<div className="ep-template-card-header">
|
||||
<span className="ep-template-card-name">{tpl.name}</span>
|
||||
<span className="ep-template-card-mode">
|
||||
{MODE_LABELS[tpl.mode]}
|
||||
</span>
|
||||
<span className="ep-template-card-mode">{MODE_LABELS[tpl.mode]}</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
@@ -146,7 +142,7 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaPanel;
|
||||
export default MediaPanel
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
* 画中画配置面板 — Drawer 形式
|
||||
* 左侧图层列表 + 右侧单图层配置 + 迷你预览区
|
||||
*/
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Drawer, Switch } from "antd";
|
||||
import type {
|
||||
PipConfig,
|
||||
PipLayer,
|
||||
PipGridPosition,
|
||||
PipAnimType,
|
||||
PipSlideDirection,
|
||||
} from "../types";
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types";
|
||||
import React, { useCallback, useMemo } from "react"
|
||||
import { Drawer, Switch } from "antd"
|
||||
import type { PipConfig, PipLayer, PipGridPosition, PipAnimType, PipSlideDirection } from "../types"
|
||||
import { DEFAULT_PIP_LAYER, DEFAULT_PIP_CONFIG } from "../types"
|
||||
|
||||
/* ──────────── 常量 ──────────── */
|
||||
|
||||
@@ -26,7 +20,7 @@ const GRID_POSITION_MAP: Record<PipGridPosition, { x: number; y: number }> = {
|
||||
bottom_left: { x: 5, y: 70 },
|
||||
bottom_center: { x: 37.5, y: 70 },
|
||||
bottom_right: { x: 70, y: 70 },
|
||||
};
|
||||
}
|
||||
|
||||
/** 九宫格位置选项 */
|
||||
const GRID_POSITIONS: PipGridPosition[] = [
|
||||
@@ -39,14 +33,14 @@ const GRID_POSITIONS: PipGridPosition[] = [
|
||||
"bottom_left",
|
||||
"bottom_center",
|
||||
"bottom_right",
|
||||
];
|
||||
]
|
||||
|
||||
/** 入场动画选项 */
|
||||
const ANIM_OPTIONS: { value: PipAnimType; label: string }[] = [
|
||||
{ value: "none", label: "无" },
|
||||
{ value: "fade_in", label: "淡入" },
|
||||
{ value: "slide_in", label: "滑入" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 滑入方向选项 */
|
||||
const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
@@ -54,7 +48,7 @@ const SLIDE_DIR_OPTIONS: { value: PipSlideDirection; label: string }[] = [
|
||||
{ value: "right", label: "→ 右" },
|
||||
{ value: "up", label: "↑ 上" },
|
||||
{ value: "down", label: "↓ 下" },
|
||||
];
|
||||
]
|
||||
|
||||
/** 预览图层颜色池 */
|
||||
const LAYER_COLORS = [
|
||||
@@ -64,22 +58,22 @@ const LAYER_COLORS = [
|
||||
"rgba(255,77,79,0.5)",
|
||||
"rgba(114,46,209,0.5)",
|
||||
"rgba(19,194,194,0.5)",
|
||||
];
|
||||
]
|
||||
|
||||
/* ──────────── Props ──────────── */
|
||||
|
||||
interface PipConfigPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: PipConfig;
|
||||
onChange: (config: PipConfig) => void;
|
||||
totalDuration: number;
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
config: PipConfig
|
||||
onChange: (config: PipConfig) => void
|
||||
totalDuration: number
|
||||
}
|
||||
|
||||
/* ──────────── 辅助函数 ──────────── */
|
||||
|
||||
let layerIdCounter = 0;
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`;
|
||||
let layerIdCounter = 0
|
||||
const genLayerId = () => `pip_layer_${Date.now()}_${++layerIdCounter}`
|
||||
|
||||
/* ──────────── 组件 ──────────── */
|
||||
|
||||
@@ -91,13 +85,13 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
totalDuration,
|
||||
}) => {
|
||||
/** 当前选中图层 ID */
|
||||
const [selectedId, setSelectedId] = React.useState<string>("");
|
||||
const [selectedId, setSelectedId] = React.useState<string>("")
|
||||
|
||||
/** 当前选中图层 */
|
||||
const selectedLayer = useMemo(
|
||||
() => config.layers.find((l) => l.id === selectedId) ?? null,
|
||||
[config.layers, selectedId],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 添加图层 ── */
|
||||
const handleAddLayer = useCallback(() => {
|
||||
@@ -106,92 +100,90 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
id: genLayerId(),
|
||||
name: `图层 ${config.layers.length + 1}`,
|
||||
z_index: config.layers.length + 1,
|
||||
};
|
||||
}
|
||||
onChange({
|
||||
...config,
|
||||
layers: [...config.layers, newLayer],
|
||||
});
|
||||
setSelectedId(newLayer.id);
|
||||
}, [config, onChange]);
|
||||
})
|
||||
setSelectedId(newLayer.id)
|
||||
}, [config, onChange])
|
||||
|
||||
/* ── 删除图层 ── */
|
||||
const handleDeleteLayer = useCallback(
|
||||
(id: string) => {
|
||||
const newLayers = config.layers.filter((l) => l.id !== id);
|
||||
onChange({ ...config, layers: newLayers });
|
||||
const newLayers = config.layers.filter((l) => l.id !== id)
|
||||
onChange({ ...config, layers: newLayers })
|
||||
if (selectedId === id) {
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "");
|
||||
setSelectedId(newLayers.length > 0 ? newLayers[0].id : "")
|
||||
}
|
||||
},
|
||||
[config, onChange, selectedId],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 更新图层 ── */
|
||||
const updateLayer = useCallback(
|
||||
(id: string, partial: Partial<PipLayer>) => {
|
||||
onChange({
|
||||
...config,
|
||||
layers: config.layers.map((l) =>
|
||||
l.id === id ? { ...l, ...partial } : l,
|
||||
),
|
||||
});
|
||||
layers: config.layers.map((l) => (l.id === id ? { ...l, ...partial } : l)),
|
||||
})
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 切换启用 ── */
|
||||
const handleEnableToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
onChange({ ...config, enabled: checked });
|
||||
onChange({ ...config, enabled: checked })
|
||||
},
|
||||
[config, onChange],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 重置 ── */
|
||||
const handleReset = useCallback(() => {
|
||||
onChange({ ...DEFAULT_PIP_CONFIG });
|
||||
setSelectedId("");
|
||||
}, [onChange]);
|
||||
onChange({ ...DEFAULT_PIP_CONFIG })
|
||||
setSelectedId("")
|
||||
}, [onChange])
|
||||
|
||||
/* ── 九宫格点击 ── */
|
||||
const handleGridClick = useCallback(
|
||||
(pos: PipGridPosition) => {
|
||||
if (!selectedLayer) return;
|
||||
const coords = GRID_POSITION_MAP[pos];
|
||||
if (!selectedLayer) return
|
||||
const coords = GRID_POSITION_MAP[pos]
|
||||
updateLayer(selectedLayer.id, {
|
||||
grid_position: pos,
|
||||
x: coords.x,
|
||||
y: coords.y,
|
||||
});
|
||||
})
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
)
|
||||
|
||||
/* ── 宽高比锁定 ── */
|
||||
const handleWidthChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { width: val };
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { width: val }
|
||||
if (selectedLayer.aspect_lock) {
|
||||
// 保持宽高比 1:1(百分比相同)
|
||||
partial.height = val;
|
||||
partial.height = val
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
)
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(val: number) => {
|
||||
if (!selectedLayer) return;
|
||||
const partial: Partial<PipLayer> = { height: val };
|
||||
if (!selectedLayer) return
|
||||
const partial: Partial<PipLayer> = { height: val }
|
||||
if (selectedLayer.aspect_lock) {
|
||||
partial.width = val;
|
||||
partial.width = val
|
||||
}
|
||||
updateLayer(selectedLayer.id, partial);
|
||||
updateLayer(selectedLayer.id, partial)
|
||||
},
|
||||
[selectedLayer, updateLayer],
|
||||
);
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -211,11 +203,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
</div>
|
||||
<div className="pip-enable-switch">
|
||||
<span>启用</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.enabled}
|
||||
onChange={handleEnableToggle}
|
||||
/>
|
||||
<Switch size="small" checked={config.enabled} onChange={handleEnableToggle} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -250,8 +238,8 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
<button
|
||||
className="pip-layer-delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteLayer(layer.id);
|
||||
e.stopPropagation()
|
||||
handleDeleteLayer(layer.id)
|
||||
}}
|
||||
title="删除图层"
|
||||
>
|
||||
@@ -295,17 +283,13 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
<div className="pip-type-btns">
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "image" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "image" })
|
||||
}
|
||||
onClick={() => updateLayer(selectedLayer.id, { material_type: "image" })}
|
||||
>
|
||||
🖼️ 图片
|
||||
</button>
|
||||
<button
|
||||
className={`pip-type-btn${selectedLayer.material_type === "video" ? " active" : ""}`}
|
||||
onClick={() =>
|
||||
updateLayer(selectedLayer.id, { material_type: "video" })
|
||||
}
|
||||
onClick={() => updateLayer(selectedLayer.id, { material_type: "video" })}
|
||||
>
|
||||
🎬 视频
|
||||
</button>
|
||||
@@ -315,8 +299,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
{/* ── 素材 URL ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">
|
||||
{selectedLayer.material_type === "image" ? "图片" : "视频"}{" "}
|
||||
URL
|
||||
{selectedLayer.material_type === "image" ? "图片" : "视频"} URL
|
||||
</label>
|
||||
<input
|
||||
className="pip-input"
|
||||
@@ -338,9 +321,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
{/* ── 位置:九宫格 + 坐标 ── */}
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">位置</label>
|
||||
<div
|
||||
style={{ display: "flex", gap: 16, alignItems: "flex-start" }}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 16, alignItems: "flex-start" }}>
|
||||
<div className="pip-grid">
|
||||
{GRID_POSITIONS.map((pos) => (
|
||||
<button
|
||||
@@ -391,9 +372,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
<div className="pip-field">
|
||||
<label className="pip-field-label">尺寸</label>
|
||||
<div className="pip-slider-row">
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
宽
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>宽</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
@@ -402,14 +381,10 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
value={selectedLayer.width}
|
||||
onChange={(e) => handleWidthChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.width}%
|
||||
</span>
|
||||
<span className="pip-slider-value">{selectedLayer.width}%</span>
|
||||
</div>
|
||||
<div className="pip-slider-row" style={{ marginTop: 6 }}>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>
|
||||
高
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: "#999", width: 20 }}>高</span>
|
||||
<input
|
||||
className="pip-slider"
|
||||
type="range"
|
||||
@@ -418,9 +393,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
value={selectedLayer.height}
|
||||
onChange={(e) => handleHeightChange(Number(e.target.value))}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.height}%
|
||||
</span>
|
||||
<span className="pip-slider-value">{selectedLayer.height}%</span>
|
||||
</div>
|
||||
<div
|
||||
className="pip-lock-row"
|
||||
@@ -431,12 +404,8 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="pip-lock-icon">
|
||||
{selectedLayer.aspect_lock ? "🔒" : "🔓"}
|
||||
</span>
|
||||
<span>
|
||||
{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}
|
||||
</span>
|
||||
<span className="pip-lock-icon">{selectedLayer.aspect_lock ? "🔒" : "🔓"}</span>
|
||||
<span>{selectedLayer.aspect_lock ? "已锁定比例" : "锁定宽高比"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -456,9 +425,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.border_radius}%
|
||||
</span>
|
||||
<span className="pip-slider-value">{selectedLayer.border_radius}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -478,9 +445,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="pip-slider-value">
|
||||
{selectedLayer.opacity}%
|
||||
</span>
|
||||
<span className="pip-slider-value">{selectedLayer.opacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -576,7 +541,7 @@ const PipConfigPanel: React.FC<PipConfigPanelProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PipConfigPanel;
|
||||
export default PipConfigPanel
|
||||
|
||||
@@ -3,46 +3,46 @@
|
||||
* 手机模型预览(150x267) + 封面预览(150x267) 并排
|
||||
* 封面右侧竖排4个方案按钮
|
||||
*/
|
||||
import React from "react";
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types";
|
||||
import React from "react"
|
||||
import type { ClipData, ClipType, TitleSettings } from "../types"
|
||||
|
||||
interface CoverScheme {
|
||||
key: string;
|
||||
label: string;
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
enabled: boolean
|
||||
position: string
|
||||
font: string
|
||||
size: number
|
||||
animation: string
|
||||
}
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
isPlaying: boolean;
|
||||
currentCoverScheme: string;
|
||||
coverSchemes: CoverScheme[];
|
||||
aiCoverLoading: boolean;
|
||||
titleSettings?: TitleSettings;
|
||||
subtitleSettings?: SubtitleSettings;
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onCoverSchemeChange: (scheme: string) => void;
|
||||
onPlayPause: () => void;
|
||||
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void;
|
||||
clips: ClipData[]
|
||||
selectedClipId: string | null
|
||||
isPlaying: boolean
|
||||
currentCoverScheme: string
|
||||
coverSchemes: CoverScheme[]
|
||||
aiCoverLoading: boolean
|
||||
titleSettings?: TitleSettings
|
||||
subtitleSettings?: SubtitleSettings
|
||||
onClipSelect: (clipId: string) => void
|
||||
onCoverSchemeChange: (scheme: string) => void
|
||||
onPlayPause: () => void
|
||||
onAiGenerateCover: (coverType: "ai_frame" | "ai_regenerate") => void
|
||||
}
|
||||
|
||||
const CLIP_TYPE_ICONS: Record<ClipType, string> = {
|
||||
voice: "🎙️",
|
||||
pip: "🖼️",
|
||||
};
|
||||
}
|
||||
|
||||
const CLIP_TYPE_LABELS: Record<ClipType, string> = {
|
||||
voice: "口播",
|
||||
pip: "画中画",
|
||||
};
|
||||
}
|
||||
|
||||
const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
clips,
|
||||
@@ -57,8 +57,8 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
}) => {
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId);
|
||||
const displayClip = selectedClip || clips[0];
|
||||
const selectedClip = clips.find((c) => c.id === selectedClipId)
|
||||
const displayClip = selectedClip || clips[0]
|
||||
|
||||
return (
|
||||
<div className="ep-preview-area">
|
||||
@@ -90,38 +90,29 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 */}
|
||||
{titleSettings &&
|
||||
!titleSettings.aiAutoSelect &&
|
||||
titleSettings.title && (
|
||||
<div
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleSettings.size, 20)}px`,
|
||||
fontFamily: titleSettings.font,
|
||||
fontWeight: titleSettings.bold ? "bold" : "normal",
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
textShadow: titleSettings.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.5)"
|
||||
: "none",
|
||||
WebkitTextStroke: titleSettings.stroke
|
||||
? "1px rgba(0,0,0,0.6)"
|
||||
: "none",
|
||||
top:
|
||||
titleSettings.position === "top"
|
||||
? "8px"
|
||||
: titleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
|
||||
transform:
|
||||
titleSettings.position === "center"
|
||||
? "translateY(-50%)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
</div>
|
||||
)}
|
||||
{titleSettings && !titleSettings.aiAutoSelect && titleSettings.title && (
|
||||
<div
|
||||
className="ep-preview-title"
|
||||
style={{
|
||||
fontSize: `${Math.min(titleSettings.size, 20)}px`,
|
||||
fontFamily: titleSettings.font,
|
||||
fontWeight: titleSettings.bold ? "bold" : "normal",
|
||||
fontStyle: titleSettings.italic ? "italic" : "normal",
|
||||
textShadow: titleSettings.shadow ? "2px 2px 4px rgba(0,0,0,0.5)" : "none",
|
||||
WebkitTextStroke: titleSettings.stroke ? "1px rgba(0,0,0,0.6)" : "none",
|
||||
top:
|
||||
titleSettings.position === "top"
|
||||
? "8px"
|
||||
: titleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: titleSettings.position === "bottom" ? "30px" : "auto",
|
||||
transform: titleSettings.position === "center" ? "translateY(-50%)" : "none",
|
||||
}}
|
||||
>
|
||||
{titleSettings.title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 字幕实时预览 */}
|
||||
{subtitleSettings?.enabled && (
|
||||
@@ -137,10 +128,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: subtitleSettings.position === "bottom" ? "8px" : "auto",
|
||||
transform:
|
||||
subtitleSettings.position === "center"
|
||||
? "translateY(-50%)"
|
||||
: "none",
|
||||
transform: subtitleSettings.position === "center" ? "translateY(-50%)" : "none",
|
||||
}}
|
||||
>
|
||||
字幕预览文字
|
||||
@@ -153,16 +141,13 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
<div className="ep-cover-preview">
|
||||
<div className="ep-cover-image">
|
||||
{displayClip ? (
|
||||
<span className="ep-cover-icon">
|
||||
{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}
|
||||
</span>
|
||||
<span className="ep-cover-icon">{CLIP_TYPE_ICONS[displayClip.type] || "🎬"}</span>
|
||||
) : (
|
||||
<span>暂无封面</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ep-cover-label">
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label ||
|
||||
"封面预览"}
|
||||
{coverSchemes.find((s) => s.key === currentCoverScheme)?.label || "封面预览"}
|
||||
</div>
|
||||
{/* AI 封面操作按钮 */}
|
||||
<div className="ep-cover-ai-btns">
|
||||
@@ -198,7 +183,7 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default PreviewPlayer;
|
||||
export default PreviewPlayer
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user