feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { getToken } from '@/services/auth';
|
||||
|
||||
/** 未登录则跳 /login */
|
||||
export default function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const token = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) navigate('/login', { replace: true });
|
||||
}, [token, navigate]);
|
||||
|
||||
if (!token) return null;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PictureOutlined } from '@ant-design/icons';
|
||||
import { InboxOutlined, PictureOutlined, ShopOutlined } from '@ant-design/icons';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface RouteMenuConfig {
|
||||
@@ -16,14 +16,30 @@ export interface RouteMenuConfig {
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
/** 菜单配置列表(当前仅「AI 图生图」一页) */
|
||||
/** 菜单配置列表 */
|
||||
export const routeMenuConfig: RouteMenuConfig[] = [
|
||||
{
|
||||
path: '/collection',
|
||||
key: '/collection',
|
||||
icon: <InboxOutlined />,
|
||||
label: '采集箱',
|
||||
title: '采集箱',
|
||||
subtitle: '查看已采集的商品,进入编辑',
|
||||
},
|
||||
{
|
||||
path: '/shops',
|
||||
key: '/shops',
|
||||
icon: <ShopOutlined />,
|
||||
label: '店铺管理',
|
||||
title: '店铺管理',
|
||||
subtitle: '绑定 Ozon 店铺 Client-Id / Api-Key',
|
||||
},
|
||||
{
|
||||
path: '/ai-image',
|
||||
key: '/ai-image',
|
||||
icon: <PictureOutlined />,
|
||||
label: 'AI 图生图',
|
||||
title: 'AI 图生图',
|
||||
label: '智能修图',
|
||||
title: '智能修图',
|
||||
subtitle: '上传图片、加水印、用万相模型进行图生图编辑',
|
||||
},
|
||||
];
|
||||
@@ -34,6 +50,9 @@ export const getPageInfo = (path: string): { title: string; subtitle: string } =
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
if (path.startsWith('/product/')) {
|
||||
return { title: '商品编辑', subtitle: '编辑商品信息、计价、文案与图片' };
|
||||
}
|
||||
const config = routeMenuConfig.find((item) => item.path === path);
|
||||
return config ? { title: config.title, subtitle: config.subtitle } : getPageInfo('/');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Card, Input, message, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { listProducts, deleteProduct, copyProduct, ProductListItem } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
export const STAGE_LABEL: Record<string, string> = {
|
||||
collected: '待编辑',
|
||||
editing: '编辑中',
|
||||
ready: '待发布',
|
||||
publishing: '发布中',
|
||||
published: '已发布',
|
||||
failed: '发布失败',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
export const STAGE_COLOR: Record<string, string> = {
|
||||
collected: 'blue',
|
||||
editing: 'gold',
|
||||
ready: 'purple',
|
||||
publishing: 'processing',
|
||||
published: 'green',
|
||||
failed: 'red',
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
export default function CollectionPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<ProductListItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stage, setStage] = useState<string | undefined>();
|
||||
const [q, setQ] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listProducts({ stage, q: q || undefined, page, page_size: pageSize });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stage, page]);
|
||||
|
||||
const onDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteProduct(id, true);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async (id: string) => {
|
||||
try {
|
||||
const clone = await copyProduct(id);
|
||||
message.success('已复制为新商品,请编辑货号/图片/变体属性');
|
||||
load();
|
||||
navigate(`/product/${clone.id}`);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ProductListItem> = [
|
||||
{
|
||||
title: '商品名',
|
||||
dataIndex: 'name',
|
||||
render: (v, r) => (
|
||||
<a onClick={() => navigate(`/product/${r.id}`)}>{v || '(未命名)'}</a>
|
||||
),
|
||||
},
|
||||
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
|
||||
{ title: '售价', dataIndex: 'price', width: 100, render: (v) => (v != null ? v.toFixed(2) : '—') },
|
||||
{ title: '来源', dataIndex: 'source_platform', width: 90, render: (v) => v || '—' },
|
||||
{
|
||||
title: '素材',
|
||||
dataIndex: 'asset_counts',
|
||||
width: 110,
|
||||
render: (v) =>
|
||||
v
|
||||
? Object.entries(v)
|
||||
.map(([k, n]) => `${k}:${n}`)
|
||||
.join(' ')
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'stage',
|
||||
width: 100,
|
||||
render: (v) => <Tag color={STAGE_COLOR[v]}>{STAGE_LABEL[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 210,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => navigate(`/product/${r.id}`)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" onClick={() => onCopy(r.id)}>
|
||||
复制
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => onDelete(r.id)}>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<Title level={4} style={{ marginTop: 0 }}>
|
||||
采集箱
|
||||
</Title>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索名称 / 货号"
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
onSearch={(v) => {
|
||||
setPage(1);
|
||||
setQ(v);
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
value={stage ?? ''}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setStage(e.target.value || undefined);
|
||||
}}
|
||||
style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid #d9d9d9' }}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STAGE_LABEL).map(([k, v]) => (
|
||||
<option key={k} value={k}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: setPage,
|
||||
showTotal: (t) => `共 ${t} 个商品`,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Button, Card, Input, message, Typography } from 'antd';
|
||||
import { login } from '@/services/auth';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(token.trim());
|
||||
message.success('登录成功');
|
||||
navigate('/collection', { replace: true });
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f5f5f5' }}>
|
||||
<Card style={{ width: 420 }}>
|
||||
<Title level={3} style={{ marginTop: 0 }}>Ozon 发布工作台</Title>
|
||||
<Text type="secondary">请输入访问 Token(对应服务端 .env 的 APP_TOKEN)</Text>
|
||||
<Input.Password
|
||||
placeholder="APP_TOKEN"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onPressEnter={onSubmit}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
<Button type="primary" block loading={loading} onClick={onSubmit} style={{ marginTop: 16 }}>
|
||||
登录
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Row,
|
||||
Col,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
TreeSelect,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { listShops, ShopItem } from '@/services/shop';
|
||||
import {
|
||||
categoryTree,
|
||||
categoryAttributes,
|
||||
attributeValues,
|
||||
type AttributeItem,
|
||||
type CategoryNode,
|
||||
} from '@/services/category';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => Promise<void>;
|
||||
}
|
||||
|
||||
/** 类目树叶子节点 value 编码为 "category_id:type_id" */
|
||||
interface TreeOption {
|
||||
title: string;
|
||||
value: string;
|
||||
selectable?: boolean;
|
||||
children?: TreeOption[];
|
||||
}
|
||||
|
||||
function buildTree(nodes: CategoryNode[], parentCid: number | null): TreeOption[] {
|
||||
return (nodes || []).map((n) => {
|
||||
const cid = n.description_category_id ?? parentCid;
|
||||
const children = n.children || [];
|
||||
// 叶子 = type 节点(有 type_id 且无子节点)
|
||||
if (children.length === 0 && n.type_id != null) {
|
||||
return { title: n.type_name || n.category_name || '?', value: `${cid}:${n.type_id}` };
|
||||
}
|
||||
return {
|
||||
title: n.category_name || n.type_name || '?',
|
||||
value: `cat-${cid}`,
|
||||
selectable: false,
|
||||
children: buildTree(children, cid),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface MappedVal {
|
||||
dictionary_value_id?: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().trim().replace(/[,,::]/g, '').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function bestMatch(paramKey: string, attrs: AttributeItem[]): AttributeItem | null {
|
||||
const pk = normalize(paramKey);
|
||||
if (!pk) return null;
|
||||
let best: AttributeItem | null = null;
|
||||
let bestScore = 0;
|
||||
for (const a of attrs) {
|
||||
const an = normalize(a.name);
|
||||
if (!an) continue;
|
||||
if (pk === an) return a; // 完全一致,直接命中
|
||||
if (an.includes(pk) || pk.includes(an)) {
|
||||
if (2 > bestScore) {
|
||||
best = a;
|
||||
bestScore = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 字典值下拉(远程搜索) */
|
||||
function DictSelect({
|
||||
shopId,
|
||||
categoryId,
|
||||
typeId,
|
||||
attributeId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
shopId: string;
|
||||
categoryId: number;
|
||||
typeId: number;
|
||||
attributeId: number;
|
||||
value?: MappedVal;
|
||||
onChange: (v: MappedVal) => void;
|
||||
}) {
|
||||
const [options, setOptions] = useState<Array<{ value: number; label: string }>>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const fetchOptions = useCallback(
|
||||
async (q?: string) => {
|
||||
setSearching(true);
|
||||
try {
|
||||
const r = await attributeValues(shopId, attributeId, categoryId, typeId, q || undefined, 50);
|
||||
setOptions((r.result ?? []).map((v) => ({ value: v.id, label: v.value })));
|
||||
} catch {
|
||||
/* 搜索失败静默,用户可重试 */
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
},
|
||||
[shopId, attributeId, categoryId, typeId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOptions();
|
||||
}, [fetchOptions]);
|
||||
|
||||
// 已选值若不在当前选项里,补进去(回显已保存映射)
|
||||
const opts = [...options];
|
||||
if (value?.dictionary_value_id != null && value.value && !opts.some((o) => o.value === value.dictionary_value_id)) {
|
||||
opts.unshift({ value: value.dictionary_value_id, label: value.value });
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="搜索并选择"
|
||||
loading={searching}
|
||||
value={value?.dictionary_value_id}
|
||||
options={opts}
|
||||
onSearch={(q) => fetchOptions(q)}
|
||||
onChange={(id, opt) => {
|
||||
const label = (opt as { label?: string } | undefined)?.label ?? String(id);
|
||||
onChange({ dictionary_value_id: id as number, value: label });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AttributePanel({ product, onSave }: Props) {
|
||||
const [shops, setShops] = useState<ShopItem[]>([]);
|
||||
const [shopId, setShopId] = useState<string>();
|
||||
const [treeData, setTreeData] = useState<TreeOption[]>([]);
|
||||
const [attributes, setAttributes] = useState<AttributeItem[]>([]);
|
||||
const [mapping, setMapping] = useState<Record<number, MappedVal>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const categoryId = product.description_category_id ?? undefined;
|
||||
const typeId = product.type_id ?? undefined;
|
||||
|
||||
const loadTree = useCallback(async (sid: string) => {
|
||||
try {
|
||||
const tree = await categoryTree(sid);
|
||||
setTreeData(buildTree(tree, null));
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
listShops()
|
||||
.then((s) => {
|
||||
setShops(s);
|
||||
if (s.length) {
|
||||
setShopId(s[0].id);
|
||||
loadTree(s[0].id);
|
||||
}
|
||||
})
|
||||
.catch((e) => message.error(apiErrorMessage(e)));
|
||||
}, [loadTree]);
|
||||
|
||||
const loadAttributes = useCallback(
|
||||
async (sid: string, catId: number, tId: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const attrs = await categoryAttributes(sid, catId, tId);
|
||||
setAttributes(attrs);
|
||||
// 回显已保存的 attributes
|
||||
const saved = (product.attributes ?? []) as Array<{
|
||||
id: number;
|
||||
values: Array<{ dictionary_value_id?: number; value?: string }>;
|
||||
}>;
|
||||
const m: Record<number, MappedVal> = {};
|
||||
for (const a of saved) {
|
||||
const v = a.values?.[0];
|
||||
if (v) m[a.id] = { dictionary_value_id: v.dictionary_value_id, value: v.value ?? '' };
|
||||
}
|
||||
setMapping(m);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[product.attributes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shopId && categoryId && typeId) {
|
||||
loadAttributes(shopId, categoryId, typeId);
|
||||
}
|
||||
}, [shopId, categoryId, typeId, loadAttributes]);
|
||||
|
||||
const onShopChange = (sid: string) => {
|
||||
setShopId(sid);
|
||||
setAttributes([]);
|
||||
setMapping({});
|
||||
loadTree(sid);
|
||||
};
|
||||
|
||||
const onCategoryChange = (value: string) => {
|
||||
const [cid, tid] = value.split(':');
|
||||
setMapping({});
|
||||
onSave({ description_category_id: Number(cid), type_id: Number(tid) });
|
||||
};
|
||||
|
||||
const autoMatch = async () => {
|
||||
if (!shopId || !categoryId || !typeId) {
|
||||
message.warning('请先选择店铺和类目');
|
||||
return;
|
||||
}
|
||||
const params = (product.raw?.params as Array<{ key: string; value: string }> | undefined) ?? [];
|
||||
if (!params.length) {
|
||||
message.info('采集数据里没有参数,无法自动匹配');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const m: Record<number, MappedVal> = { ...mapping };
|
||||
for (const p of params) {
|
||||
const best = bestMatch(p.key, attributes);
|
||||
if (!best) continue;
|
||||
if (best.dictionary_id) {
|
||||
try {
|
||||
const r = await attributeValues(shopId, best.id, categoryId, typeId, p.value, 5);
|
||||
const vals = r.result ?? [];
|
||||
if (vals.length) {
|
||||
m[best.id] = { dictionary_value_id: vals[0].id, value: vals[0].value };
|
||||
}
|
||||
} catch {
|
||||
/* 字典搜索失败,跳过该属性 */
|
||||
}
|
||||
} else {
|
||||
m[best.id] = { value: p.value };
|
||||
}
|
||||
}
|
||||
setMapping(m);
|
||||
setLoading(false);
|
||||
message.success('自动匹配完成,请核对后保存');
|
||||
};
|
||||
|
||||
const saveMapping = async () => {
|
||||
if (!attributes.length) return;
|
||||
const attrs = attributes
|
||||
.filter((a) => mapping[a.id] && mapping[a.id].value !== '')
|
||||
.map((a) => {
|
||||
const v = mapping[a.id];
|
||||
const valueObj =
|
||||
v.dictionary_value_id != null
|
||||
? { dictionary_value_id: v.dictionary_value_id, value: v.value }
|
||||
: { value: v.value };
|
||||
return { complex_id: 0, id: a.id, values: [valueObj] };
|
||||
});
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({ attributes: attrs });
|
||||
message.success('属性映射已保存');
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = [...attributes].sort((a, b) => Number(b.is_required) - Number(a.is_required));
|
||||
const required = attributes.filter((a) => a.is_required);
|
||||
const missingRequired = required.filter((a) => !mapping[a.id]?.value).map((a) => a.name);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card size="small" title="类目选择" style={{ marginBottom: 12 }}>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Text style={{ fontSize: 12 }}>店铺</Text>
|
||||
<Select
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
placeholder="选择店铺"
|
||||
value={shopId}
|
||||
onChange={onShopChange}
|
||||
options={shops.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Text style={{ fontSize: 12 }}>Ozon 类目</Text>
|
||||
<TreeSelect
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
placeholder="搜索并选择末级类目(类型)"
|
||||
value={categoryId && typeId ? `${categoryId}:${typeId}` : undefined}
|
||||
treeData={treeData}
|
||||
onChange={onCategoryChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{!categoryId || !typeId ? (
|
||||
<Empty description="请先在上方选择 Ozon 类目" />
|
||||
) : (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<span>属性映射</span>
|
||||
{loading && <Spin size="small" />}
|
||||
{!loading && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {attributes.length} 个属性,必填 {required.length} 个
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Button size="small" onClick={autoMatch}>
|
||||
自动匹配
|
||||
</Button>
|
||||
<Button size="small" type="primary" loading={saving} onClick={saveMapping}>
|
||||
保存映射
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{missingRequired.length > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={`还有 ${missingRequired.length} 个必填属性未填写:${missingRequired.slice(0, 5).join('、')}${missingRequired.length > 5 ? '…' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{sorted.length === 0 && !loading ? (
|
||||
<Empty description="该类别无属性" />
|
||||
) : (
|
||||
<div>
|
||||
{sorted.map((a) => {
|
||||
const v = mapping[a.id];
|
||||
const isDict = !!a.dictionary_id;
|
||||
return (
|
||||
<Row key={a.id} gutter={12} align="middle" style={{ marginBottom: 8 }}>
|
||||
<Col span={10}>
|
||||
<Space size={2}>
|
||||
{a.is_required && <Text type="danger">*</Text>}
|
||||
<Text style={{ fontSize: 12, wordBreak: 'break-all' }}>{a.name}</Text>
|
||||
{a.is_collection && <Tag style={{ fontSize: 10 }}>多值</Tag>}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={14}>
|
||||
{isDict ? (
|
||||
<DictSelect
|
||||
shopId={shopId!}
|
||||
categoryId={categoryId!}
|
||||
typeId={typeId!}
|
||||
attributeId={a.id}
|
||||
value={v}
|
||||
onChange={(nv) => setMapping((prev) => ({ ...prev, [a.id]: nv }))}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={a.is_required ? '必填' : '可选'}
|
||||
value={v?.value ?? ''}
|
||||
onChange={(e) =>
|
||||
setMapping((prev) => ({ ...prev, [a.id]: { value: e.target.value } }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Col, Input, message, Row, Select, Space, Tag, Typography } from 'antd';
|
||||
import { generateCopy, getAiModels, AiModelOption, CopyResponse } from '@/services/ai';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** 左侧采集信息 + 模型/生成按钮,右侧推荐标题/简介/标签 */
|
||||
export default function CopyPanel({ product, onSave }: Props) {
|
||||
const [models, setModels] = useState<AiModelOption[]>([]);
|
||||
const [model, setModel] = useState<string>('');
|
||||
const [sourceText, setSourceText] = useState('');
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [result, setResult] = useState<CopyResponse | null>(null);
|
||||
|
||||
const raw = useMemo(() => (product.raw ?? {}) as Record<string, unknown>, [product.raw]);
|
||||
|
||||
const defaultSource = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
const zhTitle = (raw.title_zh as string) || (raw.title as string);
|
||||
if (zhTitle) parts.push(`商品名:${zhTitle}`);
|
||||
const params = raw.params as Array<{ key: string; value: string }> | undefined;
|
||||
if (Array.isArray(params)) {
|
||||
parts.push(params.map((p) => `${p.key}: ${p.value}`).join('\n'));
|
||||
}
|
||||
if (typeof raw.desc === 'string' && raw.desc) parts.push(`详情:${raw.desc}`);
|
||||
if (typeof raw.sellingPoints === 'string' && raw.sellingPoints) parts.push(`卖点:${raw.sellingPoints}`);
|
||||
return parts.join('\n\n');
|
||||
}, [raw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceText && defaultSource) setSourceText(defaultSource);
|
||||
getAiModels()
|
||||
.then((r) => {
|
||||
setModels(r.models);
|
||||
setModel(r.default || r.models[0]?.id || '');
|
||||
})
|
||||
.catch(() => { /* 模型列表失败不阻断 */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (sourceText.trim().length < 10) {
|
||||
message.warning('请先粘贴至少 10 字的商品资料');
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
try {
|
||||
const r = await generateCopy({ source_text: sourceText, model });
|
||||
setResult(r);
|
||||
// 自动回填俄文简介
|
||||
if (r.description_ru) {
|
||||
await onSave({ description: r.description_ru });
|
||||
message.success('已生成并自动回填俄文简介');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 回填标题:同时写入俄文 name 和中文 raw.title_zh */
|
||||
const applyTitle = async (titleRu: string, titleZh?: string) => {
|
||||
const patch: Partial<ProductDetail> = { name: titleRu };
|
||||
if (titleZh) {
|
||||
patch.raw = { ...raw, title_zh: titleZh };
|
||||
}
|
||||
await onSave(patch);
|
||||
message.success('标题已回填(中俄双语)');
|
||||
};
|
||||
|
||||
const applyDesc = async () => {
|
||||
if (!result) return;
|
||||
await onSave({ description: result.description_ru });
|
||||
message.success('已回填俄文简介');
|
||||
};
|
||||
|
||||
return (
|
||||
<Row gutter={16}>
|
||||
<Col span={10}>
|
||||
<Card size="small" title="采集信息(AI 输入)">
|
||||
{/* 显示中文原标题作为参考 */}
|
||||
{((raw.title_zh as string) || (raw.title as string)) && (
|
||||
<div style={{ marginBottom: 8, padding: '6px 8px', background: '#f5f0ff', borderRadius: 6 }}>
|
||||
<Text style={{ fontSize: 11, color: '#888' }}>采集标题:</Text>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
{(raw.title_zh as string) || (raw.title as string)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<Input.TextArea
|
||||
rows={12}
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
placeholder="采集的商品资料(可编辑后生成)"
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
options={models.map((m) => ({ value: m.id, label: m.label }))}
|
||||
/>
|
||||
<Button type="primary" block loading={generating} onClick={onGenerate}>
|
||||
生成简介
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={14}>
|
||||
<Card size="small" title="生成结果">
|
||||
{!result ? (
|
||||
<div style={{ color: '#999' }}>点击「生成简介」后在此查看,简介会自动回填</div>
|
||||
) : (
|
||||
<div>
|
||||
<Text strong>推荐标题</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{result.titles_ru.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{ marginBottom: 10, padding: 8, background: '#fafafa', borderRadius: 6 }}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>{t}</div>
|
||||
{result.titles_zh?.[i] && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{result.titles_zh[i]}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
onClick={() => applyTitle(t, result.titles_zh?.[i])}
|
||||
>
|
||||
回填
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Space style={{ marginTop: 4 }}>
|
||||
<Text strong>俄文简介</Text>
|
||||
<Button size="small" onClick={applyDesc}>
|
||||
重新回填
|
||||
</Button>
|
||||
</Space>
|
||||
<Paragraph
|
||||
style={{ whiteSpace: 'pre-wrap', marginTop: 4, marginBottom: 4, fontSize: 13 }}
|
||||
>
|
||||
{result.description_ru}
|
||||
</Paragraph>
|
||||
<Paragraph
|
||||
type="secondary"
|
||||
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginBottom: 8 }}
|
||||
>
|
||||
{result.description_zh}
|
||||
</Paragraph>
|
||||
|
||||
<Text strong>标签</Text>
|
||||
<div style={{ marginTop: 4, marginBottom: 4 }}>
|
||||
{result.tags_ru.map((t, i) => (
|
||||
<Tag key={i}>{t}</Tag>
|
||||
))}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
||||
{result.tags_zh?.join('、')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* 表单字段标签 —— 商品编辑页统一样式。
|
||||
* 比默认 secondary 文本更重:13px / 500 / 接近正文的深色,便于扫读。
|
||||
*/
|
||||
export const fieldLabelStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'rgba(0, 0, 0, 0.82)',
|
||||
lineHeight: '20px',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
/** 字段行通用间距(同一表单内保持一致) */
|
||||
export const fieldRowStyle: CSSProperties = {
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
export default function FieldLabel({ children, style }: { children: ReactNode; style?: CSSProperties }) {
|
||||
return <div style={{ ...fieldLabelStyle, ...style }}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Checkbox, Empty, Image, Space, Tag, Typography } from 'antd';
|
||||
import { ProductAsset, ProductDetail } from '@/services/product';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const GROUP_NAME: Record<string, string> = {
|
||||
main: '主图',
|
||||
sku: 'SKU 图',
|
||||
detail: '详情图',
|
||||
video: '视频',
|
||||
param: '参数图',
|
||||
generated: '生成图',
|
||||
};
|
||||
|
||||
const STATUS_TAG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '待下载' },
|
||||
downloading: { color: 'processing', text: '下载中' },
|
||||
uploaded: { color: 'green', text: '已就绪' },
|
||||
failed: { color: 'red', text: '失败' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
assets: ProductAsset[];
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function ImagePanel({ assets, product, onSave }: Props) {
|
||||
const selected = (product.images ?? []) as string[];
|
||||
|
||||
const toggle = (url: string) => {
|
||||
const next = selected.includes(url) ? selected.filter((u) => u !== url) : [...selected, url];
|
||||
onSave({ images: next });
|
||||
};
|
||||
|
||||
const groups = [...new Set(assets.map((a) => a.group_key))];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text type="secondary">勾选图片加入发布主图(images 数组,顺序即 Ozon 展示顺序)。</Text>
|
||||
{groups.length === 0 && <Empty description="暂无素材" />}
|
||||
{groups.map((g) => (
|
||||
<div key={g} style={{ marginTop: 16 }}>
|
||||
<Text strong>{GROUP_NAME[g] ?? g}</Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginTop: 8 }}>
|
||||
{assets
|
||||
.filter((a) => a.group_key === g)
|
||||
.map((a) => {
|
||||
const url = a.stored_url || a.source_url;
|
||||
const tag = STATUS_TAG[a.status] ?? STATUS_TAG.pending;
|
||||
const usable = a.status === 'uploaded';
|
||||
return (
|
||||
<div key={a.id} style={{ width: 140 }}>
|
||||
<Image
|
||||
src={url}
|
||||
width={140}
|
||||
height={140}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><rect width='140' height='140' fill='%23eee'/><text x='20' y='70' font-size='12' fill='%23999'>无预览</text></svg>"
|
||||
/>
|
||||
<Space direction="vertical" size={2} style={{ marginTop: 6, width: '100%' }}>
|
||||
<Tag color={tag.color}>{tag.text}</Tag>
|
||||
{a.variant_name && <Text ellipsis style={{ fontSize: 12 }}>{a.variant_name}</Text>}
|
||||
{usable && a.stored_url && (
|
||||
<Checkbox
|
||||
checked={selected.includes(a.stored_url)}
|
||||
onChange={() => toggle(a.stored_url!)}
|
||||
>
|
||||
加入主图
|
||||
</Checkbox>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
TreeSelect,
|
||||
Typography,
|
||||
message,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { ThunderboltOutlined, CopyOutlined } from '@ant-design/icons';
|
||||
import { listShops, ShopItem } from '@/services/shop';
|
||||
import { categoryTree, type CategoryNode } from '@/services/category';
|
||||
import { generateCopy } from '@/services/ai';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import FieldLabel, { fieldRowStyle } from './FieldLabel';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TreeOption {
|
||||
title: string;
|
||||
value: string;
|
||||
selectable?: boolean;
|
||||
children?: TreeOption[];
|
||||
}
|
||||
|
||||
function buildTree(nodes: CategoryNode[], parentCid: number | null): TreeOption[] {
|
||||
return (nodes || []).map((n) => {
|
||||
const cid = n.description_category_id ?? parentCid;
|
||||
const children = n.children || [];
|
||||
if (children.length === 0 && n.type_id != null) {
|
||||
return { title: n.type_name || n.category_name || '?', value: `${cid}:${n.type_id}` };
|
||||
}
|
||||
return {
|
||||
title: n.category_name || n.type_name || '?',
|
||||
value: `cat-${cid}`,
|
||||
selectable: false,
|
||||
children: buildTree(children, cid),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 型号 → 货号前缀(型号 + "-",型号为空时返回空字符串) */
|
||||
function getModelPrefix(model: string): string {
|
||||
const m = model.trim();
|
||||
return m ? m + '-' : '';
|
||||
}
|
||||
|
||||
/** 从 raw 中抽取包装参数(重量/尺寸) */
|
||||
function extractPackagingFromRaw(raw: Record<string, unknown>) {
|
||||
const params = raw.params as Array<{ key: string; value: string }> | undefined;
|
||||
if (!Array.isArray(params)) return {};
|
||||
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^\w一-龥а-яА-Я]/g, '');
|
||||
|
||||
let weight: number | null = null;
|
||||
let weightUnit: 'g' | 'kg' = 'g';
|
||||
let depth: number | null = null;
|
||||
let width: number | null = null;
|
||||
let height: number | null = null;
|
||||
let dimUnit: 'mm' | 'cm' = 'mm';
|
||||
|
||||
const parseNum = (s: string) => {
|
||||
const m = s.replace(',', '.').match(/(\d+(?:\.\d+)?)/);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
};
|
||||
|
||||
for (const p of params) {
|
||||
const k = norm(p.key);
|
||||
const v = p.value;
|
||||
if (!weight && (k.includes('重量') || k.includes('вес'))) {
|
||||
const n = parseNum(v);
|
||||
if (n !== null) {
|
||||
const lower = v.toLowerCase();
|
||||
if (lower.includes('кг') || lower.includes('kg')) {
|
||||
weight = n;
|
||||
weightUnit = 'kg';
|
||||
} else {
|
||||
weight = n;
|
||||
weightUnit = 'g';
|
||||
}
|
||||
}
|
||||
}
|
||||
// 尺寸(分开字段)
|
||||
if (!depth && (k.includes('длина') || k.includes('长度'))) {
|
||||
depth = parseNum(v);
|
||||
dimUnit = v.toLowerCase().includes('мм') || v.toLowerCase().includes('mm') ? 'mm' : 'cm';
|
||||
}
|
||||
if (!width && (k.includes('ширина') || k.includes('宽度'))) {
|
||||
width = parseNum(v);
|
||||
}
|
||||
if (!height && (k.includes('высота') || k.includes('高度'))) {
|
||||
height = parseNum(v);
|
||||
}
|
||||
// 合并尺寸字段
|
||||
if ((!depth || !width || !height) && (k.includes('габарит') || k.includes('размер') || k.includes('尺寸'))) {
|
||||
const nums = v.match(/\d+(?:[.,]\d+)?/g) ?? [];
|
||||
if (nums.length >= 3) {
|
||||
depth = depth ?? parseNum(nums[0] ?? '');
|
||||
width = width ?? parseNum(nums[1] ?? '');
|
||||
height = height ?? parseNum(nums[2] ?? '');
|
||||
dimUnit = v.toLowerCase().includes('мм') || v.toLowerCase().includes('mm') ? 'mm' : 'cm';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { weight, weightUnit, depth, width, height, dimUnit };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export default function MainInfoPanel({ product, onSave }: Props) {
|
||||
const [shops, setShops] = useState<ShopItem[]>([]);
|
||||
const [treeData, setTreeData] = useState<TreeOption[]>([]);
|
||||
const [aiLoading, setAiLoading] = useState(false);
|
||||
const didAutoFill = useRef(false);
|
||||
|
||||
const raw = useMemo(() => (product.raw ?? {}) as Record<string, unknown>, [product.raw]);
|
||||
|
||||
/** 中文标题(来自 raw.title,本地可编辑,不直接调 onSave) */
|
||||
const [titleZh, setTitleZh] = useState<string>((raw.title_zh as string) ?? (raw.title as string) ?? '');
|
||||
|
||||
/** 货号后缀(本地状态,合并型号前缀后写入 offer_id) */
|
||||
const modelCode = (raw.model_code as string) ?? '';
|
||||
const prefix = getModelPrefix(modelCode);
|
||||
|
||||
// offer_id = prefix + suffix;suffix = offer_id 去掉 prefix
|
||||
const skuSuffix = product.offer_id?.startsWith(prefix)
|
||||
? product.offer_id.slice(prefix.length)
|
||||
: product.offer_id ?? '';
|
||||
|
||||
const fullOfferId = (prefix + skuSuffix).trim();
|
||||
|
||||
const loadTree = useCallback(async (shopId: string) => {
|
||||
try {
|
||||
const t = await categoryTree(shopId);
|
||||
setTreeData(buildTree(t, null));
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 初始化:加载店铺 + 类目树
|
||||
useEffect(() => {
|
||||
listShops()
|
||||
.then((s) => {
|
||||
setShops(s);
|
||||
const sid = product.shop_id ?? s[0]?.id;
|
||||
if (sid) {
|
||||
if (!product.shop_id) onSave({ shop_id: sid });
|
||||
loadTree(sid);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => message.error(apiErrorMessage(e)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 自动从 raw 回填尺寸/重量(仅首次,product.weight 为空时)
|
||||
useEffect(() => {
|
||||
if (didAutoFill.current) return;
|
||||
if (product.weight != null && product.depth != null) return; // 已有值,不覆盖
|
||||
const extracted = extractPackagingFromRaw(raw);
|
||||
if (!extracted.weight && !extracted.depth) return;
|
||||
didAutoFill.current = true;
|
||||
const patch: Partial<ProductDetail> = {};
|
||||
if (extracted.weight != null && product.weight == null) {
|
||||
patch.weight = extracted.weight;
|
||||
patch.weight_unit = extracted.weightUnit ?? 'g';
|
||||
}
|
||||
if (extracted.depth != null && product.depth == null) {
|
||||
patch.depth = extracted.depth;
|
||||
patch.dimension_unit = extracted.dimUnit ?? 'mm';
|
||||
}
|
||||
if (extracted.width != null && product.width == null) patch.width = extracted.width;
|
||||
if (extracted.height != null && product.height == null) patch.height = extracted.height;
|
||||
if (Object.keys(patch).length) onSave(patch);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const onShopChange = (sid: string) => {
|
||||
onSave({ shop_id: sid });
|
||||
loadTree(sid);
|
||||
};
|
||||
|
||||
const onCategoryChange = (value: string) => {
|
||||
const [cid, tid] = value.split(':');
|
||||
onSave({ description_category_id: Number(cid), type_id: Number(tid) });
|
||||
};
|
||||
|
||||
const onModelChange = (v: string) => {
|
||||
const newPrefix = getModelPrefix(v);
|
||||
onSave({
|
||||
raw: { ...raw, model_code: v },
|
||||
offer_id: (newPrefix + skuSuffix).trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const onSkuSuffixChange = (v: string) => {
|
||||
onSave({ offer_id: (prefix + v).trim() });
|
||||
};
|
||||
|
||||
const saveTitleZh = (v: string) => {
|
||||
onSave({ raw: { ...raw, title_zh: v } });
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text).then(() => message.success('已复制'));
|
||||
};
|
||||
|
||||
/** AI 一键生成:同时生成俄文标题 + 描述,并把中文对照写回 */
|
||||
const generateTitle = async () => {
|
||||
const parts: string[] = [];
|
||||
const zhTitle = titleZh || (raw.title as string);
|
||||
if (zhTitle) parts.push(`商品名:${zhTitle}`);
|
||||
const params = raw.params as Array<{ key: string; value: string }> | undefined;
|
||||
if (Array.isArray(params)) parts.push(params.map((p) => `${p.key}: ${p.value}`).join('\n'));
|
||||
if (typeof raw.desc === 'string' && raw.desc) parts.push(`详情:${raw.desc}`);
|
||||
if (typeof raw.sellingPoints === 'string' && raw.sellingPoints) parts.push(`卖点:${raw.sellingPoints}`);
|
||||
const source = parts.join('\n\n') || product.name || '';
|
||||
if (source.trim().length < 10) {
|
||||
message.warning('采集信息不足,无法生成标题');
|
||||
return;
|
||||
}
|
||||
setAiLoading(true);
|
||||
try {
|
||||
const r = await generateCopy({ source_text: source, model: 'deepseek-v4-flash' });
|
||||
const titleRu = r.titles_ru?.[0];
|
||||
const titleZhRes = r.titles_zh?.[0];
|
||||
if (titleRu) {
|
||||
const patch: Partial<ProductDetail> = { name: titleRu };
|
||||
if (titleZhRes) {
|
||||
patch.raw = { ...raw, title_zh: titleZhRes };
|
||||
setTitleZh(titleZhRes);
|
||||
}
|
||||
onSave(patch);
|
||||
message.success('标题已生成,中俄文已回填');
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setAiLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 行 1:店铺 + 类目 */}
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={8}>
|
||||
<FieldLabel>上架店铺</FieldLabel>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择店铺"
|
||||
value={product.shop_id ?? undefined}
|
||||
onChange={onShopChange}
|
||||
options={shops.map((s) => ({ value: s.id, label: s.name }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<FieldLabel>Ozon 类目</FieldLabel>
|
||||
<TreeSelect
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
treeNodeFilterProp="title"
|
||||
placeholder="搜索并选择末级类目"
|
||||
value={
|
||||
product.description_category_id && product.type_id
|
||||
? `${product.description_category_id}:${product.type_id}`
|
||||
: undefined
|
||||
}
|
||||
treeData={treeData}
|
||||
onChange={onCategoryChange}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 行 2:标题(中俄双栏,AI 生成) */}
|
||||
<div style={fieldRowStyle}>
|
||||
<Row gutter={0} align="middle" style={{ marginBottom: 6 }}>
|
||||
<Col flex="auto">
|
||||
<FieldLabel style={{ marginBottom: 0 }}>标题</FieldLabel>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={aiLoading}
|
||||
onClick={generateTitle}
|
||||
>
|
||||
AI 生成中俄双语
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
中文(采集/参考)
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
value={titleZh}
|
||||
onChange={(e) => setTitleZh(e.target.value)}
|
||||
onBlur={(e) => saveTitleZh(e.target.value)}
|
||||
placeholder="中文商品标题(参考)"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
|
||||
俄文(发布用)
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
value={product.name}
|
||||
onChange={(e) => onSave({ name: e.target.value })}
|
||||
placeholder="Название товара на русском"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 行 3:型号 + 货号 */}
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={8}>
|
||||
<FieldLabel>型号 (model)</FieldLabel>
|
||||
<Input
|
||||
value={modelCode}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
placeholder="如 PD-001"
|
||||
suffix={
|
||||
<Tooltip title="复制型号">
|
||||
<CopyOutlined
|
||||
style={{ cursor: 'pointer', color: '#aaa' }}
|
||||
onClick={() => copyToClipboard(modelCode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<FieldLabel>
|
||||
货号 offer_id
|
||||
{prefix && (
|
||||
<span style={{ color: '#8b5cf6', fontWeight: 400 }}>
|
||||
前缀:{prefix}
|
||||
</span>
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
{prefix && (
|
||||
<Input
|
||||
style={{ width: 120, background: '#f5f5f5', color: '#888', cursor: 'default' }}
|
||||
value={prefix}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
style={{ flex: 1 }}
|
||||
value={skuSuffix}
|
||||
onChange={(e) => onSkuSuffixChange(e.target.value)}
|
||||
placeholder="后缀"
|
||||
/>
|
||||
<Tooltip title="复制完整货号">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copyToClipboard(fullOfferId)}
|
||||
disabled={!fullOfferId}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
{fullOfferId && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
完整货号:{fullOfferId}
|
||||
</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 行 4:包装重量 + 包装尺寸 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<FieldLabel>包装重量</FieldLabel>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
style={{ flex: 1 }}
|
||||
value={product.weight ?? undefined}
|
||||
onChange={(v) => onSave({ weight: v ?? null })}
|
||||
placeholder="数值"
|
||||
min={0}
|
||||
/>
|
||||
<Select
|
||||
style={{ width: 72 }}
|
||||
value={product.weight_unit ?? 'g'}
|
||||
onChange={(v) => onSave({ weight_unit: v })}
|
||||
options={[
|
||||
{ value: 'g', label: 'g' },
|
||||
{ value: 'kg', label: 'kg' },
|
||||
]}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Col>
|
||||
<Col span={18}>
|
||||
<FieldLabel>
|
||||
包装尺寸(长 × 宽 × 高)
|
||||
<Select
|
||||
size="small"
|
||||
value={product.dimension_unit ?? 'mm'}
|
||||
onChange={(v) => onSave({ dimension_unit: v })}
|
||||
style={{ marginLeft: 8 }}
|
||||
options={[
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'cm', label: 'cm' },
|
||||
]}
|
||||
/>
|
||||
</FieldLabel>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={product.depth ?? undefined}
|
||||
onChange={(v) => onSave({ depth: v ?? null })}
|
||||
placeholder="长"
|
||||
min={0}
|
||||
/>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={product.width ?? undefined}
|
||||
onChange={(v) => onSave({ width: v ?? null })}
|
||||
placeholder="宽"
|
||||
min={0}
|
||||
/>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
value={product.height ?? undefined}
|
||||
onChange={(v) => onSave({ height: v ?? null })}
|
||||
placeholder="高"
|
||||
min={0}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
|
||||
import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
|
||||
import { getFxRate } from '@/services/fx';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import FieldLabel, { fieldRowStyle } from './FieldLabel';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type PricingPayload = Record<string, any>;
|
||||
|
||||
/** 包装信息 → 计价输入:重量统一 g */
|
||||
function weightGrams(product: ProductDetail): number {
|
||||
const w = product.weight ?? 0;
|
||||
return product.weight_unit === 'kg' ? w * 1000 : w;
|
||||
}
|
||||
|
||||
/** 包装信息 → 计价输入:尺寸统一 cm */
|
||||
function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
|
||||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1; // mm → cm
|
||||
return {
|
||||
l: (product.depth ?? 0) * scale,
|
||||
w: (product.width ?? 0) * scale,
|
||||
h: (product.height ?? 0) * scale,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。
|
||||
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
|
||||
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 预留折扣前价格)。
|
||||
*/
|
||||
export default function PriceInfoPanel({ product, onSave }: Props) {
|
||||
const pricing = (product.pricing ?? {}) as PricingPayload;
|
||||
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
|
||||
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
||||
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
|
||||
const [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
|
||||
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||
|
||||
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
|
||||
useEffect(() => {
|
||||
if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 汇率:未快照时拉取;已有计价记录的则跟随重算
|
||||
useEffect(() => {
|
||||
if (!fxRate) {
|
||||
getFxRate()
|
||||
.then((r) => {
|
||||
setFxRate(r.rate);
|
||||
if (pricing.calculatedAt) recalc({ fxRate: r.rate });
|
||||
})
|
||||
.catch((e) => message.error(apiErrorMessage(e)));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const tdPrice = pricing.tdPrice ?? 3;
|
||||
|
||||
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
|
||||
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) =>
|
||||
calculatePricing({
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
logisticsLevel: patch.level ?? level,
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice,
|
||||
discountReserve: patch.reserve ?? reserve,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
});
|
||||
|
||||
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
|
||||
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => {
|
||||
const p = {
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
level: patch.level ?? level,
|
||||
reserve: patch.reserve ?? reserve,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
};
|
||||
const r = compute(patch);
|
||||
onSave({
|
||||
pricing: {
|
||||
...pricing,
|
||||
purchasePrice: p.purchasePrice,
|
||||
profitRate: p.profitRate,
|
||||
logisticsLevel: p.level,
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice,
|
||||
discountReserve: p.reserve,
|
||||
fxRate: p.fxRate,
|
||||
logisticsFee: r.logisticsFee,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
},
|
||||
fx_rate: p.fxRate,
|
||||
price: r.sellingPriceCny,
|
||||
old_price: r.reservedPriceCny,
|
||||
currency_code: 'CNY',
|
||||
});
|
||||
};
|
||||
|
||||
const preview = fxRate ? compute() : null;
|
||||
const hint = preview ? validateLogisticsLevel(preview.sellingPriceRub, level) : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 售价 / 划线价 / 币种 */}
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={6}>
|
||||
<FieldLabel>售价 ¥</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={product.price ?? undefined}
|
||||
onChange={(v) => onSave({ price: v ?? null, currency_code: 'CNY' })}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<FieldLabel>划线价 ¥</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={product.old_price ?? undefined}
|
||||
onChange={(v) => onSave({ old_price: v ?? null })}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<FieldLabel>币种</FieldLabel>
|
||||
<Text style={{ lineHeight: '32px' }}>人民币(CNY)</Text>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
|
||||
售价/划线价随定价参数自动回填,也可手动微调
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 定价参数 */}
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={5}>
|
||||
<FieldLabel>进货价 ¥</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={purchasePrice}
|
||||
onChange={(v) => {
|
||||
setPurchasePrice(v ?? 0);
|
||||
recalc({ purchasePrice: v ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<FieldLabel>净利率 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={profitRate}
|
||||
onChange={(v) => {
|
||||
setProfitRate(v ?? 0);
|
||||
recalc({ profitRate: v ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<FieldLabel>汇率 ¥→₽</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={fxRate}
|
||||
onChange={(v) => {
|
||||
setFxRate(v ?? 0);
|
||||
recalc({ fxRate: v ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={5}>
|
||||
<FieldLabel>预留折扣 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={95}
|
||||
value={reserve}
|
||||
onChange={(v) => {
|
||||
setReserve(v ?? 0);
|
||||
recalc({ reserve: v ?? 0 });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={4} style={{ alignSelf: 'flex-end', paddingBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
重量/尺寸取自包装信息
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 物流等级 */}
|
||||
<div style={fieldRowStyle}>
|
||||
<FieldLabel>物流等级</FieldLabel>
|
||||
<Radio.Group
|
||||
block
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={level}
|
||||
onChange={(e) => {
|
||||
setLevel(e.target.value);
|
||||
recalc({ level: e.target.value });
|
||||
}}
|
||||
options={[
|
||||
{ value: 'low', label: '低 (low)' },
|
||||
{ value: 'high', label: '高 (high)' },
|
||||
{ value: 'high2', label: 'Premium (high2)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 实时结果摘要 */}
|
||||
{preview && (
|
||||
<Space wrap>
|
||||
<Tag>完全成本 ¥ {preview.totalCost.toFixed(2)}</Tag>
|
||||
<Tag>物流费 ¥ {preview.logisticsFee.toFixed(2)}</Tag>
|
||||
<Tag>销售价 ₽ {preview.sellingPriceRub.toFixed(0)}(参考)</Tag>
|
||||
{hint && <Tag color="orange">{hint}</Tag>}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
import { Col, Divider, Input, Row, Tag, Typography } from 'antd';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import CopyPanel from './CopyPanel';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** 产品属性:采集参数只读展示 / 条形码 / 品牌 / 简介 / AI 文案 */
|
||||
export default function ProductAttributesPanel({ product, onSave }: Props) {
|
||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const params = raw.params as Array<{ key: string; value: string }> | undefined;
|
||||
|
||||
const [descExpanded, setDescExpanded] = useState(false);
|
||||
|
||||
const updateRaw = (patch: Record<string, unknown>) => {
|
||||
onSave({ raw: { ...raw, ...patch } });
|
||||
};
|
||||
|
||||
const sellingPoints = typeof raw.sellingPoints === 'string' ? raw.sellingPoints : '';
|
||||
const desc = typeof raw.desc === 'string' ? raw.desc : '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 基础字段:品牌 + 条形码 */}
|
||||
<Row gutter={16} style={{ marginBottom: 12 }}>
|
||||
<Col span={8}>
|
||||
<Text style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>
|
||||
品牌(Brand)
|
||||
</Text>
|
||||
<Input
|
||||
value={(raw.brand as string) ?? ''}
|
||||
onChange={(e) => updateRaw({ brand: e.target.value })}
|
||||
placeholder="采集到的品牌名"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>
|
||||
条形码(Barcode)
|
||||
</Text>
|
||||
<Input
|
||||
value={product.barcode ?? ''}
|
||||
onChange={(e) => onSave({ barcode: e.target.value })}
|
||||
placeholder="EAN / UPC / 留空"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Text style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>
|
||||
主题标签(内部备注)
|
||||
</Text>
|
||||
<Input
|
||||
value={(raw.tags as string) ?? ''}
|
||||
onChange={(e) => updateRaw({ tags: e.target.value })}
|
||||
placeholder="逗号分隔,内部用"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 俄文简介(来自 AI 生成或手动) */}
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text style={{ fontSize: 12, color: '#666', display: 'block', marginBottom: 4 }}>
|
||||
俄文简介(description)
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 3, maxRows: 8 }}
|
||||
value={product.description ?? ''}
|
||||
onChange={(e) => onSave({ description: e.target.value })}
|
||||
placeholder="Описание товара на русском — можно сгенерировать через AI ниже"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 采集参数展示(只读,参考用) */}
|
||||
{Array.isArray(params) && params.length > 0 && (
|
||||
<div style={{ marginBottom: 12, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<Text style={{ fontSize: 12, color: '#666', fontWeight: 500 }}>
|
||||
采集参数({params.length} 项,只读参考)
|
||||
</Text>
|
||||
{params.length > 8 && (
|
||||
<a style={{ fontSize: 12 }} onClick={() => setDescExpanded(!descExpanded)}>
|
||||
{descExpanded ? '收起' : `展开全部 ${params.length} 项`}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Row gutter={[8, 4]}>
|
||||
{(descExpanded ? params : params.slice(0, 8)).map((p, i) => (
|
||||
<Col key={i} span={12}>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'baseline' }}>
|
||||
<Tag
|
||||
style={{
|
||||
fontSize: 11,
|
||||
maxWidth: 120,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{p.key}
|
||||
</Tag>
|
||||
<Text style={{ fontSize: 12 }} ellipsis={{ tooltip: p.value }}>
|
||||
{p.value}
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{!descExpanded && params.length > 8 && (
|
||||
<Text type="secondary" style={{ fontSize: 11, marginTop: 4, display: 'block' }}>
|
||||
还有 {params.length - 8} 项未显示
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 采集卖点 / 描述(只读参考) */}
|
||||
{(sellingPoints || desc) && (
|
||||
<div style={{ marginBottom: 12, padding: '12px 16px', background: '#fafafa', borderRadius: 8 }}>
|
||||
{sellingPoints && (
|
||||
<>
|
||||
<Text style={{ fontSize: 12, color: '#666', fontWeight: 500, display: 'block', marginBottom: 4 }}>
|
||||
采集卖点(参考)
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'pre-wrap' }}>
|
||||
{sellingPoints}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{desc && (
|
||||
<>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: '#666',
|
||||
fontWeight: 500,
|
||||
display: 'block',
|
||||
marginTop: sellingPoints ? 8 : 0,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
采集描述(参考)
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, whiteSpace: 'pre-wrap' }}>
|
||||
{desc.slice(0, 300)}
|
||||
{desc.length > 300 && '…'}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
<CopyPanel product={product} onSave={onSave} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
|
||||
import { getProduct, updateProduct, listAssets, ProductDetail, ProductAsset } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { STAGE_LABEL, STAGE_COLOR } from '../collection/CollectionPage';
|
||||
import MainInfoPanel from './MainInfoPanel';
|
||||
import ProductAttributesPanel from './ProductAttributesPanel';
|
||||
import PriceInfoPanel from './PriceInfoPanel';
|
||||
import ImagePanel from './ImagePanel';
|
||||
import PublishPanel from './PublishPanel';
|
||||
import AttributePanel from './AttributePanel';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'section-main', label: '主要信息' },
|
||||
{ id: 'section-sales', label: '售价信息' },
|
||||
{ id: 'section-attrs', label: '产品属性' },
|
||||
{ id: 'section-images', label: '图片素材' },
|
||||
{ id: 'section-mapping', label: '属性映射' },
|
||||
{ id: 'section-publish', label: '发布' },
|
||||
];
|
||||
|
||||
export default function ProductEditPage() {
|
||||
const { id } = useParams();
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [active, setActive] = useState('section-main');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
|
||||
setProduct(p);
|
||||
setAssets(a);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// 滚动监听:高亮当前区域
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
let current = SECTIONS[0].id;
|
||||
for (const s of SECTIONS) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (el && el.getBoundingClientRect().top <= 90) current = s.id;
|
||||
}
|
||||
setActive(current);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (partial: Partial<ProductDetail>) => {
|
||||
if (!id || !product) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateProduct(id, partial);
|
||||
setProduct(updated);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[id, product],
|
||||
);
|
||||
|
||||
if (loading || !product) {
|
||||
return (
|
||||
<div style={{ padding: 80, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{titleZh || product.name || '(未命名商品)'}
|
||||
</Title>
|
||||
<span style={{ color: STAGE_COLOR[product.stage] }}>
|
||||
{STAGE_LABEL[product.stage] || product.stage}
|
||||
</span>
|
||||
</Space>
|
||||
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
|
||||
</div>
|
||||
{product.name && product.name !== titleZh && (
|
||||
<Text type="secondary" ellipsis={{ tooltip: product.name }} style={{ display: 'block', maxWidth: '80%' }}>
|
||||
俄文:{product.name}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div id="section-main">
|
||||
<Card title="主要信息">
|
||||
<MainInfoPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-sales">
|
||||
<Card title="售价信息">
|
||||
<PriceInfoPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-attrs">
|
||||
<Card title="产品属性">
|
||||
<ProductAttributesPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-images">
|
||||
<Card title={`图片素材 (${assets.length})`}>
|
||||
<ImagePanel assets={assets} product={product} onSave={save} onRefresh={load} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-mapping">
|
||||
<Card title="属性映射">
|
||||
<AttributePanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-publish">
|
||||
<Card title="发布">
|
||||
<PublishPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧区域导航 */}
|
||||
<div style={{ width: 120, flexShrink: 0 }}>
|
||||
<div style={{ position: 'sticky', top: 80 }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[active]}
|
||||
style={{ borderInlineEnd: 0, background: 'transparent' }}
|
||||
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
|
||||
onClick={({ key }) => {
|
||||
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Descriptions, Empty, List, message, Select, Space, Tag, Typography } from 'antd';
|
||||
import { listShops, ShopItem } from '@/services/shop';
|
||||
import { publishProduct, getPublishTask, publishHistory, PublishTask } from '@/services/publish';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const STATUS_TAG: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'default', text: '等待中' },
|
||||
processing: { color: 'processing', text: '处理中' },
|
||||
moderation: { color: 'orange', text: '审核中' },
|
||||
imported: { color: 'green', text: '发布成功' },
|
||||
failed: { color: 'red', text: '失败' },
|
||||
};
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => void;
|
||||
}
|
||||
|
||||
export default function PublishPanel({ product, onSave }: Props) {
|
||||
const [shops, setShops] = useState<ShopItem[]>([]);
|
||||
const [shopId, setShopId] = useState<string | undefined>();
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [task, setTask] = useState<PublishTask | null>(null);
|
||||
const [history, setHistory] = useState<Awaited<ReturnType<typeof publishHistory>>>([]);
|
||||
|
||||
const loadShops = async () => {
|
||||
try {
|
||||
const s = await listShops();
|
||||
setShops(s);
|
||||
if (s.length && !shopId) setShopId(s[0].id);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
setHistory(await publishHistory(product.id));
|
||||
} catch {
|
||||
/* 忽略 */
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadShops();
|
||||
loadHistory();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [product.id]);
|
||||
|
||||
const doPublish = async () => {
|
||||
if (!shopId) {
|
||||
message.warning('请先选择店铺(需在「店铺管理」中添加)');
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
setTask(null);
|
||||
try {
|
||||
const r = await publishProduct(product.id, shopId);
|
||||
// 轮询直到终态
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
const t = await getPublishTask(r.task_id);
|
||||
setTask(t);
|
||||
if (t.status === 'imported' || t.status === 'failed') {
|
||||
if (t.status === 'imported') {
|
||||
onSave({});
|
||||
message.success('发布成功');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
loadHistory();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const errors = (task?.errors as Array<{ description?: string; code?: string }> | null) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{product.ozon_product_id && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`已发布到 Ozon:product_id = ${product.ozon_product_id}`}
|
||||
/>
|
||||
)}
|
||||
<Card size="small" title="发布到 Ozon">
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Text>目标店铺:</Text>
|
||||
<Select
|
||||
style={{ width: 200 }}
|
||||
placeholder="选择店铺"
|
||||
value={shopId}
|
||||
onChange={setShopId}
|
||||
options={shops.map((s) => ({ value: s.id, label: `${s.name} (${s.currency_code})` }))}
|
||||
/>
|
||||
<Button type="primary" loading={publishing} onClick={doPublish} disabled={!shopId}>
|
||||
发布
|
||||
</Button>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
发布会校验必填项(货号/名称/描述/类目/售价/尺寸重量/主图),通过后提交 Ozon 并自动轮询结果。
|
||||
</Text>
|
||||
|
||||
{task && (
|
||||
<Descriptions size="small" column={2} bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="任务状态">
|
||||
<Tag color={STATUS_TAG[task.status]?.color}>{STATUS_TAG[task.status]?.text || task.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Ozon task_id">{task.ozon_task_id}</Descriptions.Item>
|
||||
{errors.length > 0 && (
|
||||
<Descriptions.Item label="错误" span={2}>
|
||||
{errors.map((e, i) => (
|
||||
<div key={i} style={{ color: '#cf1322' }}>
|
||||
{e.description || e.code || JSON.stringify(e)}
|
||||
</div>
|
||||
))}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Text strong>发布历史</Text>
|
||||
{history.length === 0 ? (
|
||||
<Empty description="暂无发布记录" />
|
||||
) : (
|
||||
<List
|
||||
size="small"
|
||||
dataSource={history}
|
||||
renderItem={(h) => (
|
||||
<List.Item>
|
||||
<Space>
|
||||
<Tag color={STATUS_TAG[h.status]?.color}>{STATUS_TAG[h.status]?.text || h.status}</Tag>
|
||||
<Text type="secondary">task {h.ozon_task_id}</Text>
|
||||
<Text type="secondary">{new Date(h.created_at).toLocaleString()}</Text>
|
||||
</Space>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { listShops, createShop, updateShop, deleteShop, testShop, ShopItem } from '@/services/shop';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { active: 'green', invalid: 'red', disabled: 'default' };
|
||||
const STATUS_TEXT: Record<string, string> = { active: '正常', invalid: '凭证失效', disabled: '停用' };
|
||||
|
||||
export default function ShopsPage() {
|
||||
const [shops, setShops] = useState<ShopItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ShopItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setShops(await listShops());
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const onTest = async (id: string) => {
|
||||
try {
|
||||
const r = await testShop(id);
|
||||
if (r.ok) {
|
||||
message.success(`凭证有效,角色 ${r.roles?.length ?? 0} 个`);
|
||||
} else {
|
||||
message.error(r.error || '凭证校验失败');
|
||||
}
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ currency_code: 'CNY' });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (shop: ShopItem) => {
|
||||
setEditing(shop);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ name: shop.name, currency_code: shop.currency_code });
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editing) {
|
||||
const payload: Record<string, string> = { name: values.name, currency_code: values.currency_code };
|
||||
if (values.client_id) payload.client_id = values.client_id;
|
||||
if (values.api_key) payload.api_key = values.api_key;
|
||||
await updateShop(editing.id, payload);
|
||||
message.success('店铺已更新');
|
||||
} else {
|
||||
await createShop(values);
|
||||
message.success('店铺已添加');
|
||||
}
|
||||
setOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e) {
|
||||
if (e && (e as { errorFields?: unknown }).errorFields) return; // 表单校验错误
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ShopItem> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '币种', dataIndex: 'currency_code', width: 80 },
|
||||
{
|
||||
title: 'Client-Id',
|
||||
dataIndex: 'client_id_masked',
|
||||
width: 120,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v) => <Tag color={STATUS_COLOR[v]}>{STATUS_TEXT[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最近校验',
|
||||
dataIndex: 'last_checked_at',
|
||||
width: 170,
|
||||
render: (v) => (v ? new Date(v).toLocaleString() : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 250,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => onTest(r.id)}>
|
||||
测试
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openEdit(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={async () => { await deleteShop(r.id); load(); }}>
|
||||
<Button size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
店铺管理
|
||||
</Title>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
添加店铺
|
||||
</Button>
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={shops} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑 Ozon 店铺' : '添加 Ozon 店铺'}
|
||||
open={open}
|
||||
onOk={onSubmit}
|
||||
onCancel={() => setOpen(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ currency_code: 'CNY' }}>
|
||||
<Form.Item label="店铺名称" name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:主店铺" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Client-Id"
|
||||
name="client_id"
|
||||
rules={editing ? [] : [{ required: true }]}
|
||||
extra={editing ? '留空则不修改' : undefined}
|
||||
>
|
||||
<Input placeholder="卖家后台 → 设置 → Seller API" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Api-Key"
|
||||
name="api_key"
|
||||
rules={editing ? [] : [{ required: true }]}
|
||||
extra={editing ? '留空则不修改' : undefined}
|
||||
>
|
||||
<Input.Password placeholder="API 密钥" />
|
||||
</Form.Item>
|
||||
<Form.Item label="结算币种" name="currency_code">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'CNY', label: 'CNY(人民币,跨境)' },
|
||||
{ value: 'RUB', label: 'RUB(卢布)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
|
||||
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/预留折扣/汇率
|
||||
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/预留折扣价
|
||||
*/
|
||||
|
||||
export type LogisticsLevel = 'low' | 'high' | 'high2';
|
||||
|
||||
export interface PricingInput {
|
||||
purchasePrice: number; // 进货价 ¥
|
||||
profitRate: number; // 净利率 %
|
||||
logisticsLevel: LogisticsLevel;
|
||||
weightG: number; // 重量 g
|
||||
dims: { l: number; w: number; h: number }; // cm
|
||||
tdPrice: number; // 贴单费用 ¥
|
||||
discountReserve: number; // 预留折扣 %
|
||||
fxRate: number; // 汇率 CNY→RUB
|
||||
}
|
||||
|
||||
export interface PricingResult {
|
||||
logisticsFee: number;
|
||||
receivedPrice: number; // 实收价 = 进货价 × (1 + 净利率)
|
||||
profitPrice: number; // 净利润 = 进货价 × 净利率
|
||||
netRate: number; // 净到手比例
|
||||
sellingPriceCny: number; // 销售价 ¥
|
||||
fullCommission: number; // 平台总抽成
|
||||
commission: number; // 展示用平台佣金
|
||||
totalCost: number; // 完全成本
|
||||
sellingPriceRub: number; // 销售价 ₽
|
||||
reservedPriceCny: number;// 预留折扣后 ¥
|
||||
reservedPriceRub: number;// 预留折扣后 ₽
|
||||
}
|
||||
|
||||
/** 物流费(不含贴单费) */
|
||||
export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number {
|
||||
if (level === 'low') {
|
||||
return weightG <= 500 ? 3.12 + 0.026 * weightG : 23.92 + 0.01768 * weightG;
|
||||
}
|
||||
if (level === 'high') {
|
||||
return weightG <= 2000 ? 16.64 + 0.026 * weightG : 37.44 + 0.01768 * weightG;
|
||||
}
|
||||
// high2 (Premium)
|
||||
return weightG <= 5000 ? 22.88 + 0.026 * weightG : 64.48 + 0.024 * weightG;
|
||||
}
|
||||
|
||||
export function calculatePricing(input: PricingInput): PricingResult {
|
||||
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, discountReserve, fxRate } = input;
|
||||
|
||||
const logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
|
||||
const profitDecimal = profitRate / 100;
|
||||
const receivedPrice = purchasePrice * (1 + profitDecimal);
|
||||
const profitPrice = purchasePrice * profitDecimal;
|
||||
const netRate = logisticsLevel === 'low' ? 0.845 : 0.785;
|
||||
const sellingPriceCny = (receivedPrice + logisticsFee) / netRate;
|
||||
const fullCommission = sellingPriceCny * (1 - netRate);
|
||||
const commission = sellingPriceCny * (logisticsLevel === 'low' ? 0.12 : 0.18);
|
||||
const totalCost = purchasePrice + logisticsFee + fullCommission;
|
||||
|
||||
const sellingPriceRub = sellingPriceCny * fxRate;
|
||||
const reservedPriceCny = sellingPriceCny / (1 - discountReserve / 100);
|
||||
const reservedPriceRub = reservedPriceCny * fxRate;
|
||||
|
||||
return {
|
||||
logisticsFee,
|
||||
receivedPrice,
|
||||
profitPrice,
|
||||
netRate,
|
||||
sellingPriceCny,
|
||||
fullCommission,
|
||||
commission,
|
||||
totalCost,
|
||||
sellingPriceRub,
|
||||
reservedPriceCny,
|
||||
reservedPriceRub,
|
||||
};
|
||||
}
|
||||
|
||||
/** 尺寸/物流等级建议(纯提示,不阻断) */
|
||||
export function validateLogisticsLevel(sellingPriceRub: number, level: LogisticsLevel): string {
|
||||
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流';
|
||||
if (sellingPriceRub < 135 && level !== 'low') return '售价较低,建议选低等级物流';
|
||||
if (sellingPriceRub >= 135 && sellingPriceRub <= 140) return '汇率波动,建议避开 135~140 ₽ 区间';
|
||||
return '';
|
||||
}
|
||||
@@ -1,15 +1,23 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router';
|
||||
import MainLayout from '@/layouts/MainLayout';
|
||||
import AiImagePage from '@/pages/ai-image';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import ProductEditPage from '@/pages/product/ProductEditPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <MainLayout />,
|
||||
children: [
|
||||
{ index: true, element: <AiImagePage /> },
|
||||
{ index: true, element: <Navigate to="/collection" replace /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'product/:id', element: <ProductEditPage /> },
|
||||
{ path: 'shops', element: <ShopsPage /> },
|
||||
{ path: 'ai-image', element: <AiImagePage /> },
|
||||
],
|
||||
},
|
||||
// 后续加账户体系时再启用 /login
|
||||
{ path: '/login', element: <Navigate to="/collection" replace /> },
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface AiModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ModelsResponse {
|
||||
default: string;
|
||||
models: AiModelOption[];
|
||||
}
|
||||
|
||||
export interface CopyRequest {
|
||||
source_text: string;
|
||||
model_code?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface CopyResponse {
|
||||
titles_ru: string[];
|
||||
titles_zh: string[];
|
||||
description_ru: string;
|
||||
description_zh: string;
|
||||
tags_ru: string[];
|
||||
tags_zh: string[];
|
||||
model: string;
|
||||
usage: { prompt_tokens: number; completion_tokens: number };
|
||||
}
|
||||
|
||||
export function getAiModels() {
|
||||
return api.get<ModelsResponse>('/ai/models');
|
||||
}
|
||||
|
||||
export function generateCopy(payload: CopyRequest) {
|
||||
return api.post<CopyResponse>('/ai/copy', payload);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { envConfig } from '@/config/env';
|
||||
import { getToken } from './auth';
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: envConfig.apiBaseUrl,
|
||||
@@ -10,6 +11,16 @@ const apiClient: AxiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截:附带 JWT
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
@@ -26,6 +37,12 @@ export const api = {
|
||||
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
||||
|
||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
||||
};
|
||||
|
||||
/** 从异常中提取后端返回的错误信息(FastAPI 的 HTTPException detail) */
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { api } from './api';
|
||||
|
||||
const TOKEN_KEY = 'ozon_kit_jwt';
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export async function login(appToken: string): Promise<LoginResult> {
|
||||
const res = await api.post<LoginResult>('/auth/login', { token: appToken });
|
||||
setToken(res.access_token);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearToken();
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface CategoryNode {
|
||||
description_category_id?: number | null;
|
||||
category_name?: string;
|
||||
type_id?: number | null;
|
||||
type_name?: string;
|
||||
disabled?: boolean;
|
||||
children?: CategoryNode[];
|
||||
}
|
||||
|
||||
export interface AttributeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
dictionary_id?: number;
|
||||
group_id?: number;
|
||||
group_name?: string;
|
||||
is_required?: boolean;
|
||||
is_aspect?: boolean;
|
||||
is_collection?: boolean;
|
||||
max_value_count?: number;
|
||||
}
|
||||
|
||||
export interface AttributeValue {
|
||||
id: number;
|
||||
value: string;
|
||||
picture?: string;
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export function categoryTree(shopId: string, lang = 'ZH_HANS') {
|
||||
return api.post<CategoryNode[]>('/categories/tree', { shop_id: shopId, lang });
|
||||
}
|
||||
|
||||
export function categoryAttributes(shopId: string, categoryId: number, typeId: number, lang = 'ZH_HANS') {
|
||||
return api.post<AttributeItem[]>(`/categories/${categoryId}/attributes`, {
|
||||
shop_id: shopId,
|
||||
type_id: typeId,
|
||||
lang,
|
||||
});
|
||||
}
|
||||
|
||||
export function attributeValues(
|
||||
shopId: string,
|
||||
attributeId: number,
|
||||
categoryId: number,
|
||||
typeId: number,
|
||||
q?: string,
|
||||
limit = 100,
|
||||
) {
|
||||
return api.post<{ result?: AttributeValue[]; has_next?: boolean }>(
|
||||
`/categories/attribute/${attributeId}/values`,
|
||||
{ shop_id: shopId, category_id: categoryId, type_id: typeId, q, limit, lang: 'ZH_HANS' },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface FxRate {
|
||||
rate: number;
|
||||
source: string;
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
export function getFxRate() {
|
||||
return api.get<FxRate>('/fx');
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface ProductListItem {
|
||||
id: string;
|
||||
stage: string;
|
||||
name: string;
|
||||
offer_id: string;
|
||||
price: number | null;
|
||||
currency_code: string;
|
||||
source_platform: string | null;
|
||||
source_url: string | null;
|
||||
asset_counts: Record<string, number> | null;
|
||||
ozon_product_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: string;
|
||||
shop_id?: string | null;
|
||||
stage: string;
|
||||
source_platform?: string | null;
|
||||
source_item_id?: string | null;
|
||||
source_url?: string | null;
|
||||
offer_id: string;
|
||||
ozon_product_id?: number | null;
|
||||
name: string;
|
||||
description: string;
|
||||
description_category_id?: number | null;
|
||||
type_id?: number | null;
|
||||
price?: number | null;
|
||||
old_price?: number | null;
|
||||
currency_code: string;
|
||||
vat: string;
|
||||
depth?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
dimension_unit: string;
|
||||
weight?: number | null;
|
||||
weight_unit: string;
|
||||
barcode?: string | null;
|
||||
images?: string[] | null;
|
||||
primary_image?: string | null;
|
||||
images360?: string[] | null;
|
||||
color_image?: string | null;
|
||||
attributes?: unknown[] | null;
|
||||
complex_attributes?: unknown[] | null;
|
||||
raw?: Record<string, unknown> | null;
|
||||
pricing?: Record<string, unknown> | null;
|
||||
copy?: Record<string, unknown> | null;
|
||||
fx_rate?: number | null;
|
||||
asset_counts?: Record<string, number> | null;
|
||||
published_at?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductAsset {
|
||||
id: string;
|
||||
group_key: string;
|
||||
variant_name: string | null;
|
||||
sort_order: number;
|
||||
type: string;
|
||||
source_url: string;
|
||||
stored_url: string | null;
|
||||
status: 'pending' | 'downloading' | 'uploaded' | 'failed';
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function listProducts(params?: { stage?: string; q?: string; page?: number; page_size?: number }) {
|
||||
return api.get<{ total: number; items: ProductListItem[] }>('/products', { params });
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return api.get<ProductDetail>(`/products/${id}`);
|
||||
}
|
||||
|
||||
export function updateProduct(id: string, data: Partial<ProductDetail>) {
|
||||
return api.patch<ProductDetail>(`/products/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteProduct(id: string, hard = false) {
|
||||
return api.delete<{ deleted: boolean }>(`/products/${id}`, { params: { hard } });
|
||||
}
|
||||
|
||||
export function copyProduct(id: string) {
|
||||
return api.post<ProductDetail>(`/products/${id}/copy`);
|
||||
}
|
||||
|
||||
export function listAssets(id: string) {
|
||||
return api.get<ProductAsset[]>(`/products/${id}/assets`);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface PublishResult {
|
||||
task_id: string;
|
||||
ozon_task_id: number;
|
||||
}
|
||||
|
||||
export interface PublishTask {
|
||||
id: string;
|
||||
product_id: string;
|
||||
shop_id: string;
|
||||
ozon_task_id: number;
|
||||
status: 'pending' | 'processing' | 'moderation' | 'imported' | 'failed';
|
||||
errors: unknown[] | null;
|
||||
response: unknown | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
export function publishProduct(productId: string, shopId: string) {
|
||||
return api.post<PublishResult>(`/products/${productId}/publish`, { shop_id: shopId });
|
||||
}
|
||||
|
||||
export function getPublishTask(taskId: string) {
|
||||
return api.get<PublishTask>(`/publish/${taskId}`);
|
||||
}
|
||||
|
||||
export function publishHistory(productId: string) {
|
||||
return api.get<Array<{ id: string; ozon_task_id: number; status: string; errors: unknown[] | null; created_at: string; completed_at: string | null }>>(
|
||||
`/products/${productId}/publish-history`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { api } from './api';
|
||||
|
||||
export interface ShopItem {
|
||||
id: string;
|
||||
name: string;
|
||||
currency_code: string;
|
||||
status: 'active' | 'invalid' | 'disabled';
|
||||
client_id_masked: string;
|
||||
last_checked_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function listShops() {
|
||||
return api.get<ShopItem[]>('/shops');
|
||||
}
|
||||
|
||||
export function createShop(data: { name: string; client_id: string; api_key: string; currency_code?: string }) {
|
||||
return api.post<ShopItem>('/shops', data);
|
||||
}
|
||||
|
||||
export function updateShop(
|
||||
id: string,
|
||||
data: { name?: string; client_id?: string; api_key?: string; currency_code?: string },
|
||||
) {
|
||||
return api.patch<ShopItem>(`/shops/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteShop(id: string) {
|
||||
return api.delete(`/shops/${id}`);
|
||||
}
|
||||
|
||||
export function testShop(id: string) {
|
||||
return api.post<{ ok: boolean; error?: string; roles?: string[] }>(`/shops/${id}/test`);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/router/index.tsx","./src/services/api.ts","./src/services/image.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user