Files
xiaoxia-saas/apps/web/src/pages/workspace/ProjectAssets.tsx
T
2026-06-17 19:20:59 +08:00

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;