fix(P1-2): GeneratePage.tsx MOCK_MATERIALS → 真实 API
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
Tests / lint (pull_request) Failing after 172h19m55s
Tests / test (pull_request) Failing after 172h20m59s
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 172h21m26s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 172h21m31s
Deploy / Deploy Staging (push) Failing after 172h21m57s
CI/CD Pipeline / Frontend Lint (push) Failing after 172h22m25s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 172h22m32s

- 删除 MOCK_MATERIALS,改用 useQuery 从 /api/v1/assets 获取素材
- handleGenerate 改为 async:createEditPlan → generateEditPlan → 轮询任务状态
- 素材列表增加 loading/empty 状态展示
- materialIcon 兼容 MediaAsset.type → mime 映射
- 修复 Tag variant='danger' → 'error'(TypeScript 类型错误)
- 移除未使用的 AssetItem 导入

editPlans.ts 同步完成 P1-1:
- 全部 Mock 数据替换为真实 API 调用
- 新增 getMediaAssets/getMediaAsset/generateEditPlan/getEditPlanTask
- AssetItem → MediaAsset 映射函数

验收:npx tsc --noEmit 零错误(GeneratePage.tsx + editPlans.ts)
This commit is contained in:
灵应
2026-07-02 08:31:58 +08:00
parent e7b25abb48
commit c5f4a1656f
2 changed files with 160 additions and 282 deletions
+86 -201
View File
@@ -1,9 +1,10 @@
/**
* 剪辑计划 API — Mock 数据 + 预留接口
* 任务 2.14:对接后端 GET/POST/PUT /api/v1/edit-plans
* 当前使用 Mock 数据,后续替换为真实 API 调用
* 剪辑计划 API
* 对接后端 /api/v1/edit-plans 端点,完整 CRUD
*/
import type { TemplateMode } from "./editingPlanner";
import apiClient from "./client";
import { getAssetLibraries, getAssets, type AssetItem } from "./assets";
/* ============================================================
* 类型定义
@@ -84,239 +85,123 @@ export interface MediaAsset {
}
/* ============================================================
* Mock 数据
* 辅助函数:AssetItem → MediaAsset 映射
* ============================================================ */
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",
},
];
/** 从 mime_type 推断素材类型 */
function inferAssetType(mimeType: string): MediaAsset["type"] {
if (mimeType.startsWith("video/")) return "video";
if (mimeType.startsWith("image/")) return "image";
if (mimeType.startsWith("audio/")) return "audio";
return "video";
}
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",
},
];
/** 将后端 AssetItem 映射为 UI 层 MediaAsset */
function assetItemToMediaAsset(
item: AssetItem,
libraryKind?: string,
): MediaAsset {
const meta = item.metadata || {};
return {
id: item.id,
name: item.name,
type:
libraryKind === "voice"
? "audio"
: inferAssetType(item.mime_type || ""),
thumbnail_url: item.storage_key
? `/api/v1/assets/${item.id}/thumbnail`
: undefined,
duration:
typeof meta.duration === "number" ? (meta.duration as number) : undefined,
size: item.file_size,
tags: Array.isArray(meta.tags) ? (meta.tags as string[]) : [],
created_at: item.created_at || new Date().toISOString(),
quality_score: item.quality_score ?? undefined,
classification_status:
(item.classification_status as MediaAsset["classification_status"]) ||
undefined,
};
}
/* ============================================================
* Mock 延迟
* ============================================================ */
const delay = (ms = 300) => new Promise((r) => setTimeout(r, ms));
/* ============================================================
* API 函数(Mock 实现,后续替换为真实 API)
* API 函数 — 对接后端 /api/v1/edit-plans 和 /api/v1/assets
* ============================================================ */
/** 获取剪辑计划列表 */
export async function getEditPlans(): Promise<EditPlan[]> {
// TODO: 替换为 apiClient.get('/edit-plans')
await delay();
return [...mockPlans];
const response = await apiClient.get<EditPlan[]>("/edit-plans");
return response.data;
}
/** 获取单个剪辑计划 */
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 };
const response = await apiClient.get<EditPlan>(`/edit-plans/${id}`);
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<EditPlan>("/edit-plans", data);
return response.data;
}
/** 更新剪辑计划 */
export async function updateEditPlan(
id: string,
data: UpdateEditPlanRequest
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<EditPlan>(`/edit-plans/${id}`, 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);
await apiClient.delete(`/edit-plans/${id}`);
}
/** 获取素材库列表 */
/** 获取素材库列表(聚合所有 library 的 assets */
export async function getMediaAssets(): Promise<MediaAsset[]> {
// TODO: 替换为 apiClient.get('/media-assets')
await delay();
return [...MOCK_ASSETS];
const libraries = await getAssetLibraries();
const allAssets = await Promise.all(
libraries.map((lib) => getAssets(lib.id)),
);
return allAssets.flat().map((asset) => assetItemToMediaAsset(asset));
}
/** 获取单个素材 */
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<AssetItem>(`/assets/${id}`);
return assetItemToMediaAsset(response.data);
}
/** 获取所有素材(别名,GeneratePage 使用) */
export async function getAllMaterials(): Promise<MediaAsset[]> {
return getMediaAssets();
}
/** 触发剪辑计划生成 */
export async function generateEditPlan(
planId: string,
): Promise<{ task_id: string }> {
const response = await apiClient.post<{ task_id: string }>(
`/edit-plans/${planId}/generate`,
);
return response.data;
}
/** 获取剪辑计划生成任务状态 */
export async function getEditPlanTask(
planId: string,
): Promise<{ id: string; status: string; progress: number }> {
const response = await apiClient.get<{
id: string;
status: string;
progress: number;
}>(`/edit-plans/${planId}/task`);
return response.data;
}
/* ============================================================
+74 -81
View File
@@ -30,15 +30,17 @@ import {
SoundOutlined,
FormOutlined,
} from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
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 type { VoiceItem } from "@/api/voices";
import { getVoiceClones, formatDuration } from "@/api/voiceClone";
import type { VoiceClone } from "@/api/voiceClone";
import { getMediaAssets, createEditPlan, generateEditPlan } from "@/api/editPlans";
import type { MediaAsset } from "@/api/editPlans";
import VoiceCloneModal from "@/components/modals/VoiceCloneModal";
import "./generate.css";
@@ -46,72 +48,9 @@ const { TextArea } = Input;
const { Text } = Typography;
/* ================================================================
Mock 数据
Mock 数据(配音/时间线暂保留,P1-2 仅替换素材)
================================================================ */
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 +230,12 @@ const GeneratePage: React.FC = () => {
const progressTimer = useRef<ReturnType<typeof setInterval>>(undefined);
/* ── 获取素材列表(真实 API) ── */
const { data: materials = [], isLoading: materialsLoading } = useQuery<MediaAsset[]>({
queryKey: ["generate-page-materials"],
queryFn: getMediaAssets,
});
/* ── 获取克隆音色列表 ── */
const fetchClonedVoices = useCallback(async () => {
setLoadingClones(true);
@@ -336,7 +281,7 @@ const GeneratePage: React.FC = () => {
});
}, []);
const handleGenerate = useCallback(() => {
const handleGenerate = useCallback(async () => {
if (!title.trim()) {
message.warning("请输入视频标题");
return;
@@ -350,19 +295,56 @@ const GeneratePage: React.FC = () => {
setProgress(0);
setGenerated(false);
progressTimer.current = setInterval(() => {
setProgress((prev) => {
if (prev >= 100) {
try {
// 1. 创建剪辑计划
const plan = await createEditPlan({
name: title.trim(),
mode: "pip",
clips: selectedMaterials.map((assetId, i) => {
const asset = materials.find((m) => m.id === assetId);
return {
template_segment_id: `seg-${i}`,
media_asset_id: assetId,
material_type: (asset?.type || "video") as "video" | "image" | "audio",
script_text: "",
duration: asset?.duration || 10,
order: i,
};
}),
});
// 2. 触发生成
const { task_id } = await generateEditPlan(plan.id);
// 3. 轮询任务状态
progressTimer.current = setInterval(async () => {
try {
const task = await fetch(`/api/v1/tasks/${task_id}`).then((r) => r.json());
const pct = Math.min(99, Math.round(task.progress * 100) || 0);
setProgress(pct);
if (task.status === "completed") {
clearInterval(progressTimer.current);
setProgress(100);
setGenerating(false);
setGenerated(true);
message.success("视频生成完成!");
} else if (task.status === "failed") {
clearInterval(progressTimer.current);
setGenerating(false);
message.error("视频生成失败,请重试");
}
} catch {
clearInterval(progressTimer.current);
setGenerating(false);
setGenerated(true);
message.success("视频生成完成!");
return 100;
message.error("获取任务状态失败");
}
return prev + Math.random() * 12 + 3;
});
}, 600);
}, [title, selectedMaterials]);
}, 2000);
} catch {
setGenerating(false);
message.error("创建生成任务失败");
}
}, [title, selectedMaterials, materials]);
/* ── 字数统计 ── */
const titleCount = title.length;
@@ -473,9 +455,19 @@ const GeneratePage: React.FC = () => {
</Text>
</div>
<div className="xx-section-body">
{materialsLoading ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#94a3b8" }}>
...
</div>
) : materials.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 0", color: "#94a3b8" }}>
</div>
) : (
<div className="xx-material-grid">
{MOCK_MATERIALS.map((m) => {
{materials.map((m) => {
const selected = selectedMaterials.includes(m.id);
const mimeForIcon = m.type === "video" ? "video/mp4" : m.type === "image" ? "image/png" : "audio/mpeg";
return (
<div
key={m.id}
@@ -492,15 +484,15 @@ const GeneratePage: React.FC = () => {
}}
>
<div className="xx-material-thumb">
{materialIcon(m.mime_type)}
{materialIcon(mimeForIcon)}
</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)}` : ""}
{m.type.toUpperCase()}
{m.size ? ` · ${formatSize(m.size)}` : ""}
</Typography.Paragraph>
</div>
<div className="xx-material-check">
@@ -510,6 +502,7 @@ const GeneratePage: React.FC = () => {
);
})}
</div>
)}
{/* 上传入口 */}
<Upload.Dragger
@@ -694,7 +687,7 @@ const GeneratePage: React.FC = () => {
)}
{cv.status === "failed" && (
<Tag
variant="danger"
variant="error"
style={{ marginLeft: 6 }}
>