feat(assets): enforce smart diagnosis workflow

This commit is contained in:
Xiaoxia AI
2026-06-23 20:36:47 +08:00
parent d052645307
commit bb3a7e5e15
3 changed files with 106 additions and 4 deletions
+37
View File
@@ -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 }
+40 -2
View File
@@ -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>