diff --git a/apps/web/src/api/editPlans.ts b/apps/web/src/api/editPlans.ts index b75760d04..453412d32 100644 --- a/apps/web/src/api/editPlans.ts +++ b/apps/web/src/api/editPlans.ts @@ -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; + created_at: string; + updated_at: string; +} + +/** 创建剪辑计划请求(后端要求 template_id + name 必填) */ +export interface CreateEditPlanRequest { + template_id: string; + name: string; + config?: Record; + total_duration?: number; +} + +/** 更新剪辑计划请求 */ +export interface UpdateEditPlanRequest { + name?: string; + config?: Record; + 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[]; -} - -/** 更新剪辑计划请求 */ -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 { - // 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 { + const response = await apiClient.get("/edit-plans", { params }); + return response.data.items || []; } /** 获取单个剪辑计划 */ -export async function getEditPlan(id: string): Promise { - // 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 { + const response = await apiClient.get(`/edit-plans/${planId}`); + return response.data; } /** 创建剪辑计划 */ export async function createEditPlan( - data: CreateEditPlanRequest + data: CreateEditPlanRequest, ): Promise { - // 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 { - // 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 { - // TODO: 替换为 apiClient.delete(`/edit-plans/${id}`) - await delay(); - mockPlans = mockPlans.filter((p) => p.id !== id); +export async function deleteEditPlan(planId: string): Promise { + await apiClient.delete(`/edit-plans/${planId}`); } -/** 获取素材库列表 */ -export async function getMediaAssets(): Promise { - // TODO: 替换为 apiClient.get('/media-assets') - await delay(); - return [...MOCK_ASSETS]; +/** 触发剪辑计划生成 */ +export async function generateEditPlan( + planId: string, +): Promise { + const response = await apiClient.post(`/edit-plans/${planId}/generate`); + return response.data; +} + +/** 获取剪辑计划生成状态(轮询用) */ +export async function getGenerationStatus( + planId: string, +): Promise { + 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 { + 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 { - // 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; + const ext = asset as AssetItem & Record; + 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 = { }; /** 计划状态标签 */ -export const PLAN_STATUS_LABELS: Record = { +export const PLAN_STATUS_LABELS: Record = { 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 }, diff --git a/apps/web/src/components/AssetSelector/AssetSelector.tsx b/apps/web/src/components/AssetSelector/AssetSelector.tsx index bba4d0f8f..3e926a78b 100644 --- a/apps/web/src/components/AssetSelector/AssetSelector.tsx +++ b/apps/web/src/components/AssetSelector/AssetSelector.tsx @@ -139,9 +139,6 @@ const AssetSelector: React.FC = ({ /* ── 选中状态 ── */ 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 = ({ [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 = ({ setSearchText(v)} + onChange={(e) => setSearchText(e.target.value)} prefix="🔍" /> @@ -341,7 +325,7 @@ const AssetSelector: React.FC = ({ 已选 {selectedIds.length} 项
-
diff --git a/apps/web/src/pages/editing-planner/EditingPlanner.tsx b/apps/web/src/pages/editing-planner/EditingPlanner.tsx index da7f610de..accc33f1f 100644 --- a/apps/web/src/pages/editing-planner/EditingPlanner.tsx +++ b/apps/web/src/pages/editing-planner/EditingPlanner.tsx @@ -181,7 +181,7 @@ const EditingPlanner: React.FC = () => { data, }: { id: string; - data: { name?: string; clips?: EditPlanClip[] }; + data: { name?: string; config?: Record; 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, }); } diff --git a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx index cd2ca78a4..763d6e4c6 100644 --- a/apps/web/src/pages/editing-planner/components/MediaPanel.tsx +++ b/apps/web/src/pages/editing-planner/components/MediaPanel.tsx @@ -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 = ({ const [filterCategory, setFilterCategory] = useState(""); const [selectedAssetIds, setSelectedAssetIds] = useState([]); + /* 先获取素材库列表,再用第一个 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, }); /* 过滤模板 */ diff --git a/apps/web/src/pages/generate/GeneratePage.tsx b/apps/web/src/pages/generate/GeneratePage.tsx index f8028024b..feb7c9bec 100644 --- a/apps/web/src/pages/generate/GeneratePage.tsx +++ b/apps/web/src/pages/generate/GeneratePage.tsx @@ -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>(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({ + 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 = () => {
- {MOCK_MATERIALS.map((m) => { - const selected = selectedMaterials.includes(m.id); - return ( -
toggleMaterial(m.id)} - role="button" - tabIndex={0} - aria-pressed={selected} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggleMaterial(m.id); - } - }} - > -
- {materialIcon(m.mime_type)} + {materialsLoading ? ( + + 加载素材中… + + ) : materials.length === 0 ? ( + + 暂无素材,请先在素材库中上传 + + ) : ( + materials.map((m) => { + const selected = selectedMaterials.includes(m.id); + return ( +
toggleMaterial(m.id)} + role="button" + tabIndex={0} + aria-pressed={selected} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggleMaterial(m.id); + } + }} + > +
+ {materialIcon(m.mime_type)} +
+
+ + {m.name} + + + {m.mime_type.split("/")[1].toUpperCase()} + {m.file_size ? ` · ${formatSize(m.file_size)}` : ""} + +
+
+ +
-
- - {m.name} - - - {m.mime_type.split("/")[1].toUpperCase()} - {m.file_size ? ` · ${formatSize(m.file_size)}` : ""} - -
-
- -
-
- ); - })} + ); + }) + )}
{/* 上传入口 */} @@ -694,7 +721,7 @@ const GeneratePage: React.FC = () => { )} {cv.status === "failed" && ( 失败 diff --git a/apps/web/src/pages/my-voices/MyVoices.tsx b/apps/web/src/pages/my-voices/MyVoices.tsx index 8d28c18f7..f108d6b4f 100644 --- a/apps/web/src/pages/my-voices/MyVoices.tsx +++ b/apps/web/src/pages/my-voices/MyVoices.tsx @@ -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"; /* ============================================================ diff --git a/apps/web/src/pages/voices/VoiceLibrary.tsx b/apps/web/src/pages/voices/VoiceLibrary.tsx index c0fc669d7..10f00e5fa 100644 --- a/apps/web/src/pages/voices/VoiceLibrary.tsx +++ b/apps/web/src/pages/voices/VoiceLibrary.tsx @@ -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 = ({ - 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(null); const [currentTime, setCurrentTime] = useState(0); - const intervalRef = useRef | null>(null); + const intervalRef = useRef(null); const filteredPreset = useMemo(() => { let list = presetVoices;