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

This commit is contained in:
DevOps Bot
2026-07-06 17:14:23 +08:00
27 changed files with 1914 additions and 260 deletions
+34 -20
View File
@@ -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);
+15 -2
View File
@@ -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 }) => {
+2 -2
View File
@@ -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 -3
View File
@@ -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);
+5 -5
View File
@@ -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");
});
+3 -2
View File
@@ -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,
+3 -3
View File
@@ -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,
});
+1
View File
@@ -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";
+5 -1
View File
@@ -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);
+5 -1
View File
@@ -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>
);
+177 -104
View File
@@ -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>
);
+66 -17
View File
@@ -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">
+2 -1
View File
@@ -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>
+2 -10
View File
@@ -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;
+20 -1
View File
@@ -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>
+321
View File
@@ -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;
}
+8
View File
@@ -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: () =>