feat(collection): 采集箱开发

This commit is contained in:
R524809
2026-09-04 17:32:28 +08:00
parent edf7939963
commit 87db74fb89
16 changed files with 452 additions and 50 deletions
@@ -1,8 +1,26 @@
import PagePlaceholder from '@/components/PagePlaceholder';
import { useCallback, useEffect, useState } from 'react';
import { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd';
import type { TableProps } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import type { CollectionItem } from '@atlas/contracts';
import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api';
import { usePlatform } from '@/services/platform';
/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */
const CollectionPage = () => (
<PagePlaceholder title="采集箱" description="查看已采集的商品,进入编辑上架,模块开发中。" />
);
export default CollectionPage;
const statusLabel: Record<string, string> = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' };
export default function CollectionPage() {
const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState<CollectionItem[]>([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState<string[]>([]); const [q, setQ] = useState(''); const [source, setSource] = useState<string>(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>();
const load = useCallback(async () => { if (!currentPlatform) return; setLoading(true); try { const result = await collectionApi.list(currentPlatform.id, { ...(q.trim() ? { q: q.trim() } : {}), ...(source ? { source_platform_code: source as 'OZON' } : {}) }); setItems(result.data); setMeta(result.meta); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, message, q, source]);
useEffect(() => { void load(); }, [load]);
const create = async (values: { title?: string; note?: string }) => { try { await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); await load(); } catch (error) { message.error(apiErrorMessage(error)); } };
const remove = (ids: string[], all = false) => modal.confirm({ title: all ? '清空采集箱' : '删除采集商品', content: all ? `将清空当前 ${meta.active_count} 条采集草稿,不会删除或下架任何平台商品。确定继续吗?` : '仅删除 Atlas 内的采集草稿,不会删除或下架任何平台商品。确定继续吗?', okText: all ? '清空' : '删除', okButtonProps: { danger: true }, onOk: async () => { try { const result = all ? await collectionApi.clear(meta.active_count) : await collectionApi.bulkDelete(ids); message.success(`已删除 ${result.deleted_count} 条采集草稿`); setSelected([]); await load(); } catch (error) { message.error(apiErrorMessage(error)); } } });
const columns: TableProps<CollectionItem>['columns'] = [
{ title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => <Tag>{value.kind === 'manual' ? '手动创建' : value.platform_code}</Tag> }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => <Typography.Text type="secondary">{value.detail}</Typography.Text> }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => <Space><Button type="link" size="small" onClick={() => message.info('商品编辑将在下一任务提供')}>编辑上架</Button><Button type="link" danger size="small" onClick={() => remove([item.id])}>删除</Button></Space> },
];
return <Card title="采集箱" extra={<Space><Button icon={<ReloadOutlined />} onClick={() => void load()} /><Button danger disabled={!items.length} onClick={() => remove([], true)}>清空采集箱</Button><Button danger icon={<DeleteOutlined />} disabled={!selected.length} onClick={() => remove(selected)}>批量删除</Button><Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>手动创建商品</Button></Space>}>
<Progress percent={meta.collection_limit ? Math.round(meta.active_count / meta.collection_limit * 100) : 0} format={() => `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} />
<Space wrap style={{ marginBottom: 16 }}><Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /><Select allowClear placeholder="采集来源" value={source} onChange={setSource} style={{ width: 140 }} options={[{ value: 'OZON', label: 'Ozon' }, { value: '1688', label: '1688' }, { value: 'TAOBAO', label: '淘宝' }, { value: 'TMALL', label: '天猫' }]} /><Button type="primary" onClick={() => void load()}>查询</Button></Space>
<Table rowKey="id" loading={loading} columns={columns} dataSource={items} pagination={false} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => setSelected(keys.map(String)) }} locale={{ emptyText: '暂无采集商品,点击右上角手动创建商品' }} />
<Modal title="手动创建商品" open={createOpen} footer={null} onCancel={() => setCreateOpen(false)} destroyOnHidden><Form form={form} layout="vertical" onFinish={create}><Form.Item name="title" label="商品名称"><Input maxLength={300} placeholder="可稍后在商品编辑中完善" /></Form.Item><Form.Item name="note" label="备注"><Input.TextArea maxLength={2000} /></Form.Item><Space><Button type="primary" htmlType="submit">创建草稿</Button><Button onClick={() => setCreateOpen(false)}>取消</Button></Space></Form></Modal>
</Card>;
}
@@ -21,7 +21,6 @@ import {
} from 'antd';
import type { TableProps, UploadFile } from 'antd';
import {
CopyOutlined,
FileImageOutlined,
PlusOutlined,
ReloadOutlined,
@@ -165,6 +164,21 @@ const WatermarksPage = () => {
}
}, [templates, assetPreviewUrls]);
/**
* 模板列表不承诺始终带预览 URL;编辑既有图片模板时按需换取新的短时地址。
* 这样不会把对象存储 URL 写入模板数据,也避免打开编辑框时只显示空占位。
*/
const loadAssetPreviewUrl = useCallback(
async (assetId: string): Promise<string> => {
const cached = assetPreviewUrls[assetId];
if (cached) return cached;
const access = await mediaApi.createPreviewUrl(assetId);
setAssetPreviewUrls((previous) => ({ ...previous, [assetId]: access.url }));
return access.url;
},
[assetPreviewUrls]
);
const filtered = useMemo(
() =>
templates.filter((template) => {
@@ -232,6 +246,42 @@ const WatermarksPage = () => {
form.setFieldsValue(initial);
setPreviewValues(initial);
setEditorOpen(true);
if (template.kind === 'image' && assetId && !assetPreviewUrls[assetId]) {
void loadAssetPreviewUrl(assetId)
.then((previewUrl) => {
setImageAsset((current) =>
current?.id === assetId
? { id: assetId, previewUrl }
: current
);
})
.catch(() => undefined);
}
};
/**
* 类型切换会丢弃另一种类型的专属配置(尤其是已上传图片),先明确告知用户。
* 表单字段由这里显式写入,避免 Select 在确认框出现前就提交了类型变更。
*/
const requestKindChange = (nextKind: WatermarkKind) => {
const currentKind = form.getFieldValue('kind') as WatermarkKind | undefined;
if (!currentKind || currentKind === nextKind) return;
const applyChange = () => {
form.setFieldValue('kind', nextKind);
setPreviewValues((previous) => ({
...(previous ?? (form.getFieldsValue() as WatermarkFormValues)),
kind: nextKind,
}));
if (currentKind === 'image') setImageAsset(null);
};
modal.confirm({
title: '切换水印类型',
content: '切换后将清空当前类型的专属配置,是否继续?',
okText: '继续切换',
cancelText: '取消',
onOk: applyChange,
});
};
const handleBeforeUpload = async (file: UploadFile): Promise<boolean> => {
@@ -307,7 +357,7 @@ const WatermarksPage = () => {
}
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
if (values.text_content?.trim() !== current.text_content) {
configPatch.text_content = values.text_content?.trim() ?? null;
configPatch.text_content = values.text_content.trim();
}
if (values.font_code !== current.font_code) {
configPatch.font_code = values.font_code;
@@ -332,7 +382,8 @@ const WatermarksPage = () => {
diff.name = values.name.trim();
}
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
if (imageAsset && imageAsset.id !== current.image_asset?.id) {
const currentAssetId = current.image_asset?.id ?? current.image_asset_id;
if (imageAsset && imageAsset.id !== currentAssetId) {
configPatch.image_asset_id = imageAsset.id;
}
if (percentToScale(values.scalePercent) !== current.scale_ratio) {
@@ -436,9 +487,7 @@ const WatermarksPage = () => {
let url = assetPreviewUrls[assetId];
if (!url) {
try {
const access = await mediaApi.createPreviewUrl(assetId);
url = access.url;
setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url }));
url = await loadAssetPreviewUrl(assetId);
} catch {
// 换取失败按原样打开,图片渲染失败会显示不可用提示
}
@@ -469,29 +518,34 @@ const WatermarksPage = () => {
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (targets.length === 1) {
await watermarksApi.remove(targets[0].id);
message.success('水印模板已删除');
} else {
setBatchDeleting(true);
let failed = 0;
for (const template of targets) {
try {
await watermarksApi.remove(template.id);
} catch (err) {
failed += 1;
message.error(`${template.name}:${apiErrorMessage(err)}`);
try {
if (targets.length === 1) {
await watermarksApi.remove(targets[0].id);
message.success('水印模板已删除');
} else {
setBatchDeleting(true);
let failed = 0;
for (const template of targets) {
try {
await watermarksApi.remove(template.id);
} catch (err) {
failed += 1;
message.error(`${template.name}:${apiErrorMessage(err)}`);
}
}
if (failed === 0) {
message.success(`已删除 ${targets.length} 个模板`);
} else {
message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`);
}
}
setSelectedIds([]);
await loadTemplates();
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setBatchDeleting(false);
if (failed === 0) {
message.success(`已删除 ${targets.length} 个模板`);
} else {
message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`);
}
}
setSelectedIds([]);
await loadTemplates();
},
});
};
@@ -747,8 +801,10 @@ const WatermarksPage = () => {
<Input maxLength={120} placeholder="例如:右下角品牌水印" />
</Form.Item>
<Form.Item name="kind" label="水印类型" required>
<Form.Item label="水印类型" required>
<Select
value={previewValues?.kind ?? form.getFieldValue('kind') ?? 'text'}
onChange={requestKindChange}
options={[
{ value: 'text', label: '文字水印' },
{ value: 'image', label: '图片水印' },