feat(edit): 编辑页开发
This commit is contained in:
@@ -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: '设置' },
|
||||
|
||||
@@ -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;
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user