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 { ProductEditorValidationOperations } from './product-editor.validation.js';
|
||||||
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
import { ProductEditorPublishPreflightOperations } from './product-editor.publish-preflight.js';
|
||||||
import {
|
import {
|
||||||
|
capturedOzonCategorySignals,
|
||||||
latestProductAiSourceMaterial,
|
latestProductAiSourceMaterial,
|
||||||
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
|
||||||
productAiSourceMaterial as resolveSourceMaterial,
|
productAiSourceMaterial as resolveSourceMaterial,
|
||||||
productAiSourceOverrides as selectSourceMaterialOverrides,
|
productAiSourceOverrides as selectSourceMaterialOverrides,
|
||||||
} from './product-editor.source-material.js';
|
} from './product-editor.source-material.js';
|
||||||
@@ -200,6 +200,11 @@ export class ProductEditorService {
|
|||||||
product,
|
product,
|
||||||
initialProfile,
|
initialProfile,
|
||||||
);
|
);
|
||||||
|
const capturedCategoryMatch = await this.capturedCategoryMatch(
|
||||||
|
transaction,
|
||||||
|
product,
|
||||||
|
profile.platform_id,
|
||||||
|
);
|
||||||
const variants = await transaction.query<{
|
const variants = await transaction.query<{
|
||||||
id: string;
|
id: string;
|
||||||
source_variant_key: string | null;
|
source_variant_key: string | null;
|
||||||
@@ -460,6 +465,7 @@ export class ProductEditorService {
|
|||||||
})),
|
})),
|
||||||
source_material: sourceMaterial,
|
source_material: sourceMaterial,
|
||||||
source_material_state: sourceMaterialResult.state,
|
source_material_state: sourceMaterialResult.state,
|
||||||
|
captured_category_match: capturedCategoryMatch,
|
||||||
ai_input: {
|
ai_input: {
|
||||||
source_material: sourceMaterial,
|
source_material: sourceMaterial,
|
||||||
default_physical_spec: defaultPhysicalSpec,
|
default_physical_spec: defaultPhysicalSpec,
|
||||||
@@ -1740,8 +1746,8 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时,
|
* Ozon 公开页仅验证首级面包屑和 Type_0 类型值稳定;二、三级导航不可
|
||||||
* 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。
|
* 用于发布类目判定。只有唯一活动叶子候选才可自动回填,已有选择绝不覆盖。
|
||||||
*/
|
*/
|
||||||
private async applyCapturedCategory(
|
private async applyCapturedCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
@@ -1749,34 +1755,13 @@ export class ProductEditorService {
|
|||||||
profile: ProfileRow,
|
profile: ProfileRow,
|
||||||
): Promise<ProfileRow> {
|
): Promise<ProfileRow> {
|
||||||
if (profile.platform_product_category_id) return profile;
|
if (profile.platform_product_category_id) return profile;
|
||||||
const categories = latestProductAiSourceMaterial(product).categories;
|
const match = await this.capturedCategoryMatch(
|
||||||
const sourcePath = normalizeCapturedCategoryPath(categories);
|
transaction,
|
||||||
if (!sourcePath) return profile;
|
product,
|
||||||
|
profile.platform_id,
|
||||||
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')
|
if (match.status !== 'matched') return profile;
|
||||||
return profile;
|
const matched = match.candidates[0];
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const updated = await transaction.query<{ id: string }>(
|
const updated = await transaction.query<{ id: string }>(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`UPDATE atlas.product_platform_profiles
|
||||||
@@ -1792,6 +1777,93 @@ export class ProductEditorService {
|
|||||||
: profile;
|
: 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。 */
|
/** 兼容旧客户端的门店类目 UUID,并统一解析成平台类目 UUID。 */
|
||||||
private async resolveSelectedPlatformCategoryId(
|
private async resolveSelectedPlatformCategoryId(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
|
|||||||
@@ -134,14 +134,16 @@ function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */
|
/**
|
||||||
export function normalizedCategoryPath(
|
* Ozon 公开详情页的后二、三级导航并不稳定;自动类目匹配只使用首级
|
||||||
categories: Array<{ name: string }>,
|
* 面包屑和页面结构化特征中的 Type_0 值。该信号缺失时必须交给人工选择。
|
||||||
): string | null {
|
*/
|
||||||
const names = categories
|
export function capturedOzonCategorySignals(
|
||||||
.map((category) =>
|
source: ProductAiSourceMaterial,
|
||||||
category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(),
|
): { rootCategoryName: string | null; typeName: string | null } {
|
||||||
)
|
const rootCategoryName = source.categories[0]?.name?.trim() || null;
|
||||||
.filter(Boolean);
|
const typeName = source.attributes.find((attribute) =>
|
||||||
return names.length ? names.join(' › ') : null;
|
/(?:^|:)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 suffix = randomUUID();
|
||||||
|
const root = await seedStoreCategory(
|
||||||
|
`m35-root-${suffix}`,
|
||||||
|
'0',
|
||||||
|
'M3.5 来源',
|
||||||
|
'M3.5 来源',
|
||||||
|
);
|
||||||
const captured = await seedStoreCategory(
|
const captured = await seedStoreCategory(
|
||||||
`m35-source-${suffix}`,
|
`m35-source-${suffix}`,
|
||||||
'source-type',
|
'source-type',
|
||||||
'M3.5 采集叶',
|
'M3.5 类型',
|
||||||
'M3.5 来源 › M3.5 采集叶',
|
'M3.5 来源 › M3.5 类型',
|
||||||
);
|
);
|
||||||
const manual = await seedStoreCategory(
|
const manual = await seedStoreCategory(
|
||||||
`m35-manual-${suffix}`,
|
`m35-manual-${suffix}`,
|
||||||
@@ -1369,8 +1375,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'M3.5 手动叶',
|
'M3.5 手动叶',
|
||||||
'M3.5 手动 › 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 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(
|
await tx.query(
|
||||||
`UPDATE atlas.collection_items
|
`UPDATE atlas.collection_items
|
||||||
SET source_kind = 'platform', source_platform_id = $1,
|
SET source_kind = 'platform', source_platform_id = $1,
|
||||||
@@ -1382,7 +1394,9 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
common: {
|
common: {
|
||||||
categories: [
|
categories: [
|
||||||
{ key: 'source-root', name: ' M3.5 来源 ' },
|
{ 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',
|
'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) =>
|
await database.workspaceTransaction(workspaceId, (tx) =>
|
||||||
tx.query(
|
tx.query(
|
||||||
`UPDATE atlas.product_platform_profiles
|
`UPDATE atlas.product_platform_profiles
|
||||||
|
|||||||
@@ -1127,6 +1127,16 @@ const ProductEditPage = () => {
|
|||||||
categoryPaths={categories.paths}
|
categoryPaths={categories.paths}
|
||||||
categoryNotFoundContent={categoryNotFoundContent}
|
categoryNotFoundContent={categoryNotFoundContent}
|
||||||
onCategorySearch={setCategorySearch}
|
onCategorySearch={setCategorySearch}
|
||||||
|
onSelectCategoryCandidate={(categoryId) => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
profile: {
|
||||||
|
platform_product_category_id: categoryId,
|
||||||
|
category_source: 'manual',
|
||||||
|
},
|
||||||
|
attributes: [],
|
||||||
|
});
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||||
|
|||||||
@@ -115,6 +115,29 @@
|
|||||||
white-space: nowrap;
|
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 {
|
.collected-attributes {
|
||||||
border: 1px solid #ece9f1;
|
border: 1px solid #ece9f1;
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 {
|
import type {
|
||||||
CollectionItemInspection,
|
CollectionItemInspection,
|
||||||
ProductAiSourceField,
|
ProductAiSourceField,
|
||||||
@@ -56,6 +67,8 @@ export function CollectedProductInfoPanel({
|
|||||||
onAcceptLatest,
|
onAcceptLatest,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const sourceDescription = collected?.description ?? '';
|
const sourceDescription = collected?.description ?? '';
|
||||||
|
// 类目自动回填始终基于最新采集快照,不能被 AI 输入素材的用户覆盖影响。
|
||||||
|
const capturedCategories = snapshot.source_material_state.latest.categories;
|
||||||
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
|
const [descriptionDraft, setDescriptionDraft] = useState(sourceDescription);
|
||||||
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
|
useEffect(() => setDescriptionDraft(sourceDescription), [sourceDescription]);
|
||||||
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
|
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
|
||||||
@@ -185,6 +198,28 @@ export function CollectedProductInfoPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="collected-field">
|
||||||
<span>规格 / 参数</span>
|
<span>规格 / 参数</span>
|
||||||
{collected?.attributes.length ? (
|
{collected?.attributes.length ? (
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export function ProductAttributeSection({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||||
aria-label="按 SKU 分别填写主题标签"
|
aria-label="按 SKU 分别填写主题标签"
|
||||||
onClick={onUseVariantTags}
|
onClick={onUseVariantTags}
|
||||||
/>
|
/>
|
||||||
@@ -123,7 +123,7 @@ export function ProductAttributeSection({
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||||
aria-label="按 SKU 分别填写产品描述"
|
aria-label="按 SKU 分别填写产品描述"
|
||||||
onClick={onUseVariantDescription}
|
onClick={onUseVariantDescription}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -50,6 +50,40 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 的换行阈值保持一致。 */
|
/* 包装尺寸跟随主要信息卡片的实际宽度,和表单 Label 的换行阈值保持一致。 */
|
||||||
@container (max-width: 479px) {
|
@container (max-width: 479px) {
|
||||||
.package-dimensions-controls {
|
.package-dimensions-controls {
|
||||||
@@ -59,4 +93,7 @@
|
|||||||
.package-dimension-separator {
|
.package-dimension-separator {
|
||||||
display: none;
|
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 type { FormInstance } from 'antd';
|
||||||
import { PlusCircleOutlined } from '@ant-design/icons';
|
import { PlusCircleOutlined } from '@ant-design/icons';
|
||||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||||
@@ -42,7 +42,7 @@ function PackageFields({
|
|||||||
<Tooltip title="按 SKU 分别填写包装重量">
|
<Tooltip title="按 SKU 分别填写包装重量">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||||
aria-label="按 SKU 分别填写包装重量"
|
aria-label="按 SKU 分别填写包装重量"
|
||||||
onClick={onUseVariantPackageWeight}
|
onClick={onUseVariantPackageWeight}
|
||||||
/>
|
/>
|
||||||
@@ -68,7 +68,7 @@ function PackageFields({
|
|||||||
<Tooltip title="按 SKU 分别填写包装尺寸">
|
<Tooltip title="按 SKU 分别填写包装尺寸">
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<PlusCircleOutlined />}
|
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||||
aria-label="按 SKU 分别填写包装尺寸"
|
aria-label="按 SKU 分别填写包装尺寸"
|
||||||
onClick={onUseVariantPackageDimensions}
|
onClick={onUseVariantPackageDimensions}
|
||||||
/>
|
/>
|
||||||
@@ -88,6 +88,7 @@ type Props = {
|
|||||||
categoryPaths: Map<string, string[]>;
|
categoryPaths: Map<string, string[]>;
|
||||||
categoryNotFoundContent: string;
|
categoryNotFoundContent: string;
|
||||||
onCategorySearch: (query: string) => void;
|
onCategorySearch: (query: string) => void;
|
||||||
|
onSelectCategoryCandidate: (categoryId: string) => void;
|
||||||
usesVariantPackageWeight: boolean;
|
usesVariantPackageWeight: boolean;
|
||||||
usesVariantPackageDimensions: boolean;
|
usesVariantPackageDimensions: boolean;
|
||||||
onUseVariantPackageWeight: () => void;
|
onUseVariantPackageWeight: () => void;
|
||||||
@@ -108,6 +109,7 @@ export function ProductMainInfoSection({
|
|||||||
categoryPaths,
|
categoryPaths,
|
||||||
categoryNotFoundContent,
|
categoryNotFoundContent,
|
||||||
onCategorySearch,
|
onCategorySearch,
|
||||||
|
onSelectCategoryCandidate,
|
||||||
usesVariantPackageWeight,
|
usesVariantPackageWeight,
|
||||||
usesVariantPackageDimensions,
|
usesVariantPackageDimensions,
|
||||||
onUseVariantPackageWeight,
|
onUseVariantPackageWeight,
|
||||||
@@ -135,6 +137,31 @@ export function ProductMainInfoSection({
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
name={['profile', 'platform_product_category_id']}
|
name={['profile', 'platform_product_category_id']}
|
||||||
label="产品类目"
|
label="产品类目"
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
|||||||
"base_source_snapshot_revision": 2,
|
"base_source_snapshot_revision": 2,
|
||||||
"has_source_updates": true
|
"has_source_updates": true
|
||||||
},
|
},
|
||||||
|
"captured_category_match": {
|
||||||
|
"status": "ambiguous", "root_category_name": "儿童用品", "type_name": "小雕塑",
|
||||||
|
"candidates": [{ "id": "uuid", "name": "小雕塑", "path": "儿童用品 › … › 小雕塑", "external_category_id": "123", "external_type_id": "456" }]
|
||||||
|
},
|
||||||
"ai_input": {
|
"ai_input": {
|
||||||
"source_material": {}, "default_physical_spec": {}, "default_package_spec": {},
|
"source_material": {}, "default_physical_spec": {}, "default_package_spec": {},
|
||||||
"variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [],
|
"variants": [{ "variant_id": "uuid", "display_name": "黑色 / M", "specification_values": [],
|
||||||
@@ -76,6 +80,8 @@ Headers:`Idempotency-Key` 必填,页头平台由 header 指定,body 为空
|
|||||||
|
|
||||||
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
|
`source_material` 是供页面展示和 AI 使用的**有效来源素材**,由当前 `collection_items.normalized_payload` 的最新规范化值与用户明确修改过的顶层字段合并而成。未修改字段随重复采集自动更新;已修改字段继续保留用户值。`source_material_state.latest` 保留本次最新规范化来源,`overridden_fields` 表示用户覆盖字段,`changed_fields` 表示复采后与用户稿不同的覆盖字段,页面可据此提示、逐项恢复或全部采用最新采集值。原始 `normalized_payload` 与 `raw_evidence` 始终作为采集证据保留,不因编辑而改变。
|
||||||
|
|
||||||
|
`captured_category_match` 仅对 Ozon 来源返回自动类目判定结果。服务端只使用公开详情页的**一级**面包屑和结构化 `Type_0` 商品类型,在当前平台目录的活动可发布叶子中精确匹配;公开页后二、三级导航不参与判定。`matched` 时服务端已自动写入 `source_capture`;`ambiguous` 时前端展示 `candidates` 的完整 Atlas 路径,用户确认后按普通手选类目保存为 `manual`;`unmatched`、`unavailable` 均不得自动选择。
|
||||||
|
|
||||||
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
|
`ai_input` 是后续 AI 任务的服务端输入视图:由上述有效 `source_material`、父商品默认实物/包装规格,以及每个 SKU 合并继承后的有效规格与规格轴组成。
|
||||||
|
|
||||||
## 2. 原子保存草稿与 SKU 规则
|
## 2. 原子保存草稿与 SKU 规则
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ export type ProductAiSourceMaterialState = {
|
|||||||
has_source_updates: boolean;
|
has_source_updates: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Ozon 来源一级类目和商品类型在平台目录中的只读匹配结果。 */
|
||||||
|
export type CapturedCategoryMatch = {
|
||||||
|
status: 'unavailable' | 'unmatched' | 'matched' | 'ambiguous';
|
||||||
|
root_category_name: string | null;
|
||||||
|
type_name: string | null;
|
||||||
|
candidates: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ProductAiInput = {
|
export type ProductAiInput = {
|
||||||
source_material: ProductAiSourceMaterial;
|
source_material: ProductAiSourceMaterial;
|
||||||
default_physical_spec: PhysicalSpec;
|
default_physical_spec: PhysicalSpec;
|
||||||
@@ -266,6 +280,7 @@ export type ProductEditorSnapshot = {
|
|||||||
}>;
|
}>;
|
||||||
source_material: ProductAiSourceMaterial;
|
source_material: ProductAiSourceMaterial;
|
||||||
source_material_state: ProductAiSourceMaterialState;
|
source_material_state: ProductAiSourceMaterialState;
|
||||||
|
captured_category_match: CapturedCategoryMatch;
|
||||||
ai_input: ProductAiInput;
|
ai_input: ProductAiInput;
|
||||||
warnings: Array<{ code: string; message: string }>;
|
warnings: Array<{ code: string; message: string }>;
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user