377 lines
12 KiB
TypeScript
377 lines
12 KiB
TypeScript
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, TagsOutlined } from '@ant-design/icons';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import {
|
|
createAssetLibrary,
|
|
getAssetLibraries,
|
|
getAssets,
|
|
getClassificationJob,
|
|
getIngestJob,
|
|
submitClassificationJob,
|
|
uploadAsset,
|
|
type AssetItem,
|
|
} from '@/api/assets';
|
|
|
|
const { Dragger } = Upload;
|
|
|
|
const classificationOptions = [
|
|
{ label: '全部分类', value: 'all' },
|
|
{ label: '未分类', value: 'unclassified' },
|
|
{ label: 'scenic', value: 'scenic' },
|
|
{ label: 'product', value: 'product' },
|
|
{ label: 'person', value: 'person' },
|
|
{ label: 'animal', value: 'animal' },
|
|
{ label: 'food', value: 'food' },
|
|
{ label: 'tech', value: 'tech' },
|
|
{ label: 'sport', value: 'sport' },
|
|
{ label: 'music', value: 'music' },
|
|
{ label: 'other', value: 'other' },
|
|
];
|
|
|
|
const ProjectAssets: React.FC = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const projectId = id || '';
|
|
const [libraryId, setLibraryId] = useState<string>('');
|
|
const [workspaceId, setWorkspaceId] = useState('demo-workspace');
|
|
const [createLibraryOpen, setCreateLibraryOpen] = useState(false);
|
|
const [uploading, setUploading] = useState(false);
|
|
const [ingestJobId, setIngestJobId] = useState<string>('');
|
|
const [classificationJobId, setClassificationJobId] = useState<string>('');
|
|
const [classifyingAssetId, setClassifyingAssetId] = useState<string>('');
|
|
const [classificationFilter, setClassificationFilter] = useState<string>('all');
|
|
const [form] = Form.useForm();
|
|
|
|
const librariesQuery = useQuery({
|
|
queryKey: ['asset-libraries', projectId],
|
|
queryFn: () => getAssetLibraries(projectId),
|
|
enabled: !!projectId,
|
|
});
|
|
|
|
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 ingestJobQuery = useQuery({
|
|
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;
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const status = ingestJobQuery.data?.status;
|
|
if (status === 'completed') {
|
|
setUploading(false);
|
|
assetsQuery.refetch();
|
|
message.success('素材导入完成,已自动发起分类');
|
|
}
|
|
if (status === 'failed') {
|
|
setUploading(false);
|
|
message.error(ingestJobQuery.data?.error_message || '素材导入失败');
|
|
}
|
|
}, [ingestJobQuery.data?.status, assetsQuery]);
|
|
|
|
useEffect(() => {
|
|
const status = classificationJobQuery.data?.status;
|
|
if (status === 'completed') {
|
|
setClassifyingAssetId('');
|
|
assetsQuery.refetch();
|
|
message.success(`分类完成:${classificationJobQuery.data?.classification || 'unknown'}`);
|
|
}
|
|
if (status === 'failed') {
|
|
setClassifyingAssetId('');
|
|
message.error(classificationJobQuery.data?.error_message || '素材分类失败');
|
|
}
|
|
}, [classificationJobQuery.data?.status, classificationJobQuery.data?.classification, assetsQuery]);
|
|
|
|
const createLibraryMutation = useMutation({
|
|
mutationFn: createAssetLibrary,
|
|
onSuccess: (library) => {
|
|
message.success('素材库创建成功');
|
|
setCreateLibraryOpen(false);
|
|
librariesQuery.refetch();
|
|
setLibraryId(library.id);
|
|
form.resetFields();
|
|
},
|
|
onError: () => message.error('素材库创建失败'),
|
|
});
|
|
|
|
const classificationMutation = useMutation({
|
|
mutationFn: submitClassificationJob,
|
|
onSuccess: (job) => {
|
|
setClassificationJobId(job.id);
|
|
message.info('已发起素材分类任务');
|
|
},
|
|
onError: () => {
|
|
setClassifyingAssetId('');
|
|
message.error('发起分类失败');
|
|
},
|
|
});
|
|
|
|
const libraryOptions = useMemo(
|
|
() =>
|
|
(librariesQuery.data || []).map((item) => ({
|
|
label: `${item.name} (${item.kind})`,
|
|
value: item.id,
|
|
})),
|
|
[librariesQuery.data]
|
|
);
|
|
|
|
const filteredAssets = useMemo(() => {
|
|
const items = assetsQuery.data || [];
|
|
if (classificationFilter === 'all') return items;
|
|
if (classificationFilter === 'unclassified') {
|
|
return items.filter((item) => !item.metadata?.classification);
|
|
}
|
|
return items.filter((item) => item.metadata?.classification === classificationFilter);
|
|
}, [assetsQuery.data, classificationFilter]);
|
|
|
|
const renderClassification = (asset: AssetItem) => {
|
|
const autoClassification = String(asset.metadata?.auto_classification || '');
|
|
if (classifyingAssetId === asset.id || autoClassification === 'queued') {
|
|
return <Tag color="processing">分类中</Tag>;
|
|
}
|
|
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>
|
|
{autoClassification === 'completed' ? <Tag color="green">自动</Tag> : null}
|
|
</Space>
|
|
);
|
|
};
|
|
|
|
const columns = [
|
|
{ title: '名称', dataIndex: 'name', key: 'name' },
|
|
{
|
|
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) => {
|
|
const hasClassification = !!record.metadata?.classification;
|
|
const isCurrentClassifying = classifyingAssetId === record.id;
|
|
const isAutoClassifying = record.metadata?.auto_classification === 'queued';
|
|
return (
|
|
<Button
|
|
icon={<TagsOutlined />}
|
|
loading={isCurrentClassifying && classificationMutation.isPending}
|
|
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id)}
|
|
onClick={() => {
|
|
setClassifyingAssetId(record.id);
|
|
classificationMutation.mutate({
|
|
workspace_id: record.workspace_id,
|
|
project_id: record.project_id,
|
|
asset_id: record.id,
|
|
});
|
|
}}
|
|
>
|
|
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
|
|
</Button>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const customUpload = async (options: any) => {
|
|
const { file, onSuccess, onError } = options;
|
|
if (!libraryId) {
|
|
message.warning('请先选择或创建素材库');
|
|
return;
|
|
}
|
|
try {
|
|
setUploading(true);
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('workspace_id', workspaceId);
|
|
formData.append('project_id', projectId);
|
|
formData.append('library_id', libraryId);
|
|
const result = await uploadAsset(formData);
|
|
setIngestJobId(result.ingest_job_id);
|
|
onSuccess(result);
|
|
message.info('文件已上传,正在导入处理中');
|
|
} catch (error) {
|
|
setUploading(false);
|
|
onError(error);
|
|
message.error('上传失败');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: 24 }}>
|
|
<Row gutter={16} style={{ marginBottom: 16 }}>
|
|
<Col span={24}>
|
|
<Card
|
|
title="项目素材管理"
|
|
extra={
|
|
<Space>
|
|
<Select
|
|
style={{ width: 180 }}
|
|
value={classificationFilter}
|
|
options={classificationOptions}
|
|
onChange={setClassificationFilter}
|
|
/>
|
|
<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 }}
|
|
type={ingestJobQuery.data.status === 'failed' ? 'error' : 'info'}
|
|
message={`导入任务状态:${ingestJobQuery.data.status}`}
|
|
description={ingestJobQuery.data.error_message || `任务 ID: ${ingestJobQuery.data.id}`}
|
|
/>
|
|
)}
|
|
|
|
{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}
|
|
customRequest={customUpload}
|
|
showUploadList={false}
|
|
disabled={uploading}
|
|
style={{ marginBottom: 24 }}
|
|
>
|
|
<p className="ant-upload-drag-icon">
|
|
<InboxOutlined />
|
|
</p>
|
|
<p className="ant-upload-text">点击或拖拽文件到这里上传素材</p>
|
|
<p className="ant-upload-hint">支持视频、音频、图片文件;上传后自动进入导入任务</p>
|
|
</Dragger>
|
|
|
|
{filteredAssets.length ? (
|
|
<Table rowKey="id" columns={columns} dataSource={filteredAssets} pagination={false} />
|
|
) : (
|
|
<Empty description="当前筛选条件下没有素材" />
|
|
)}
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Modal
|
|
title="新建素材库"
|
|
open={createLibraryOpen}
|
|
onCancel={() => setCreateLibraryOpen(false)}
|
|
onOk={() => form.submit()}
|
|
confirmLoading={createLibraryMutation.isPending}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={(values) => {
|
|
createLibraryMutation.mutate({
|
|
workspace_id: workspaceId,
|
|
project_id: projectId,
|
|
name: values.name,
|
|
kind: values.kind,
|
|
});
|
|
}}
|
|
>
|
|
<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: '请输入素材库名称' }]}>
|
|
<Input placeholder="例如:项目视频库" />
|
|
</Form.Item>
|
|
<Form.Item label="素材库类型" name="kind" initialValue="video" rules={[{ required: true }]}>
|
|
<Select options={[{ label: '视频库', value: 'video' }, { label: '配音库', value: 'voice' }]} />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProjectAssets;
|
|
export const Component = ProjectAssets;
|