feat(phase7): add batch classification progress feedback
Deploy / Deploy Staging (push) Failing after 5s
Deploy / Deploy Production (push) Has been skipped
Tests / test (push) Failing after 23s
Tests / lint (push) Failing after 21s

This commit is contained in:
Xiaoxia AI
2026-06-17 19:30:30 +08:00
parent 4e0291fb44
commit af487c5f6f
+90 -96
View File
@@ -9,6 +9,7 @@ import {
Form,
Input,
Modal,
Progress,
Row,
Select,
Space,
@@ -58,6 +59,7 @@ const ProjectAssets: React.FC = () => {
const [classifyingAssetId, setClassifyingAssetId] = useState<string>('');
const [classificationFilter, setClassificationFilter] = useState<string>('all');
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [batchProgress, setBatchProgress] = useState({ total: 0, submitted: 0, failed: 0, skipped: 0, running: false });
const [form] = Form.useForm();
const librariesQuery = useQuery({
@@ -150,20 +152,14 @@ const ProjectAssets: React.FC = () => {
});
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 filteredAssets = useMemo(() => {
const items = assetsQuery.data || [];
if (classificationFilter === 'all') return items;
if (classificationFilter === 'unclassified') {
return items.filter((item) => !item.metadata?.classification);
}
if (classificationFilter === 'unclassified') return items.filter((item) => !item.metadata?.classification);
return items.filter((item) => item.metadata?.classification === classificationFilter);
}, [assetsQuery.data, classificationFilter]);
@@ -172,33 +168,71 @@ const ProjectAssets: React.FC = () => {
[filteredAssets, selectedRowKeys]
);
const selectableAssets = useMemo(
() =>
selectedAssets.filter(
(item) => item.metadata?.auto_classification !== 'queued' && classifyingAssetId !== item.id
),
[selectedAssets, classifyingAssetId]
);
const handleBatchClassification = async () => {
if (!selectableAssets.length) {
message.warning('没有可批量分类的素材');
if (!selectedAssets.length) {
message.warning('请先选择素材');
return;
}
try {
for (const asset of selectableAssets) {
const skippedAssets = selectedAssets.filter(
(item) => item.metadata?.auto_classification === 'queued' || classifyingAssetId === item.id
);
const runnableAssets = selectedAssets.filter(
(item) => item.metadata?.auto_classification !== 'queued' && classifyingAssetId !== item.id
);
if (!runnableAssets.length) {
message.warning('选中的素材都处于分类中,无法重复提交');
return;
}
setBatchProgress({
total: selectedAssets.length,
submitted: 0,
failed: 0,
skipped: skippedAssets.length,
running: true,
});
let submitted = 0;
let failed = 0;
for (const asset of runnableAssets) {
try {
await submitClassificationJob({
workspace_id: asset.workspace_id,
project_id: asset.project_id,
asset_id: asset.id,
});
submitted += 1;
} catch {
failed += 1;
}
message.success(`已发起 ${selectableAssets.length} 个分类任务`);
setSelectedRowKeys([]);
assetsQuery.refetch();
} catch {
message.error('批量发起分类失败');
setBatchProgress({
total: selectedAssets.length,
submitted,
failed,
skipped: skippedAssets.length,
running: true,
});
}
setBatchProgress({
total: selectedAssets.length,
submitted,
failed,
skipped: skippedAssets.length,
running: false,
});
if (failed === 0) {
message.success(`批量分类已提交:成功 ${submitted},跳过 ${skippedAssets.length}`);
} else {
message.warning(`批量分类已完成:成功 ${submitted},失败 ${failed},跳过 ${skippedAssets.length}`);
}
setSelectedRowKeys([]);
assetsQuery.refetch();
};
const renderClassification = (asset: AssetItem) => {
@@ -222,23 +256,9 @@ const ProjectAssets: React.FC = () => {
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: '类型', 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',
@@ -250,7 +270,7 @@ const ProjectAssets: React.FC = () => {
<Button
icon={<TagsOutlined />}
loading={isCurrentClassifying && classificationMutation.isPending}
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id)}
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id) || batchProgress.running}
onClick={() => {
setClassifyingAssetId(record.id);
classificationMutation.mutate({
@@ -260,13 +280,7 @@ const ProjectAssets: React.FC = () => {
});
}}
>
{isAutoClassifying
? '自动分类中...'
: isCurrentClassifying
? '分类中...'
: hasClassification
? '重新分类'
: '发起分类'}
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
</Button>
);
},
@@ -297,6 +311,10 @@ const ProjectAssets: React.FC = () => {
}
};
const batchPercent = batchProgress.total
? Math.round(((batchProgress.submitted + batchProgress.failed + batchProgress.skipped) / batchProgress.total) * 100)
: 0;
return (
<div style={{ padding: 24 }}>
<Row gutter={16} style={{ marginBottom: 16 }}>
@@ -305,12 +323,7 @@ const ProjectAssets: React.FC = () => {
title="项目素材管理"
extra={
<Space>
<Select
style={{ width: 180 }}
value={classificationFilter}
options={classificationOptions}
onChange={setClassificationFilter}
/>
<Select style={{ width: 180 }} value={classificationFilter} options={classificationOptions} onChange={setClassificationFilter} />
<Select
style={{ width: 280 }}
placeholder="选择素材库"
@@ -319,19 +332,13 @@ const ProjectAssets: React.FC = () => {
onChange={setLibraryId}
loading={librariesQuery.isLoading}
/>
<Button icon={<TagsOutlined />} disabled={!selectedRowKeys.length} onClick={handleBatchClassification}>
<Button icon={<TagsOutlined />} disabled={!selectedRowKeys.length || batchProgress.running} onClick={handleBatchClassification}>
({selectedRowKeys.length})
</Button>
<Button icon={<PlusOutlined />} onClick={() => setCreateLibraryOpen(true)}>
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
librariesQuery.refetch();
assetsQuery.refetch();
}}
>
<Button icon={<ReloadOutlined />} onClick={() => { librariesQuery.refetch(); assetsQuery.refetch(); }}>
</Button>
</Space>
@@ -351,24 +358,24 @@ const ProjectAssets: React.FC = () => {
style={{ marginBottom: 16 }}
type={classificationJobQuery.data.status === 'failed' ? 'error' : 'success'}
message={`分类任务状态:${classificationJobQuery.data.status}`}
description={
classificationJobQuery.data.error_message ||
`分类结果:${classificationJobQuery.data.classification || '处理中'}`
}
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>
{batchProgress.total > 0 && (
<Card size="small" style={{ marginBottom: 16 }} title="批量分类进度">
<Progress percent={batchPercent} status={batchProgress.running ? 'active' : batchProgress.failed > 0 ? 'exception' : 'success'} />
<Space size="large">
<span>{batchProgress.total}</span>
<span>{batchProgress.submitted}</span>
<span>{batchProgress.failed}</span>
<span>{batchProgress.skipped}</span>
</Space>
</Card>
)}
<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>
@@ -376,10 +383,7 @@ const ProjectAssets: React.FC = () => {
{filteredAssets.length ? (
<Table
rowKey="id"
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
rowSelection={{ selectedRowKeys, onChange: setSelectedRowKeys }}
columns={columns}
dataSource={filteredAssets}
pagination={false}
@@ -391,13 +395,7 @@ const ProjectAssets: React.FC = () => {
</Col>
</Row>
<Modal
title="新建素材库"
open={createLibraryOpen}
onCancel={() => setCreateLibraryOpen(false)}
onOk={() => form.submit()}
confirmLoading={createLibraryMutation.isPending}
>
<Modal title="新建素材库" open={createLibraryOpen} onCancel={() => setCreateLibraryOpen(false)} onOk={() => form.submit()} confirmLoading={createLibraryMutation.isPending}>
<Form
form={form}
layout="vertical"
@@ -410,12 +408,8 @@ const ProjectAssets: React.FC = () => {
});
}}
>
<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="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>