feat(edit|extension): 编辑页类目采集开发

This commit is contained in:
R524809
2026-09-22 13:47:24 +08:00
parent 2b06ef0692
commit 95a2a37f1b
12 changed files with 30302 additions and 51 deletions
@@ -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="产品类目"