feat(edit): 编辑页开发

This commit is contained in:
R524809
2026-09-11 17:28:58 +08:00
parent 1b03ea5871
commit da7342b466
21 changed files with 1910 additions and 857 deletions
+14
View File
@@ -1,5 +1,6 @@
import {
CalculatorOutlined,
BugOutlined,
CodeSandboxOutlined,
EditOutlined,
GlobalOutlined,
@@ -37,6 +38,13 @@ export const routeMenuConfig: RouteMenuConfig[] = [
icon: <InboxOutlined />,
label: '采集箱',
},
{
// 放在 /shops 之前,避免前缀匹配时被“店铺管理”抢先选中。
path: '/shops/category-tree-debug',
key: '/shops/category-tree-debug',
icon: <BugOutlined />,
label: '调试',
},
{
path: '/shops',
key: '/shops',
@@ -70,6 +78,12 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
{ icon: <EditOutlined />, label: '商品编辑' },
];
}
if (path === '/shops/category-tree-debug') {
return [
{ icon: <ShopOutlined />, label: '店铺管理' },
{ icon: <BugOutlined />, label: 'Ozon 类目树调试' },
];
}
if (path === '/settings/profile') {
return [
{ icon: <SettingOutlined />, label: '设置' },
+36 -126
View File
@@ -104,47 +104,21 @@
color: #344054;
font-weight: 600;
}
.product-editor .ant-form-item-label > label {
font-size: 16px;
}
.product-editor .ant-input,
.product-editor .ant-select-selection-item,
.product-editor .ant-select-selection-placeholder {
font-size: 16px;
}
.product-editor .ant-input:not(textarea) {
min-height: 40px;
}
.product-editor .ant-input-group-addon {
font-size: 16px;
}
.product-editor .ant-select .ant-select-selector {
min-height: 40px !important;
font-size: 16px;
}
.product-editor .ant-select-single .ant-select-selector {
height: 40px !important;
}
.product-editor .ant-select-single .ant-select-selection-item,
.product-editor .ant-select-single .ant-select-selection-placeholder {
line-height: 38px !important;
}
.product-editor .ant-select-multiple .ant-select-selector {
padding-top: 3px;
padding-bottom: 3px;
}
.package-fields {
margin-top: 22px;
padding-top: 20px;
border-top: 1px solid #f0eef4;
}
.package-fields > .ant-form-item:first-child {
.package-fields > .package-weight-field {
width: max-content;
max-width: 100%;
}
.package-fields > .ant-form-item:first-child .ant-input {
.package-fields > .package-weight-field .ant-input-group-wrapper {
width: 150px;
}
.package-fields > .package-weight-field .ant-input {
width: 100%;
}
.package-dimensions {
margin-bottom: 0;
}
@@ -163,30 +137,18 @@
}
.package-dimension-unit {
color: #667085;
font-size: 16px;
line-height: 40px;
}
.editor-standard-cascader .ant-select-selector {
min-height: 40px !important;
font-size: 16px !important;
}
.editor-standard-cascader .ant-select-selection-placeholder,
.editor-standard-cascader .ant-select-selection-item {
line-height: 38px !important;
font-size: 16px !important;
line-height: 32px;
}
.editor-category-popup .ant-cascader-menu {
max-height: 320px;
/* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */
height: 324px !important;
max-height: 324px;
}
.editor-category-popup .ant-cascader-menu-item {
display: flex;
min-height: 40px;
align-items: center;
font-size: 16px;
line-height: 24px;
}
.supply-link-control .ant-btn {
height: 40px;
min-width: 40px;
}
.category-row {
@@ -220,79 +182,46 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-standard-select {
min-height: 40px !important;
font-size: 16px !important;
}
.editor-standard-select .ant-select-content {
min-height: 38px;
font-size: 16px !important;
}
.editor-standard-select .ant-select-content-has-value,
.editor-standard-select .ant-select-placeholder,
.editor-standard-select .ant-select-input {
font-size: 16px !important;
}
.category-select-footer {
display: flex;
justify-content: flex-end;
padding: 4px 8px 6px;
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
}
.editor-standard-select.ant-select-single {
height: 40px !important;
}
.editor-standard-select.ant-select-single .ant-select-content {
height: 38px;
line-height: 38px;
}
.editor-standard-select.ant-select-single .ant-select-input {
height: 38px;
line-height: 38px !important;
}
.editor-standard-select.ant-select-multiple .ant-select-content {
min-height: 40px !important;
padding: 3px 0;
}
.editor-standard-select.ant-select-multiple .ant-select-content-item {
min-height: 32px;
display: flex;
align-items: center;
}
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
.editor-standard-select.ant-select-multiple .ant-select-input {
font-size: 16px !important;
}
.editor-standard-select.ant-select-multiple .ant-select-placeholder,
.editor-standard-select.ant-select-multiple .ant-select-input {
line-height: 32px !important;
}
/* 产品类目 Cascader:控件高度与字号对齐页面文本框(40px / 16px) */
.editor-standard-cascader.ant-select {
--ant-select-height: 40px;
--ant-select-font-size: 16px;
--ant-select-line-height: 24px;
--ant-select-font-height: 24px;
}
.editor-standard-cascader .ant-select-input {
font-size: 16px;
}
.editor-standard-cascader .ant-select-placeholder {
font-size: 16px;
}
.product-editor .ant-cascader-dropdown .ant-cascader-menu-item {
font-size: 16px;
}
.dynamic-attributes {
margin-top: 24px;
padding-top: 22px;
border-top: 1px solid #eeeaf4;
}
.dynamic-attributes h5.ant-typography {
margin: 0 0 14px;
margin: 0;
font-size: 16px;
}
.dynamic-attributes-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.dynamic-attributes-heading .ant-typography-secondary {
display: block;
margin-top: 4px;
}
.dynamic-attributes-heading .ant-btn {
flex: none;
}
.editor-short-input.ant-input {
width: 170px;
}
.dynamic-attributes-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 132px;
color: #7a7485;
}
.dynamic-attribute-list {
display: grid;
grid-template-columns: 1fr;
@@ -318,23 +247,16 @@
}
.sku-section .ant-input {
border-radius: 8px;
font-size: 14px;
}
.sku-section textarea.ant-input {
min-height: 58px;
resize: none;
}
.sku-section .ant-input-group-addon {
border-radius: 0 8px 8px 0;
font-size: 13px;
}
.sku-section .ant-input-group .ant-input {
border-radius: 8px 0 0 8px;
}
.sku-section .ant-input:not(textarea) {
height: 32px;
min-height: 32px;
}
.sku-card-list {
display: flex;
flex-direction: column;
@@ -502,8 +424,6 @@
flex: 0 0 86px !important;
}
.sku-spec-panel .ant-form-item-label > label {
height: 32px;
font-size: 14px;
font-weight: 600;
}
.sku-spec-panel .ant-form-item-control {
@@ -570,13 +490,6 @@
.source-update-alert .ant-alert-description {
font-size: 12px;
}
.collected-info-panel .ant-input {
font-size: 14px;
}
.collected-info-panel .ant-input:not(textarea) {
height: 32px;
min-height: 32px;
}
.collected-field {
margin-top: 16px;
}
@@ -691,9 +604,6 @@
.other-section .editor-section-heading {
margin-bottom: 8px;
}
.supply-open-button {
height: 40px;
}
.editor-footer {
position: sticky;
bottom: 0;
@@ -740,7 +650,7 @@
.editor-form-grid .span-two {
grid-column: span 1;
}
.package-fields > .ant-form-item:first-child,
.package-fields > .package-weight-field,
.package-dimensions-controls {
width: 100%;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd';
import type {
ProductAiSourceField,
ProductAiSourceMaterial,
ProductEditorSnapshot,
} from '@atlas/contracts';
export type CollectedProductSummary = {
price: string;
brand: string;
physicalDimensions: string;
physicalWeight: string;
packageDimensions: string;
packageWeight: string;
};
type Props = {
snapshot: ProductEditorSnapshot;
collected: ProductAiSourceMaterial | null;
summary: CollectedProductSummary;
overriddenFields: ProductAiSourceField[];
onRestore: (field: Extract<ProductAiSourceField, 'title' | 'description'>) => void;
onUpdate: (field: Extract<ProductAiSourceField, 'title' | 'description'>, value: string) => void;
onShowDifferences: () => void;
onAcceptLatest: () => void;
};
export function CollectedProductInfoPanel({
snapshot,
collected,
summary,
overriddenFields,
onRestore,
onUpdate,
onShowDifferences,
onAcceptLatest,
}: Props) {
return (
<aside className="editor-aside">
<Card className="collected-info-panel" bordered={false}>
<div className="collected-info-heading">
<div>
<Typography.Title level={4}>采集的商品信息</Typography.Title>
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
</div>
<Tag>第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集</Tag>
</div>
{snapshot.source_material_state.has_source_updates && (
<Alert
className="source-update-alert"
type="warning"
showIcon
message="检测到新的采集信息"
description="未编辑字段已采用最新值;用户修改过的字段仍保持不变。"
action={
<Space direction="vertical" size={4}>
<Button size="small" onClick={onShowDifferences}>
查看变化
</Button>
<Button size="small" type="primary" onClick={onAcceptLatest}>
全部采用最新
</Button>
</Space>
}
/>
)}
<div className="collected-field">
<div className="collected-field-label">
<span>标题</span>
{overriddenFields.includes('title') && (
<Button type="link" size="small" onClick={() => onRestore('title')}>
恢复来源值
</Button>
)}
</div>
<Input
value={collected?.title ?? snapshot.product.common.source_title ?? ''}
placeholder="采集标题"
onChange={(event) => onUpdate('title', event.target.value)}
/>
</div>
<div className="collected-field">
<div className="collected-field-label">
<span>商品描述</span>
{overriddenFields.includes('description') && (
<Button
type="link"
size="small"
onClick={() => onRestore('description')}
>
恢复来源值
</Button>
)}
</div>
<Input.TextArea
value={collected?.description ?? ''}
rows={12}
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
onChange={(event) => onUpdate('description', event.target.value)}
/>
</div>
<div className="collected-field">
<span>采集基础数据</span>
<div className="collected-summary">
<div><b>价格</b><strong>{summary.price}</strong></div>
<div><b>品牌</b><strong>{summary.brand}</strong></div>
<div><b>商品尺寸</b><strong>{summary.physicalDimensions}</strong></div>
<div><b>商品重量</b><strong>{summary.physicalWeight}</strong></div>
<div><b>包装尺寸</b><strong>{summary.packageDimensions}</strong></div>
<div><b>包装重量</b><strong>{summary.packageWeight}</strong></div>
</div>
</div>
<div className="collected-field">
<span>规格 / 参数</span>
{collected?.attributes.length ? (
<div className="collected-attributes">
{collected.attributes.map((attribute) => (
<div key={`${attribute.key}:${attribute.value}`}>
<b>{attribute.name}</b><span>{attribute.value}</span>
</div>
))}
</div>
) : (
<Typography.Text type="secondary">本次采集未取得规格或参数。</Typography.Text>
)}
</div>
</Card>
</aside>
);
}
@@ -0,0 +1,156 @@
import { Card, Cascader, Form, Input, Select, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type { ProductEditorSnapshot } from '@atlas/contracts';
type NamePath = Array<string | number>;
type CategoryOption = {
value: string;
label: string;
isLeaf?: boolean;
children?: CategoryOption[];
};
function SectionTitle() {
return (
<div className="editor-section-heading">
<div>
<Typography.Title level={4}>
<span className="editor-section-number">01</span>主要信息
</Typography.Title>
<Typography.Text type="secondary">
上架所需的核心资料。包装信息用于定价、物流与发布。
</Typography.Text>
</div>
</div>
);
}
function PackageFields({ name }: { name: NamePath }) {
return (
<div className="package-fields">
<Form.Item
name={[...name, 'shipping_weight_g']}
label="包装重量"
rules={[{ required: true, message: '请填写包装重量' }]}
>
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
</Form.Item>
<Form.Item label="包装尺寸" required className="package-dimensions">
<div className="package-dimensions-controls">
<Form.Item name={[...name, 'length_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="长" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="宽" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="高" />
</Form.Item>
<span className="package-dimension-unit">mm</span>
</div>
</Form.Item>
</div>
);
}
type Props = {
form: FormInstance;
snapshot: ProductEditorSnapshot;
selectedCategoryId: string | null | undefined;
categoryOptions: CategoryOption[];
categoryPaths: Map<string, string[]>;
categoryNotFoundContent: string;
onCategorySearch: (query: string) => void;
};
export function ProductMainInfoSection({
form,
snapshot,
selectedCategoryId,
categoryOptions,
categoryPaths,
categoryNotFoundContent,
onCategorySearch,
}: Props) {
return (
<Card className="editor-section" bordered={false}>
<SectionTitle />
<div className="editor-form-grid">
<Form.Item
name={['profile', 'target_store_id']}
label="上架店铺"
className="span-full"
rules={[{ required: true, message: '请选择上架店铺' }]}
>
<Select
className="editor-standard-select"
options={snapshot.profile.available_target_stores.map((store) => ({
value: store.id,
label: `${store.name}(${store.currency_code})`,
}))}
onChange={() => {
form.setFieldValue(['profile', 'store_product_category_id'], null);
form.setFieldValue(['profile', 'category_source'], null);
form.setFieldValue('attributes', []);
}}
/>
</Form.Item>
<Form.Item
name={['profile', 'store_product_category_id']}
label="产品类目"
className="span-full"
rules={[{ required: true, message: '请选择产品类目' }]}
getValueProps={(categoryId: string | null) => ({
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
})}
getValueFromEvent={(value: string[]) => value.at(-1) ?? null}
>
<Cascader
className="editor-standard-cascader"
classNames={{ popup: { root: 'editor-category-popup' } }}
showSearch
onSearch={onCategorySearch}
placeholder="选择当前店铺可发布的产品类目"
options={categoryOptions}
notFoundContent={categoryNotFoundContent}
getPopupContainer={(node) => node.parentElement ?? document.body}
onChange={(value) => {
const categoryId = value.at(-1) ?? null;
if (categoryId !== selectedCategoryId)
form.setFieldValue('attributes', []);
form.setFieldValue(
['profile', 'category_source'],
categoryId ? 'manual' : null
);
}}
/>
</Form.Item>
<Form.Item
name={['common', 'source_title']}
label="标题"
className="span-full"
rules={[{ required: true, message: '请填写标题' }, { max: 1000 }]}
>
<Input placeholder="整理后的商品标题" />
</Form.Item>
<Form.Item
name={['common', 'model']}
label="型号"
rules={[{ required: true, message: '请填写型号' }]}
>
<Input className="editor-short-input" placeholder="首次上架前需填写" />
</Form.Item>
<Form.Item
name={['common', 'brand']}
label="品牌"
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]}
>
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
</Form.Item>
</div>
<PackageFields name={['common', 'default_package_spec']} />
</Card>
);
}
@@ -0,0 +1,50 @@
import { Button, Card, Form, Input, Space, Tooltip, Typography } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
type Props = {
canOpenSupplyUrl: boolean;
platformName: string;
onOpenSupplyUrl: () => void;
};
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
return (
<Card className="editor-section other-section" bordered={false}>
<div className="editor-section-heading">
<div>
<Typography.Title level={4}>
<span className="editor-section-number">04</span>其它
</Typography.Title>
<Typography.Text type="secondary">与供货和发布相关的补充信息。</Typography.Text>
</div>
</div>
<div className="editor-form-grid">
<Form.Item label="货源链接" className="span-two">
<Space.Compact block className="supply-link-control">
<Form.Item
name={['common', 'purchase_url']}
noStyle
rules={[{ type: 'url', message: '请输入有效 URL' }]}
>
<Input placeholder="https://..." />
</Form.Item>
<Tooltip title="打开货源链接">
<Button
className="supply-open-button"
icon={<ExportOutlined />}
disabled={!canOpenSupplyUrl}
onClick={onOpenSupplyUrl}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Form.Item name={['common', 'supply_note']} label="备注" className="span-full">
<Input.TextArea rows={2} maxLength={4000} placeholder="供应商、采购注意事项等" />
</Form.Item>
</div>
<Typography.Text type="secondary">
已上架 SKU 的编辑仅保存 Atlas 草稿,不会自动更新 {platformName} 商品。
</Typography.Text>
</Card>
);
}
@@ -0,0 +1,49 @@
.category-debug-page {
display: grid;
gap: 16px;
}
.category-debug-intro {
width: 100%;
}
.category-debug-intro .ant-typography {
margin-bottom: 0;
}
.category-debug-alert {
margin-bottom: 0;
}
.category-debug-result .ant-card-body {
min-height: 280px;
}
.category-debug-loading {
display: grid;
min-height: 240px;
place-items: center;
}
.category-debug-tree {
max-height: 640px;
overflow: auto;
font-size: 14px;
}
.category-debug-node-meta {
font-size: 12px;
}
.category-debug-json pre {
max-height: 720px;
margin: 0;
overflow: auto;
padding: 16px;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
white-space: pre;
}
@@ -0,0 +1,154 @@
import { useMemo, useState } from 'react';
import { Alert, Button, Card, Collapse, Empty, Space, Spin, Tree, Typography } from 'antd';
import type { DataNode } from 'antd/es/tree';
import { ApiOutlined, BugOutlined, ReloadOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { categoryTreeDebugApi } from '@/services/categoryTreeDebug';
import { usePlatform } from '@/services/platform';
import './CategoryTreeDebugPage.css';
type OzonCategoryNode = {
description_category_id?: number | string;
category_name?: string;
type_id?: number | string | null;
type_name?: string | null;
disabled?: boolean;
children?: OzonCategoryNode[];
};
function categoryNodes(value: unknown): OzonCategoryNode[] {
if (!value || typeof value !== 'object') return [];
const result = (value as { result?: unknown }).result;
return Array.isArray(result) ? (result as OzonCategoryNode[]) : [];
}
function toTreeData(nodes: OzonCategoryNode[], ancestry = ''): DataNode[] {
return nodes.map((node, index) => {
const id = String(node.description_category_id ?? `unknown-${index}`);
const key = `${ancestry}/${id}:${String(node.type_id ?? 0)}:${index}`;
const children = Array.isArray(node.children) ? node.children : [];
const isTypeLeaf = !node.category_name?.trim() && Boolean(node.type_name?.trim());
const name = node.category_name?.trim() || node.type_name?.trim() || '未命名类目';
return {
key,
title: (
<Space size={6} wrap>
<Typography.Text>{name}</Typography.Text>
<Typography.Text type="secondary" className="category-debug-node-meta">
{isTypeLeaf
? `类型 ID=${String(node.type_id ?? 0)}`
: `类目 ID=${id} · 类型 ID=${String(node.type_id ?? 0)}`}
</Typography.Text>
{node.disabled === true && <Typography.Text type="warning">不可建品</Typography.Text>}
</Space>
),
children: children.length ? toTreeData(children, key) : undefined,
};
});
}
/**
* Ozon 类目树诊断页。请求始终由 API 服务端发起,浏览器没有也不会收到门店密钥。
*/
const CategoryTreeDebugPage = () => {
const { currentPlatform, context, contextLoading } = usePlatform();
const [rawResponse, setRawResponse] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nodes = useMemo(() => categoryNodes(rawResponse), [rawResponse]);
const treeData = useMemo(() => toTreeData(nodes), [nodes]);
const rawJson = useMemo(
() => (rawResponse === null ? '' : JSON.stringify(rawResponse, null, 2)),
[rawResponse],
);
const fetchTree = async () => {
if (!currentPlatform) return;
setLoading(true);
setError(null);
try {
const response = await categoryTreeDebugApi.fetchDefaultStoreTree(currentPlatform.id);
setRawResponse(response);
} catch (requestError) {
setRawResponse(null);
setError(apiErrorMessage(requestError));
} finally {
setLoading(false);
}
};
const unavailable = !currentPlatform || !context?.default_store;
return (
<div className="category-debug-page">
<Card
title={
<Space>
<BugOutlined />
Ozon 类目树调试
</Space>
}
extra={
<Button
type="primary"
icon={<ReloadOutlined />}
loading={loading}
disabled={unavailable || contextLoading}
onClick={() => void fetchTree()}
>
获取类目
</Button>
}
>
<Space direction="vertical" size="middle" className="category-debug-intro">
<Typography.Paragraph>
使用当前平台的默认门店请求 Ozon 官方
<Typography.Text code>/v1/description-category/tree</Typography.Text>,并原样展示响应。
</Typography.Paragraph>
<Alert
type="info"
showIcon
icon={<ApiOutlined />}
message={
context?.default_store
? `默认门店:${context.default_store.name}`
: '当前平台未设置默认门店'
}
description="请求由 Atlas 服务端代发;Client ID 与 API Key 不会发送到浏览器、日志或响应内容。"
/>
</Space>
</Card>
{error && <Alert className="category-debug-alert" type="error" showIcon message={error} />}
<Card className="category-debug-result" title="类目树">
{loading ? (
<div className="category-debug-loading"><Spin tip="正在向 Ozon 获取完整类目树…" /></div>
) : treeData.length ? (
<Tree
className="category-debug-tree"
showLine={{ showLeafIcon: false }}
defaultExpandParent={false}
treeData={treeData}
/>
) : (
<Empty description="点击“获取类目”查看 Ozon 返回的类目树" />
)}
</Card>
{rawResponse !== null && (
<Collapse
className="category-debug-json"
items={[
{
key: 'raw-json',
label: `原始 JSON 响应(${rawJson.length.toLocaleString()} 字符)`,
children: <pre>{rawJson}</pre>,
},
]}
/>
)}
</div>
);
};
export default CategoryTreeDebugPage;
+24 -8
View File
@@ -19,6 +19,7 @@ import {
import type { MenuProps, TableProps } from 'antd';
import {
ApiOutlined,
BugOutlined,
CloudDownloadOutlined,
DeleteOutlined,
MoreOutlined,
@@ -361,6 +362,14 @@ const ShopsPage = () => {
if (result?.operation) void pollOperation(result.operation.id);
});
const handleSyncCategoryTree = (store: Store) =>
withPending(store, async () => {
const result = await storesApi.requestCategoryTreeSync(store.id);
message.success(
`门店类目已刷新:${result.active_category_count}/${result.category_count} 个可发布类目`
);
});
const handleSyncWarehouses = (store: Store) =>
withPending(store, async () => {
const result = await storesApi.requestWarehouseSync(store.id);
@@ -401,6 +410,7 @@ const ShopsPage = () => {
disabled: store.is_default,
},
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
{ key: 'sync-category-tree', icon: <CloudDownloadOutlined />, label: '刷新门店类目' },
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
{ type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
@@ -411,6 +421,7 @@ const ShopsPage = () => {
({ key }) => {
if (key === 'set-default') void handleSetDefault(store);
else if (key === 'verify') void handleVerify(store);
else if (key === 'sync-category-tree') void handleSyncCategoryTree(store);
else if (key === 'sync') void handleSyncWarehouses(store);
else if (key === 'delete') handleDelete(store);
};
@@ -549,14 +560,19 @@ const ShopsPage = () => {
<Card
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!currentPlatform}
onClick={openCreate}
>
添加店铺
</Button>
<Space>
<Button href="/shops/category-tree-debug" icon={<BugOutlined />}>
类目调试
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
disabled={!currentPlatform}
onClick={openCreate}
>
添加店铺
</Button>
</Space>
}
>
{platforms.length === 0 && !currentPlatform ? (
+2
View File
@@ -12,6 +12,7 @@ import ProductEditPage from '@/pages/products/ProductEditPage';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage';
import CategoryTreeDebugPage from '@/pages/shops/CategoryTreeDebugPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session';
@@ -59,6 +60,7 @@ export const router = createBrowserRouter([
{ path: 'collection', element: <CollectionPage /> },
{ path: 'collection-test/:id', element: <CollectionInspectionPage /> },
{ path: 'shops', element: <ShopsPage /> },
{ path: 'shops/category-tree-debug', element: <CategoryTreeDebugPage /> },
{ path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
@@ -0,0 +1,9 @@
import { api } from '@/services/api';
/** 仅调试页使用:服务端代表当前平台默认门店读取 Ozon 原始响应。 */
export const categoryTreeDebugApi = {
fetchDefaultStoreTree: (platformId: string) =>
api.post<unknown>('/stores/default/category-tree-debug', undefined, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
};
+11
View File
@@ -17,6 +17,11 @@ export interface UpdateStoreOptions {
revision: number;
}
export interface CategoryTreeRefreshResult {
category_count: number;
active_category_count: number;
}
/**
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
@@ -57,6 +62,12 @@ export const storesApi = {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
/** 同步重新拉取并写入该门店完整类目树;密钥仅在服务端短暂使用。 */
requestCategoryTreeSync: (storeId: string) =>
api.post<CategoryTreeRefreshResult>(`/stores/${storeId}/category-tree-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestWarehouseSync: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },