feat(product-edit):商品编辑页初版
This commit is contained in:
@@ -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 }} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,6 +63,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'pricing', element: <PricingPage /> },
|
||||
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
|
||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||
{ path: 'products/:id/editor', element: <ProductEditPage /> },
|
||||
// 个人中心设置页:从页头用户菜单进入,不在左侧菜单中出现
|
||||
{ path: 'settings/profile', element: <ProfileSettingsPage /> },
|
||||
{ path: 'settings/password', element: <PasswordSettingsPage /> },
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
ProductEditorContext,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductEditorDraftResponse,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from './api';
|
||||
|
||||
export const productEditorApi = {
|
||||
enter: (collectionItemId: string, platformId: string) =>
|
||||
api.post<ProductEditorContext>(`/collection-items/${collectionItemId}/editor-contexts`, undefined, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
read: (productId: string, platformId: string) =>
|
||||
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
|
||||
headers: { 'X-Atlas-Platform-Id': platformId },
|
||||
}),
|
||||
save: (
|
||||
productId: string,
|
||||
platformId: string,
|
||||
revisions: { product: number; profile: number },
|
||||
payload: SaveProductEditorDraftRequest,
|
||||
) => api.put<SaveProductEditorDraftResponse>(`/products/${productId}/editor-draft`, payload, {
|
||||
headers: {
|
||||
'X-Atlas-Platform-Id': platformId,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user