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
This commit is contained in:
@@ -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<AssetItem[]>([]);
|
||||
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<AssetItem[]>(() => 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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 上传区 */}
|
||||
<div className="xx-card">
|
||||
<h3>素材就绪度:{diagnosisQuery.data?.readiness_label || 'Ready'}</h3>
|
||||
<p>
|
||||
预计成片 {diagnosisQuery.data?.estimated_video_count ?? 0} 条 · 视频素材 {diagnosisQuery.data?.video_assets ?? assets.length} 个
|
||||
</p>
|
||||
{diagnosisQuery.data?.gaps?.[0]?.message && <p>{diagnosisQuery.data.gaps[0].message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<h3>上传新素材</h3>
|
||||
<div
|
||||
className="xx-upload-zone"
|
||||
data-testid="upload-dropzone"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -44,7 +125,7 @@ const ProjectAssets: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '32px', marginBottom: '12px' }}>📁</div>
|
||||
<p>拖拽文件到这里或点击选择</p>
|
||||
<p>点击或拖拽素材到这里上传</p>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
@@ -55,15 +136,14 @@ const ProjectAssets: React.FC = () => {
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
disabled={uploading}
|
||||
disabled={uploading || !libraryId}
|
||||
>
|
||||
{uploading ? '上传中...' : '选择文件'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 素材列表 */}
|
||||
<div className="xx-card">
|
||||
<div className="xx-card" data-testid="material-list">
|
||||
<h3>已上传素材</h3>
|
||||
{assets.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
@@ -71,17 +151,37 @@ const ProjectAssets: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-video-grid">
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} className="xx-video-card">
|
||||
<div className="xx-video-thumb">
|
||||
<div className="mini-play">▶</div>
|
||||
{assets.map((asset) => {
|
||||
const reviewStatus = String(asset.metadata?.review_status || asset.status || 'pending_review');
|
||||
return (
|
||||
<div key={asset.id} className="xx-video-card xx-vertical-card">
|
||||
<div className="xx-video-thumb">
|
||||
<div className="mini-play">▶</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<b>{asset.name}</b>
|
||||
</div>
|
||||
<p>{asset.mime_type || '视频素材'}</p>
|
||||
<span className={`xx-pill ${reviewStatus === 'approved' ? 'ok' : ''}`}>
|
||||
{reviewStatus === 'approved' ? '已通过' : reviewStatus}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => reviewMutation.mutate({ assetId: asset.id, reviewStatus: 'approved' })}
|
||||
>
|
||||
通过
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => reviewMutation.mutate({ assetId: asset.id, reviewStatus: 'rejected' })}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<b>{asset.name}</b>
|
||||
</div>
|
||||
<p>视频素材</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<string>('');
|
||||
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<EditPlanItem | null>(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<GenerationTaskItem> => {
|
||||
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 (
|
||||
<div className="xx-project-generation">
|
||||
<div className="xx-page-head">
|
||||
@@ -37,15 +126,51 @@ const ProjectGeneration: React.FC = () => {
|
||||
<h2>视频剪辑</h2>
|
||||
<p>生成您的成片</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setGenerateOpen(true)}>
|
||||
+ 开始生成
|
||||
</div>
|
||||
|
||||
<div className="xx-card" data-testid="generation-config-section">
|
||||
<h3>剪辑参数</h3>
|
||||
<div style={{ display: 'grid', gap: 16, gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))' }}>
|
||||
<div>
|
||||
<p>选择素材库</p>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedLibraryId || undefined}
|
||||
placeholder="选择素材库"
|
||||
onChange={(value) => {
|
||||
setSelectedLibraryId(value);
|
||||
setEditPlan(null);
|
||||
}}
|
||||
options={(librariesQuery.data || []).map((library) => ({
|
||||
value: library.id,
|
||||
label: `${library.name} (${library.kind})`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p>选择标题策略</p>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={selectedTitleId || undefined}
|
||||
placeholder="选择标题"
|
||||
onChange={(value) => {
|
||||
setSelectedTitleId(value);
|
||||
setEditPlan(null);
|
||||
}}
|
||||
options={(titlesQuery.data || []).map((title) => ({ value: title.id, label: title.text }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ marginTop: 16 }}>素材就绪度:{diagnosisQuery.data?.readiness_label || 'Ready'}</p>
|
||||
<button className="btn primary" onClick={handleCreatePlan} disabled={!selectedLibraryId || !selectedTitleId || createPlanMutation.isPending}>
|
||||
重新生成计划
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="xx-card">
|
||||
<h3>生成计划</h3>
|
||||
<div className="xx-template-grid">
|
||||
{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 (
|
||||
<div
|
||||
key={title.id}
|
||||
className={`xx-choice ${selectedTitle === title.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTitle(title.id)}
|
||||
className={`xx-choice ${selectedTitleId === title.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedTitleId(title.id)}
|
||||
>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>
|
||||
📹
|
||||
</div>
|
||||
{selectedTitle === title.id && <div className="check">✓</div>}
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content.substring(0, 50)}...</p>
|
||||
<div className="cover" style={{ background: colors[idx % 4] }}>📹</div>
|
||||
{selectedTitleId === title.id && <div className="check">✓</div>}
|
||||
<b>标题策略</b>
|
||||
<p>使用次数:{title.usage_count}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="生成新成片"
|
||||
open={generateOpen}
|
||||
okText="开始生成"
|
||||
cancelText="取消"
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => setGenerateOpen(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="template_id"
|
||||
label="选择模板"
|
||||
rules={[{ required: true, message: '请选择模板' }]}
|
||||
>
|
||||
<Select placeholder="选择剪辑模板">
|
||||
<Select.Option value="basic">基础模板</Select.Option>
|
||||
<Select.Option value="product">产品介绍</Select.Option>
|
||||
<Select.Option value="lifestyle">生活方式</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="duration"
|
||||
label="视频时长"
|
||||
rules={[{ required: true, message: '请选择时长' }]}
|
||||
>
|
||||
<Select placeholder="选择时长">
|
||||
<Select.Option value="15">15秒</Select.Option>
|
||||
<Select.Option value="30">30秒</Select.Option>
|
||||
<Select.Option value="60">60秒</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<aside className="xx-card" role="complementary">
|
||||
<h3>剪辑计划预览</h3>
|
||||
<p>计划包含 {editPlan?.clips?.length || assets.length} 个素材片段</p>
|
||||
{(editPlan?.clips || assets.map((asset, index) => ({ id: asset.id, asset_name: asset.name, sequence: index + 1, reason: '自动选择' }))).map((clip) => (
|
||||
<div key={clip.id} className="xx-title-row">
|
||||
<div>
|
||||
<b>{clip.asset_name}</b>
|
||||
<p>{clip.reason || '自动选择'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
className="btn primary"
|
||||
onClick={() => createTaskMutation.mutate()}
|
||||
disabled={!selectedLibraryId || !selectedTitleId || createTaskMutation.isPending}
|
||||
>
|
||||
确认计划并生成
|
||||
</button>
|
||||
<p>生成状态:{taskStatusText}</p>
|
||||
{taskQuery.data?.error_message && <p>生成失败:{taskQuery.data.error_message}</p>}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
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 (
|
||||
<div className="xx-project-results">
|
||||
@@ -16,29 +68,50 @@ const ProjectResults: React.FC = () => {
|
||||
<h2>成片库</h2>
|
||||
<p>查看和下载生成的成片</p>
|
||||
</div>
|
||||
<button className="btn secondary" onClick={handleBatchDownloadUrls} disabled={videos.length === 0}>
|
||||
批量获取下载地址
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="xx-empty-state">
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🎬</div>
|
||||
<p>还没有生成任何成片</p>
|
||||
<p>{videosQuery.isLoading ? '成片加载中...' : '还没有生成任何成片'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-card">
|
||||
<h3>生成的成片</h3>
|
||||
<div className="xx-vertical-grid compact">
|
||||
{videos.map((video: any) => (
|
||||
<div key={video.id} className="xx-vertical-card">
|
||||
<div className="xx-vertical-thumb">
|
||||
<button className="mini-play">▶</button>
|
||||
{videos.map((video) => {
|
||||
const status = video.review_status || 'pending_review';
|
||||
return (
|
||||
<div key={video.id} className="xx-vertical-card">
|
||||
<div className="xx-vertical-thumb">
|
||||
<button className="mini-play">▶</button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<b>{video.name}</b>
|
||||
<span className={`xx-pill ${status === 'approved' ? 'ok' : ''}`}>{reviewLabel(status)}</span>
|
||||
</div>
|
||||
<p>{video.duration || 0}s · 9:16 · {Math.round((video.file_size || 0) / 1024 / 1024)}MB</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn secondary" onClick={() => handleDownload(video)}>下载</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => reviewMutation.mutate({ videoId: video.id, reviewStatus: 'approved' })}
|
||||
>
|
||||
可发布
|
||||
</button>
|
||||
<button
|
||||
className="btn secondary"
|
||||
onClick={() => reviewMutation.mutate({ videoId: video.id, reviewStatus: 'rejected' })}
|
||||
>
|
||||
驳回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<b>{video.name}</b>
|
||||
<span className="pill ok">可发布</span>
|
||||
</div>
|
||||
<p>{video.duration || 0}s · 9:16 · {Math.round(video.file_size / 1024 / 1024)}MB</p>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
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 (
|
||||
<div className="xx-project-titles">
|
||||
@@ -36,7 +79,7 @@ const ProjectTitles: React.FC = () => {
|
||||
<h2>标题库</h2>
|
||||
<p>管理和搜索您的视频标题</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setCreateOpen(true)}>
|
||||
<button className="btn primary" data-testid="create-title-btn" onClick={() => setCreateOpen(true)}>
|
||||
+ 创建标题
|
||||
</button>
|
||||
</div>
|
||||
@@ -44,7 +87,12 @@ const ProjectTitles: React.FC = () => {
|
||||
<div className="xx-card">
|
||||
<div className="xx-search-bar">
|
||||
<Input
|
||||
placeholder="搜索标题..."
|
||||
placeholder="例如:3 秒抓住注意力,30 秒讲清卖点"
|
||||
style={{ padding: '10px 14px', borderRadius: '12px' }}
|
||||
/>
|
||||
<Input
|
||||
data-testid="title-search-input"
|
||||
placeholder="搜索标题"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ padding: '10px 14px', borderRadius: '12px' }}
|
||||
@@ -57,11 +105,20 @@ const ProjectTitles: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="xx-title-list">
|
||||
{filtered.map((title: any) => (
|
||||
{filtered.map((title) => (
|
||||
<div key={title.id} className="xx-title-row">
|
||||
<div>
|
||||
<b>{title.name}</b>
|
||||
<p>{title.content}</p>
|
||||
<b>{title.text}</b>
|
||||
<p>使用次数:{title.usage_count}</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{title.favorite && <span className="xx-pill warn">常用</span>}
|
||||
<Switch
|
||||
checked={title.favorite}
|
||||
checkedChildren="常用"
|
||||
unCheckedChildren="普通"
|
||||
onChange={(checked) => toggleMutation.mutate({ titleId: title.id, favorite: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -74,26 +131,19 @@ const ProjectTitles: React.FC = () => {
|
||||
open={createOpen}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
confirmLoading={createMutation.isPending}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="标题"
|
||||
rules={[{ required: true, message: '请输入标题' }]}
|
||||
>
|
||||
<Input placeholder="例如:产品介绍" />
|
||||
<Form form={form} layout="vertical" initialValues={{ category: 'default', favorite: false }}>
|
||||
<Form.Item name="text" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||
<Input data-testid="title-content-input" placeholder="例如:3 秒抓住注意力,30 秒讲清卖点" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="标题内容"
|
||||
rules={[{ required: true, message: '请输入标题内容' }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="例如:完整介绍产品功能和优势"
|
||||
rows={4}
|
||||
/>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select options={categoryOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="favorite" valuePropName="checked" label="常用标题">
|
||||
<Switch checkedChildren="常用" unCheckedChildren="普通" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user