Merge develop into main - v0.1.120
Auto Merge PRs / auto-merge (push) Failing after 1m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 67h39m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 67h39m12s
CI/CD Pipeline / Deploy Staging (push) Failing after 67h37m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 67h22m51s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Auto Merge PRs / auto-merge (push) Failing after 1m32s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 67h39m58s
CI/CD Pipeline / Frontend Lint (push) Failing after 67h39m12s
CI/CD Pipeline / Deploy Staging (push) Failing after 67h37m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 67h22m51s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -20,7 +20,7 @@ const routeBrowserApiToTestApi = async (
|
||||
};
|
||||
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -40,6 +40,7 @@ async function loginWithRetry(
|
||||
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type TemplateResponse = { id: string };
|
||||
type AssetListResponse = {
|
||||
items: Array<{
|
||||
id: string;
|
||||
@@ -109,7 +110,6 @@ test.describe("Core generation flow", () => {
|
||||
expect(upload.status()).toBe(200);
|
||||
|
||||
// Wait for asset to be ready
|
||||
let sourceAssetId = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -121,13 +121,35 @@ test.describe("Core generation flow", () => {
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((a) => a.name === sourceFileName);
|
||||
if (!asset) return "missing";
|
||||
sourceAssetId = asset.id;
|
||||
return asset.status;
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] },
|
||||
)
|
||||
.toBe("ready");
|
||||
|
||||
// Create an editing template so the generate page has at least one template
|
||||
// (templates are now loaded from API; new users have none by default)
|
||||
const template = await request.post(`${apiBase}/templates`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试模板 ${suffix}`,
|
||||
mode: "pip",
|
||||
estimated_duration: 30,
|
||||
segments: [
|
||||
{
|
||||
segment_order: 1,
|
||||
duration_min: 5,
|
||||
duration_max: 30,
|
||||
material_type: "video",
|
||||
},
|
||||
],
|
||||
tags: ["e2e"],
|
||||
},
|
||||
});
|
||||
expect(template.status(), await template.text()).toBe(201);
|
||||
const templateData = (await template.json()) as TemplateResponse;
|
||||
expect(templateData.id).toBeTruthy();
|
||||
|
||||
// Set auth in localStorage
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
@@ -165,38 +187,30 @@ test.describe("Core generation flow", () => {
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 2: select material
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /选择素材/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /选择素材/ })).toBeVisible();
|
||||
const librarySelect = page.locator("select").first();
|
||||
await librarySelect.selectOption({ label: libraryName });
|
||||
const materialLabel = page.getByText(sourceFileName).locator("..");
|
||||
await expect(
|
||||
materialLabel.locator("input[type='checkbox']"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(materialLabel.locator("input[type='checkbox']")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await materialLabel.locator("input[type='checkbox']").check();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 3: title
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /选择标题/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /选择标题/ })).toBeVisible();
|
||||
const titleText = `E2E Test ${suffix}`;
|
||||
await page.getByPlaceholder("输入自定义标题…").fill(titleText);
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 4: voice
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /选择配音/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /选择配音/ })).toBeVisible();
|
||||
const firstVoiceCard = page.locator(".xx-voice-choice-item").first();
|
||||
await firstVoiceCard.click();
|
||||
await page.getByRole("button", { name: "下一步" }).click();
|
||||
|
||||
// Step 5: confirm and generate
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /确认生成/ }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: /确认生成/ })).toBeVisible();
|
||||
|
||||
// Wait for plan creation API to be called
|
||||
const createPlanPromise = page.waitForResponse(
|
||||
@@ -222,7 +236,7 @@ test.describe("Core generation flow", () => {
|
||||
|
||||
// Generation may fail in test env (no worker), that's OK
|
||||
// Just verify the flow started - check page shows generation-related UI
|
||||
const hasProgress = await page
|
||||
await page
|
||||
.getByText(/生成中|生成完成|生成失败/)
|
||||
.isVisible({ timeout: 15_000 })
|
||||
.catch(() => false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async (
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -60,6 +60,19 @@ test.describe("Title library flow", () => {
|
||||
const login = await loginWithRetry(request, email, PASSWORD);
|
||||
expect(login.status(), await login.text()).toBe(200);
|
||||
const loginData = (await login.json()) as { access_token: string };
|
||||
const headers = { Authorization: `Bearer ${loginData.access_token}` };
|
||||
|
||||
// Create a title so the titles page has at least one title card to display
|
||||
// (titles are loaded from API; new users have none by default)
|
||||
const createTitle = await request.post(`${apiBase}/titles`, {
|
||||
headers,
|
||||
data: {
|
||||
name: `E2E 测试标题 ${suffix}`,
|
||||
text: `E2E 测试标题内容 ${suffix}`,
|
||||
category: "default",
|
||||
},
|
||||
});
|
||||
expect(createTitle.status(), await createTitle.text()).toBe(201);
|
||||
|
||||
await page.addInitScript(
|
||||
({ token, user }) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "SmokePass123!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -21,7 +21,7 @@ const routeBrowserApiToTestApi = async (
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 覆盖:路由守卫、订阅降级、过期处理、订阅状态检查
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -18,7 +18,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -37,7 +37,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:创建素材库、列出素材库、创建素材记录
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -38,7 +38,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
@@ -62,7 +62,7 @@ async function createAuthedUser(request: any, label: string) {
|
||||
|
||||
/** 创建一个项目并返回 project id */
|
||||
async function createProject(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
headers: Record<string, string>,
|
||||
suffix: string,
|
||||
): Promise<string> {
|
||||
@@ -185,7 +185,7 @@ test.describe("素材库流程", () => {
|
||||
const items = data.items || [];
|
||||
expect(items.length, "应至少有 2 个素材库").toBeGreaterThanOrEqual(2);
|
||||
|
||||
const kinds = items.map((i: any) => i.kind);
|
||||
const kinds = items.map((i: { kind: string }) => i.kind);
|
||||
expect(kinds).toContain("video");
|
||||
expect(kinds).toContain("image");
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:注册(正向/反向)、登录(正向/反向)、登出、获取当前用户信息
|
||||
* 每个测试独立,使用随机邮箱避免冲突。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -18,6 +18,7 @@ function uniqueUsername(prefix: string): string {
|
||||
}
|
||||
|
||||
/** 从错误响应中提取错误消息文本,兼容新老格式 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractErrorMessage(body: any): string {
|
||||
if (!body) return "";
|
||||
// 新格式: { error: { code: "...", message: "..." } }
|
||||
@@ -30,7 +31,7 @@ function extractErrorMessage(body: any): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试(最多等 65s) */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 覆盖:创建项目、列出项目、获取项目详情
|
||||
* 每个测试独立,先注册登录获取 auth token。
|
||||
*/
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||
|
||||
const PASSWORD = "Test123456!";
|
||||
const apiBase = process.env.E2E_API_BASE || "/api/v1";
|
||||
@@ -19,7 +19,7 @@ function uniqueUsername(prefix: string): string {
|
||||
|
||||
/** 登录操作,遇到 429 限流自动等待重试 */
|
||||
async function loginWithRetry(
|
||||
request: any,
|
||||
request: APIRequestContext,
|
||||
email: string,
|
||||
password: string,
|
||||
maxRetries = 2,
|
||||
@@ -38,7 +38,7 @@ async function loginWithRetry(
|
||||
}
|
||||
|
||||
/** 注册并登录,返回 { headers, email, username, userId } */
|
||||
async function createAuthedUser(request: any, label: string) {
|
||||
async function createAuthedUser(request: APIRequestContext, label: string) {
|
||||
const email = uniqueEmail(label);
|
||||
const username = uniqueUsername(label);
|
||||
|
||||
|
||||
@@ -16,10 +16,12 @@ import Sidebar from "./Sidebar";
|
||||
import "./MainLayout.css";
|
||||
|
||||
/** 侧边栏上下文 —— 子组件可读取折叠状态 */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SidebarContext = React.createContext<SidebarContextValue>({
|
||||
collapsed: false,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* V21 Input 输入框
|
||||
* 封装 Ant Design Input,应用 V21 设计系统样式
|
||||
*/
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React from "react";
|
||||
import { Input as AntInput } from "antd";
|
||||
import type { InputProps as AntInputProps } from "antd";
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
|
||||
@@ -31,7 +31,11 @@
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--color-primary-500), var(--color-primary-600));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px
|
||||
color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
|
||||
@@ -347,6 +347,53 @@
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
/* 左侧 Tab 切换 */
|
||||
.ep-left-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.ep-left-tab {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.ep-left-tab:hover {
|
||||
color: var(--text-primary, #1e293b);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
.ep-left-tab.active {
|
||||
color: var(--primary, #6366f1);
|
||||
border-bottom-color: var(--primary, #6366f1);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
}
|
||||
|
||||
/* 素材 Tab 容器 */
|
||||
.ep-assets-tab {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.ep-assets-tab::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.ep-assets-tab::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color, #e2e8f0);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 模板列表 */
|
||||
.ep-template-list {
|
||||
flex: 1;
|
||||
@@ -504,7 +551,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-deepest, #0a0a14));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ep-bg-card-hover, #1a1a2e),
|
||||
var(--ep-bg-deepest, #0a0a14)
|
||||
);
|
||||
height: calc(100% - 20px);
|
||||
position: relative;
|
||||
}
|
||||
@@ -554,6 +605,34 @@
|
||||
color: var(--ep-text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
/* 标题/字幕实时预览叠加层 */
|
||||
.ep-preview-title,
|
||||
.ep-preview-subtitle {
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
right: 4px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
pointer-events: none;
|
||||
z-index: 5;
|
||||
word-break: break-all;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ep-preview-title {
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.ep-preview-subtitle {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 封面预览 */
|
||||
.ep-cover-preview {
|
||||
width: 150px;
|
||||
@@ -572,7 +651,11 @@
|
||||
.ep-cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--ep-bg-card-hover, #1a1a2e), var(--ep-bg-card, #13131f));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--ep-bg-card-hover, #1a1a2e),
|
||||
var(--ep-bg-card, #13131f)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -805,7 +888,9 @@
|
||||
|
||||
.ep-clip-card.selected {
|
||||
border-color: var(--primary, #6366f1);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft, #eef2ff), 0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
box-shadow:
|
||||
0 0 0 2px var(--primary-soft, #eef2ff),
|
||||
0 4px 12px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.ep-clip-card.drag-over {
|
||||
@@ -819,7 +904,11 @@
|
||||
|
||||
.ep-clip-thumbnail {
|
||||
height: 52px;
|
||||
background: linear-gradient(135deg, var(--bg-secondary, #f8fafc), var(--border-light, #f1f5f9));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--bg-secondary, #f8fafc),
|
||||
var(--border-light, #f1f5f9)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -898,13 +987,190 @@
|
||||
.ep-track-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.ep-track-empty-icon {
|
||||
font-size: 28px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ep-track-empty-text {
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ep-track-empty-add-btn {
|
||||
margin-top: 4px;
|
||||
padding: 6px 18px;
|
||||
background: var(--primary, #6366f1);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 14px);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast, 0.15s ease);
|
||||
}
|
||||
|
||||
.ep-track-empty-add-btn:hover {
|
||||
background: var(--primary-hover, #4f46e5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
/* 轨道末尾 "+" 添加卡片 — 对齐 V21 原型虚线 "+" 卡 */
|
||||
.ep-track-add-card-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-track-add-card {
|
||||
min-width: 60px;
|
||||
height: 96px;
|
||||
border: 2px dashed var(--line, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ep-track-add-card:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
color: var(--primary, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.ep-track-add-card.locked {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.ep-track-add-card.locked:hover {
|
||||
border-color: var(--line, #e2e8f0);
|
||||
color: #94a3b8;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.ep-track-add-card-wrapper > .ep-add-clip-picker {
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* 添加片段按钮 + 选择器 */
|
||||
.ep-add-clip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ep-add-clip-btn {
|
||||
padding: 4px 12px;
|
||||
background: var(--primary, #6366f1);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 14px);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast, 0.15s ease);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ep-add-clip-btn:hover {
|
||||
background: var(--primary-hover, #4f46e5);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||
}
|
||||
|
||||
.ep-add-clip-picker {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
width: 260px;
|
||||
max-height: 320px;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
border: 1px solid var(--line, #e2e8f0);
|
||||
border-radius: var(--radius-md, 18px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-title {
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #64748b);
|
||||
border-bottom: 1px solid var(--line, #e2e8f0);
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-empty {
|
||||
padding: 20px 14px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-list {
|
||||
overflow-y: auto;
|
||||
max-height: 260px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary, #0f172a);
|
||||
transition: background var(--transition-fast, 0.15s ease);
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-item:hover {
|
||||
background: var(--bg-hover, #f1f5f9);
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-icon {
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ep-add-clip-picker-type {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 一镜到底提示 */
|
||||
.ep-one-take-hint {
|
||||
padding: 6px 16px;
|
||||
@@ -1119,6 +1385,57 @@
|
||||
color: var(--primary, #6366f1);
|
||||
}
|
||||
|
||||
/* 标题预设样式按钮 — 方形 T 字预览 */
|
||||
.ep-title-presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ep-title-preset-btn {
|
||||
flex: 1;
|
||||
min-width: 44px;
|
||||
height: 48px;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
border: 2px solid var(--border-color, #e2e8f0);
|
||||
border-radius: var(--radius-xs, 8px);
|
||||
color: var(--text-primary, #1e293b);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.ep-title-preset-btn:hover {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
}
|
||||
|
||||
.ep-title-preset-btn.active {
|
||||
border-color: var(--primary, #6366f1);
|
||||
background: var(--primary-soft, #eef2ff);
|
||||
box-shadow: 0 0 0 1px var(--primary, #6366f1);
|
||||
}
|
||||
|
||||
.ep-title-preset-t {
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ep-title-preset-label {
|
||||
font-size: 10px;
|
||||
color: var(--text-tertiary, #94a3b8);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ep-title-preset-btn.active .ep-title-preset-label {
|
||||
color: var(--primary, #6366f1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* AI推荐按钮 */
|
||||
.ep-ai-recommend-btn {
|
||||
width: 100%;
|
||||
@@ -1333,7 +1650,9 @@
|
||||
stroke: var(--primary, #6366f1);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.5s ease, stroke 0.3s;
|
||||
transition:
|
||||
stroke-dashoffset 0.5s ease,
|
||||
stroke 0.3s;
|
||||
}
|
||||
|
||||
.ep-gen-progress-pct {
|
||||
@@ -1475,7 +1794,9 @@
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
transition: color 0.2s, background 0.2s;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
@@ -1490,11 +1811,15 @@
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
|
||||
/* Skeleton loading */
|
||||
@keyframes ep-skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.ep-skeleton {
|
||||
@@ -1654,8 +1979,12 @@
|
||||
}
|
||||
|
||||
@keyframes ep-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.ep-modal {
|
||||
@@ -1668,8 +1997,14 @@
|
||||
}
|
||||
|
||||
@keyframes ep-scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.ep-modal-header {
|
||||
@@ -1751,4 +2086,3 @@
|
||||
text-align: center;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,11 @@ const EditingPlanner: React.FC = () => {
|
||||
|
||||
/* ── 素材库 ── */
|
||||
const [mediaAssets, setMediaAssets] = useState<MediaAsset[]>([]);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
|
||||
const handleAssetSelect = (ids: string[]) => {
|
||||
setSelectedAssetIds(ids);
|
||||
};
|
||||
|
||||
/* ── 生成历史 ── */
|
||||
const [genHistoryOpen, setGenHistoryOpen] = useState(false);
|
||||
@@ -245,7 +250,7 @@ const EditingPlanner: React.FC = () => {
|
||||
setDraftTags(tpl.tags.join(", "));
|
||||
})
|
||||
.catch(() => msg.error("加载模板详情失败"));
|
||||
}, [loadedTemplateId]);
|
||||
}, [loadedTemplateId, resetClips]);
|
||||
|
||||
/* ──────────── 计算 ──────────── */
|
||||
|
||||
@@ -688,6 +693,9 @@ const EditingPlanner: React.FC = () => {
|
||||
onLoadTemplate={handleLoadTemplate}
|
||||
onSearchChange={setSearchQuery}
|
||||
onFilterChange={setCurrentFilter}
|
||||
mediaAssets={mediaAssets}
|
||||
onAssetSelect={handleAssetSelect}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
/>
|
||||
|
||||
{/* 中栏 flex-1 */}
|
||||
@@ -700,6 +708,8 @@ const EditingPlanner: React.FC = () => {
|
||||
currentCoverScheme={currentCoverScheme}
|
||||
coverSchemes={COVER_SCHEMES}
|
||||
aiCoverLoading={aiCoverLoading}
|
||||
titleSettings={titleSettings}
|
||||
subtitleSettings={subtitleSettings}
|
||||
onClipSelect={handleClipSelect}
|
||||
onCoverSchemeChange={setCurrentCoverScheme}
|
||||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||||
|
||||
@@ -81,6 +81,73 @@ const BGM_OPTIONS = [
|
||||
{ value: "bgm_04", label: "🎵 科技感" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 标题样式预设 — 一键应用一组样式
|
||||
* 注意:预设只绑定样式属性(粗细、描边、阴影、字号),不绑定字体家族
|
||||
*/
|
||||
const TITLE_PRESETS = [
|
||||
{
|
||||
key: "default",
|
||||
label: "默认",
|
||||
style: {
|
||||
size: 24,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "classic",
|
||||
label: "经典",
|
||||
style: {
|
||||
size: 24,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bold",
|
||||
label: "醒目",
|
||||
style: {
|
||||
size: 32,
|
||||
bold: true,
|
||||
italic: false,
|
||||
stroke: true,
|
||||
shadow: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "elegant",
|
||||
label: "柔和",
|
||||
style: {
|
||||
size: 20,
|
||||
bold: false,
|
||||
italic: false,
|
||||
stroke: false,
|
||||
shadow: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 判断当前设置匹配哪个预设(不含 font 字段) */
|
||||
function getActivePreset(settings: TitleSettings): string | null {
|
||||
for (const p of TITLE_PRESETS) {
|
||||
if (
|
||||
settings.size === p.style.size &&
|
||||
settings.bold === p.style.bold &&
|
||||
settings.italic === p.style.italic &&
|
||||
settings.stroke === p.style.stroke &&
|
||||
settings.shadow === p.style.shadow
|
||||
) {
|
||||
return p.key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
selectedClip,
|
||||
titleSettings,
|
||||
@@ -172,6 +239,46 @@ const ClipPropertiesPanel: React.FC<ClipPropertiesPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">预设样式</label>
|
||||
<div className="ep-title-presets">
|
||||
{TITLE_PRESETS.map((p) => {
|
||||
const isActive = getActivePreset(titleSettings) === p.key;
|
||||
return (
|
||||
<button
|
||||
key={p.key}
|
||||
className={`ep-title-preset-btn${isActive ? " active" : ""}`}
|
||||
onClick={() => onTitleSettingsChange({ ...p.style })}
|
||||
title={p.label}
|
||||
>
|
||||
<span
|
||||
className="ep-title-preset-t"
|
||||
style={{
|
||||
fontWeight: p.style.bold ? 700 : 400,
|
||||
fontStyle: p.style.italic ? "italic" : "normal",
|
||||
WebkitTextStroke: p.style.stroke
|
||||
? "1px currentColor"
|
||||
: undefined,
|
||||
textShadow: p.style.shadow
|
||||
? "2px 2px 4px rgba(0,0,0,0.5)"
|
||||
: undefined,
|
||||
fontSize:
|
||||
p.style.size >= 32
|
||||
? "22px"
|
||||
: p.style.size >= 24
|
||||
? "18px"
|
||||
: "16px",
|
||||
}}
|
||||
>
|
||||
T
|
||||
</span>
|
||||
<span className="ep-title-preset-label">{p.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ep-field">
|
||||
<label className="ep-field-label">样式</label>
|
||||
<div className="ep-style-btns">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 左侧模板面板 — V8 原型 1:1 还原
|
||||
* 纯模板列表 + chip 分类筛选(无素材 Tab)
|
||||
* 左侧面板 — V8 原型 1:1 还原
|
||||
* Tab 切换:模板列表 + 素材库
|
||||
*/
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import type { EditingTemplate } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS } from "@/api/editingPlanner";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
import AssetSelector from "@/components/AssetSelector/AssetSelector";
|
||||
|
||||
interface MediaPanelProps {
|
||||
templates: EditingTemplate[];
|
||||
@@ -16,6 +18,10 @@ interface MediaPanelProps {
|
||||
onLoadTemplate: (id: string) => void;
|
||||
onSearchChange: (q: string) => void;
|
||||
onFilterChange: (f: string) => void;
|
||||
// 素材相关
|
||||
mediaAssets?: MediaAsset[];
|
||||
onAssetSelect?: (ids: string[]) => void;
|
||||
selectedAssetIds?: string[];
|
||||
}
|
||||
|
||||
const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
@@ -28,80 +34,117 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
|
||||
onLoadTemplate,
|
||||
onSearchChange,
|
||||
onFilterChange,
|
||||
mediaAssets = [],
|
||||
onAssetSelect,
|
||||
selectedAssetIds = [],
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<"templates" | "assets">(
|
||||
"templates",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="ep-left-panel">
|
||||
{/* 标题栏 */}
|
||||
<div className="ep-left-header">
|
||||
<span className="ep-left-title">📋 已保存模板</span>
|
||||
{/* Tab 切换 */}
|
||||
<div className="ep-left-tabs">
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "templates" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("templates")}
|
||||
>
|
||||
📋 模板
|
||||
</button>
|
||||
<button
|
||||
className={`ep-left-tab ${activeTab === "assets" ? "active" : ""}`}
|
||||
onClick={() => setActiveTab("assets")}
|
||||
>
|
||||
📁 素材
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
{/* 模板 Tab */}
|
||||
{activeTab === "templates" && (
|
||||
<>
|
||||
{/* 搜索 */}
|
||||
<div className="ep-search-wrap ep-media-panel-inner">
|
||||
<span className="ep-search-icon">🔍</span>
|
||||
<input
|
||||
className="ep-search-input"
|
||||
placeholder="搜索模板..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
|
||||
{/* Chip 分类筛选 */}
|
||||
<div className="ep-filter-chips">
|
||||
{filterCategories.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
className={`ep-filter-chip ${currentFilter === cat ? "active" : ""}`}
|
||||
onClick={() => onFilterChange(cat)}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* 模板列表 */}
|
||||
<div className="ep-template-list">
|
||||
{loading ? (
|
||||
<div className="ep-loading">
|
||||
<span>⏳</span>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="ep-empty">
|
||||
<span>📭</span>
|
||||
<span>暂无模板</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
) : (
|
||||
templates.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`ep-template-card ${loadedTemplateId === tpl.id ? "active" : ""}`}
|
||||
onClick={() => onLoadTemplate(tpl.id)}
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
<div className="ep-template-card-meta">
|
||||
<span>⏱️ {tpl.estimated_duration}s</span>
|
||||
<span>📐 {tpl.segments.length}片段</span>
|
||||
</div>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div className="ep-template-card-tags">
|
||||
{tpl.tags.map((tag) => (
|
||||
<span key={tag} className="ep-template-tag">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 素材 Tab */}
|
||||
{activeTab === "assets" && (
|
||||
<div className="ep-assets-tab">
|
||||
<AssetSelector
|
||||
assets={mediaAssets}
|
||||
selectedIds={selectedAssetIds}
|
||||
onSelectionChange={onAssetSelect}
|
||||
showQualityFilter={false}
|
||||
showBatchSelect={false}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,26 @@ interface CoverScheme {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TitleSettings {
|
||||
aiAutoSelect: boolean;
|
||||
title: string;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
stroke: boolean;
|
||||
shadow: boolean;
|
||||
}
|
||||
|
||||
interface SubtitleSettings {
|
||||
enabled: boolean;
|
||||
position: string;
|
||||
font: string;
|
||||
size: number;
|
||||
animation: string;
|
||||
}
|
||||
|
||||
interface PreviewPlayerProps {
|
||||
clips: ClipData[];
|
||||
selectedClipId: string | null;
|
||||
@@ -26,6 +46,8 @@ interface PreviewPlayerProps {
|
||||
currentCoverScheme: string;
|
||||
coverSchemes: CoverScheme[];
|
||||
aiCoverLoading: boolean;
|
||||
titleSettings?: TitleSettings;
|
||||
subtitleSettings?: SubtitleSettings;
|
||||
onClipSelect: (clipId: string) => void;
|
||||
onCoverSchemeChange: (scheme: string) => void;
|
||||
onPlayPause: () => void;
|
||||
@@ -46,6 +68,8 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
currentCoverScheme,
|
||||
coverSchemes,
|
||||
aiCoverLoading,
|
||||
titleSettings,
|
||||
subtitleSettings,
|
||||
onCoverSchemeChange,
|
||||
onPlayPause,
|
||||
onAiGenerateCover,
|
||||
@@ -78,6 +102,64 @@ const PreviewPlayer: React.FC<PreviewPlayerProps> = ({
|
||||
) : (
|
||||
<span className="ep-phone-empty-hint">暂无片段</span>
|
||||
)}
|
||||
|
||||
{/* 标题实时预览 */}
|
||||
{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 && (
|
||||
<div
|
||||
className="ep-preview-subtitle"
|
||||
style={{
|
||||
fontSize: `${Math.min(subtitleSettings.size, 14)}px`,
|
||||
fontFamily: subtitleSettings.font,
|
||||
top:
|
||||
subtitleSettings.position === "top"
|
||||
? "8px"
|
||||
: subtitleSettings.position === "center"
|
||||
? "50%"
|
||||
: "auto",
|
||||
bottom: subtitleSettings.position === "bottom" ? "8px" : "auto",
|
||||
transform:
|
||||
subtitleSettings.position === "center"
|
||||
? "translateY(-50%)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
字幕预览文字
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 水平轨道时间线 — V8 原型 1:1 还原
|
||||
* 时间标尺(20px) + 水平片段卡片轨道(100x100) + HTML5拖拽排序
|
||||
* 支持「添加片段」按钮直接选择素材添加,不依赖拖拽
|
||||
*/
|
||||
import React, { useState, useRef } from "react";
|
||||
import type { MediaAsset } from "@/api/editPlans";
|
||||
@@ -30,6 +31,12 @@ interface TimelinePanelProps {
|
||||
mediaAssets: MediaAsset[];
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
audio: "🎵",
|
||||
};
|
||||
|
||||
const MATERIAL_ICONS: Record<string, string> = {
|
||||
video: "🎬",
|
||||
image: "🖼️",
|
||||
@@ -46,10 +53,32 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onClipRemove,
|
||||
onAssetDropToTimeline,
|
||||
onAssetDropToClip,
|
||||
mediaAssets,
|
||||
}) => {
|
||||
const [dragIdx, setDragIdx] = useState<number | null>(null);
|
||||
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
|
||||
const dragRef = useRef<number | null>(null);
|
||||
const [showAddPicker, setShowAddPicker] = useState(false);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 点击外部关闭素材选择器 ── */
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setShowAddPicker(false);
|
||||
}
|
||||
};
|
||||
if (showAddPicker) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddPicker]);
|
||||
|
||||
/* ── 选择素材添加片段 ── */
|
||||
const handlePickAsset = (asset: MediaAsset) => {
|
||||
onAssetDropToTimeline(asset);
|
||||
setShowAddPicker(false);
|
||||
};
|
||||
|
||||
/* ── 片段拖拽排序 ── */
|
||||
const handleDragStart = (e: React.DragEvent, idx: number) => {
|
||||
@@ -193,7 +222,12 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
onDragOver={handleEmptyDragOver}
|
||||
>
|
||||
{clips.length === 0 ? (
|
||||
<div className="ep-track-empty">🎬 拖入素材或从模板加载片段</div>
|
||||
<div className="ep-track-empty">
|
||||
<div className="ep-track-empty-icon">🎬</div>
|
||||
<div className="ep-track-empty-text">
|
||||
点击右侧 + 添加片段,或拖入素材
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
clips.map((clip, idx) => (
|
||||
<div
|
||||
@@ -230,6 +264,57 @@ const TimelinePanel: React.FC<TimelinePanelProps> = ({
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* ── 轨道末尾 "+" 添加卡片(对齐 V21 原型 epAddSegment) ── */}
|
||||
<div className="ep-track-add-card-wrapper" ref={pickerRef}>
|
||||
<div
|
||||
className={`ep-track-add-card${currentMode === "one_take" ? " locked" : ""}`}
|
||||
onClick={() => {
|
||||
if (currentMode !== "one_take") setShowAddPicker((v) => !v);
|
||||
}}
|
||||
title={
|
||||
currentMode === "one_take" ? "一镜到底模式下不可添加" : "添加片段"
|
||||
}
|
||||
>
|
||||
{currentMode === "one_take" ? "🔒" : "+"}
|
||||
</div>
|
||||
|
||||
{/* 素材选择面板 */}
|
||||
{showAddPicker && (
|
||||
<div className="ep-add-clip-picker">
|
||||
<div className="ep-add-clip-picker-title">选择素材添加为片段</div>
|
||||
{mediaAssets.length === 0 ? (
|
||||
<div className="ep-add-clip-picker-empty">
|
||||
暂无可用素材,请先在左侧素材库导入
|
||||
</div>
|
||||
) : (
|
||||
<div className="ep-add-clip-picker-list">
|
||||
{mediaAssets.map((asset) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
className="ep-add-clip-picker-item"
|
||||
onClick={() => handlePickAsset(asset)}
|
||||
>
|
||||
<span className="ep-add-clip-picker-icon">
|
||||
{ASSET_TYPE_ICONS[asset.type] || "📁"}
|
||||
</span>
|
||||
<span className="ep-add-clip-picker-name">
|
||||
{asset.name}
|
||||
</span>
|
||||
<span className="ep-add-clip-picker-type">
|
||||
{asset.type === "image"
|
||||
? "图片"
|
||||
: asset.type === "audio"
|
||||
? "音频"
|
||||
: "视频"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
import type { AssetItem } from "@/api/assets";
|
||||
import { getAssets, getAssetLibraries } from "@/api/assets";
|
||||
import { createEditPlan, generateEditPlan } from "@/api/editPlans";
|
||||
import { getEditingTemplates } from "@/api/editingPlanner";
|
||||
import { MODE_LABELS, type TemplateMode } from "@/api/editingPlanner";
|
||||
import { getTitles } from "@/api/titles";
|
||||
import apiClient from "@/api/client";
|
||||
import { fetchPresetVoices } from "@/api/voices";
|
||||
import type { PresetVoiceItem } from "@/api/voices";
|
||||
@@ -40,42 +43,19 @@ const CLONE_STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
failed: { label: "失败", color: "var(--error-color, #ef4444)" },
|
||||
};
|
||||
|
||||
/* ── 模板定义 ── */
|
||||
interface TemplateOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
thumb: string;
|
||||
gradient: string;
|
||||
abbr: string;
|
||||
}
|
||||
|
||||
const TEMPLATE_OPTIONS: TemplateOption[] = [
|
||||
{
|
||||
id: "pip",
|
||||
label: "画中画混剪",
|
||||
desc: "产品展示 · 30秒",
|
||||
thumb: "PIP",
|
||||
gradient: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
||||
abbr: "PIP",
|
||||
},
|
||||
{
|
||||
id: "one-shot",
|
||||
label: "一镜到底",
|
||||
desc: "口播 · 45秒",
|
||||
thumb: "ONE",
|
||||
gradient: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
abbr: "ONE",
|
||||
},
|
||||
{
|
||||
id: "person-talking",
|
||||
label: "人物口播",
|
||||
desc: "知识分享 · 60秒",
|
||||
thumb: "PER",
|
||||
gradient: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
abbr: "PER",
|
||||
},
|
||||
];
|
||||
/* ── 模板渐变色映射(根据 mode 分配视觉样式) ── */
|
||||
const MODE_GRADIENTS: Record<string, string> = {
|
||||
pip: "linear-gradient(135deg, #fbbf24, #f59e0b)",
|
||||
one_take: "linear-gradient(135deg, #3b82f6, #1d4ed8)",
|
||||
voice_over: "linear-gradient(135deg, #6366f1, #4f46e5)",
|
||||
voice_pip: "linear-gradient(135deg, #10b981, #059669)",
|
||||
};
|
||||
const MODE_ABBRS: Record<string, string> = {
|
||||
pip: "PIP",
|
||||
one_take: "ONE",
|
||||
voice_over: "VOI",
|
||||
voice_pip: "VP",
|
||||
};
|
||||
|
||||
/* ── 配音预设卡片 ── */
|
||||
interface VoicePresetCard {
|
||||
@@ -116,14 +96,7 @@ const STEPS = [
|
||||
{ key: 5, label: "确认生成" },
|
||||
];
|
||||
|
||||
/* ── 标题选项 Mock ── */
|
||||
const TITLE_OPTIONS = [
|
||||
"3秒抓住注意力,30秒讲清卖点",
|
||||
"产品种草 | 一分钟了解核心优势",
|
||||
"客户真实反馈,看完你就懂了",
|
||||
"行业干货分享,建议收藏",
|
||||
"新品首发,限时优惠不容错过",
|
||||
];
|
||||
/* ── 标题选项从 API 加载,不再硬编码 ── */
|
||||
|
||||
/* ================================================================
|
||||
组件
|
||||
@@ -133,14 +106,37 @@ const GeneratePage: React.FC = () => {
|
||||
/* ── 步骤状态 ── */
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
/* ── 模板 ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("pip");
|
||||
/* ── 模板(从 API 加载用户自制模板) ── */
|
||||
const [selectedTemplate, setSelectedTemplate] = useState("");
|
||||
const { data: userTemplates = [] } = useQuery({
|
||||
queryKey: ["generate-templates"],
|
||||
queryFn: () => getEditingTemplates(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
/* 模板加载完成后自动选中第一个 */
|
||||
useEffect(() => {
|
||||
if (userTemplates.length > 0 && !selectedTemplate) {
|
||||
setSelectedTemplate(userTemplates[0].id);
|
||||
}
|
||||
}, [userTemplates, selectedTemplate]);
|
||||
|
||||
/* ── 素材 ── */
|
||||
const [selectedMaterials, setSelectedMaterials] = useState<string[]>([]);
|
||||
|
||||
/* ── 标题 ── */
|
||||
const [title, setTitle] = useState("");
|
||||
const { data: userTitles = [] } = useQuery({
|
||||
queryKey: ["generate-titles"],
|
||||
queryFn: () => getTitles(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
/* 当选中模板开启了「AI自动匹配标题」,自动填入模板预设标题 */
|
||||
useEffect(() => {
|
||||
const tpl = userTemplates.find((t) => t.id === selectedTemplate);
|
||||
if (tpl?.title_config?.ai_auto_select && tpl.title_config.content) {
|
||||
setTitle(tpl.title_config.content);
|
||||
}
|
||||
}, [selectedTemplate, userTemplates]);
|
||||
|
||||
/* ── 配音 ── */
|
||||
const [selectedVoice, setSelectedVoice] = useState<string>("");
|
||||
@@ -412,43 +408,74 @@ const GeneratePage: React.FC = () => {
|
||||
await generateEditPlan(plan.id);
|
||||
|
||||
const poll = async () => {
|
||||
const status = await apiClient.get(
|
||||
`/edit-plans/${plan.id}/generation-status`,
|
||||
);
|
||||
const data = status.data;
|
||||
try {
|
||||
const status = await apiClient.get(
|
||||
`/edit-plans/${plan.id}/generation-status`,
|
||||
);
|
||||
const data = status.data;
|
||||
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
if (data.plan_status === "completed") {
|
||||
setProgress(100);
|
||||
setGenerating(false);
|
||||
setGenerated(true);
|
||||
message.success("视频生成完成!");
|
||||
return;
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false);
|
||||
// 提取后端返回的错误详情,便于排查
|
||||
const errorMsg =
|
||||
data.error_message ||
|
||||
data.error ||
|
||||
data.message ||
|
||||
(data.clips || []).find(
|
||||
(c: { status: string }) => c.status === "failed",
|
||||
)?.error_message ||
|
||||
"视频生成失败,请联系管理员或重试";
|
||||
console.error("[生成失败] planId:", plan.id, "响应:", data);
|
||||
message.error(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
const clips = data.clips || [];
|
||||
const total = clips.length || 1;
|
||||
const done = clips.filter(
|
||||
(c: { status: string }) => c.status === "completed",
|
||||
).length;
|
||||
setProgress(Math.round((done / total) * 100));
|
||||
|
||||
progressTimer.current = setTimeout(
|
||||
poll,
|
||||
2000,
|
||||
) as unknown as ReturnType<typeof setInterval>;
|
||||
} catch (pollErr) {
|
||||
// 轮询接口本身出错(网络/鉴权等),记录并继续轮询一次
|
||||
console.error("[轮询出错] planId:", plan.id, pollErr);
|
||||
progressTimer.current = setTimeout(
|
||||
poll,
|
||||
3000,
|
||||
) as unknown as ReturnType<typeof setInterval>;
|
||||
}
|
||||
if (data.plan_status === "failed") {
|
||||
setGenerating(false);
|
||||
message.error("视频生成失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const clips = data.clips || [];
|
||||
const total = clips.length || 1;
|
||||
const done = clips.filter(
|
||||
(c: { status: string }) => c.status === "completed",
|
||||
).length;
|
||||
setProgress(Math.round((done / total) * 100));
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>;
|
||||
};
|
||||
|
||||
progressTimer.current = setTimeout(poll, 2000) as unknown as ReturnType<
|
||||
typeof setInterval
|
||||
>;
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error("生成失败:", err);
|
||||
setGenerating(false);
|
||||
message.error("生成失败,请重试");
|
||||
// 提取 axios 响应中的后端错误信息
|
||||
const axiosErr = err as {
|
||||
response?: {
|
||||
data?: { message?: string; error?: string; detail?: string };
|
||||
};
|
||||
};
|
||||
const backendMsg =
|
||||
axiosErr.response?.data?.message ||
|
||||
axiosErr.response?.data?.error ||
|
||||
axiosErr.response?.data?.detail ||
|
||||
"";
|
||||
message.error(backendMsg || "生成失败,请重试");
|
||||
}
|
||||
}, [
|
||||
title,
|
||||
@@ -494,7 +521,7 @@ const GeneratePage: React.FC = () => {
|
||||
|
||||
/* ── 辅助 ── */
|
||||
const getTemplateName = () =>
|
||||
TEMPLATE_OPTIONS.find((t) => t.id === selectedTemplate)?.label ?? "未选择";
|
||||
userTemplates.find((t) => t.id === selectedTemplate)?.name ?? "未选择";
|
||||
|
||||
const getVoiceName = () => {
|
||||
if (voiceMode === "clone") {
|
||||
@@ -513,34 +540,73 @@ const GeneratePage: React.FC = () => {
|
||||
const renderStep1 = () => (
|
||||
<div className="xx-form-section">
|
||||
<h3>🎨 选择模板</h3>
|
||||
<div className="xx-choice-list">
|
||||
{TEMPLATE_OPTIONS.map((tpl) => (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedTemplate(tpl.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTemplate === tpl.id}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedTemplate(tpl.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="xx-choice-check">✓</span>
|
||||
{userTemplates.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<p>暂无可用模板</p>
|
||||
<p style={{ fontSize: 13, color: "var(--text-tertiary)" }}>
|
||||
请先在「剪辑计划编辑器」中创建模板
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-choice-list">
|
||||
{userTemplates.map((tpl) => (
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{ background: tpl.gradient }}
|
||||
key={tpl.id}
|
||||
className={`xx-choice-item ${selectedTemplate === tpl.id ? "selected" : ""}`}
|
||||
onClick={() => setSelectedTemplate(tpl.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selectedTemplate === tpl.id}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setSelectedTemplate(tpl.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tpl.abbr}
|
||||
<span className="xx-choice-check">✓</span>
|
||||
<div
|
||||
className="xx-choice-thumb"
|
||||
style={{
|
||||
background: MODE_GRADIENTS[tpl.mode] || MODE_GRADIENTS.pip,
|
||||
}}
|
||||
>
|
||||
{MODE_ABBRS[tpl.mode] || "TPL"}
|
||||
</div>
|
||||
<h4>{tpl.name}</h4>
|
||||
<p>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode} ·{" "}
|
||||
{tpl.estimated_duration}s · {tpl.segments.length}片段
|
||||
</p>
|
||||
{tpl.tags.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{tpl.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
borderRadius: 6,
|
||||
background: "var(--bg-secondary)",
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h4>{tpl.label}</h4>
|
||||
<p>{tpl.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -653,12 +719,12 @@ const GeneratePage: React.FC = () => {
|
||||
<div className="xx-form-section">
|
||||
<h3>📝 选择标题</h3>
|
||||
<div className="xx-form-field">
|
||||
<label>选择标题</label>
|
||||
<label>从标题库选择</label>
|
||||
<select value={title} onChange={(e) => setTitle(e.target.value)}>
|
||||
<option value="">请选择标题…</option>
|
||||
{TITLE_OPTIONS.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
{userTitles.map((t) => (
|
||||
<option key={t.id} value={t.content}>
|
||||
{t.content}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -672,6 +738,13 @@ const GeneratePage: React.FC = () => {
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
{userTitles.length === 0 && (
|
||||
<p
|
||||
style={{ fontSize: 12, color: "var(--text-tertiary)", marginTop: 8 }}
|
||||
>
|
||||
标题库为空,请前往「标题管理」添加标题,或手动输入
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -408,24 +408,41 @@
|
||||
}
|
||||
|
||||
.xx-clone-avatar.ready {
|
||||
background: linear-gradient(135deg, var(--color-secondary-500), var(--color-secondary-600));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-secondary-500),
|
||||
var(--color-secondary-600)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.xx-clone-avatar.processing {
|
||||
background: linear-gradient(135deg, var(--color-accent-500), var(--color-accent-600));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-accent-500),
|
||||
var(--color-accent-600)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
animation: xx-clone-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.xx-clone-avatar.failed {
|
||||
background: linear-gradient(135deg, var(--color-gray-400), var(--color-gray-500));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-gray-400),
|
||||
var(--color-gray-500)
|
||||
);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
@keyframes xx-clone-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-info {
|
||||
@@ -465,7 +482,11 @@
|
||||
|
||||
.xx-clone-progress-bar {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--warning-color), var(--success-color));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--warning-color),
|
||||
var(--success-color)
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
@@ -474,8 +495,10 @@
|
||||
width: 100%;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--warning-color) 0%, var(--warning-color) 25%,
|
||||
var(--success-color) 25%, var(--success-color) 50%,
|
||||
var(--warning-color) 0%,
|
||||
var(--warning-color) 25%,
|
||||
var(--success-color) 25%,
|
||||
var(--success-color) 50%,
|
||||
var(--warning-color) 50%
|
||||
);
|
||||
background-size: 60px 100%;
|
||||
@@ -483,8 +506,12 @@
|
||||
}
|
||||
|
||||
@keyframes xx-clone-progress-flow {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 60px 0; }
|
||||
from {
|
||||
background-position: 0 0;
|
||||
}
|
||||
to {
|
||||
background-position: 60px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.xx-clone-progress-text {
|
||||
@@ -518,13 +545,23 @@
|
||||
}
|
||||
|
||||
@keyframes xx-fade {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes xx-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -640,7 +677,11 @@
|
||||
aspect-ratio: 9 / 16;
|
||||
max-height: 400px;
|
||||
border-radius: var(--radius-md);
|
||||
background: linear-gradient(135deg, var(--color-gray-900), var(--color-primary-900));
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--color-gray-900),
|
||||
var(--color-primary-900)
|
||||
);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-inverse);
|
||||
@@ -654,7 +695,11 @@
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at 72% 28%, rgba(255, 255, 255, 0.2), transparent 40%);
|
||||
background: radial-gradient(
|
||||
circle at 72% 28%,
|
||||
rgba(255, 255, 255, 0.2),
|
||||
transparent 40%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -777,7 +822,11 @@
|
||||
|
||||
.xx-progress-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary-500), var(--color-primary-600));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--color-primary-500),
|
||||
var(--color-primary-600)
|
||||
);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ const MyTemplates: React.FC = () => {
|
||||
>
|
||||
{MODE_LABELS[tpl.mode as TemplateMode] || tpl.mode}
|
||||
</Tag>
|
||||
<Tag color="green">用户自制</Tag>
|
||||
</div>
|
||||
|
||||
<div className="mt-card-meta">
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ClockCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Modal, Input, Tooltip } from "@/components/ui";
|
||||
import type { ButtonProps } from "antd";
|
||||
import PageHead from "@/components/layout/PageHead";
|
||||
import { useCloneProgress } from "@/hooks/useCloneProgress";
|
||||
import {
|
||||
@@ -378,7 +379,7 @@ const MyVoices: React.FC = () => {
|
||||
onOk={handleDeleteConfirm}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true } as any}
|
||||
okButtonProps={{ danger: true } as ButtonProps}
|
||||
>
|
||||
<p>确定要删除这个克隆音色吗?删除后无法恢复。</p>
|
||||
</Modal>
|
||||
|
||||
@@ -112,19 +112,11 @@
|
||||
}
|
||||
|
||||
.xx-mv-card--processing {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--accent-color) 30%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--accent-color) 30%, transparent);
|
||||
}
|
||||
|
||||
.xx-mv-card--failed {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--error-color) 25%,
|
||||
transparent
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--error-color) 25%, transparent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* 成品库 — 产品详情页
|
||||
* 路由:/app/products/:id
|
||||
* 展示视频播放器 + 完整元数据 + 下载/分享/删除操作
|
||||
*/
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DownloadOutlined,
|
||||
ShareAltOutlined,
|
||||
DeleteOutlined,
|
||||
PlayCircleFilled,
|
||||
PauseCircleFilled,
|
||||
SoundOutlined,
|
||||
MutedOutlined,
|
||||
ExpandOutlined,
|
||||
LoadingOutlined,
|
||||
WarningOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
getProduct,
|
||||
deleteProduct,
|
||||
getProductDownloadUrl,
|
||||
type ProductItem,
|
||||
} from "../../api/products";
|
||||
import { Button } from "../../components/ui";
|
||||
import "./products.css";
|
||||
|
||||
/* ============================================================
|
||||
* 工具函数
|
||||
* ============================================================ */
|
||||
|
||||
/** 格式化时长(秒 → "MM:SS") */
|
||||
const formatDuration = (seconds: number): string => {
|
||||
if (!seconds || seconds <= 0) return "00:00";
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
/** 格式化文件大小(MB) */
|
||||
const formatFileSize = (mb: number): string => {
|
||||
if (!mb || mb <= 0) return "-";
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
return `${(mb / 1024).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
/** 格式化日期 */
|
||||
const formatDate = (dateStr: string): string => {
|
||||
if (!dateStr) return "-";
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
/** 状态标签 */
|
||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||
completed: { label: "已完成", color: "#10b981" },
|
||||
processing: { label: "处理中", color: "#6366f1" },
|
||||
pending: { label: "待处理", color: "#f59e0b" },
|
||||
failed: { label: "失败", color: "#ef4444" },
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
* 主组件
|
||||
* ============================================================ */
|
||||
const ProductDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/* ── 获取产品详情 ── */
|
||||
const {
|
||||
data: product,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery<ProductItem, Error>({
|
||||
queryKey: ["product", id],
|
||||
queryFn: () => getProduct(id!),
|
||||
enabled: !!id,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
/* ── 删除 mutation ── */
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteProduct(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
navigate("/app/products");
|
||||
},
|
||||
});
|
||||
|
||||
/* ── 视频播放器状态 ── */
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [buffered, setBuffered] = useState(0);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
/* ── 自动隐藏控制条 ── */
|
||||
const resetHideTimer = useCallback(() => {
|
||||
setShowControls(true);
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
if (isPlaying) {
|
||||
hideTimerRef.current = setTimeout(() => setShowControls(false), 3000);
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
/* ── 播放控制 ── */
|
||||
const togglePlay = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (v.paused) {
|
||||
v.play().catch(() => {});
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const v = videoRef.current;
|
||||
const bar = progressRef.current;
|
||||
if (!v || !bar || !duration) return;
|
||||
const rect = bar.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
v.currentTime = ratio * duration;
|
||||
},
|
||||
[duration],
|
||||
);
|
||||
|
||||
const handleVolumeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = videoRef.current;
|
||||
const val = parseFloat(e.target.value);
|
||||
if (v) v.volume = val;
|
||||
setVolume(val);
|
||||
setIsMuted(val === 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (isMuted) {
|
||||
v.muted = false;
|
||||
v.volume = volume || 1;
|
||||
setIsMuted(false);
|
||||
} else {
|
||||
v.muted = true;
|
||||
setIsMuted(true);
|
||||
}
|
||||
}, [isMuted, volume]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
if (!document.fullscreenElement) {
|
||||
el.requestFullscreen?.().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen?.().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── 视频事件监听 ── */
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onTimeUpdate = () => setCurrentTime(v.currentTime);
|
||||
const onLoadedMetadata = () => setDuration(v.duration);
|
||||
const onProgress = () => {
|
||||
if (v.buffered.length > 0) {
|
||||
setBuffered(v.buffered.end(v.buffered.length - 1));
|
||||
}
|
||||
};
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
const onFSChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
|
||||
v.addEventListener("play", onPlay);
|
||||
v.addEventListener("pause", onPause);
|
||||
v.addEventListener("timeupdate", onTimeUpdate);
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata);
|
||||
v.addEventListener("progress", onProgress);
|
||||
v.addEventListener("ended", onEnded);
|
||||
document.addEventListener("fullscreenchange", onFSChange);
|
||||
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("timeupdate", onTimeUpdate);
|
||||
v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
v.removeEventListener("progress", onProgress);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
document.removeEventListener("fullscreenchange", onFSChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
/* 播放时自动隐藏/显示控制条 */
|
||||
useEffect(() => {
|
||||
resetHideTimer();
|
||||
return () => {
|
||||
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
|
||||
};
|
||||
}, [isPlaying, resetHideTimer]);
|
||||
|
||||
/* ── 下载 ── */
|
||||
const handleDownload = async () => {
|
||||
if (!product || product.status !== "completed") return;
|
||||
try {
|
||||
const { url } = await getProductDownloadUrl(product.id);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "";
|
||||
a.click();
|
||||
} catch {
|
||||
// message.error handled by caller
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 分享 ── */
|
||||
const handleShare = () => {
|
||||
if (!product) return;
|
||||
const link = `${window.location.origin}/app/products/${product.id}`;
|
||||
navigator.clipboard?.writeText(link).then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 删除 ── */
|
||||
const handleDelete = () => {
|
||||
if (!id) return;
|
||||
deleteMutation.mutate();
|
||||
};
|
||||
|
||||
/* ── 加载状态 ── */
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-loading">
|
||||
<LoadingOutlined
|
||||
style={{ fontSize: 32, color: "var(--primary-color)" }}
|
||||
spin
|
||||
/>
|
||||
<p style={{ color: "var(--text-secondary)", marginTop: 16 }}>
|
||||
加载中…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 错误状态 ── */
|
||||
if (isError || !product) {
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
<div className="xx-detail-error">
|
||||
<WarningOutlined
|
||||
style={{ fontSize: 48, color: "var(--error-color)" }}
|
||||
/>
|
||||
<h3>加载失败</h3>
|
||||
<p>{error?.message || "无法获取产品信息"}</p>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="md"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = STATUS_MAP[product.status] || {
|
||||
label: product.status,
|
||||
color: "#94a3b8",
|
||||
};
|
||||
const progress = Math.round((currentTime / (duration || 1)) * 100);
|
||||
const bufferedPct = Math.round((buffered / (duration || 1)) * 100);
|
||||
|
||||
return (
|
||||
<div className="xx-page xx-product-detail-page">
|
||||
{/* ── 顶部导航 ── */}
|
||||
<div className="xx-detail-header">
|
||||
<button
|
||||
className="xx-detail-back-btn"
|
||||
onClick={() => navigate("/app/products")}
|
||||
>
|
||||
<ArrowLeftOutlined /> 返回成品库
|
||||
</button>
|
||||
<div className="xx-detail-actions">
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={handleDownload}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<ShareAltOutlined />}
|
||||
onClick={handleShare}
|
||||
disabled={product.status !== "completed"}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="danger"
|
||||
buttonSize="sm"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={handleDelete}
|
||||
loading={deleteMutation.isPending}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 主体内容 ── */}
|
||||
<div className="xx-detail-body">
|
||||
{/* 视频播放器 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`xx-detail-player ${isFullscreen ? "is-fullscreen" : ""}`}
|
||||
onMouseMove={resetHideTimer}
|
||||
onClick={togglePlay}
|
||||
>
|
||||
{product.video_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={product.video_url}
|
||||
poster={product.thumbnail_url || undefined}
|
||||
playsInline
|
||||
preload="metadata"
|
||||
/>
|
||||
) : (
|
||||
<div className="xx-detail-player-empty">
|
||||
{product.thumbnail_url ? (
|
||||
<img src={product.thumbnail_url} alt={product.title} />
|
||||
) : (
|
||||
<div className="xx-detail-player-placeholder">
|
||||
<PlayCircleFilled
|
||||
style={{ fontSize: 64, color: "var(--text-tertiary)" }}
|
||||
/>
|
||||
<p>视频暂不可用</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 控制条 */}
|
||||
<div
|
||||
className={`xx-detail-player-controls ${showControls || !isPlaying ? "visible" : ""}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
className="xx-dp-progress"
|
||||
ref={progressRef}
|
||||
onClick={handleSeek}
|
||||
>
|
||||
<div className="xx-dp-progress-track">
|
||||
<div
|
||||
className="xx-dp-progress-buffered"
|
||||
style={{ width: `${bufferedPct}%` }}
|
||||
/>
|
||||
<div
|
||||
className="xx-dp-progress-played"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-dp-bar">
|
||||
{/* 左:播放/暂停 */}
|
||||
<button className="xx-dp-btn" onClick={togglePlay}>
|
||||
{isPlaying ? <PauseCircleFilled /> : <PlayCircleFilled />}
|
||||
</button>
|
||||
|
||||
{/* 时间 */}
|
||||
<span className="xx-dp-time">
|
||||
{formatDuration(currentTime)} / {formatDuration(duration)}
|
||||
</span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* 音量 */}
|
||||
<div className="xx-dp-volume">
|
||||
<button className="xx-dp-btn" onClick={toggleMute}>
|
||||
{isMuted ? <MutedOutlined /> : <SoundOutlined />}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={isMuted ? 0 : volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="xx-dp-volume-slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 全屏 */}
|
||||
<button className="xx-dp-btn" onClick={toggleFullscreen}>
|
||||
<ExpandOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 产品信息 */}
|
||||
<div className="xx-detail-info">
|
||||
<h1 className="xx-detail-title">{product.title}</h1>
|
||||
|
||||
<div className="xx-detail-meta-grid">
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">状态</span>
|
||||
<span
|
||||
className="xx-detail-meta-value"
|
||||
style={{ color: statusInfo.color }}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">时长</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDuration(product.duration_seconds ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">分辨率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{product.resolution || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">文件大小</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatFileSize(product.file_size ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">查重率</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{(product.duplicate_rate ?? 0) > 0
|
||||
? `${(product.duplicate_rate ?? 0).toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="xx-detail-meta-item">
|
||||
<span className="xx-detail-meta-label">创建时间</span>
|
||||
<span className="xx-detail-meta-value">
|
||||
{formatDate(product.created_at ?? "")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
@@ -11,6 +11,7 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { message, Popconfirm } from "antd";
|
||||
import {
|
||||
SearchOutlined,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
CloseOutlined,
|
||||
CheckOutlined,
|
||||
CloudUploadOutlined,
|
||||
EyeOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@/components/ui";
|
||||
import {
|
||||
@@ -311,7 +313,8 @@ const VideoPlayer: React.FC<{
|
||||
onClose: () => void;
|
||||
onDownload: (product: ProductItem) => void;
|
||||
onShare: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare }) => {
|
||||
onViewDetail: (product: ProductItem) => void;
|
||||
}> = ({ product, onClose, onDownload, onShare, onViewDetail }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -480,6 +483,14 @@ const VideoPlayer: React.FC<{
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
buttonSize="sm"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => onViewDetail(product)}
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button buttonType="primary" buttonSize="sm" onClick={onClose}>
|
||||
关闭
|
||||
@@ -495,6 +506,7 @@ const VideoPlayer: React.FC<{
|
||||
* ============================================================ */
|
||||
const ProductLibrary: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* ── 获取成品列表 ── */
|
||||
const {
|
||||
@@ -645,6 +657,12 @@ const ProductLibrary: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/* 查看详情 — 跳转到产品详情页 */
|
||||
const handleViewDetail = (product: ProductItem) => {
|
||||
setPlayingProduct(null); // 关闭播放器
|
||||
navigate(`/app/products/${product.id}`);
|
||||
};
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = (id: string) => {
|
||||
deleteMutation.mutate(id);
|
||||
@@ -912,6 +930,7 @@ const ProductLibrary: React.FC = () => {
|
||||
onClose={() => setPlayingProduct(null)}
|
||||
onDownload={handleDownload}
|
||||
onShare={handleShare}
|
||||
onViewDetail={handleViewDetail}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -631,6 +631,327 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
产品详情页
|
||||
============================================================ */
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.xx-detail-loading,
|
||||
.xx-detail-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 60vh;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-detail-error h3 {
|
||||
font-size: 20px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: var(--space-md) 0 0;
|
||||
}
|
||||
|
||||
.xx-detail-error p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin: 0 0 var(--space-lg);
|
||||
}
|
||||
|
||||
/* 顶部导航 */
|
||||
.xx-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-detail-back-btn:hover {
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-soft);
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* 主体 */
|
||||
.xx-detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: var(--space-lg);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.xx-detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 播放器 */
|
||||
.xx-detail-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.xx-detail-player video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player.is-fullscreen {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.xx-detail-player-empty img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-player-placeholder p {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 播放器控制条 */
|
||||
.xx-detail-player-controls {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
|
||||
padding: var(--space-lg) var(--space-md) var(--space-md);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xx-detail-player-controls.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.xx-dp-progress {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.xx-dp-progress-track {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
transition: height var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-progress:hover .xx-dp-progress-track {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-buffered {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.xx-dp-progress-played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: var(--primary-color);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* 控制栏 */
|
||||
.xx-dp-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.xx-dp-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-xs);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.xx-dp-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.xx-dp-time {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.xx-dp-volume {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider {
|
||||
width: 72px;
|
||||
height: 4px;
|
||||
appearance: none;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xx-dp-volume-slider::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 产品信息面板 */
|
||||
.xx-detail-info {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(226, 232, 240, 0.95);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-xl);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.xx-detail-title {
|
||||
font-size: 22px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 var(--space-lg);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.xx-detail-meta-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.xx-detail-meta-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 详情页响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.xx-product-detail-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.xx-detail-header {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.xx-detail-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.xx-detail-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
响应式
|
||||
============================================================ */
|
||||
|
||||
@@ -36,8 +36,7 @@
|
||||
|
||||
.vc-card:hover {
|
||||
border-color: var(--primary-light);
|
||||
box-shadow: 0 4px 20px
|
||||
color-mix(in srgb, var(--primary-color) 8%);
|
||||
box-shadow: 0 4px 20px color-mix(in srgb, var(--primary-color) 8%);
|
||||
}
|
||||
|
||||
/* 右上角操作按钮 */
|
||||
@@ -103,7 +102,7 @@
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-color)));
|
||||
background: linear-gradient(135deg, var(--accent-color));
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
@@ -282,7 +281,7 @@
|
||||
font-weight: 500;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 4px 12px var(--shadow-sm));
|
||||
box-shadow: 0 4px 12px var(--shadow-sm);
|
||||
animation: vc-toast-in 0.25s ease-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -318,7 +317,7 @@
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--overlay-bg));
|
||||
background: var(--overlay-bg);
|
||||
animation: vc-fade-in 0.2s;
|
||||
}
|
||||
|
||||
@@ -337,7 +336,7 @@
|
||||
padding: 24px;
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 40px);
|
||||
box-shadow: 0 20px 60px var(--shadow-lg));
|
||||
box-shadow: 0 20px 60px var(--shadow-lg);
|
||||
animation: vc-scale-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
/** 首页路由组件:已登录跳 dashboard,未登录显示落地页 */
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const HomeRoute: React.FC = () => {
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const hasAccessToken = Boolean(localStorage.getItem("access_token"));
|
||||
@@ -127,6 +128,13 @@ export const router = createBrowserRouter([
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "products/:id",
|
||||
lazy: () =>
|
||||
import("@/pages/products/ProductDetail").then((m) => ({
|
||||
Component: m.default,
|
||||
})),
|
||||
},
|
||||
{
|
||||
path: "editing-planner",
|
||||
lazy: () =>
|
||||
|
||||
Reference in New Issue
Block a user