feat(ai-edit): 开发 AI 填报功能
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
.product-editor {
|
||||
max-width: 1500px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 92px;
|
||||
/* 固定操作条不参与文档流,末尾内容需保留可滚动的安全空间。 */
|
||||
padding-bottom: 132px;
|
||||
}
|
||||
|
||||
/* 编辑器快照加载前占满内容可视区,避免加载图标受页面留白影响而偏移。 */
|
||||
@@ -165,16 +166,63 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* AI 类目候选为整行单选项,确认按钮只在用户明确选择后可用。 */
|
||||
.ai-category-modal .ant-modal-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ai-category-modal-intro {
|
||||
margin: 0 0 18px;
|
||||
color: #667085;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ai-category-candidates.ant-radio-group {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-height: min(50vh, 400px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #e4e7ec;
|
||||
border-radius: 10px;
|
||||
color: #344054;
|
||||
line-height: 1.5;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper:hover {
|
||||
border-color: #a99bea;
|
||||
background: #faf9ff;
|
||||
}
|
||||
.ai-category-candidate.ant-radio-wrapper:has(.ant-radio-checked) {
|
||||
border-color: #7958e5;
|
||||
background: #f7f4ff;
|
||||
}
|
||||
.ai-category-candidate .ant-radio {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.ai-category-candidate > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 底部操作条 */
|
||||
.editor-footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: max(16px, env(safe-area-inset-bottom));
|
||||
left: 220px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
margin-top: 0;
|
||||
padding: 13px 16px;
|
||||
border: 1px solid #e4e0eb;
|
||||
border-radius: 12px;
|
||||
@@ -182,6 +230,11 @@
|
||||
box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
/* 左侧导航折叠后,让固定操作条与实际内容区的左边缘对齐。 */
|
||||
.main-sider.ant-layout-sider-collapsed ~ .main-content-layout .editor-footer {
|
||||
left: 80px;
|
||||
}
|
||||
.footer-ai-actions {
|
||||
display: flex;
|
||||
}
|
||||
@@ -220,7 +273,17 @@
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
}
|
||||
/* 移动端导航改为抽屉,固定条回到完整视口宽度。 */
|
||||
@media (max-width: 767px) {
|
||||
.editor-footer {
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.product-editor {
|
||||
padding-bottom: 188px;
|
||||
}
|
||||
.editor-header-main,
|
||||
.editor-footer {
|
||||
align-items: flex-start;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Alert, App as AntdApp, Button, Form, Space, Spin, Tag, Typography } from 'antd';
|
||||
import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileTextOutlined,
|
||||
RobotOutlined,
|
||||
SaveOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
AiCopyOutput,
|
||||
CollectionItemInspection,
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductAiSourceField,
|
||||
ProductAiSourceMaterial,
|
||||
ProductAiTask,
|
||||
ProductAiAutofillResult,
|
||||
ProductAiSkuCopyResult,
|
||||
ProductCopyField,
|
||||
ProductCopyTranslation,
|
||||
ProductEditorSnapshot,
|
||||
ProductEditorCategoryOption,
|
||||
SaveProductEditorDraftRequest,
|
||||
@@ -30,6 +35,11 @@ import { ProductAttributeSection } from './components/ProductAttributeSection';
|
||||
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
type ComparisonRow,
|
||||
} from './components/ProductCopyAiDialogs';
|
||||
import './ProductEditPage.css';
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
@@ -137,6 +147,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues {
|
||||
source_sku: variant.source_sku,
|
||||
specification_values: variant.specification_values,
|
||||
purchase_cost_cny: variant.purchase_cost_cny,
|
||||
title: variant.title,
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
@@ -274,6 +285,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
source_sku: nullable(variant.source_sku),
|
||||
purchase_cost_cny: nullable(variant.purchase_cost_cny),
|
||||
offer_id: nullable(variant.offer_id),
|
||||
title: nullable(variant.title),
|
||||
specification_values: variant.specification_values.map((item) => ({
|
||||
axis_key: item.axis_key.trim(),
|
||||
value_key: item.value_key.trim(),
|
||||
@@ -424,6 +436,22 @@ const ProductEditPage = () => {
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
const [usesVariantTags, setUsesVariantTags] = useState(false);
|
||||
const [usesVariantDescription, setUsesVariantDescription] = useState(false);
|
||||
const [usesVariantTitle, setUsesVariantTitle] = useState(false);
|
||||
const [copyTranslations, setCopyTranslations] = useState<ProductCopyTranslation[]>([]);
|
||||
const [aiAutofillLoading, setAiAutofillLoading] = useState(false);
|
||||
const [aiAutofillStatus, setAiAutofillStatus] = useState('');
|
||||
const [categoryCandidates, setCategoryCandidates] = useState<
|
||||
NonNullable<ProductAiAutofillResult['category_candidates']> | null
|
||||
>(null);
|
||||
const [selectedCandidateId, setSelectedCandidateId] = useState<string | null>(null);
|
||||
const [comparison, setComparison] = useState<{
|
||||
variantId: string | null;
|
||||
field: ProductCopyField;
|
||||
} | null>(null);
|
||||
const [skuCopyVariantId, setSkuCopyVariantId] = useState<string | null>(null);
|
||||
const [skuCopyLoading, setSkuCopyLoading] = useState(false);
|
||||
const [skuCopyResult, setSkuCopyResult] = useState<AiCopyOutput | null>(null);
|
||||
const [skuCopyError, setSkuCopyError] = useState<string | null>(null);
|
||||
const blocker = useBlocker(dirty);
|
||||
const sourceSpecsApplied = useRef(false);
|
||||
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
|
||||
@@ -470,6 +498,8 @@ const ProductEditPage = () => {
|
||||
);
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
|
||||
setCopyTranslations(next.copy_translations);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
message.error(apiErrorMessage(error));
|
||||
@@ -765,35 +795,13 @@ const ProductEditPage = () => {
|
||||
setUsesVariantPackageDimensions(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantTags = () => {
|
||||
const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[];
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
tags: variant.tags.length ? variant.tags : tags,
|
||||
})),
|
||||
});
|
||||
setUsesVariantTags(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */
|
||||
const useVariantDescription = () => {
|
||||
const description = form.getFieldValue(['profile', 'description']) as string | null;
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
form.setFieldsValue({
|
||||
variants: variants.map((variant) => ({
|
||||
...variant,
|
||||
description: variant.description ?? description,
|
||||
})),
|
||||
});
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
};
|
||||
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
|
||||
const useVariantTags = () => setUsesVariantTags(true);
|
||||
const useVariantDescription = () => setUsesVariantDescription(true);
|
||||
const useVariantTitle = () => setUsesVariantTitle(true);
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
if (!productId || !currentPlatform || !snapshot) return;
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
@@ -804,11 +812,17 @@ const ProductEditPage = () => {
|
||||
...values,
|
||||
ai_source_material: sourceDraft ?? snapshot.source_material,
|
||||
ai_source_overridden_fields: sourceOverriddenFields,
|
||||
copy_translations: copyTranslations.filter(
|
||||
(item) =>
|
||||
!item.variant_id ||
|
||||
values.variants.some((variant) => variant.id === item.variant_id)
|
||||
),
|
||||
})
|
||||
);
|
||||
message.success('草稿已保存');
|
||||
setDirty(false);
|
||||
await load();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') {
|
||||
message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存');
|
||||
@@ -817,10 +831,234 @@ const ProductEditPage = () => {
|
||||
message.warning('商品已在其他位置更新,已重新加载最新内容');
|
||||
await load();
|
||||
} else message.error(apiErrorMessage(error));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const upsertTranslation = (
|
||||
previous: ProductCopyTranslation[],
|
||||
variantId: string | null,
|
||||
field: ProductCopyField,
|
||||
russian: string | string[],
|
||||
chinese: string | string[],
|
||||
taskId: string
|
||||
) => [
|
||||
...previous.filter((item) => !(item.variant_id === variantId && item.field === field)),
|
||||
{
|
||||
variant_id: variantId,
|
||||
field,
|
||||
russian_value: russian,
|
||||
chinese_value: chinese,
|
||||
source_task_id: taskId,
|
||||
},
|
||||
];
|
||||
const addCopyTranslations = (variantId: string | null, copy: AiCopyOutput, taskId: string) => {
|
||||
setCopyTranslations((current) => {
|
||||
let next = current;
|
||||
if (copy.title)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'title',
|
||||
copy.title.ru,
|
||||
copy.title.zh,
|
||||
taskId
|
||||
);
|
||||
if (copy.tags?.length)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'tags',
|
||||
copy.tags.map((tag) => tag.ru),
|
||||
copy.tags.map((tag) => tag.zh),
|
||||
taskId
|
||||
);
|
||||
if (copy.description)
|
||||
next = upsertTranslation(
|
||||
next,
|
||||
variantId,
|
||||
'description',
|
||||
copy.description.ru,
|
||||
copy.description.zh,
|
||||
taskId
|
||||
);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
/** 任务输出必须与发起时的版本一致,避免异步结果覆盖后续人工编辑。 */
|
||||
const waitForAiTask = async (taskId: string): Promise<ProductAiTask> => {
|
||||
if (!currentPlatform) throw new Error('平台未选择');
|
||||
for (let attempt = 0; attempt < 60; attempt += 1) {
|
||||
if (attempt) await wait(2_000);
|
||||
const task = await productEditorApi.aiTask(taskId, currentPlatform.id);
|
||||
if (['succeeded', 'partial', 'failed'].includes(task.status)) return task;
|
||||
}
|
||||
throw new Error('AI 任务仍在处理,请稍后重试');
|
||||
};
|
||||
const selectCategoryCandidate = (
|
||||
candidates: NonNullable<ProductAiAutofillResult['category_candidates']>
|
||||
) => {
|
||||
setSelectedCandidateId(null);
|
||||
setCategoryCandidates(candidates);
|
||||
};
|
||||
const confirmCategoryCandidate = () => {
|
||||
if (!selectedCandidateId) return;
|
||||
form.setFieldsValue({
|
||||
profile: {
|
||||
platform_product_category_id: selectedCandidateId,
|
||||
category_source: 'ai_suggestion',
|
||||
},
|
||||
attributes: [],
|
||||
});
|
||||
setDirty(true);
|
||||
setCategoryCandidates(null);
|
||||
setSelectedCandidateId(null);
|
||||
};
|
||||
const applyAutofill = (result: ProductAiAutofillResult, taskId: string) => {
|
||||
const values = form.getFieldsValue(true) as EditorFormValues;
|
||||
const profile = { ...values.profile };
|
||||
const common = { ...values.common };
|
||||
const variants = [...values.variants];
|
||||
if (result.category && !profile.platform_product_category_id) {
|
||||
profile.platform_product_category_id = result.category.category_id;
|
||||
profile.category_source = 'ai_suggestion';
|
||||
} else if (result.category_candidates?.length)
|
||||
selectCategoryCandidate(result.category_candidates);
|
||||
if (result.model && !common.model) common.model = result.model;
|
||||
if (result.package_spec) {
|
||||
const spec = { ...common.default_package_spec };
|
||||
let packageFilled = false;
|
||||
for (const key of [
|
||||
'length_mm',
|
||||
'width_mm',
|
||||
'height_mm',
|
||||
'shipping_weight_g',
|
||||
] as const) {
|
||||
if (!spec[key]) {
|
||||
spec[key] = result.package_spec[key];
|
||||
packageFilled = true;
|
||||
}
|
||||
}
|
||||
if (packageFilled && spec.status !== 'confirmed') spec.status = 'ambiguous';
|
||||
common.default_package_spec = spec;
|
||||
}
|
||||
for (const offer of result.offers ?? []) {
|
||||
const index = variants.findIndex((variant) => variant.id === offer.variant_id);
|
||||
if (index >= 0 && !variants[index].offer_id)
|
||||
variants[index] = { ...variants[index], offer_id: offer.offer_id };
|
||||
}
|
||||
const copy = result.spu_copy;
|
||||
if (copy?.title && !profile.title) {
|
||||
profile.title = copy.title.ru;
|
||||
addCopyTranslations(null, { title: copy.title }, taskId);
|
||||
}
|
||||
if (copy?.tags?.length && !profile.tags.length) {
|
||||
profile.tags = copy.tags.map((tag) => tag.ru);
|
||||
addCopyTranslations(null, { tags: copy.tags }, taskId);
|
||||
}
|
||||
if (copy?.description && !profile.description) {
|
||||
profile.description = copy.description.ru;
|
||||
addCopyTranslations(null, { description: copy.description }, taskId);
|
||||
}
|
||||
form.setFieldsValue({ profile, common, variants });
|
||||
setDirty(true);
|
||||
if (result.warnings.length) message.warning(result.warnings.join(';'));
|
||||
else message.success('AI 结果已填入表单,请检查后保存');
|
||||
};
|
||||
const startAutofill = async () => {
|
||||
if (!snapshot || !currentPlatform || aiAutofillLoading) return;
|
||||
setAiAutofillLoading(true);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
setAiAutofillStatus('正在分析采集信息…');
|
||||
const task = await productEditorApi.aiAutofill(snapshot.profile.id, currentPlatform.id);
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
if (completed.status === 'failed' || !completed.result)
|
||||
throw new Error(completed.error_message ?? 'AI 生成失败');
|
||||
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
latest.source_material.source_snapshot_revision !==
|
||||
completed.input_revision.source_snapshot ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
) {
|
||||
message.warning(
|
||||
'商品或采集信息在 AI 处理期间已变化,结果未自动回填,请重新点击 AI 填报'
|
||||
);
|
||||
return;
|
||||
}
|
||||
applyAutofill(completed.result as ProductAiAutofillResult, completed.id);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
} finally {
|
||||
setAiAutofillLoading(false);
|
||||
setAiAutofillStatus('');
|
||||
}
|
||||
};
|
||||
const generateSkuCopy = async () => {
|
||||
if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return;
|
||||
setSkuCopyLoading(true);
|
||||
setSkuCopyError(null);
|
||||
setSkuCopyResult(null);
|
||||
try {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
|
||||
const formAtStart = JSON.stringify(form.getFieldsValue(true));
|
||||
const task = await productEditorApi.aiSkuCopy(
|
||||
snapshot.profile.id,
|
||||
skuCopyVariantId,
|
||||
currentPlatform.id
|
||||
);
|
||||
const completed = await waitForAiTask(task.ai_task_id);
|
||||
if (completed.status === 'failed' || !completed.result)
|
||||
throw new Error(completed.error_message ?? 'SKU 文案生成失败');
|
||||
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
if (
|
||||
latest.product.revision !== completed.input_revision.product ||
|
||||
latest.profile.revision !== completed.input_revision.profile ||
|
||||
JSON.stringify(form.getFieldsValue(true)) !== formAtStart
|
||||
)
|
||||
throw new Error('商品已被其他操作更新,请重新生成');
|
||||
skuCopyTaskId.current = completed.id;
|
||||
setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy);
|
||||
} catch (error) {
|
||||
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
|
||||
} finally {
|
||||
setSkuCopyLoading(false);
|
||||
}
|
||||
};
|
||||
const applySkuCopy = () => {
|
||||
if (!skuCopyVariantId || !skuCopyResult) return;
|
||||
const variants = form.getFieldValue('variants') as EditorFormValues['variants'];
|
||||
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
|
||||
if (index < 0) return;
|
||||
const copy = skuCopyResult;
|
||||
form.setFieldValue(
|
||||
'variants',
|
||||
variants.map((variant, current) =>
|
||||
current !== index
|
||||
? variant
|
||||
: {
|
||||
...variant,
|
||||
title: copy.title?.ru ?? variant.title,
|
||||
tags: copy.tags?.map((tag) => tag.ru) ?? variant.tags,
|
||||
description: copy.description?.ru ?? variant.description,
|
||||
}
|
||||
)
|
||||
);
|
||||
// SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。
|
||||
if (skuCopyTaskId.current)
|
||||
addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current);
|
||||
setUsesVariantTitle(true);
|
||||
setUsesVariantTags(true);
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
setSkuCopyVariantId(null);
|
||||
message.success('已应用到当前 SKU,保存草稿后生效');
|
||||
};
|
||||
const skuCopyTaskId = useRef<string | null>(null);
|
||||
const showFutureFeature = (name: string) =>
|
||||
modal.info({
|
||||
title: `${name}将在下一阶段接入`,
|
||||
@@ -861,8 +1099,8 @@ const ProductEditPage = () => {
|
||||
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||
{
|
||||
description,
|
||||
source_snapshot_revision:
|
||||
(sourceDraft ?? snapshot.source_material).source_snapshot_revision,
|
||||
source_snapshot_revision: (sourceDraft ?? snapshot.source_material)
|
||||
.source_snapshot_revision,
|
||||
}
|
||||
);
|
||||
setSnapshot((current) =>
|
||||
@@ -891,8 +1129,7 @@ const ProductEditPage = () => {
|
||||
? {
|
||||
...current,
|
||||
description: saved.source_material.description,
|
||||
source_snapshot_revision:
|
||||
saved.source_material.source_snapshot_revision,
|
||||
source_snapshot_revision: saved.source_material.source_snapshot_revision,
|
||||
}
|
||||
: saved.source_material
|
||||
);
|
||||
@@ -1072,8 +1309,45 @@ const ProductEditPage = () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const hasSourceCategory = snapshot.profile.category.source === 'source_capture';
|
||||
const summary = collectedSummary(collected ?? snapshot.source_material);
|
||||
const currentValues = form.getFieldsValue(true) as EditorFormValues;
|
||||
const activeSku = skuCopyVariantId
|
||||
? currentValues.variants?.find((variant) => variant.id === skuCopyVariantId)
|
||||
: null;
|
||||
const comparisonVariant = comparison?.variantId
|
||||
? currentValues.variants?.find((variant) => variant.id === comparison.variantId)
|
||||
: null;
|
||||
const comparisonRows: ComparisonRow[] = (['title', 'tags', 'description'] as const).map(
|
||||
(field) => {
|
||||
const ownValue = comparisonVariant?.[field];
|
||||
const inherited =
|
||||
!!comparisonVariant &&
|
||||
(field === 'tags' ? !Array.isArray(ownValue) || !ownValue.length : !ownValue);
|
||||
const russianValue =
|
||||
inherited || !comparisonVariant ? currentValues.profile?.[field] : ownValue;
|
||||
const russian = Array.isArray(russianValue)
|
||||
? russianValue.join('、')
|
||||
: (russianValue ?? '');
|
||||
const translation = copyTranslations.find(
|
||||
(item) =>
|
||||
item.variant_id === (inherited ? null : (comparison?.variantId ?? null)) &&
|
||||
item.field === field
|
||||
);
|
||||
const savedRussian = Array.isArray(translation?.russian_value)
|
||||
? translation.russian_value.join('、')
|
||||
: (translation?.russian_value ?? '');
|
||||
const chinese = Array.isArray(translation?.chinese_value)
|
||||
? translation.chinese_value.join('、')
|
||||
: (translation?.chinese_value ?? '');
|
||||
return {
|
||||
field,
|
||||
russian,
|
||||
chinese: savedRussian === russian ? chinese : '',
|
||||
inherited,
|
||||
stale: !!translation && savedRussian !== russian,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
@@ -1139,6 +1413,9 @@ const ProductEditPage = () => {
|
||||
}}
|
||||
usesVariantPackageWeight={usesVariantPackageWeight}
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseVariantTitle={useVariantTitle}
|
||||
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={useVariantPackageDimensions}
|
||||
/>
|
||||
@@ -1165,6 +1442,7 @@ const ProductEditPage = () => {
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
onUseVariantTags={useVariantTags}
|
||||
onUseVariantDescription={useVariantDescription}
|
||||
onCompareCopy={(field) => setComparison({ variantId: null, field })}
|
||||
/>
|
||||
|
||||
<ProductVariantSection
|
||||
@@ -1187,6 +1465,14 @@ const ProductEditPage = () => {
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onAiCopy={(variantId) => {
|
||||
setSkuCopyVariantId(variantId);
|
||||
setSkuCopyResult(null);
|
||||
setSkuCopyError(null);
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
/>
|
||||
|
||||
<ProductOtherSection
|
||||
@@ -1213,15 +1499,10 @@ const ProductEditPage = () => {
|
||||
<Space className="footer-ai-actions" wrap>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
onClick={() => showFutureFeature('AI 类目推荐')}
|
||||
loading={aiAutofillLoading}
|
||||
onClick={() => void startAutofill()}
|
||||
>
|
||||
{hasSourceCategory ? '重新推荐类目' : '推荐类目'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
onClick={() => showFutureFeature('AI 文案生成')}
|
||||
>
|
||||
生成标题、标签和简介
|
||||
{aiAutofillStatus || 'AI 填报'}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<FileTextOutlined />}
|
||||
@@ -1243,6 +1524,68 @@ const ProductEditPage = () => {
|
||||
<Button disabled>选择 SKU 并首次上架</Button>
|
||||
</Space>
|
||||
</footer>
|
||||
<ProductCopyComparisonModal
|
||||
open={!!comparison}
|
||||
scopeLabel={
|
||||
comparison?.variantId
|
||||
? `SKU · ${comparisonVariant?.display_name || '未命名'}`
|
||||
: 'SPU'
|
||||
}
|
||||
rows={comparisonRows}
|
||||
focus={comparison?.field ?? 'title'}
|
||||
onClose={() => setComparison(null)}
|
||||
/>
|
||||
<SkuCopyAiModal
|
||||
open={!!skuCopyVariantId}
|
||||
variantName={activeSku?.display_name || '未命名 SKU'}
|
||||
sourceTitle={(sourceDraft ?? snapshot.source_material).title || ''}
|
||||
sourceDescription={(sourceDraft ?? snapshot.source_material).description || ''}
|
||||
specifications={
|
||||
activeSku?.specification_values
|
||||
.map((item) => `${item.axis_key}: ${item.value_name}`)
|
||||
.join(';') ?? ''
|
||||
}
|
||||
loading={skuCopyLoading}
|
||||
result={skuCopyResult}
|
||||
error={skuCopyError}
|
||||
onGenerate={() => void generateSkuCopy()}
|
||||
onApply={applySkuCopy}
|
||||
onClose={() => setSkuCopyVariantId(null)}
|
||||
/>
|
||||
<Modal
|
||||
className="ai-category-modal"
|
||||
title="确认 AI 推荐类目"
|
||||
open={!!categoryCandidates}
|
||||
centered
|
||||
width={560}
|
||||
okText="使用此类目"
|
||||
okButtonProps={{ disabled: !selectedCandidateId }}
|
||||
cancelText="暂不选择"
|
||||
onOk={confirmCategoryCandidate}
|
||||
onCancel={() => {
|
||||
setCategoryCandidates(null);
|
||||
setSelectedCandidateId(null);
|
||||
}}
|
||||
>
|
||||
<p className="ai-category-modal-intro">
|
||||
根据采集信息找到以下可能的类目。请选择最符合商品的一个,确认后会填入表单。
|
||||
</p>
|
||||
<Radio.Group
|
||||
className="ai-category-candidates"
|
||||
value={selectedCandidateId}
|
||||
onChange={(event) => setSelectedCandidateId(event.target.value)}
|
||||
>
|
||||
{categoryCandidates?.map((candidate) => (
|
||||
<Radio
|
||||
key={candidate.category_id}
|
||||
className="ai-category-candidate"
|
||||
value={candidate.category_id}
|
||||
>
|
||||
{candidate.path}
|
||||
</Radio>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons';
|
||||
import { EditOutlined, PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
@@ -41,6 +41,7 @@ type Props = {
|
||||
usesVariantDescription: boolean;
|
||||
onUseVariantTags: () => void;
|
||||
onUseVariantDescription: () => void;
|
||||
onCompareCopy: (field: 'tags' | 'description') => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,6 +71,7 @@ export function ProductAttributeSection({
|
||||
usesVariantDescription,
|
||||
onUseVariantTags,
|
||||
onUseVariantDescription,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||
const [richContent, setRichContent] = useState('');
|
||||
@@ -95,41 +97,53 @@ export function ProductAttributeSection({
|
||||
title="产品属性"
|
||||
description="用于当前平台发布与 AI 文案生成。"
|
||||
/>
|
||||
{!usesVariantTags && (
|
||||
<Form.Item label="主题标签">
|
||||
<div className="profile-override-control">
|
||||
<Form.Item name={['profile', 'tags']} noStyle>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="主题标签">
|
||||
<div className="profile-override-control">
|
||||
<Form.Item name={['profile', 'tags']} noStyle>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
{!usesVariantTags && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写主题标签"
|
||||
onClick={onUseVariantTags}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
{!usesVariantDescription && (
|
||||
<Form.Item label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<div className="profile-override-control profile-override-control--description">
|
||||
<Form.Item name={['profile', 'description']} noStyle>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看主题标签中文对照"
|
||||
onClick={() => onCompareCopy('tags')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item label="产品描述" rules={[{ max: 100000 }]}>
|
||||
<div className="profile-override-control profile-override-control--description">
|
||||
<Form.Item name={['profile', 'description']} noStyle>
|
||||
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||
</Form.Item>
|
||||
{!usesVariantDescription && (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined style={{ fontSize: 20 }} />}
|
||||
aria-label="按 SKU 分别填写产品描述"
|
||||
onClick={onUseVariantDescription}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
)}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看产品描述中文对照"
|
||||
onClick={() => onCompareCopy('description')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name={['common', 'barcode']} label="条形码">
|
||||
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
|
||||
</Form.Item>
|
||||
@@ -156,10 +170,12 @@ export function ProductAttributeSection({
|
||||
onClick={() => {
|
||||
onToggleExpanded();
|
||||
window.requestAnimationFrame(() =>
|
||||
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
document
|
||||
.getElementById('dynamic-attributes')
|
||||
?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.copy-comparison-head,
|
||||
.copy-comparison-row,
|
||||
.sku-copy-ai-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.copy-comparison-head {
|
||||
font-weight: 600;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.copy-comparison-row {
|
||||
border-top: 1px solid #e9e9e9;
|
||||
padding: 14px 0;
|
||||
}
|
||||
.copy-comparison-row--focus {
|
||||
background: #f6f9ff;
|
||||
}
|
||||
.copy-comparison-text,
|
||||
.sku-copy-ai-source,
|
||||
.sku-copy-ai-result p {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sku-copy-ai-source {
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
.sku-copy-ai-zh {
|
||||
color: #666;
|
||||
}
|
||||
.sku-copy-ai-error {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sku-copy-ai-loading {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.copy-comparison-head,
|
||||
.copy-comparison-row,
|
||||
.sku-copy-ai-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.copy-comparison-head {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Alert, Button, Modal, Space, Spin, Tag, Typography } from 'antd';
|
||||
import type { AiCopyOutput, ProductCopyField } from '@atlas/contracts';
|
||||
import './ProductCopyAiDialogs.css';
|
||||
|
||||
type ComparisonRow = {
|
||||
field: ProductCopyField;
|
||||
russian: string;
|
||||
chinese: string;
|
||||
inherited: boolean;
|
||||
stale: boolean;
|
||||
};
|
||||
|
||||
const labels: Record<ProductCopyField, string> = {
|
||||
title: '标题',
|
||||
tags: '主题标签',
|
||||
description: '产品描述',
|
||||
};
|
||||
|
||||
/** SPU 与 SKU 共用一个中俄对照弹窗,继承及过期状态逐字段展示。 */
|
||||
export function ProductCopyComparisonModal({
|
||||
open,
|
||||
scopeLabel,
|
||||
rows,
|
||||
focus,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
scopeLabel: string;
|
||||
rows: ComparisonRow[];
|
||||
focus: ProductCopyField;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`${scopeLabel} · 中文对照`}
|
||||
onCancel={onClose}
|
||||
footer={<Button onClick={onClose}>关闭</Button>}
|
||||
width={880}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="copy-comparison-head">
|
||||
<span>俄文</span>
|
||||
<span>中文对照</span>
|
||||
</div>
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.field}
|
||||
className={`copy-comparison-row ${row.field === focus ? 'copy-comparison-row--focus' : ''}`}
|
||||
>
|
||||
<div>
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.inherited && <Tag color="blue">继承 SPU</Tag>}
|
||||
<div className="copy-comparison-text">{row.russian || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>{labels[row.field]}</Typography.Text>
|
||||
{row.stale && <Tag color="orange">对照待更新</Tag>}
|
||||
<div className="copy-comparison-text">{row.chinese || '暂无中文对照'}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** 当前 SKU 生成弹窗只预览结果;点击应用后才写入该 SKU 的未保存表单。 */
|
||||
export function SkuCopyAiModal({
|
||||
open,
|
||||
variantName,
|
||||
sourceTitle,
|
||||
sourceDescription,
|
||||
specifications,
|
||||
loading,
|
||||
result,
|
||||
error,
|
||||
onGenerate,
|
||||
onApply,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
variantName: string;
|
||||
sourceTitle: string;
|
||||
sourceDescription: string;
|
||||
specifications: string;
|
||||
loading: boolean;
|
||||
result: AiCopyOutput | null;
|
||||
error: string | null;
|
||||
onGenerate: () => void;
|
||||
onApply: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`AI 生成当前 SKU 文案 · ${variantName}`}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
<Button onClick={onGenerate} loading={loading}>
|
||||
AI 生成
|
||||
</Button>
|
||||
<Button type="primary" disabled={!result || loading} onClick={onApply}>
|
||||
应用到当前 SKU
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert type="warning" showIcon message={error} className="sku-copy-ai-error" />
|
||||
)}
|
||||
<div className="sku-copy-ai-layout">
|
||||
<div>
|
||||
<Typography.Title level={5}>中文来源与 SKU 信息</Typography.Title>
|
||||
<Typography.Text strong>SKU 名称</Typography.Text>
|
||||
<p>{variantName}</p>
|
||||
<Typography.Text strong>来源标题</Typography.Text>
|
||||
<p>{sourceTitle || '未采集'}</p>
|
||||
<Typography.Text strong>来源描述</Typography.Text>
|
||||
<p className="sku-copy-ai-source">{sourceDescription || '未采集'}</p>
|
||||
<Typography.Text strong>SKU 规格</Typography.Text>
|
||||
<p>{specifications || '未采集'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={5}>当前 SKU 的生成结果</Typography.Title>
|
||||
{loading && (
|
||||
<Spin tip="正在生成">
|
||||
<div className="sku-copy-ai-loading" />
|
||||
</Spin>
|
||||
)}
|
||||
{!loading && !result && (
|
||||
<Typography.Text type="secondary">
|
||||
点击下方「AI 生成」后在此预览。
|
||||
</Typography.Text>
|
||||
)}
|
||||
{result && (
|
||||
<div className="sku-copy-ai-result">
|
||||
<Typography.Text strong>标题</Typography.Text>
|
||||
<p>{result.title?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.title?.zh || ''}</p>
|
||||
<Typography.Text strong>主题标签</Typography.Text>
|
||||
<p>{result.tags?.map((tag) => tag.ru).join('、') || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">
|
||||
{result.tags?.map((tag) => tag.zh).join('、') || ''}
|
||||
</p>
|
||||
<Typography.Text strong>产品描述</Typography.Text>
|
||||
<p>{result.description?.ru || '未生成'}</p>
|
||||
<p className="sku-copy-ai-zh">{result.description?.zh || ''}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ComparisonRow };
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined } from '@ant-design/icons';
|
||||
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
@@ -93,6 +93,9 @@ type Props = {
|
||||
usesVariantPackageDimensions: boolean;
|
||||
onUseVariantPackageWeight: () => void;
|
||||
onUseVariantPackageDimensions: () => void;
|
||||
usesVariantTitle: boolean;
|
||||
onUseVariantTitle: () => void;
|
||||
onCompareTitle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -114,6 +117,9 @@ export function ProductMainInfoSection({
|
||||
usesVariantPackageDimensions,
|
||||
onUseVariantPackageWeight,
|
||||
onUseVariantPackageDimensions,
|
||||
usesVariantTitle,
|
||||
onUseVariantTitle,
|
||||
onCompareTitle,
|
||||
}: Props) {
|
||||
return (
|
||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||
@@ -170,7 +176,10 @@ export function ProductMainInfoSection({
|
||||
getValueProps={(categoryId: string | null) => ({
|
||||
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
|
||||
})}
|
||||
getValueFromEvent={(value: string[]) => value.at(-1) ?? null}
|
||||
// Cascader 清除时会传入 undefined,统一折算为空类目避免读取空值。
|
||||
getValueFromEvent={(value: unknown) =>
|
||||
Array.isArray(value) ? (value.at(-1) ?? null) : null
|
||||
}
|
||||
>
|
||||
<Cascader
|
||||
className="editor-standard-cascader"
|
||||
@@ -182,7 +191,7 @@ export function ProductMainInfoSection({
|
||||
notFoundContent={categoryNotFoundContent}
|
||||
getPopupContainer={(node) => node.parentElement ?? document.body}
|
||||
onChange={(value) => {
|
||||
const categoryId = value.at(-1) ?? null;
|
||||
const categoryId = Array.isArray(value) ? (value.at(-1) ?? null) : null;
|
||||
if (categoryId !== selectedCategoryId)
|
||||
form.setFieldValue('attributes', []);
|
||||
form.setFieldValue(
|
||||
@@ -194,11 +203,47 @@ export function ProductMainInfoSection({
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'source_title']}
|
||||
label="标题"
|
||||
label="中文商品名"
|
||||
className="span-full"
|
||||
rules={[{ required: true, message: '请填写标题' }, { max: 1000 }]}
|
||||
rules={[{ required: true, message: '请填写中文商品名' }, { max: 1000 }]}
|
||||
>
|
||||
<Input placeholder="整理后的商品标题" />
|
||||
<Input placeholder="填写中文商品名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="俄文发布标题" className="span-full" required>
|
||||
<div className="profile-override-control">
|
||||
<Form.Item
|
||||
name={['profile', 'title']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
whitespace: true,
|
||||
message: '请填写俄文发布标题',
|
||||
},
|
||||
{ max: 1000 },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
|
||||
</Form.Item>
|
||||
{!usesVariantTitle && (
|
||||
<Tooltip title="在 SKU 中分别编辑标题">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="在 SKU 中分别编辑标题"
|
||||
onClick={onUseVariantTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="查看标题中文对照">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看标题中文对照"
|
||||
onClick={onCompareTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'model']}
|
||||
|
||||
@@ -157,26 +157,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Ozon 变体属性与 SKU 摘要同属卡片主体;必填项在折叠状态下也始终可见。 */
|
||||
.sku-attribute-panel {
|
||||
margin-top: 12px;
|
||||
padding: 12px 0 0 87px;
|
||||
border-top: 1px solid #f0edf3;
|
||||
}
|
||||
.sku-attribute-panel h5.ant-typography {
|
||||
margin: 0 0 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label {
|
||||
flex: 0 0 120px !important;
|
||||
text-align: left;
|
||||
}
|
||||
.sku-attribute-panel .ant-form-item-label > label {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.sku-card-value strong {
|
||||
min-height: 32px;
|
||||
padding-top: 5px;
|
||||
@@ -338,7 +318,7 @@
|
||||
.sku-info-variant-description {
|
||||
grid-column: span 2;
|
||||
}
|
||||
/* 动态 SKU 属性也属于同一信息网格,不能再形成独立的横向属性区。 */
|
||||
/* 所有动态 SKU 属性与包装、文案字段共用“SKU 信息”网格。 */
|
||||
.sku-info-panel .dynamic-attribute-list {
|
||||
display: contents;
|
||||
}
|
||||
@@ -384,9 +364,6 @@
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.sku-attribute-panel {
|
||||
padding-left: 0;
|
||||
}
|
||||
.sku-inline-dimensions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,14 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
DownOutlined,
|
||||
PlusOutlined,
|
||||
RobotOutlined,
|
||||
TranslationOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
CollectionItemInspection,
|
||||
ProductAttributeDictionaryValue,
|
||||
@@ -124,6 +131,9 @@ type Props = {
|
||||
usesVariantPackageDimensions: boolean;
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -151,23 +161,19 @@ export function ProductVariantSection({
|
||||
usesVariantPackageDimensions,
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||
const requiredAspectDefinitions = activeAttributeSchema.filter(
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
definition.is_required &&
|
||||
definition.name.trim() !== '商品颜色' &&
|
||||
!definition.editor_field
|
||||
);
|
||||
// SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。
|
||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||
.filter(
|
||||
(definition) =>
|
||||
definition.is_aspect &&
|
||||
(!definition.is_required || definition.name.trim() === '商品颜色') &&
|
||||
!definition.editor_field
|
||||
)
|
||||
.sort((left, right) => skuInfoAttributeOrder(left) - skuInfoAttributeOrder(right));
|
||||
@@ -196,6 +202,7 @@ export function ProductVariantSection({
|
||||
source_sku: null,
|
||||
specification_values: [],
|
||||
purchase_cost_cny: null,
|
||||
title: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
physical_spec: {
|
||||
@@ -269,6 +276,9 @@ export function ProductVariantSection({
|
||||
display_name: variantValue.display_name
|
||||
? `${variantValue.display_name} 副本`
|
||||
: 'SKU 副本',
|
||||
title: null,
|
||||
tags: [],
|
||||
description: null,
|
||||
offer_id: null,
|
||||
media_asset_ids: [],
|
||||
});
|
||||
@@ -425,39 +435,6 @@ export function ProductVariantSection({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{requiredAspectDefinitions.length > 0 && (
|
||||
<div className="sku-attribute-panel">
|
||||
<Typography.Title level={5}>
|
||||
Ozon 变体属性
|
||||
</Typography.Title>
|
||||
{sourceId ? (
|
||||
<DynamicAttributeFields
|
||||
form={form}
|
||||
definitions={requiredAspectDefinitions}
|
||||
scope="variant"
|
||||
variantId={sourceId}
|
||||
dictionaryValues={dictionaryValues}
|
||||
catalogSyncing={catalogSyncing}
|
||||
onLoadDictionaryValues={
|
||||
onLoadDictionaryValues
|
||||
}
|
||||
onSyncDictionaryValues={
|
||||
onSyncDictionaryValues
|
||||
}
|
||||
onOpenDictionaryValues={
|
||||
onOpenDictionaryValues
|
||||
}
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
保存新 SKU 后即可填写其 Ozon 变体属性。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="sku-card-expanded">
|
||||
<div className="sku-media-panel">
|
||||
@@ -569,6 +546,7 @@ export function ProductVariantSection({
|
||||
usesVariantPackageDimensions ||
|
||||
usesVariantTags ||
|
||||
usesVariantDescription ||
|
||||
usesVariantTitle ||
|
||||
(skuInfoAspectDefinitions.length > 0 &&
|
||||
sourceId)) && (
|
||||
<div className="sku-info-panel">
|
||||
@@ -615,31 +593,146 @@ export function ProductVariantSection({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{usesVariantTitle && (
|
||||
<Form.Item
|
||||
label="俄文发布标题"
|
||||
layout="vertical"
|
||||
>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[field.name, 'title']}
|
||||
noStyle
|
||||
>
|
||||
<Input placeholder="留空则继承 SPU 标题" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 文案"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'title'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 标题中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'title'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantTags && (
|
||||
<Form.Item
|
||||
name={[field.name, 'tags']}
|
||||
label="主题标签"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-tags"
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="输入后回车添加标签"
|
||||
/>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[field.name, 'tags']}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
placeholder="留空则继承 SPU 标签"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 标签"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'tags'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 标签中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'tags'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{usesVariantDescription && (
|
||||
<Form.Item
|
||||
name={[field.name, 'description']}
|
||||
label="产品描述"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-description"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="填写该 SKU 的产品描述"
|
||||
/>
|
||||
<Space.Compact
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name={[
|
||||
field.name,
|
||||
'description',
|
||||
]}
|
||||
noStyle
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="留空则继承 SPU 描述"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<RobotOutlined />}
|
||||
aria-label="AI 生成当前 SKU 描述"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onAiCopy(
|
||||
sourceId,
|
||||
'description'
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
icon={
|
||||
<TranslationOutlined />
|
||||
}
|
||||
aria-label="查看当前 SKU 描述中文对照"
|
||||
disabled={!sourceId}
|
||||
onClick={() =>
|
||||
sourceId &&
|
||||
onCompareCopy(
|
||||
sourceId,
|
||||
'description'
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 &&
|
||||
|
||||
Reference in New Issue
Block a user