feat(product-edit):商品编辑页初版

This commit is contained in:
R524809
2026-09-09 15:11:58 +08:00
parent 319887726e
commit 130a005231
19 changed files with 1541 additions and 21 deletions
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
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';
@@ -6,16 +7,18 @@ import type { CollectionItem } from '@atlas/contracts';
import { collectionApi } from '@/services/collection';
import { apiErrorMessage } from '@/services/api';
import { usePlatform } from '@/services/platform';
import { productEditorApi } from '@/services/productEditor';
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 { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const navigate = useNavigate(); 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 openEditor = async (itemId: string) => { if (!currentPlatform) return; try { const context = await productEditorApi.enter(itemId, currentPlatform.id); navigate(context.editor_path); } catch (error) { message.error(apiErrorMessage(error)); } };
const create = async (values: { title?: string; note?: string }) => { try { const created = await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); if (currentPlatform) await openEditor(created.collection_item.id); else 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> },
{ 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={() => void openEditor(item.id)}>编辑上架</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 }} />
+170 -10
View File
@@ -1,16 +1,176 @@
import { useParams } from 'react-router-dom';
import PagePlaceholder from '@/components/PagePlaceholder';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { App as AntdApp, Alert, Button, Card, Descriptions, Divider, Form, Input, Select, Space, Spin, Typography } from 'antd';
import { DeleteOutlined, PlusOutlined, SaveOutlined } from '@ant-design/icons';
import type { ProductEditorSnapshot, SaveProductEditorDraftRequest } from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { productEditorApi } from '@/services/productEditor';
import { usePlatform } from '@/services/platform';
/** 商品 AI 编辑页:编辑 SPU/SKU、媒体、定价与首次发布(业务模块尚未实现) */
type VariantValues = {
id: string | null;
client_key: string | null;
copy_from_variant_id: string | null;
display_name: string | null;
source_sku: string | null;
purchase_cost_cny: string | null;
offer_id: string | null;
};
type EditorFormValues = SaveProductEditorDraftRequest;
function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
return {
common: snapshot.product.common,
profile: {
target_store_id: snapshot.profile.target_store.id,
fulfillment: snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment'],
title: snapshot.profile.title,
description: snapshot.profile.description,
tags: snapshot.profile.tags,
},
variants: snapshot.variants.map((variant) => ({
id: variant.id,
client_key: null,
copy_from_variant_id: null,
display_name: variant.display_name,
source_sku: variant.source_sku,
purchase_cost_cny: variant.purchase_cost_cny,
offer_id: variant.listing.offer_id,
})),
};
}
function nullable(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function normalizedValues(values: EditorFormValues): EditorFormValues {
return {
...values,
common: {
source_title: nullable(values.common.source_title), brand: nullable(values.common.brand),
model: nullable(values.common.model), barcode: nullable(values.common.barcode),
purchase_url: nullable(values.common.purchase_url), supply_note: nullable(values.common.supply_note),
},
profile: {
...values.profile, title: nullable(values.profile.title), description: nullable(values.profile.description),
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
},
variants: values.variants.map((variant) => ({
...variant, display_name: nullable(variant.display_name), source_sku: nullable(variant.source_sku),
purchase_cost_cny: nullable(variant.purchase_cost_cny), offer_id: nullable(variant.offer_id),
})),
};
}
/** M3.1 正式编辑入口:产品、当前平台文案与 SKU 基础数据一次性原子保存。 */
const ProductEditPage = () => {
const { id } = useParams<{ id: string }>();
const { id: productId } = useParams<{ id: string }>();
const { currentPlatform } = usePlatform();
const { message } = AntdApp.useApp();
const navigate = useNavigate();
const [form] = Form.useForm<EditorFormValues>();
const [snapshot, setSnapshot] = useState<ProductEditorSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
return (
<PagePlaceholder
title={`商品编辑(${id ?? '未知 ID'})`}
description="商品 AI 编辑页:SPU/SKU、媒体、定价与发布,模块开发中。"
/>
);
const load = useCallback(async () => {
if (!productId || !currentPlatform) return;
setLoading(true);
try {
const next = await productEditorApi.read(productId, currentPlatform.id);
setSnapshot(next);
form.setFieldsValue(formValues(next));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoading(false);
}
}, [currentPlatform, form, message, productId]);
useEffect(() => { void load(); }, [load]);
const save = async (values: EditorFormValues) => {
if (!productId || !currentPlatform || !snapshot) return;
setSaving(true);
try {
await productEditorApi.save(productId, currentPlatform.id, {
product: snapshot.product.revision,
profile: snapshot.profile.revision,
}, normalizedValues(values));
message.success('草稿已保存');
await load();
} catch (error) {
if (apiErrorCode(error) === 'VERSION_CONFLICT') {
message.warning('商品已在其他位置更新,已重新加载最新内容');
await load();
} else {
message.error(apiErrorMessage(error));
}
} finally {
setSaving(false);
}
};
if (!currentPlatform) return <Alert type="warning" message="正在读取平台上下文" />;
if (loading && !snapshot) return <Spin size="large" style={{ display: 'block', margin: '72px auto' }} />;
if (!snapshot) return <Alert type="error" message="无法读取商品编辑数据" action={<Button onClick={() => void load()}>重试</Button>} />;
return <Form form={form} layout="vertical" onFinish={save} requiredMark={false}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Space style={{ width: '100%', justifyContent: 'space-between' }} align="start">
<div><Typography.Title level={3} style={{ margin: 0 }}>商品编辑</Typography.Title><Typography.Text type="secondary">当前平台:{currentPlatform.name} · 草稿保存不会发布商品</Typography.Text></div>
<Space><Button onClick={() => navigate('/collection')}>返回采集箱</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
</Space>
</Card>
{snapshot.warnings.map((warning) => <Alert key={warning.code} type="warning" message={warning.message} showIcon />)}
<Card title="商品基础信息">
<Form.Item name={['common', 'source_title']} label="商品名称" rules={[{ max: 1000 }]}><Input placeholder="商品名称" /></Form.Item>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item name={['common', 'brand']} label="品牌" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={['common', 'model']} label="型号" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={['common', 'barcode']} label="条形码" style={{ flex: 1 }}><Input /></Form.Item>
</Space>
<Form.Item name={['common', 'purchase_url']} label="采购链接" rules={[{ type: 'url', message: '请输入有效 URL' }]}><Input /></Form.Item>
<Form.Item name={['common', 'supply_note']} label="货源备注"><Input.TextArea rows={2} maxLength={4000} /></Form.Item>
</Card>
<Card title="当前平台资料">
<Descriptions size="small" column={2} style={{ marginBottom: 18 }}>
<Descriptions.Item label="目标店铺">{snapshot.profile.target_store.name}({snapshot.profile.target_store.currency_code})</Descriptions.Item>
<Descriptions.Item label="市场">{snapshot.profile.market_code ?? '未配置'}</Descriptions.Item>
</Descriptions>
<Space style={{ width: '100%' }} size={16} align="start">
<Form.Item name={['profile', 'fulfillment']} label="履约方式" style={{ width: 180 }}><Select options={[{ value: 'unknown', label: '待确认' }, { value: 'rfbs', label: 'rFBS' }, { value: 'fbs', label: 'FBS' }, { value: 'fbo', label: 'FBO' }, { value: 'fbp', label: 'FBP' }]} /></Form.Item>
<Form.Item name={['profile', 'tags']} label="标签" style={{ flex: 1 }}><Select mode="tags" tokenSeparators={[',']} placeholder="输入后回车添加" /></Form.Item>
</Space>
<Form.Item name={['profile', 'title']} label="平台标题" rules={[{ max: 1000 }]}><Input /></Form.Item>
<Form.Item name={['profile', 'description']} label="平台描述" rules={[{ max: 100000 }]}><Input.TextArea rows={4} /></Form.Item>
<Form.Item name={['profile', 'target_store_id']} hidden><Input /></Form.Item>
</Card>
<Card title="SKU 与首次上架货号" extra={<Typography.Text type="secondary">保存草稿不提交 Ozon</Typography.Text>}>
<Form.List name="variants">
{(fields, { add, remove }) => <Space direction="vertical" style={{ width: '100%' }} size={12}>
{fields.map((field, index) => <Card key={field.key} size="small" title={`SKU ${index + 1}`} extra={fields.length > 1 ? <Button type="text" danger icon={<DeleteOutlined />} onClick={() => remove(field.name)}>删除</Button> : null}>
<Form.Item name={[field.name, 'id']} hidden><Input /></Form.Item>
<Form.Item name={[field.name, 'client_key']} hidden><Input /></Form.Item>
<Form.Item name={[field.name, 'copy_from_variant_id']} hidden><Input /></Form.Item>
<Space style={{ width: '100%' }} size={12} align="start">
<Form.Item name={[field.name, 'display_name']} label="SKU 名称" style={{ flex: 2 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'source_sku']} label="来源 SKU" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'offer_id']} label="Ozon 货号" style={{ flex: 1 }}><Input /></Form.Item>
<Form.Item name={[field.name, 'purchase_cost_cny']} label="采购价(CNY)" style={{ flex: 1 }} rules={[{ pattern: /^\d+(\.\d{1,4})?$/, message: '请输入金额' }]}><Input /></Form.Item>
</Space>
</Card>)}
<Button type="dashed" icon={<PlusOutlined />} onClick={() => add({ id: null, client_key: crypto.randomUUID(), copy_from_variant_id: null, display_name: null, source_sku: null, purchase_cost_cny: null, offer_id: null } satisfies VariantValues)}>新增 SKU</Button>
</Space>}
</Form.List>
</Card>
<Divider style={{ margin: 0 }} />
<Space style={{ justifyContent: 'flex-end', width: '100%' }}><Button onClick={() => navigate('/collection')}>取消</Button><Button type="primary" htmlType="submit" loading={saving} icon={<SaveOutlined />}>保存草稿</Button></Space>
</Space>
</Form>;
};
export default ProductEditPage;