feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+16
View File
@@ -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}</>;
}
+23 -4
View File
@@ -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>
);
}
+46
View File
@@ -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>
);
}
+400
View File
@@ -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>
);
}
+185
View File
@@ -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>
);
}
+25
View File
@@ -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>;
}
+82
View File
@@ -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>
);
}
+451
View File
@@ -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 + suffixsuffix = 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>
);
}
+242
View File
@@ -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>
);
}
+153
View File
@@ -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={`已发布到 Ozonproduct_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>
);
}
+179
View File
@@ -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>
);
}
+84
View File
@@ -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 '';
}
+9 -1
View File
@@ -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 /> },
]);
+36
View File
@@ -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);
}
+17
View File
@@ -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 */
+31
View File
@@ -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();
}
+57
View File
@@ -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' },
);
}
+11
View File
@@ -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');
}
+94
View File
@@ -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`);
}
+32
View File
@@ -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`,
);
}
+34
View File
@@ -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
View File
@@ -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"}