feat(assets): enforce smart diagnosis workflow
This commit is contained in:
@@ -5,6 +5,7 @@ const PASSWORD = 'SmokePass123!';
|
||||
type WorkspaceResponse = { id?: string; workspace_id?: string };
|
||||
type ProjectResponse = { id: string };
|
||||
type LibraryResponse = { id: string };
|
||||
type AssetListResponse = { items: Array<{ name: string; status: string; mime_type?: string; file_type?: string }> };
|
||||
type GenerationTaskResponse = { id: string; status: string; progress: number; result_count: number; error_message?: string | null };
|
||||
type GeneratedVideoResponse = { id: string; name: string; file_url: string; file_size: number };
|
||||
|
||||
@@ -53,6 +54,39 @@ test.describe('Core generation and download flow', () => {
|
||||
expect(library.status(), await library.text()).toBe(200);
|
||||
const libraryData = (await library.json()) as LibraryResponse;
|
||||
|
||||
const upload = await request.post(`${apiBase}/upload`, {
|
||||
headers,
|
||||
multipart: {
|
||||
workspace_id: workspaceId || '',
|
||||
project_id: projectData.id,
|
||||
library_id: libraryData.id,
|
||||
file: {
|
||||
name: 'e2e-generation-source.MOV',
|
||||
mimeType: 'video/quicktime',
|
||||
buffer: Buffer.from('playwright generation source mov'),
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(upload.status(), await upload.text()).toBe(200);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const assets = await request.get(`${apiBase}/assets`, {
|
||||
headers,
|
||||
params: { library_id: libraryData.id },
|
||||
});
|
||||
if (!assets.ok()) {
|
||||
return `http_${assets.status()}`;
|
||||
}
|
||||
const data = (await assets.json()) as AssetListResponse;
|
||||
const asset = data.items.find((item) => item.name === 'e2e-generation-source.MOV');
|
||||
return asset ? `${asset.mime_type || asset.file_type || ''}:${asset.status}` : 'missing';
|
||||
},
|
||||
{ timeout: 30_000, intervals: [1_000, 2_000, 3_000] }
|
||||
)
|
||||
.toMatch(/^(video\/quicktime|video\/mp4|video)?:ready$/);
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('邮箱').fill(email);
|
||||
await page.getByPlaceholder('密码').fill(PASSWORD);
|
||||
@@ -64,6 +98,9 @@ test.describe('Core generation and download flow', () => {
|
||||
await page.locator('.ant-select-selector').first().click();
|
||||
await page.getByText(`${libraryName} (video)`).click();
|
||||
|
||||
await expect(page.getByText(/素材准备度:/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('button', { name: '发起生成' })).toBeEnabled({ timeout: 20_000 });
|
||||
|
||||
const createTaskResponsePromise = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/v1/generation/tasks') && response.request().method() === 'POST',
|
||||
{ timeout: 30_000 }
|
||||
|
||||
@@ -100,6 +100,7 @@ const ProjectAssets: React.FC = () => {
|
||||
const [classificationSourceFilter, setClassificationSourceFilter] = useState<string>('all');
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
const [batchMode, setBatchMode] = useState<'unclassified_only' | 'include_classified'>('unclassified_only');
|
||||
const [smartViewFilter, setSmartViewFilter] = useState<string>('all');
|
||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>(defaultBatchProgress);
|
||||
const [batchJobIds, setBatchJobIds] = useState<string[]>([]);
|
||||
const [batchFailedIds, setBatchFailedIds] = useState<string[]>([]);
|
||||
@@ -281,6 +282,16 @@ const ProjectAssets: React.FC = () => {
|
||||
const filteredAssets = useMemo(() => {
|
||||
let items = assetsQuery.data || [];
|
||||
|
||||
if (smartViewFilter === 'recommended') {
|
||||
items = items.filter((item) => item.status === 'ready' && item.mime_type?.startsWith('video'));
|
||||
} else if (smartViewFilter === 'needs_attention') {
|
||||
items = items.filter((item) => item.status !== 'ready' || (typeof item.quality_score === 'number' && item.quality_score < 60));
|
||||
} else if (smartViewFilter === 'unclassified') {
|
||||
items = items.filter((item) => !item.metadata?.classification || item.classification_status === 'failed');
|
||||
} else if (smartViewFilter === 'voice') {
|
||||
items = items.filter((item) => item.mime_type?.startsWith('audio'));
|
||||
}
|
||||
|
||||
if (classificationFilter !== 'all') {
|
||||
if (classificationFilter === 'unclassified') {
|
||||
items = items.filter((item) => !item.metadata?.classification);
|
||||
@@ -299,8 +310,12 @@ const ProjectAssets: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (smartViewFilter === 'recent') {
|
||||
return items.slice(0, 10);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [assetsQuery.data, classificationFilter, classificationSourceFilter]);
|
||||
}, [assetsQuery.data, classificationFilter, classificationSourceFilter, smartViewFilter]);
|
||||
|
||||
const selectedAssets = useMemo(
|
||||
() => filteredAssets.filter((item) => selectedRowKeys.includes(item.id)),
|
||||
@@ -481,6 +496,19 @@ const ProjectAssets: React.FC = () => {
|
||||
<Space>
|
||||
<Select style={{ width: 180 }} value={classificationFilter} options={classificationOptions} onChange={setClassificationFilter} />
|
||||
<Select style={{ width: 180 }} value={classificationSourceFilter} options={sourceOptions} onChange={setClassificationSourceFilter} />
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={smartViewFilter}
|
||||
options={[
|
||||
{ label: '全部素材', value: 'all' },
|
||||
{ label: '推荐素材', value: 'recommended' },
|
||||
{ label: '慎用素材', value: 'needs_attention' },
|
||||
{ label: '未分类素材', value: 'unclassified' },
|
||||
{ label: '最近上传', value: 'recent' },
|
||||
{ label: '配音素材', value: 'voice' },
|
||||
]}
|
||||
onChange={setSmartViewFilter}
|
||||
/>
|
||||
<Select style={{ width: 180 }} value={batchMode} options={batchModeOptions} onChange={(value) => setBatchMode(value)} />
|
||||
<Select
|
||||
style={{ width: 280 }}
|
||||
@@ -584,8 +612,18 @@ const ProjectAssets: React.FC = () => {
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{diagnosisQuery.data.smart_views.map((item) => (
|
||||
<Tag key={item.key}>{item.label}:{item.count}</Tag>
|
||||
<Tag
|
||||
key={item.key}
|
||||
color={smartViewFilter === item.key ? 'blue' : undefined}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setSmartViewFilter(item.key)}
|
||||
>
|
||||
{item.label}:{item.count}
|
||||
</Tag>
|
||||
))}
|
||||
{smartViewFilter !== 'all' && (
|
||||
<Button size="small" onClick={() => setSmartViewFilter('all')}>清除智能筛选</Button>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router-dom';
|
||||
import { Alert, Button, Card, Form, Input, Select, Space, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { createGenerationTask, getGeneratedVideoDownloadUrl, getGenerationResults, getGenerationTask } from '@/api/generation';
|
||||
import { getAssetLibraries } from '@/api/assets';
|
||||
import { getAssetLibraries, getProjectAssetDiagnosis } from '@/api/assets';
|
||||
import { getProject } from '@/api/projects';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -79,6 +79,13 @@ const ProjectGeneration: React.FC = () => {
|
||||
enabled: !!projectId,
|
||||
});
|
||||
|
||||
const diagnosisQuery = useQuery({
|
||||
queryKey: ['generation-asset-diagnosis', projectId],
|
||||
queryFn: () => getProjectAssetDiagnosis(projectId),
|
||||
enabled: !!projectId,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const taskQuery = useQuery({
|
||||
queryKey: ['generation-task', taskId],
|
||||
queryFn: () => getGenerationTask(taskId),
|
||||
@@ -123,6 +130,8 @@ const ProjectGeneration: React.FC = () => {
|
||||
const failedReason = taskQuery.data?.status === 'failed'
|
||||
? humanizeGenerationError(taskQuery.data.error_message)
|
||||
: '';
|
||||
const hasCriticalGap = !!diagnosisQuery.data?.gaps.some((gap) => gap.severity === 'critical');
|
||||
const canSubmitGeneration = !!workspaceId && !!user?.id && !hasCriticalGap && !diagnosisQuery.isLoading;
|
||||
|
||||
const openSignedDownload = async (videoId: string) => {
|
||||
try {
|
||||
@@ -158,6 +167,24 @@ const ProjectGeneration: React.FC = () => {
|
||||
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="生成结果查询失败" />
|
||||
)}
|
||||
|
||||
{diagnosisQuery.data && (
|
||||
<Alert
|
||||
style={{ marginBottom: 16 }}
|
||||
type={hasCriticalGap ? 'error' : diagnosisQuery.data.gaps.length ? 'warning' : 'success'}
|
||||
showIcon
|
||||
message={`素材准备度:${diagnosisQuery.data.readiness_score} / ${diagnosisQuery.data.readiness_label}`}
|
||||
description={
|
||||
diagnosisQuery.data.gaps.length
|
||||
? diagnosisQuery.data.gaps.map((gap) => `${gap.message}:${gap.recommendation}`).join(';')
|
||||
: '素材准备度良好,可以发起生成。'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{diagnosisQuery.isError && (
|
||||
<Alert style={{ marginBottom: 16 }} type="error" showIcon message="素材诊断加载失败" description="请先回到素材页确认素材状态,或刷新后重试。" />
|
||||
)}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
@@ -182,7 +209,7 @@ const ProjectGeneration: React.FC = () => {
|
||||
<Input placeholder="可选:例如 default-strategy" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={generationMutation.isPending} disabled={!workspaceId || !user?.id}>发起生成</Button>
|
||||
<Button type="primary" htmlType="submit" loading={generationMutation.isPending} disabled={!canSubmitGeneration}>发起生成</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user