fix: Phase 8 前端 P1 问题修复 — 严格对照后端 API Schema #163

Merged
xiaoxia merged 1 commits from feature/fix-p1-mock-data-v2 into develop 2026-07-02 10:18:38 +08:00
7 changed files with 336 additions and 390 deletions
+168 -249
View File
@@ -1,15 +1,82 @@
/**
* 剪辑计划 API — Mock 数据 + 预留接口
* 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans
* 当前使用 Mock 数据,后续替换为真实 API 调用
* 剪辑计划 API — 对接后端 Edit Plans Schema
* 字段名严格匹配后端 API 响应
*/
import type { TemplateMode } from "./editingPlanner";
import apiClient from "./client";
import type { AssetItem } from "./assets";
/* ============================================================
* 类型定义
* 后端 API 类型(严格匹配后端 Schema)
* ============================================================ */
/** 剪辑计划中的片段 */
/** 剪辑计划状态枚举 */
export type EditPlanStatus =
| "draft"
| "editing"
| "rendering"
| "completed"
| "failed";
/** 剪辑计划(后端响应) */
export interface EditPlan {
id: string;
template_id: string;
name: string;
status: EditPlanStatus;
total_duration: number;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求(后端要求 template_id + name 必填) */
export interface CreateEditPlanRequest {
template_id: string;
name: string;
config?: Record<string, unknown>;
total_duration?: number;
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
config?: Record<string, unknown>;
total_duration?: number;
status?: EditPlanStatus;
}
/** 生成响应 */
export interface GenerateResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id: string;
clip_count: number;
}
/** 片段生成状态 */
export interface ClipStatusItem {
clip_id: string;
clip_type: string;
order: number;
status: string;
asset_id?: string;
text_content?: string;
duration?: number;
}
/** 生成状态轮询响应 */
export interface GenerationStatusResponse {
plan_id: string;
plan_status: EditPlanStatus;
generation_task_id?: string;
clips: ClipStatusItem[];
}
/* ============================================================
* 前端 UI 类型(EditingPlanner 组件依赖,保留兼容)
* ============================================================ */
/** 剪辑计划中的片段(UI 层类型) */
export interface EditPlanClip {
id: string;
template_segment_id: string;
@@ -33,37 +100,7 @@ export interface TransitionEffect {
duration: number; // 转场时长(秒)
}
/** 剪辑计划 */
export interface EditPlan {
id: string;
name: string;
template_id?: string;
mode: TemplateMode;
clips: EditPlanClip[];
/** 总预估时长 */
total_duration: number;
/** 状态 */
status: "draft" | "ready" | "generating" | "completed" | "failed";
created_at: string;
updated_at: string;
}
/** 创建剪辑计划请求 */
export interface CreateEditPlanRequest {
name: string;
template_id?: string;
mode: TemplateMode;
clips: Omit<EditPlanClip, "id">[];
}
/** 更新剪辑计划请求 */
export interface UpdateEditPlanRequest {
name?: string;
clips?: EditPlanClip[];
status?: EditPlan["status"];
}
/** 素材库资产 */
/** 素材库资产(UI 层类型,映射自后端 AssetResponse */
export interface MediaAsset {
id: string;
name: string;
@@ -84,239 +121,116 @@ export interface MediaAsset {
}
/* ============================================================
* Mock 数据
* ============================================================ */
const MOCK_ASSETS: MediaAsset[] = [
{
id: "asset-001",
name: "产品展示-正面.mp4",
type: "video",
thumbnail_url: "https://picsum.photos/seed/asset001/320/180",
duration: 15,
size: 12_500_000,
tags: ["产品", "展示"],
created_at: "2026-06-20T10:00:00Z",
quality_score: 92,
classification_status: "completed",
},
{
id: "asset-002",
name: "使用教程-片段A.mp4",
type: "video",
thumbnail_url: "https://picsum.photos/seed/asset002/320/180",
duration: 20,
size: 18_000_000,
tags: ["教程", "使用"],
created_at: "2026-06-21T14:00:00Z",
quality_score: 85,
classification_status: "completed",
},
{
id: "asset-003",
name: "背景音乐-轻快.mp3",
type: "audio",
duration: 120,
size: 3_200_000,
tags: ["BGM", "轻快"],
created_at: "2026-06-18T09:00:00Z",
quality_score: 78,
classification_status: "completed",
},
{
id: "asset-004",
name: "封面图-主图.jpg",
type: "image",
thumbnail_url: "https://picsum.photos/seed/asset004/320/180",
size: 850_000,
tags: ["封面", "主图"],
created_at: "2026-06-22T11:00:00Z",
quality_score: 88,
classification_status: "completed",
},
{
id: "asset-005",
name: "细节特写-侧面.mp4",
type: "video",
thumbnail_url: "https://picsum.photos/seed/asset005/320/180",
duration: 10,
size: 8_500_000,
tags: ["产品", "细节"],
created_at: "2026-06-23T16:00:00Z",
quality_score: 72,
classification_status: "completed",
},
{
id: "asset-006",
name: "开箱视频-片段.mp4",
type: "video",
thumbnail_url: "https://picsum.photos/seed/asset006/320/180",
duration: 25,
size: 22_000_000,
tags: ["开箱", "展示"],
created_at: "2026-06-19T08:00:00Z",
quality_score: 65,
classification_status: "processing",
},
{
id: "asset-007",
name: "配音-产品介绍.wav",
type: "audio",
duration: 45,
size: 5_600_000,
tags: ["配音", "产品"],
created_at: "2026-06-24T10:00:00Z",
quality_score: 95,
classification_status: "completed",
},
{
id: "asset-008",
name: "场景图-生活场景.jpg",
type: "image",
thumbnail_url: "https://picsum.photos/seed/asset008/320/180",
size: 1_200_000,
tags: ["场景", "生活"],
created_at: "2026-06-25T13:00:00Z",
quality_score: 58,
classification_status: "pending",
},
];
let mockPlans: EditPlan[] = [
{
id: "plan-001",
name: "产品展示视频 v1",
template_id: "tpl-001",
mode: "pip",
clips: [
{
id: "clip-001",
template_segment_id: "seg-001",
media_asset_id: "asset-001",
material_type: "video",
script_text: "大家好,今天给大家带来一款超值好物!",
duration: 10,
transition: { type: "fade", duration: 0.5 },
order: 0,
},
{
id: "clip-002",
template_segment_id: "seg-002",
media_asset_id: "asset-005",
material_type: "video",
script_text: "来看看这个细节做工,真的绝了",
duration: 8,
transition: { type: "dissolve", duration: 0.3 },
order: 1,
},
{
id: "clip-003",
template_segment_id: "seg-003",
material_type: "image",
script_text: "多种颜色可选,总有一款适合你",
duration: 6,
transition: { type: "none", duration: 0 },
order: 2,
},
],
total_duration: 24,
status: "draft",
created_at: "2026-06-25T10:00:00Z",
updated_at: "2026-06-25T15:00:00Z",
},
];
/* ============================================================
* Mock 延迟
* ============================================================ */
const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms));
/* ============================================================
* API 函数(Mock 实现,后续替换为真实 API)
* API 函数 — 严格对接后端
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(): Promise<EditPlan[]> {
// TODO: 替换为 apiClient.get('/edit-plans')
await delay();
return [...mockPlans];
export async function getEditPlans(params?: {
page?: number;
page_size?: number;
template_id?: string;
status?: string;
}): Promise<EditPlan[]> {
const response = await apiClient.get("/edit-plans", { params });
return response.data.items || [];
}
/** 获取单个剪辑计划 */
export async function getEditPlan(id: string): Promise<EditPlan> {
// TODO: 替换为 apiClient.get(`/edit-plans/${id}`)
await delay();
const plan = mockPlans.find((p) => p.id === id);
if (!plan) throw new Error(`剪辑计划 ${id} 不存在`);
return { ...plan };
export async function getEditPlan(planId: string): Promise<EditPlan> {
const response = await apiClient.get(`/edit-plans/${planId}`);
return response.data;
}
/** 创建剪辑计划 */
export async function createEditPlan(
data: CreateEditPlanRequest
data: CreateEditPlanRequest,
): Promise<EditPlan> {
// TODO: 替换为 apiClient.post('/edit-plans', data)
await delay();
const now = new Date().toISOString();
const plan: EditPlan = {
id: `plan-${Date.now()}`,
name: data.name,
template_id: data.template_id,
mode: data.mode,
clips: data.clips.map((c, i) => ({
...c,
id: `clip-${Date.now()}-${i}`,
})),
total_duration: data.clips.reduce((sum, c) => sum + c.duration, 0),
status: "draft",
created_at: now,
updated_at: now,
};
mockPlans = [plan, ...mockPlans];
return plan;
const response = await apiClient.post("/edit-plans", data);
return response.data;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
id: string,
data: UpdateEditPlanRequest
planId: string,
data: UpdateEditPlanRequest,
): Promise<EditPlan> {
// TODO: 替换为 apiClient.put(`/edit-plans/${id}`, data)
await delay();
const idx = mockPlans.findIndex((p) => p.id === id);
if (idx === -1) throw new Error(`剪辑计划 ${id} 不存在`);
mockPlans[idx] = {
...mockPlans[idx],
...data,
total_duration:
data.clips?.reduce((sum, c) => sum + c.duration, 0) ??
mockPlans[idx].total_duration,
updated_at: new Date().toISOString(),
};
return { ...mockPlans[idx] };
const response = await apiClient.put(`/edit-plans/${planId}`, data);
return response.data;
}
/** 删除剪辑计划 */
export async function deleteEditPlan(id: string): Promise<void> {
// TODO: 替换为 apiClient.delete(`/edit-plans/${id}`)
await delay();
mockPlans = mockPlans.filter((p) => p.id !== id);
export async function deleteEditPlan(planId: string): Promise<void> {
await apiClient.delete(`/edit-plans/${planId}`);
}
/** 获取素材库列表 */
export async function getMediaAssets(): Promise<MediaAsset[]> {
// TODO: 替换为 apiClient.get('/media-assets')
await delay();
return [...MOCK_ASSETS];
/** 触发剪辑计划生成 */
export async function generateEditPlan(
planId: string,
): Promise<GenerateResponse> {
const response = await apiClient.post(`/edit-plans/${planId}/generate`);
return response.data;
}
/** 获取剪辑计划生成状态(轮询用) */
export async function getGenerationStatus(
planId: string,
): Promise<GenerationStatusResponse> {
const response = await apiClient.get(
`/edit-plans/${planId}/generation-status`,
);
return response.data;
}
/**
* 获取素材库列表 — 调用 GET /api/v1/assets?library_id=xxx
* 将后端 AssetResponse 映射为前端 MediaAsset 类型
*/
export async function getMediaAssets(
libraryId?: string,
): Promise<MediaAsset[]> {
const response = await apiClient.get("/assets", {
params: libraryId ? { library_id: libraryId } : undefined,
});
const items: AssetItem[] = response.data.items || [];
return items.map(mapAssetToMediaAsset);
}
/** 获取单个素材 */
export async function getMediaAsset(id: string): Promise<MediaAsset> {
// TODO: 替换为 apiClient.get(`/media-assets/${id}`)
await delay();
const asset = MOCK_ASSETS.find((a) => a.id === id);
if (!asset) throw new Error(`素材 ${id} 不存在`);
return { ...asset };
const response = await apiClient.get(`/assets/${id}`);
return mapAssetToMediaAsset(response.data);
}
/* ============================================================
* 映射函数:AssetResponse → MediaAsset
* ============================================================ */
function inferMediaType(mimeType: string): "video" | "image" | "audio" {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
return "audio";
}
function mapAssetToMediaAsset(asset: AssetItem): MediaAsset {
const meta = (asset.metadata || {}) as Record<string, unknown>;
const ext = asset as AssetItem & Record<string, unknown>;
return {
id: asset.id,
name: asset.name,
type: inferMediaType(asset.mime_type || ""),
thumbnail_url: typeof ext.thumbnail_url === "string" ? ext.thumbnail_url : undefined,
duration:
typeof ext.duration === "number"
? ext.duration
: typeof meta.duration === "number"
? (meta.duration as number)
: undefined,
size: asset.file_size ?? undefined,
tags: [],
created_at: asset.created_at ?? "",
quality_score: asset.quality_score ?? undefined,
classification_status: (asset.classification_status ?? undefined) as MediaAsset["classification_status"],
};
}
/* ============================================================
@@ -353,16 +267,21 @@ export const MATERIAL_TYPE_ICONS: Record<string, string> = {
};
/** 计划状态标签 */
export const PLAN_STATUS_LABELS: Record<EditPlan["status"], string> = {
export const PLAN_STATUS_LABELS: Record<EditPlanStatus, string> = {
draft: "草稿",
ready: "就绪",
generating: "生成中",
editing: "编辑中",
rendering: "渲染中",
completed: "已完成",
failed: "失败",
};
/** 质量分筛选选项 */
export const QUALITY_OPTIONS: { value: string; label: string; min?: number; max?: number }[] = [
export const QUALITY_OPTIONS: {
value: string;
label: string;
min?: number;
max?: number;
}[] = [
{ value: "all", label: "全部质量" },
{ value: "high", label: "高质量 (80-100)", min: 80, max: 100 },
{ value: "medium", label: "中质量 (50-79)", min: 50, max: 79 },
@@ -139,9 +139,6 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
/* ── 选中状态 ── */
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
const allFilteredSelected =
filteredAssets.length > 0 &&
filteredAssets.every((a) => selectedSet.has(a.id));
/* ── 选择操作 ── */
const toggleSelect = useCallback(
@@ -170,19 +167,6 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
[onSelectionChange, selectedIds, filteredAssets],
);
const toggleSelectAll = useCallback(() => {
if (!onSelectionChange) return;
if (allFilteredSelected) {
// 反选:取消当前过滤结果中的所有选中
const filteredIds = new Set(filteredAssets.map((a) => a.id));
onSelectionChange(selectedIds.filter((id) => !filteredIds.has(id)));
} else {
// 全选:合并当前过滤结果到选中列表
const newSet = new Set(selectedIds);
filteredAssets.forEach((a) => newSet.add(a.id));
onSelectionChange(Array.from(newSet));
}
}, [onSelectionChange, selectedIds, filteredAssets, allFilteredSelected]);
const clearSelection = useCallback(() => {
onSelectionChange?.([]);
@@ -299,7 +283,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
<Input
placeholder="搜索素材..."
value={searchText}
onChange={(v: string) => setSearchText(v)}
onChange={(e) => setSearchText(e.target.value)}
prefix="🔍"
/>
</div>
@@ -341,7 +325,7 @@ const AssetSelector: React.FC<AssetSelectorProps> = ({
{selectedIds.length}
</span>
<div className="as-batch-bar-actions">
<Button buttonType="ghost" buttonSize="xs" onClick={clearSelection}>
<Button buttonType="ghost" buttonSize="sm" onClick={clearSelection}>
</Button>
</div>
@@ -181,7 +181,7 @@ const EditingPlanner: React.FC = () => {
data,
}: {
id: string;
data: { name?: string; clips?: EditPlanClip[] };
data: { name?: string; config?: Record<string, unknown>; total_duration?: number };
}) => updateEditPlan(id, data),
onSuccess: () => {
showToast("剪辑计划已更新", "success");
@@ -433,18 +433,25 @@ const EditingPlanner: React.FC = () => {
return;
}
// 保存剪辑计划
// 保存剪辑计划 — 字段严格匹配后端 Schema
if (editPlanId) {
updatePlanMutation.mutate({
id: editPlanId,
data: { name: draftName.trim(), clips },
data: {
name: draftName.trim(),
config: { clips },
},
});
} else {
const totalDuration = clips.reduce((s, c) => s + c.duration, 0);
savePlanMutation.mutate({
template_id: loadedTemplateId || "default",
name: draftName.trim(),
template_id: loadedTemplateId || undefined,
mode: currentMode,
clips: clips.map(({ id: _id, ...rest }) => rest),
config: {
mode: currentMode,
clips: clips.map(({ id: _id, ...rest }) => rest),
},
total_duration: totalDuration,
});
}
@@ -17,6 +17,7 @@ import {
getMediaAssets,
type MediaAsset,
} from "@/api/editPlans";
import { getAssetLibraries } from "@/api/assets";
import AssetSelector from "@/components/AssetSelector/AssetSelector";
/** antd Tag color → V21 Tag variant */
@@ -60,10 +61,18 @@ const MediaPanel: React.FC<MediaPanelProps> = ({
const [filterCategory, setFilterCategory] = useState("");
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
/* 先获取素材库列表,再用第一个 library_id 获取素材 */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
/* 素材数据查询 */
const { data: assets = [], isLoading: isLoadingAssets } = useQuery({
queryKey: ["media-assets"],
queryFn: getMediaAssets,
const { data: assets = [] } = useQuery({
queryKey: ["media-assets", libraryId],
queryFn: () => getMediaAssets(libraryId),
enabled: libraryId !== undefined,
});
/* 过滤模板 */
+136 -109
View File
@@ -4,6 +4,7 @@
* 使用 Task 1.5 UI 组件 + V21 CSS 变量,Mock 数据
*/
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Typography,
Collapse,
@@ -36,6 +37,12 @@ import Card from "@/components/ui/Card";
import Tag from "@/components/ui/Tag";
import Form, { FormItem } from "@/components/ui/Form";
import type { AssetItem } from "@/api/assets";
import { getAssets, getAssetLibraries } from "@/api/assets";
import {
createEditPlan,
generateEditPlan,
} from "@/api/editPlans";
import apiClient from "@/api/client";
import type { VoiceItem } from "@/api/voices";
import { getVoiceClones, formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
@@ -46,72 +53,9 @@ const { TextArea } = Input;
const { Text } = Typography;
/* ================================================================
Mock 数据
Mock 数据(配音 & 时间线保留,素材改为 API 获取)
================================================================ */
const MOCK_MATERIALS: AssetItem[] = [
{
id: "m1",
library_id: "lib1",
name: "产品展示视频.mp4",
storage_key: "",
mime_type: "video/mp4",
metadata: { duration: 30 },
file_size: 15_000_000,
status: "active",
},
{
id: "m2",
library_id: "lib1",
name: "品牌宣传片.mp4",
storage_key: "",
mime_type: "video/mp4",
metadata: { duration: 60 },
file_size: 42_000_000,
status: "active",
},
{
id: "m3",
library_id: "lib1",
name: "用户访谈片段.mp4",
storage_key: "",
mime_type: "video/mp4",
metadata: { duration: 45 },
file_size: 28_000_000,
status: "active",
},
{
id: "m4",
library_id: "lib2",
name: "Logo 动画.png",
storage_key: "",
mime_type: "image/png",
metadata: {},
file_size: 500_000,
status: "active",
},
{
id: "m5",
library_id: "lib2",
name: "背景音乐.mp3",
storage_key: "",
mime_type: "audio/mpeg",
metadata: { duration: 120 },
file_size: 3_500_000,
status: "active",
},
{
id: "m6",
library_id: "lib1",
name: "功能演示录屏.mp4",
storage_key: "",
mime_type: "video/mp4",
metadata: { duration: 90 },
file_size: 55_000_000,
status: "active",
},
];
const MOCK_VOICES: VoiceItem[] = [
{
id: "v1",
@@ -291,6 +235,19 @@ const GeneratePage: React.FC = () => {
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
/* ── 素材数据:通过 API 获取 ── */
const { data: libraries = [] } = useQuery({
queryKey: ["asset-libraries"],
queryFn: getAssetLibraries,
});
const libraryId = libraries.length > 0 ? libraries[0].id : undefined;
const { data: materials = [], isLoading: materialsLoading } = useQuery<AssetItem[]>({
queryKey: ["generate-assets", libraryId],
queryFn: () => getAssets(libraryId!),
enabled: libraryId !== undefined,
});
/* ── 获取克隆音色列表 ── */
const fetchClonedVoices = useCallback(async () => {
setLoadingClones(true);
@@ -336,7 +293,7 @@ const GeneratePage: React.FC = () => {
});
}, []);
const handleGenerate = useCallback(() => {
const handleGenerate = useCallback(async () => {
if (!title.trim()) {
message.warning("请输入视频标题");
return;
@@ -350,19 +307,79 @@ const GeneratePage: React.FC = () => {
setProgress(0);
setGenerated(false);
progressTimer.current = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
clearInterval(progressTimer.current);
try {
// 1. 创建剪辑计划
const plan = await createEditPlan({
template_id: "default",
name: title.trim(),
config: {
asset_ids: selectedMaterials,
voice_id: selectedVoice || undefined,
ratio: videoRatio,
style,
duration,
auto_subtitles: autoSubtitles,
bgm,
},
total_duration: duration,
});
// 2. 触发视频生成
await generateEditPlan(plan.id);
// 3. 轮询生成状态(使用 apiClient)
const poll = async () => {
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 100;
return;
}
return prev + Math.random() * 12 + 3;
});
}, 600);
}, [title, selectedMaterials]);
if (data.plan_status === "failed") {
setGenerating(false);
message.error("视频生成失败");
return;
}
// 根据 clips 状态计算进度
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) {
console.error("生成失败:", err);
setGenerating(false);
message.error("生成失败,请重试");
}
}, [
title,
selectedMaterials,
selectedVoice,
videoRatio,
style,
duration,
autoSubtitles,
bgm,
]);
/* ── 字数统计 ── */
const titleCount = title.length;
@@ -474,41 +491,51 @@ const GeneratePage: React.FC = () => {
</div>
<div className="xx-section-body">
<div className="xx-material-grid">
{MOCK_MATERIALS.map((m) => {
const selected = selectedMaterials.includes(m.id);
return (
<div
key={m.id}
className={`xx-material-card ${selected ? "xx-material-card-selected" : ""}`}
onClick={() => toggleMaterial(m.id)}
role="button"
tabIndex={0}
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleMaterial(m.id);
}
}}
>
<div className="xx-material-thumb">
{materialIcon(m.mime_type)}
{materialsLoading ? (
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1", textAlign: "center", padding: "24px 0" }}>
</Typography.Paragraph>
) : materials.length === 0 ? (
<Typography.Paragraph style={{ color: "var(--text-secondary)", gridColumn: "1 / -1", textAlign: "center", padding: "24px 0" }}>
</Typography.Paragraph>
) : (
materials.map((m) => {
const selected = selectedMaterials.includes(m.id);
return (
<div
key={m.id}
className={`xx-material-card ${selected ? "xx-material-card-selected" : ""}`}
onClick={() => toggleMaterial(m.id)}
role="button"
tabIndex={0}
aria-pressed={selected}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleMaterial(m.id);
}
}}
>
<div className="xx-material-thumb">
{materialIcon(m.mime_type)}
</div>
<div className="xx-material-info">
<Typography.Paragraph className="xx-material-name">
{m.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-material-meta">
{m.mime_type.split("/")[1].toUpperCase()}
{m.file_size ? ` · ${formatSize(m.file_size)}` : ""}
</Typography.Paragraph>
</div>
<div className="xx-material-check">
<CheckCircleFilled />
</div>
</div>
<div className="xx-material-info">
<Typography.Paragraph className="xx-material-name">
{m.name}
</Typography.Paragraph>
<Typography.Paragraph className="xx-material-meta">
{m.mime_type.split("/")[1].toUpperCase()}
{m.file_size ? ` · ${formatSize(m.file_size)}` : ""}
</Typography.Paragraph>
</div>
<div className="xx-material-check">
<CheckCircleFilled />
</div>
</div>
);
})}
);
})
)}
</div>
{/* 上传入口 */}
@@ -694,7 +721,7 @@ const GeneratePage: React.FC = () => {
)}
{cv.status === "failed" && (
<Tag
variant="danger"
variant="error"
style={{ marginLeft: 6 }}
>
+1 -1
View File
@@ -16,7 +16,7 @@ import {
ClockCircleOutlined,
} from "@ant-design/icons";
import { Button, Modal, Input, Tooltip } from "@/components/ui";
import { PageHead } from "@/components/layout/PageHead";
import PageHead from "@/components/layout/PageHead";
import "./my-voices.css";
/* ============================================================
+4 -4
View File
@@ -18,8 +18,8 @@ import {
HeartOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Button, Input, Select, Modal } from "@/components/ui";
import { PageHead } from "@/components/layout/PageHead";
import { Button, Input, Select } from "@/components/ui";
import PageHead from "@/components/layout/PageHead";
import "./voices.css";
/* ============================================================
@@ -207,7 +207,7 @@ interface VoiceCardProps {
}
const VoiceCard: React.FC<VoiceCardProps> = ({
id, name, subtitle, tags, duration, gender,
id: _id, name, subtitle, tags, duration, gender,
isPlaying, isSelected, currentTime, starred, status = "ready",
onPlay, onPause, onSeek, onSelect, onToggleStar,
}) => {
@@ -308,7 +308,7 @@ const VoiceLibrary: React.FC = () => {
const [playingId, setPlayingId] = useState<string | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const intervalRef = useRef<number | null>(null);
const filteredPreset = useMemo(() => {
let list = presetVoices;