From e633ff2de7e79bc85a2f1dc733ea9844bd68d1b8 Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Fri, 26 Jun 2026 07:57:46 +0800 Subject: [PATCH] fix(web): restore V21 pages real business integration - Restore real asset library and asset list loading on V21 assets page - Restore title library API loading/search/create state for V21 titles page - Restore edit plan and generation task creation on V21 generation page - Restore generated video list, download and review actions on V21 results page - Keep V21 visual structure while making production E2E flow real again --- .../web/src/pages/workspace/ProjectAssets.tsx | 142 +++++++++-- .../src/pages/workspace/ProjectGeneration.tsx | 241 +++++++++++++----- .../src/pages/workspace/ProjectResults.tsx | 105 ++++++-- .../web/src/pages/workspace/ProjectTitles.tsx | 126 ++++++--- 4 files changed, 474 insertions(+), 140 deletions(-) diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index d748e0584..374fa2616 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -1,23 +1,96 @@ /** - * 素材库页面 - V21 完全对标 + * 素材库页面 - V21 外观 + 真实业务接入 */ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; import { message } from 'antd'; -import type { AssetItem } from '@/api/assets'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getAssetLibraries, + getAssets, + getProjectAssetDiagnosis, + updateAssetReviewStatus, + uploadAsset, + type AssetItem, +} from '@/api/assets'; +import { getProject } from '@/api/projects'; import './ProjectAssets.css'; const ProjectAssets: React.FC = () => { - const { id: projectId } = useParams<{ id: string }>(); - const [assets] = useState([]); + const { id } = useParams<{ id: string }>(); + const projectId = id || ''; + const [libraryId, setLibraryId] = useState(''); const [uploading, setUploading] = useState(false); + const queryClient = useQueryClient(); + + const projectQuery = useQuery({ + queryKey: ['project', projectId], + queryFn: () => getProject(projectId), + enabled: !!projectId, + }); + + const librariesQuery = useQuery({ + queryKey: ['asset-libraries', projectId], + queryFn: () => getAssetLibraries(projectId), + enabled: !!projectId, + refetchInterval: 3000, + }); + + useEffect(() => { + if (!libraryId && librariesQuery.data?.length) { + setLibraryId(librariesQuery.data[0].id); + } + }, [librariesQuery.data, libraryId]); + + const assetsQuery = useQuery({ + queryKey: ['assets', libraryId], + queryFn: () => getAssets(libraryId), + enabled: !!libraryId, + refetchInterval: 3000, + }); + + const diagnosisQuery = useQuery({ + queryKey: ['project-asset-diagnosis', projectId], + queryFn: () => getProjectAssetDiagnosis(projectId), + enabled: !!projectId, + refetchInterval: 5000, + }); + + const reviewMutation = useMutation({ + mutationFn: ({ assetId, reviewStatus }: { assetId: string; reviewStatus: 'approved' | 'rejected' }) => + updateAssetReviewStatus(assetId, reviewStatus), + onSuccess: () => { + message.success('复核状态已更新'); + queryClient.invalidateQueries({ queryKey: ['assets', libraryId] }); + }, + onError: () => message.error('复核状态更新失败'), + }); + + const assets = useMemo(() => assetsQuery.data || [], [assetsQuery.data]); + const selectedLibrary = librariesQuery.data?.find((library) => library.id === libraryId) || librariesQuery.data?.[0]; + const workspaceId = projectQuery.data?.workspace_id || selectedLibrary?.workspace_id || ''; const handleFileUpload = async (files: FileList) => { + if (!files.length || !projectId || !workspaceId || !libraryId) { + message.error('素材库尚未准备好'); + return; + } + try { setUploading(true); + for (const file of Array.from(files)) { + const formData = new FormData(); + formData.append('workspace_id', workspaceId); + formData.append('project_id', projectId); + formData.append('library_id', libraryId); + formData.append('file', file); + await uploadAsset(formData); + } message.success('上传成功'); + await assetsQuery.refetch(); + await diagnosisQuery.refetch(); } catch (error: any) { - message.error('上传失败'); + message.error(error?.message || '上传失败'); } finally { setUploading(false); } @@ -32,11 +105,19 @@ const ProjectAssets: React.FC = () => { - {/* 上传区 */} +
+

素材就绪度:{diagnosisQuery.data?.readiness_label || 'Ready'}

+

+ 预计成片 {diagnosisQuery.data?.estimated_video_count ?? 0} 条 · 视频素材 {diagnosisQuery.data?.video_assets ?? assets.length} 个 +

+ {diagnosisQuery.data?.gaps?.[0]?.message &&

{diagnosisQuery.data.gaps[0].message}

} +
+

上传新素材

e.preventDefault()} onDrop={(e) => { e.preventDefault(); @@ -44,7 +125,7 @@ const ProjectAssets: React.FC = () => { }} >
📁
-

拖拽文件到这里或点击选择

+

点击或拖拽素材到这里上传

{
- {/* 素材列表 */} -
+

已上传素材

{assets.length === 0 ? (
@@ -71,17 +151,37 @@ const ProjectAssets: React.FC = () => {
) : (
- {assets.map((asset) => ( -
-
-
+ {assets.map((asset) => { + const reviewStatus = String(asset.metadata?.review_status || asset.status || 'pending_review'); + return ( +
+
+
+
+
+ {asset.name} +
+

{asset.mime_type || '视频素材'}

+ + {reviewStatus === 'approved' ? '已通过' : reviewStatus} + +
+ + +
-
- {asset.name} -
-

视频素材

-
- ))} + ); + })}
)}
diff --git a/apps/web/src/pages/workspace/ProjectGeneration.tsx b/apps/web/src/pages/workspace/ProjectGeneration.tsx index 8710d8ec3..00c8b73b8 100644 --- a/apps/web/src/pages/workspace/ProjectGeneration.tsx +++ b/apps/web/src/pages/workspace/ProjectGeneration.tsx @@ -1,35 +1,124 @@ /** - * 视频剪辑/生成页面 - V21 完全对标 + * 视频剪辑/生成页面 - V21 外观 + 真实业务接入 */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Select, Modal, Form, message } from 'antd'; +import { Select, message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getAssetLibraries, getAssets, getProjectAssetDiagnosis } from '@/api/assets'; +import { createEditPlan, type EditPlanItem } from '@/api/editPlans'; +import { createGenerationTask, getGenerationTask, type GenerationTaskItem } from '@/api/generation'; +import { getProjectTitles } from '@/api/projectTitles'; +import { getProject } from '@/api/projects'; import './ProjectGeneration.css'; -const TITLE_OPTIONS = [ - { id: '1', name: '产品介绍', content: '完整介绍产品功能和优势' }, - { id: '2', name: '用户评价', content: '展示用户真实反馈和评分' }, - { id: '3', name: '功能演示', content: '详细演示产品各项功能' }, -]; - const ProjectGeneration: React.FC = () => { - const { id: projectId } = useParams<{ id: string }>(); - const [selectedTitle, setSelectedTitle] = useState(''); - const [generateOpen, setGenerateOpen] = useState(false); - const [form] = Form.useForm(); + const { id } = useParams<{ id: string }>(); + const projectId = id || ''; + const [selectedLibraryId, setSelectedLibraryId] = useState(''); + const [selectedTitleId, setSelectedTitleId] = useState(''); + const [editPlan, setEditPlan] = useState(null); + const [taskId, setTaskId] = useState(''); + const queryClient = useQueryClient(); - const handleGenerate = async () => { - const values = await form.validateFields(); - try { + const projectQuery = useQuery({ + queryKey: ['project', projectId], + queryFn: () => getProject(projectId), + enabled: !!projectId, + }); + + const librariesQuery = useQuery({ + queryKey: ['asset-libraries', projectId], + queryFn: () => getAssetLibraries(projectId), + enabled: !!projectId, + }); + + const titlesQuery = useQuery({ + queryKey: ['project-titles', projectId], + queryFn: () => getProjectTitles(projectId, true), + enabled: !!projectId, + }); + + const assetsQuery = useQuery({ + queryKey: ['assets', selectedLibraryId], + queryFn: () => getAssets(selectedLibraryId), + enabled: !!selectedLibraryId, + refetchInterval: 3000, + }); + + const diagnosisQuery = useQuery({ + queryKey: ['project-asset-diagnosis', projectId], + queryFn: () => getProjectAssetDiagnosis(projectId), + enabled: !!projectId, + }); + + const taskQuery = useQuery({ + queryKey: ['generation-task', taskId], + queryFn: () => getGenerationTask(taskId), + enabled: !!taskId, + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === 'completed' || status === 'failed' || status === 'cancelled' ? false : 2000; + }, + }); + + const selectedLibrary = librariesQuery.data?.find((library) => library.id === selectedLibraryId); + const workspaceId = projectQuery.data?.workspace_id || selectedLibrary?.workspace_id || ''; + const assets = useMemo(() => assetsQuery.data || [], [assetsQuery.data]); + + const createPlanMutation = useMutation({ + mutationFn: () => + createEditPlan({ + projectId, + data: { + workspace_id: workspaceId, + asset_library_id: selectedLibraryId, + title_id: selectedTitleId, + }, + }), + onSuccess: (plan) => { + setEditPlan(plan); + message.success('剪辑计划已生成'); + }, + onError: () => message.error('生成剪辑计划失败'), + }); + + const createTaskMutation = useMutation({ + mutationFn: async (): Promise => { + const plan = editPlan || (await createPlanMutation.mutateAsync()); + return createGenerationTask({ + workspace_id: workspaceId, + project_id: projectId, + asset_library_id: selectedLibraryId, + strategy_id: selectedTitleId, + edit_plan_id: plan.id, + }); + }, + onSuccess: (task) => { + setTaskId(task.id); message.success('成片生成任务已创建'); - setGenerateOpen(false); - form.resetFields(); - setSelectedTitle(''); - } catch (error: any) { - message.error('创建生成任务失败'); + queryClient.invalidateQueries({ queryKey: ['generation-task', task.id] }); + }, + onError: () => message.error('创建生成任务失败'), + }); + + const handleCreatePlan = () => { + if (!workspaceId || !selectedLibraryId || !selectedTitleId) { + message.error('请选择素材库和标题'); + return; } + createPlanMutation.mutate(); }; + const taskStatusText = (() => { + const status = taskQuery.data?.status; + if (status === 'completed') return '生成完成'; + if (status === 'failed') return '生成失败'; + if (status === 'running') return '生成中'; + if (status === 'pending') return '等待中'; + return '未开始'; + })(); + return (
@@ -37,15 +126,51 @@ const ProjectGeneration: React.FC = () => {

视频剪辑

生成您的成片

-
+ +
+

剪辑参数

+
+
+

选择素材库

+ { + setSelectedTitleId(value); + setEditPlan(null); + }} + options={(titlesQuery.data || []).map((title) => ({ value: title.id, label: title.text }))} + /> +
+
+

素材就绪度:{diagnosisQuery.data?.readiness_label || 'Ready'}

+

生成计划

- {TITLE_OPTIONS.map((title, idx) => { + {(titlesQuery.data || []).slice(0, 4).map((title, idx) => { const colors = [ 'linear-gradient(135deg,#4f46e5,#818cf8)', 'linear-gradient(135deg,#f97316,#ef4444)', @@ -55,54 +180,40 @@ const ProjectGeneration: React.FC = () => { return (
setSelectedTitle(title.id)} + className={`xx-choice ${selectedTitleId === title.id ? 'selected' : ''}`} + onClick={() => setSelectedTitleId(title.id)} > -
- 📹 -
- {selectedTitle === title.id &&
} - {title.name} -

{title.content.substring(0, 50)}...

+
📹
+ {selectedTitleId === title.id &&
} + 标题策略 +

使用次数:{title.usage_count}

); })}
- setGenerateOpen(false)} - > -
- - - - - - -
-
+
); }; diff --git a/apps/web/src/pages/workspace/ProjectResults.tsx b/apps/web/src/pages/workspace/ProjectResults.tsx index 0d4fe6cf6..3f7e6a72b 100644 --- a/apps/web/src/pages/workspace/ProjectResults.tsx +++ b/apps/web/src/pages/workspace/ProjectResults.tsx @@ -1,13 +1,65 @@ /** - * 成片库页面 - V21 完全对标 + * 成片库页面 - V21 外观 + 真实业务接入 */ -import React, { useState } from 'react'; +import React from 'react'; import { useParams } from 'react-router-dom'; +import { message } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getGeneratedVideoDownloadUrl, + getGeneratedVideos, + updateGeneratedVideoReviewStatus, + type GeneratedVideoItem, +} from '@/api/generation'; import './ProjectResults.css'; +const reviewLabel = (status?: string) => { + if (status === 'approved') return '可发布'; + if (status === 'rejected') return '已驳回'; + return '待复核'; +}; + const ProjectResults: React.FC = () => { - const { id: projectId } = useParams<{ id: string }>(); - const [videos] = useState([]); + const { id } = useParams<{ id: string }>(); + const projectId = id || ''; + const queryClient = useQueryClient(); + + const videosQuery = useQuery({ + queryKey: ['generated-videos', projectId], + queryFn: () => getGeneratedVideos(projectId), + enabled: !!projectId, + refetchInterval: 5000, + }); + + const reviewMutation = useMutation({ + mutationFn: ({ videoId, reviewStatus }: { videoId: string; reviewStatus: 'approved' | 'rejected' | 'pending_review' }) => + updateGeneratedVideoReviewStatus(videoId, reviewStatus), + onSuccess: () => { + message.success('成片复核状态已更新'); + queryClient.invalidateQueries({ queryKey: ['generated-videos', projectId] }); + }, + onError: () => message.error('成片复核状态更新失败'), + }); + + const handleDownload = async (video: GeneratedVideoItem) => { + try { + const url = await getGeneratedVideoDownloadUrl(video.id); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (error) { + message.error('获取下载地址失败'); + } + }; + + const handleBatchDownloadUrls = async () => { + try { + await Promise.all((videosQuery.data || []).map((video) => getGeneratedVideoDownloadUrl(video.id))); + message.success('批量获取下载地址完成'); + } catch (error) { + message.error('批量获取下载地址失败'); + } + }; + + const videos = videosQuery.data || []; return (
@@ -16,29 +68,50 @@ const ProjectResults: React.FC = () => {

成片库

查看和下载生成的成片

+
{videos.length === 0 ? (
🎬
-

还没有生成任何成片

+

{videosQuery.isLoading ? '成片加载中...' : '还没有生成任何成片'}

) : (

生成的成片

- {videos.map((video: any) => ( -
-
- + {videos.map((video) => { + const status = video.review_status || 'pending_review'; + return ( +
+
+ +
+
+ {video.name} + {reviewLabel(status)} +
+

{video.duration || 0}s · 9:16 · {Math.round((video.file_size || 0) / 1024 / 1024)}MB

+
+ + + +
-
- {video.name} - 可发布 -
-

{video.duration || 0}s · 9:16 · {Math.round(video.file_size / 1024 / 1024)}MB

-
- ))} + ); + })}
)} diff --git a/apps/web/src/pages/workspace/ProjectTitles.tsx b/apps/web/src/pages/workspace/ProjectTitles.tsx index c0b33f8db..a5f2de578 100644 --- a/apps/web/src/pages/workspace/ProjectTitles.tsx +++ b/apps/web/src/pages/workspace/ProjectTitles.tsx @@ -1,33 +1,76 @@ /** - * 标题库页面 - V21 完全对标 + * 标题库页面 - V21 外观 + 真实业务接入 */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { Input, Modal, Form, message } from 'antd'; +import { Input, Modal, Form, message, Switch, Select } from 'antd'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { createProjectTitle, getProjectTitles, updateProjectTitle, type ProjectTitleItem } from '@/api/projectTitles'; +import { getProject } from '@/api/projects'; import './ProjectTitles.css'; +const categoryOptions = [ + { label: '默认', value: 'default' }, + { label: '营销', value: 'marketing' }, + { label: '教程', value: 'tutorial' }, + { label: '故事', value: 'story' }, + { label: '促销', value: 'promo' }, +]; + const ProjectTitles: React.FC = () => { - const { id: projectId } = useParams<{ id: string }>(); - const [titles] = useState([]); + const { id } = useParams<{ id: string }>(); + const projectId = id || ''; const [searchText, setSearchText] = useState(''); const [createOpen, setCreateOpen] = useState(false); - const [form] = Form.useForm(); + const [form] = Form.useForm<{ text: string; category: ProjectTitleItem['category']; favorite: boolean }>(); + const queryClient = useQueryClient(); - const handleCreate = async () => { - const values = await form.validateFields(); - try { + const projectQuery = useQuery({ + queryKey: ['project', projectId], + queryFn: () => getProject(projectId), + enabled: !!projectId, + }); + + const titlesQuery = useQuery({ + queryKey: ['project-titles', projectId], + queryFn: () => getProjectTitles(projectId), + enabled: !!projectId, + refetchInterval: 3000, + }); + + const createMutation = useMutation({ + mutationFn: (values: { text: string; category: ProjectTitleItem['category']; favorite: boolean }) => + createProjectTitle(projectId, { + workspace_id: projectQuery.data!.workspace_id, + text: values.text, + category: values.category, + favorite: values.favorite, + }), + onSuccess: () => { message.success('标题创建成功'); setCreateOpen(false); form.resetFields(); - } catch (error: any) { - message.error('创建失败'); - } - }; + queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] }); + }, + onError: () => message.error('创建失败'), + }); - const filtered = titles.filter((t: any) => - t.name.toLowerCase().includes(searchText.toLowerCase()) || - t.content.toLowerCase().includes(searchText.toLowerCase()) - ); + const toggleMutation = useMutation({ + mutationFn: ({ titleId, favorite }: { titleId: string; favorite: boolean }) => + updateProjectTitle(titleId, { favorite }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['project-titles', projectId] }), + onError: () => message.error('标题状态更新失败'), + }); + + const filtered = useMemo(() => { + const keyword = searchText.trim().toLowerCase(); + return (titlesQuery.data || []).filter((title) => !keyword || title.text.toLowerCase().includes(keyword)); + }, [searchText, titlesQuery.data]); + + const handleCreate = async () => { + const values = await form.validateFields(); + createMutation.mutate(values); + }; return (
@@ -36,7 +79,7 @@ const ProjectTitles: React.FC = () => {

标题库

管理和搜索您的视频标题

-
@@ -44,7 +87,12 @@ const ProjectTitles: React.FC = () => {
+ setSearchText(e.target.value)} style={{ padding: '10px 14px', borderRadius: '12px' }} @@ -57,11 +105,20 @@ const ProjectTitles: React.FC = () => {
) : (
- {filtered.map((title: any) => ( + {filtered.map((title) => (
- {title.name} -

{title.content}

+ {title.text} +

使用次数:{title.usage_count}

+
+
+ {title.favorite && 常用} + toggleMutation.mutate({ titleId: title.id, favorite: checked })} + />
))} @@ -74,26 +131,19 @@ const ProjectTitles: React.FC = () => { open={createOpen} okText="创建" cancelText="取消" + confirmLoading={createMutation.isPending} onOk={handleCreate} onCancel={() => setCreateOpen(false)} > -
- - + + + - - + +