feat(edit|extension): 编辑页类目采集开发
This commit is contained in:
@@ -19,8 +19,8 @@ import { bindEditorFields } from './product-editor.attribute-bindings.js';
|
||||
import { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||
import {
|
||||
capturedOzonCategorySignals,
|
||||
latestProductAiSourceMaterial,
|
||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||
productAiSourceMaterial as resolveSourceMaterial,
|
||||
productAiSourceOverrides as selectSourceMaterialOverrides,
|
||||
} from './product-editor.source-material.js';
|
||||
@@ -200,6 +200,11 @@ export class ProductEditorService {
|
||||
product,
|
||||
initialProfile,
|
||||
);
|
||||
const capturedCategoryMatch = await this.capturedCategoryMatch(
|
||||
transaction,
|
||||
product,
|
||||
profile.platform_id,
|
||||
);
|
||||
const variants = await transaction.query<{
|
||||
id: string;
|
||||
source_variant_key: string | null;
|
||||
@@ -460,6 +465,7 @@ export class ProductEditorService {
|
||||
})),
|
||||
source_material: sourceMaterial,
|
||||
source_material_state: sourceMaterialResult.state,
|
||||
captured_category_match: capturedCategoryMatch,
|
||||
ai_input: {
|
||||
source_material: sourceMaterial,
|
||||
default_physical_spec: defaultPhysicalSpec,
|
||||
@@ -1740,8 +1746,8 @@ export class ProductEditorService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时,
|
||||
* 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。
|
||||
* Ozon 公开页仅验证首级面包屑和 Type_0 类型值稳定;二、三级导航不可
|
||||
* 用于发布类目判定。只有唯一活动叶子候选才可自动回填,已有选择绝不覆盖。
|
||||
*/
|
||||
private async applyCapturedCategory(
|
||||
transaction: DatabaseTransaction,
|
||||
@@ -1749,34 +1755,13 @@ export class ProductEditorService {
|
||||
profile: ProfileRow,
|
||||
): Promise<ProfileRow> {
|
||||
if (profile.platform_product_category_id) return profile;
|
||||
const categories = latestProductAiSourceMaterial(product).categories;
|
||||
const sourcePath = normalizeCapturedCategoryPath(categories);
|
||||
if (!sourcePath) return profile;
|
||||
|
||||
const sourcePlatform = await transaction.query<{ code: string }>(
|
||||
`SELECT platform.code
|
||||
FROM atlas.collection_items item
|
||||
JOIN atlas.platforms platform ON platform.id = item.source_platform_id
|
||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||
[product.collection_item_id],
|
||||
const match = await this.capturedCategoryMatch(
|
||||
transaction,
|
||||
product,
|
||||
profile.platform_id,
|
||||
);
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON')
|
||||
return profile;
|
||||
|
||||
const category = await transaction.query<{
|
||||
id: string;
|
||||
external_type_id: string | null;
|
||||
}>(
|
||||
`SELECT id, external_type_id
|
||||
FROM atlas.platform_product_categories
|
||||
WHERE platform_id = $1 AND is_active
|
||||
AND lower(regexp_replace(path, '\\s+', ' ', 'g')) = $2
|
||||
ORDER BY id
|
||||
LIMIT 1`,
|
||||
[profile.platform_id, sourcePath],
|
||||
);
|
||||
const matched = category.rows[0];
|
||||
if (!matched) return profile;
|
||||
if (match.status !== 'matched') return profile;
|
||||
const matched = match.candidates[0];
|
||||
|
||||
const updated = await transaction.query<{ id: string }>(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
@@ -1792,6 +1777,93 @@ export class ProductEditorService {
|
||||
: profile;
|
||||
}
|
||||
|
||||
/** 返回 Ozon 来源信号对应的可发布叶子候选,供自动回填或用户确认。 */
|
||||
private async capturedCategoryMatch(
|
||||
transaction: DatabaseTransaction,
|
||||
product: ProductRow,
|
||||
platformId: string,
|
||||
): Promise<ProductEditorSnapshot['captured_category_match']> {
|
||||
const unavailable = {
|
||||
status: 'unavailable' as const,
|
||||
root_category_name: null,
|
||||
type_name: null,
|
||||
candidates: [],
|
||||
};
|
||||
const sourcePlatform = await transaction.query<{ code: string }>(
|
||||
`SELECT platform.code
|
||||
FROM atlas.collection_items item
|
||||
JOIN atlas.platforms platform ON platform.id = item.source_platform_id
|
||||
WHERE item.id = $1 AND item.deleted_at IS NULL`,
|
||||
[product.collection_item_id],
|
||||
);
|
||||
if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return unavailable;
|
||||
|
||||
const signals = capturedOzonCategorySignals(
|
||||
latestProductAiSourceMaterial(product),
|
||||
);
|
||||
if (!signals.rootCategoryName || !signals.typeName) {
|
||||
return {
|
||||
...unavailable,
|
||||
root_category_name: signals.rootCategoryName,
|
||||
type_name: signals.typeName,
|
||||
};
|
||||
}
|
||||
const candidates = await transaction.query<{
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
external_category_id: string;
|
||||
external_type_id: string;
|
||||
}>(
|
||||
`WITH RECURSIVE ancestors AS (
|
||||
SELECT category.id AS candidate_id, category.parent_id AS ancestor_parent_id,
|
||||
category.name AS ancestor_name
|
||||
FROM atlas.platform_product_categories category
|
||||
WHERE category.platform_id = $1
|
||||
AND category.is_active
|
||||
AND category.external_type_id IS NOT NULL
|
||||
AND category.external_type_id <> '0'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM atlas.platform_product_categories child
|
||||
WHERE child.platform_id = category.platform_id
|
||||
AND child.parent_id = category.id
|
||||
)
|
||||
AND lower(regexp_replace(
|
||||
coalesce(category.source_payload->>'type_name', category.name),
|
||||
'\\s+', ' ', 'g'
|
||||
)) = lower(regexp_replace($2, '\\s+', ' ', 'g'))
|
||||
UNION ALL
|
||||
SELECT ancestors.candidate_id, parent.parent_id, parent.name
|
||||
FROM ancestors
|
||||
JOIN atlas.platform_product_categories parent
|
||||
ON parent.id = ancestors.ancestor_parent_id
|
||||
AND parent.platform_id = $1
|
||||
)
|
||||
SELECT category.id, category.name, category.path,
|
||||
category.external_category_id, category.external_type_id
|
||||
FROM ancestors
|
||||
JOIN atlas.platform_product_categories category
|
||||
ON category.id = ancestors.candidate_id
|
||||
WHERE ancestors.ancestor_parent_id IS NULL
|
||||
AND lower(regexp_replace(ancestors.ancestor_name, '\\s+', ' ', 'g'))
|
||||
= lower(regexp_replace($3, '\\s+', ' ', 'g'))
|
||||
ORDER BY category.path, category.id
|
||||
LIMIT 20`,
|
||||
[platformId, signals.typeName, signals.rootCategoryName],
|
||||
);
|
||||
const status = candidates.rows.length === 1
|
||||
? 'matched'
|
||||
: candidates.rows.length
|
||||
? 'ambiguous'
|
||||
: 'unmatched';
|
||||
return {
|
||||
status,
|
||||
root_category_name: signals.rootCategoryName,
|
||||
type_name: signals.typeName,
|
||||
candidates: candidates.rows,
|
||||
};
|
||||
}
|
||||
|
||||
/** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */
|
||||
private async resolveSelectedPlatformCategoryId(
|
||||
transaction: DatabaseTransaction,
|
||||
|
||||
@@ -134,14 +134,16 @@ function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */
|
||||
export function normalizedCategoryPath(
|
||||
categories: Array<{ name: string }>,
|
||||
): string | null {
|
||||
const names = categories
|
||||
.map((category) =>
|
||||
category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(),
|
||||
)
|
||||
.filter(Boolean);
|
||||
return names.length ? names.join(' › ') : null;
|
||||
/**
|
||||
* Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
|
||||
* 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
|
||||
*/
|
||||
export function capturedOzonCategorySignals(
|
||||
source: ProductAiSourceMaterial,
|
||||
): { rootCategoryName: string | null; typeName: string | null } {
|
||||
const rootCategoryName = source.categories[0]?.name?.trim() || null;
|
||||
const typeName = source.attributes.find((attribute) =>
|
||||
/(?:^|:)type_0$/i.test(attribute.key.trim()),
|
||||
)?.value.trim() || null;
|
||||
return { rootCategoryName, typeName };
|
||||
}
|
||||
|
||||
@@ -1355,13 +1355,19 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('M3.5 按 Ozon 采集层级回填空类目,且绝不覆盖已保存选择', async () => {
|
||||
it('M3.5 按 Ozon 一级类目和类型回填唯一候选,并返回多候选供确认', async () => {
|
||||
const suffix = randomUUID();
|
||||
const root = await seedStoreCategory(
|
||||
`m35-root-${suffix}`,
|
||||
'0',
|
||||
'M3.5 来源',
|
||||
'M3.5 来源',
|
||||
);
|
||||
const captured = await seedStoreCategory(
|
||||
`m35-source-${suffix}`,
|
||||
'source-type',
|
||||
'M3.5 采集叶',
|
||||
'M3.5 来源 › M3.5 采集叶',
|
||||
'M3.5 类型',
|
||||
'M3.5 来源 › M3.5 类型',
|
||||
);
|
||||
const manual = await seedStoreCategory(
|
||||
`m35-manual-${suffix}`,
|
||||
@@ -1369,8 +1375,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'M3.5 手动叶',
|
||||
'M3.5 手动 › M3.5 手动叶',
|
||||
);
|
||||
seededCategoryIds.push(captured.id, manual.id);
|
||||
seededCategoryIds.push(root.id, captured.id, manual.id);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_product_categories
|
||||
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
|
||||
WHERE id = $2`,
|
||||
[root.id, captured.id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.collection_items
|
||||
SET source_kind = 'platform', source_platform_id = $1,
|
||||
@@ -1382,7 +1394,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
common: {
|
||||
categories: [
|
||||
{ key: 'source-root', name: ' M3.5 来源 ' },
|
||||
{ key: 'source-leaf', name: 'M3.5 采集叶' },
|
||||
],
|
||||
attributes: [
|
||||
{ key: 'ozon:Type_0', name: '类型', value: 'M3.5 类型' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
@@ -1411,6 +1425,37 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
||||
'source_capture',
|
||||
);
|
||||
|
||||
const duplicate = await seedStoreCategory(
|
||||
`m35-duplicate-${suffix}`,
|
||||
'duplicate-type',
|
||||
'M3.5 类型',
|
||||
'M3.5 来源 › 另一分支 › M3.5 类型',
|
||||
);
|
||||
seededCategoryIds.push(duplicate.id);
|
||||
await database.workspaceTransaction(workspaceId, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.platform_product_categories
|
||||
SET parent_id = $1, source_payload = '{"type_name":"M3.5 类型"}'::jsonb
|
||||
WHERE id = $2`,
|
||||
[root.id, duplicate.id],
|
||||
);
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
SET platform_product_category_id = NULL, platform_type_id = NULL,
|
||||
category_source = NULL
|
||||
WHERE id = $1`,
|
||||
[first.profileId],
|
||||
);
|
||||
});
|
||||
const ambiguous = await request(
|
||||
`/api/products/${first.productId}/editor`,
|
||||
{ headers: headers({ 'X-Atlas-Platform-Id': platformId }) },
|
||||
);
|
||||
assert.equal(ambiguous.response.status, 200);
|
||||
assert.equal(ambiguous.body.data.profile.category.id, null);
|
||||
assert.equal(ambiguous.body.data.captured_category_match.status, 'ambiguous');
|
||||
assert.equal(ambiguous.body.data.captured_category_match.candidates.length, 2);
|
||||
|
||||
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.product_platform_profiles
|
||||
|
||||
@@ -1127,6 +1127,16 @@ const ProductEditPage = () => {
|
||||
categoryPaths={categories.paths}
|
||||
categoryNotFoundContent={categoryNotFoundContent}
|
||||
onCategorySearch={setCategorySearch}
|
||||
onSelectCategoryCandidate={(categoryId) => {
|
||||
form.setFieldsValue({
|
||||
profile: {
|
||||
platform_product_category_id: categoryId,
|
||||
category_source: 'manual',
|
||||
},
|
||||
attributes: [],
|
||||
});
|
||||
setDirty(true);
|
||||
}}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
|
||||
@@ -115,6 +115,29 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 来源页层级仅用于展示和诊断,不表示已选中的可发布类目。 */
|
||||
.collected-category-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px 0;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #ece9f1;
|
||||
border-radius: 8px;
|
||||
background: #fbfaff;
|
||||
color: #352f43;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.collected-category-breadcrumb .ant-breadcrumb-link {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.collected-category-hint.ant-typography {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 规格 / 参数列表 */
|
||||
.collected-attributes {
|
||||
border: 1px solid #ece9f1;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Collapse,
|
||||
Image,
|
||||
Input,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductAiSourceField,
|
||||
@@ -56,6 +67,8 @@ export function CollectedProductInfoPanel({
|
||||
onAcceptLatest,
|
||||
}: Props) {
|
||||
const sourceDescription = collected?.description ?? '';
|
||||
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
|
||||
const capturedCategories = snapshot.source_material_state.latest.categories;
|
||||
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
|
||||
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
|
||||
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
|
||||
@@ -185,6 +198,28 @@ export function CollectedProductInfoPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
<span>来源类目面包屑</span>
|
||||
{capturedCategories.length ? (
|
||||
<>
|
||||
<Breadcrumb
|
||||
className="collected-category-breadcrumb"
|
||||
separator="›"
|
||||
items={capturedCategories.map((category, index) => ({
|
||||
key: `${category.key}:${index}`,
|
||||
title: category.name,
|
||||
}))}
|
||||
/>
|
||||
<Typography.Text type="secondary" className="collected-category-hint">
|
||||
自动匹配仅使用一级类目与商品类型;后续层级仅作为来源参考。
|
||||
</Typography.Text>
|
||||
</>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
本次采集未取得来源类目。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="collected-field">
|
||||
<span>规格 / 参数</span>
|
||||
{collected?.attributes.length ? (
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ProductAttributeSection({
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写主题标签"
|
||||
onClick={onUseVariantTags}
|
||||
/>
|
||||
@@ -123,7 +123,7 @@ export function ProductAttributeSection({
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写产品描述"
|
||||
onClick={onUseVariantDescription}
|
||||
/>
|
||||
|
||||
@@ -50,6 +50,40 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 多候选只提供确认入口;实际发布类目仍由用户保存后的 UUID 决定。 */
|
||||
.captured-category-candidates {
|
||||
margin: -10px 0 14px 100px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d8d0ff;
|
||||
border-radius: 8px;
|
||||
background: #faf9ff;
|
||||
}
|
||||
.captured-category-candidates-title {
|
||||
color: #5e5273;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.captured-category-candidate-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
.captured-category-candidate-list .ant-radio-wrapper {
|
||||
align-items: flex-start;
|
||||
margin-inline-end: 0;
|
||||
color: #352f43;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.captured-category-candidate-path,
|
||||
.captured-category-candidate-id {
|
||||
display: block;
|
||||
}
|
||||
.captured-category-candidate-id {
|
||||
margin-top: 2px;
|
||||
color: #77718a;
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 包装尺寸跟随主要信息卡片的实际宽度,和表单 Label 的换行阈值保持一致。 */
|
||||
@container (max-width: 479px) {
|
||||
.package-dimensions-controls {
|
||||
@@ -59,4 +93,7 @@
|
||||
.package-dimension-separator {
|
||||
display: none;
|
||||
}
|
||||
.captured-category-candidates {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Select, Tooltip } from 'antd';
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
@@ -42,7 +42,7 @@ function PackageFields({
|
||||
<Tooltip title="按 SKU 分别填写包装重量">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写包装重量"
|
||||
onClick={onUseVariantPackageWeight}
|
||||
/>
|
||||
@@ -68,7 +68,7 @@ function PackageFields({
|
||||
<Tooltip title="按 SKU 分别填写包装尺寸">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写包装尺寸"
|
||||
onClick={onUseVariantPackageDimensions}
|
||||
/>
|
||||
@@ -88,6 +88,7 @@ type Props = {
|
||||
categoryPaths: Map<string, string[]>;
|
||||
categoryNotFoundContent: string;
|
||||
onCategorySearch: (query: string) => void;
|
||||
onSelectCategoryCandidate: (categoryId: string) => void;
|
||||
usesVariantPackageWeight: boolean;
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseVariantPackageWeight: () => void;
|
||||
@@ -108,6 +109,7 @@ export function ProductMainInfoSection({
|
||||
categoryPaths,
|
||||
categoryNotFoundContent,
|
||||
onCategorySearch,
|
||||
onSelectCategoryCandidate,
|
||||
usesVariantPackageWeight,
|
||||
usesVariantPackageDimensions,
|
||||
onUseVariantPackageWeight,
|
||||
@@ -135,6 +137,31 @@ export function ProductMainInfoSection({
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{snapshot.captured_category_match.status === 'ambiguous' &&
|
||||
!selectedCategoryId && (
|
||||
<div className="captured-category-candidates span-full">
|
||||
<div className="captured-category-candidates-title">
|
||||
根据来源一级类目“{snapshot.captured_category_match.root_category_name}”
|
||||
与商品类型“{snapshot.captured_category_match.type_name}”找到多个候选,请确认。
|
||||
</div>
|
||||
<Radio.Group
|
||||
className="captured-category-candidate-list"
|
||||
onChange={(event) => onSelectCategoryCandidate(event.target.value)}
|
||||
>
|
||||
{snapshot.captured_category_match.candidates.map((candidate) => (
|
||||
<Radio key={candidate.id} value={candidate.id}>
|
||||
<span className="captured-category-candidate-path">
|
||||
{candidate.path}
|
||||
</span>
|
||||
<span className="captured-category-candidate-id">
|
||||
类目 ID:{candidate.external_category_id} · 类型 ID:
|
||||
{candidate.external_type_id}
|
||||
</span>
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
)}
|
||||
<Form.Item
|
||||
name={['profile', 'platform_product_category_id']}
|
||||
label="产品类目"
|
||||
|
||||
Reference in New Issue
Block a user