feat(phase7): add asset classification workflow
This commit is contained in:
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: '请输入素材库名称' }]}>
|
||||
|
||||
Reference in New Issue
Block a user