feat(phase7): add asset classification workflow

This commit is contained in:
Xiaoxia AI
2026-06-17 18:29:48 +08:00
parent 8aeb46aded
commit db1ebc4e9d
12 changed files with 484 additions and 330 deletions
+28 -1
View File
@@ -33,6 +33,17 @@ export interface IngestJob {
result_asset_id: string;
}
export interface ClassificationJob {
id: string;
workspace_id: string;
project_id: string;
asset_id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
classification: string;
confidence: number;
error_message: string;
}
export const getAssetLibraries = async (projectId: string): Promise<AssetLibraryItem[]> => {
const response = await apiClient.get('/asset-libraries', {
params: { project_id: projectId },
@@ -57,7 +68,9 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
return response.data.items;
};
export const uploadAsset = async (formData: FormData): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
export const uploadAsset = async (
formData: FormData
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
const response = await apiClient.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@@ -68,3 +81,17 @@ export const getIngestJob = async (jobId: string): Promise<IngestJob> => {
const response = await apiClient.get(`/ingest-jobs/${jobId}`);
return response.data;
};
export const submitClassificationJob = async (data: {
workspace_id: string;
project_id: string;
asset_id: string;
}): Promise<ClassificationJob> => {
const response = await apiClient.post('/classification-jobs', data);
return response.data;
};
export const getClassificationJob = async (jobId: string): Promise<ClassificationJob> => {
const response = await apiClient.get(`/classification-jobs/${jobId}`);
return response.data;
};
+160 -25
View File
@@ -1,9 +1,34 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Alert, Button, Card, Col, Empty, Form, Input, Modal, Row, Select, Space, Table, Tag, Upload, message } from 'antd';
import { InboxOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import {
Alert,
Button,
Card,
Col,
Empty,
Form,
Input,
Modal,
Row,
Select,
Space,
Table,
Tag,
Upload,
message,
} from 'antd';
import { InboxOutlined, PlusOutlined, ReloadOutlined, TagsOutlined } from '@ant-design/icons';
import { useMutation, useQuery } from '@tanstack/react-query';
import { createAssetLibrary, getAssetLibraries, getAssets, getIngestJob, uploadAsset } from '@/api/assets';
import {
createAssetLibrary,
getAssetLibraries,
getAssets,
getClassificationJob,
getIngestJob,
submitClassificationJob,
uploadAsset,
type AssetItem,
} from '@/api/assets';
const { Dragger } = Upload;
@@ -14,7 +39,8 @@ const ProjectAssets: React.FC = () => {
const [workspaceId, setWorkspaceId] = useState('demo-workspace');
const [createLibraryOpen, setCreateLibraryOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [jobId, setJobId] = useState<string>('');
const [ingestJobId, setIngestJobId] = useState<string>('');
const [classificationJobId, setClassificationJobId] = useState<string>('');
const [form] = Form.useForm();
const librariesQuery = useQuery({
@@ -36,9 +62,19 @@ const ProjectAssets: React.FC = () => {
});
const ingestJobQuery = useQuery({
queryKey: ['ingest-job', jobId],
queryFn: () => getIngestJob(jobId),
enabled: !!jobId,
queryKey: ['ingest-job', ingestJobId],
queryFn: () => getIngestJob(ingestJobId),
enabled: !!ingestJobId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'completed' || status === 'failed' ? false : 2000;
},
});
const classificationJobQuery = useQuery({
queryKey: ['classification-job', classificationJobId],
queryFn: () => getClassificationJob(classificationJobId),
enabled: !!classificationJobId,
refetchInterval: (query) => {
const status = query.state.data?.status;
return status === 'completed' || status === 'failed' ? false : 2000;
@@ -56,7 +92,18 @@ const ProjectAssets: React.FC = () => {
setUploading(false);
message.error(ingestJobQuery.data?.error_message || '素材导入失败');
}
}, [ingestJobQuery.data?.status]);
}, [ingestJobQuery.data?.status, assetsQuery]);
useEffect(() => {
const status = classificationJobQuery.data?.status;
if (status === 'completed') {
assetsQuery.refetch();
message.success(`分类完成:${classificationJobQuery.data?.classification || 'unknown'}`);
}
if (status === 'failed') {
message.error(classificationJobQuery.data?.error_message || '素材分类失败');
}
}, [classificationJobQuery.data?.status, classificationJobQuery.data?.classification, assetsQuery]);
const createLibraryMutation = useMutation({
mutationFn: createAssetLibrary,
@@ -70,15 +117,76 @@ const ProjectAssets: React.FC = () => {
onError: () => message.error('素材库创建失败'),
});
const classificationMutation = useMutation({
mutationFn: submitClassificationJob,
onSuccess: (job) => {
setClassificationJobId(job.id);
message.info('已发起素材分类任务');
},
onError: () => message.error('发起分类失败'),
});
const libraryOptions = useMemo(
() => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
() =>
(librariesQuery.data || []).map((item) => ({
label: `${item.name} (${item.kind})`,
value: item.id,
})),
[librariesQuery.data]
);
const renderClassification = (asset: AssetItem) => {
const classification = String(asset.metadata?.classification || '');
const confidence = Number(asset.metadata?.classification_confidence || 0);
if (!classification) {
return <Tag color="default">未分类</Tag>;
}
return (
<Space>
<Tag color="blue">{classification}</Tag>
<Tag>{Math.round(confidence * 100)}%</Tag>
</Space>
);
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (v: string) => <Tag>{v}</Tag> },
{ title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true },
{
title: '类型',
dataIndex: 'mime_type',
key: 'mime_type',
render: (value: string) => <Tag>{value}</Tag>,
},
{
title: '分类结果',
key: 'classification',
render: (_: unknown, record: AssetItem) => renderClassification(record),
},
{
title: '存储键',
dataIndex: 'storage_key',
key: 'storage_key',
ellipsis: true,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: AssetItem) => (
<Button
icon={<TagsOutlined />}
loading={classificationMutation.isPending}
onClick={() =>
classificationMutation.mutate({
workspace_id: record.workspace_id,
project_id: record.project_id,
asset_id: record.id,
})
}
>
发起分类
</Button>
),
},
];
const customUpload = async (options: any) => {
@@ -95,7 +203,7 @@ const ProjectAssets: React.FC = () => {
formData.append('project_id', projectId);
formData.append('library_id', libraryId);
const result = await uploadAsset(formData);
setJobId(result.ingest_job_id);
setIngestJobId(result.ingest_job_id);
onSuccess(result);
message.info('文件已上传,正在导入处理中');
} catch (error) {
@@ -109,18 +217,33 @@ const ProjectAssets: React.FC = () => {
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={24}>
<Card title="项目素材管理" extra={<Space>
<Select
style={{ width: 280 }}
placeholder="选择素材库"
value={libraryId || undefined}
options={libraryOptions}
onChange={setLibraryId}
loading={librariesQuery.isLoading}
/>
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}>新建素材库</Button>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); }}>刷新</Button>
</Space>}>
<Card
title="项目素材管理"
extra={
<Space>
<Select
style={{ width: 280 }}
placeholder="选择素材库"
value={libraryId || undefined}
options={libraryOptions}
onChange={setLibraryId}
loading={librariesQuery.isLoading}
/>
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}>
新建素材库
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
librariesQuery.refetch();
assetsQuery.refetch();
}}
>
刷新
</Button>
</Space>
}
>
{ingestJobQuery.data && (
<Alert
style={{ marginBottom: 16 }}
@@ -130,6 +253,18 @@ const ProjectAssets: React.FC = () => {
/>
)}
{classificationJobQuery.data && (
<Alert
style={{ marginBottom: 16 }}
type={classificationJobQuery.data.status === 'failed' ? 'error' : 'success'}
message={`分类任务状态:${classificationJobQuery.data.status}`}
description={
classificationJobQuery.data.error_message ||
`分类结果:${classificationJobQuery.data.classification || '处理中'}`
}
/>
)}
<Dragger
name="file"
multiple={false}
@@ -173,7 +308,7 @@ const ProjectAssets: React.FC = () => {
});
}}
>
<Form.Item label="Workspace ID" name="workspaceIdHint">
<Form.Item label="Workspace ID">
<Input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} />
</Form.Item>
<Form.Item label="素材库名称" name="name" rules={[{ required: true, message: '请输入素材库名称' }]}>