feat(edit): 编辑页上报ozon 开发
This commit is contained in:
+12
-4
@@ -1,19 +1,27 @@
|
||||
# Web 前端交接:商品编辑器与 AI 填报
|
||||
|
||||
更新日期:2026-09-23
|
||||
更新日期:2026-09-30
|
||||
|
||||
本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。
|
||||
|
||||
## 当前状态(2026-09-23)
|
||||
## 当前状态(2026-09-30)
|
||||
|
||||
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 生图、首次上架仍是占位/不可用入口。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
|
||||
- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 套图生成仍是占位入口;首次上架已接入,见下方记录。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。
|
||||
- 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。
|
||||
- SPU 的「中文商品名」和「俄文发布标题」在页面中均标为必填;发布就绪校验也要求俄文标题。标题、标签、描述各有中文对照入口;标题旁有加号,可开启 SKU 标题覆盖。SKU 默认继承 SPU 文案,用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。
|
||||
- SPU 的「中文商品名」和「俄文标题」标示首次上架必填,草稿保存允许留空;俄文标题可在通用与按 SKU 设置间切换并保存模式,按 SKU 设置时清空、禁用 SPU 标题并在各 SKU 填写。标题、标签、描述各有中文对照入口;用户可在当前 SKU 弹窗中单独生成、预览并明确应用,不因全局 AI 填报而批量覆盖 SKU。
|
||||
- SKU 的 Ozon `is_aspect` 字段现在直接放在展开卡片的「SKU 信息」中,不另显示「Ozon 变体属性」标题。产品类目 Cascader 的清除事件按空类目处理,避免读取 `undefined.at`。来源采集信息侧栏仍展示来源资料与差异处理。
|
||||
- 关键组件:`ProductMainInfoSection.tsx`(名称、类目、SPU 标题等)、`ProductAttributeSection.tsx`(SPU 属性、标签与描述)、`ProductVariantSection.tsx`(SKU 信息及动态变体字段)、`ProductCopyAiDialogs.tsx`(SKU AI 生成与中俄对照)。商品编辑流程编排仍主要在 `ProductEditPage.tsx`。
|
||||
|
||||
下一轮前端回归应覆盖固定 footer 在不同视口/侧栏状态下不遮挡内容、类目清除和候选确认、AI 任务失败/部分成功/版本过期、SPU/SKU 文案继承与中文对照保存后重读。俄文禁汉字的最终拦截在 Worker,旧文案不会由前端自动清洗。本次更新只修改交接文档,未重新运行浏览器回归。
|
||||
|
||||
## 2026-09-30 首次上架
|
||||
|
||||
- `ProductPublishActions.tsx` 提供「上架至 OZON」「部分上架」,沿用父页面草稿保存与版本锁,按指定 SKU 调用预检;超过 50 个需分批选择,任一待提交项失败不创建任务。
|
||||
- 选择表禁选已导入/进行中 SKU,并显示原因;确认摘要列出店铺、货号、售价和图片数量。任务列表恢复最近任务,轮询后仅合并 listing 状态,保留未保存表单。
|
||||
- 发布标题最多 500、货号最多 50、SKU 图片最多 30;中文来源商品名仍为 1000。已导入或进行中锁定型号,进行中锁定换店;其他草稿资料可以编辑。
|
||||
- Web 类型检查及构建通过。隔离数据库页面验证了禁选、全部/部分预检、缺货号阻断、确认提交和刷新恢复;没有向真实 Ozon 店铺导入。
|
||||
- VAT 首期省略且生产发布默认关闭;真实店铺与七牛图片交付待验收。后端契约见 `docs/api/product-editor-and-publishing.md` 第 7 节。
|
||||
|
||||
## 2026-09-11 历史前端记录(以下内容仅供追溯)
|
||||
|
||||
### 商品编辑器
|
||||
|
||||
@@ -3,6 +3,13 @@ import type { InputProps } from 'antd';
|
||||
|
||||
const digitsOnly = /^\d+$/;
|
||||
|
||||
/** 仅去掉数值本身为整数的历史小数尾零;非整数原值留给用户核对。 */
|
||||
export function normalizePackageInteger(value: string | null | undefined): string {
|
||||
const text = value ?? '';
|
||||
if (!/^\d+(?:\.0+)?$/.test(text)) return text;
|
||||
return text.replace(/\.0+$/, '').replace(/^0+(?=\d)/, '');
|
||||
}
|
||||
|
||||
export function isPositivePackageInteger(value: unknown): value is string {
|
||||
return typeof value === 'string' && digitsOnly.test(value) && Number(value) > 0;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { PackageIntegerInput } from '@/components/PackageIntegerInput';
|
||||
import { PackageIntegerInput, normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { percentToRate, rateToPercent } from './pricingPercentage';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
|
||||
@@ -326,7 +326,16 @@ export function PricingCalculator({
|
||||
const [policyName, setPolicyName] = useState<string | null>(null);
|
||||
const [policySaving, setPolicySaving] = useState(false);
|
||||
const [localCatalog, setLocalCatalog] = useState(catalog);
|
||||
const [input, setInput] = useState(() => structuredClone(initial));
|
||||
const [input, setInput] = useState(() => ({
|
||||
...structuredClone(initial),
|
||||
package: {
|
||||
...initial.package,
|
||||
shipping_weight_g: normalizePackageInteger(initial.package.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(initial.package.length_mm),
|
||||
width_mm: normalizePackageInteger(initial.package.width_mm),
|
||||
height_mm: normalizePackageInteger(initial.package.height_mm),
|
||||
},
|
||||
}));
|
||||
const [preview, setPreview] = useState<PricingPreview | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
|
||||
import { pricingApi } from '@/services/pricing';
|
||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { defaultPricing, PricingCalculator } from './PricingCalculator';
|
||||
import { pricingInputProblem } from './pricingInputValidation';
|
||||
type Values = SaveProductEditorDraftRequest;
|
||||
@@ -27,10 +28,10 @@ export function skuPricingInput(
|
||||
base = values.common.default_package_spec,
|
||||
pkg = v.package_spec;
|
||||
const effectivePackage = {
|
||||
shipping_weight_g: pkg.shipping_weight_g ?? base.shipping_weight_g ?? '',
|
||||
length_mm: pkg.length_mm ?? base.length_mm ?? '',
|
||||
width_mm: pkg.width_mm ?? base.width_mm ?? '',
|
||||
height_mm: pkg.height_mm ?? base.height_mm ?? '',
|
||||
shipping_weight_g: normalizePackageInteger(pkg.shipping_weight_g ?? base.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(pkg.length_mm ?? base.length_mm),
|
||||
width_mm: normalizePackageInteger(pkg.width_mm ?? base.width_mm),
|
||||
height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm),
|
||||
};
|
||||
return {
|
||||
store_id: values.profile.target_store_id,
|
||||
@@ -538,7 +539,14 @@ export function SkuPricing({
|
||||
]);
|
||||
setModal({
|
||||
...modal,
|
||||
package: { ...base, status: base.status },
|
||||
package: {
|
||||
...base,
|
||||
shipping_weight_g: normalizePackageInteger(base.shipping_weight_g),
|
||||
length_mm: normalizePackageInteger(base.length_mm),
|
||||
width_mm: normalizePackageInteger(base.width_mm),
|
||||
height_mm: normalizePackageInteger(base.height_mm),
|
||||
status: base.status,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -47,6 +47,88 @@
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-publish-status-slot:not(:empty) {
|
||||
margin-top: 14px;
|
||||
}
|
||||
.editor-publish-status {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--publish-status-border);
|
||||
border-radius: 10px;
|
||||
background: var(--publish-status-background);
|
||||
color: #344054;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.editor-publish-status:hover {
|
||||
border-color: var(--publish-status-accent);
|
||||
box-shadow: 0 2px 8px rgba(16, 24, 40, 0.06);
|
||||
}
|
||||
.editor-publish-status:focus-visible {
|
||||
outline: 2px solid var(--publish-status-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.editor-publish-status--progress {
|
||||
--publish-status-border: #d9d6f6;
|
||||
--publish-status-background: #f7f6ff;
|
||||
--publish-status-accent: #7c52e8;
|
||||
}
|
||||
.editor-publish-status--success {
|
||||
--publish-status-border: #cdebd5;
|
||||
--publish-status-background: #f5fbf6;
|
||||
--publish-status-accent: #389e55;
|
||||
}
|
||||
.editor-publish-status--warning {
|
||||
--publish-status-border: #f5e2b9;
|
||||
--publish-status-background: #fffaf0;
|
||||
--publish-status-accent: #ad6800;
|
||||
}
|
||||
.editor-publish-status--error {
|
||||
--publish-status-border: #f2cccc;
|
||||
--publish-status-background: #fff7f7;
|
||||
--publish-status-accent: #cf3d3d;
|
||||
}
|
||||
.editor-publish-status-icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--publish-status-accent);
|
||||
font-size: 16px;
|
||||
}
|
||||
.editor-publish-status-content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
}
|
||||
.editor-publish-status-title {
|
||||
color: #252339;
|
||||
font-weight: 600;
|
||||
}
|
||||
.editor-publish-status-count {
|
||||
color: #73718a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.editor-publish-status-link {
|
||||
flex: 0 0 auto;
|
||||
color: var(--publish-status-accent);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.editor-publish-status-link .anticon {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */
|
||||
.editor-workspace {
|
||||
@@ -299,6 +381,10 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.editor-publish-status-content {
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
}
|
||||
.product-editor {
|
||||
padding-bottom: 230px;
|
||||
}
|
||||
|
||||
@@ -27,16 +27,18 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import { usePlatform } from '@/services/platform';
|
||||
import { collectionApi } from '@/services/collection';
|
||||
import { isPositivePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import { isPositivePackageInteger, normalizePackageInteger } from '@/components/PackageIntegerInput';
|
||||
import {
|
||||
CollectedProductInfoPanel,
|
||||
type CollectedProductSummary,
|
||||
} from './components/CollectedProductInfoPanel';
|
||||
import { ProductAttributeSection } from './components/ProductAttributeSection';
|
||||
import { ProductPublishActions } from './components/ProductPublishActions';
|
||||
import { ProductMainInfoSection } from './components/ProductMainInfoSection';
|
||||
import { ProductOtherSection } from './components/ProductOtherSection';
|
||||
import { ProductVariantSection } from './components/ProductVariantSection';
|
||||
import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
|
||||
import {
|
||||
ProductCopyComparisonModal,
|
||||
SkuCopyAiModal,
|
||||
@@ -97,7 +99,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
common: {
|
||||
...snapshot.product.common,
|
||||
default_physical_spec: snapshot.product.default_physical_spec,
|
||||
default_package_spec: snapshot.product.default_package_spec,
|
||||
default_package_spec: normalizedPackageSpec(snapshot.product.default_package_spec),
|
||||
package_weight_mode: snapshot.product.package_weight_mode,
|
||||
package_dimensions_mode: snapshot.product.package_dimensions_mode,
|
||||
},
|
||||
@@ -107,7 +109,8 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
platformCode.toUpperCase() === 'OZON' && snapshot.profile.fulfillment === 'unknown'
|
||||
? 'rfbs'
|
||||
: (snapshot.profile.fulfillment as EditorFormValues['profile']['fulfillment']),
|
||||
title: snapshot.profile.title,
|
||||
title: snapshot.profile.title_mode === 'variant' ? null : snapshot.profile.title,
|
||||
title_mode: snapshot.profile.title_mode,
|
||||
description: snapshot.profile.description,
|
||||
tags: snapshot.profile.tags,
|
||||
platform_product_category_id: snapshot.profile.category.id,
|
||||
@@ -141,7 +144,7 @@ function formValues(snapshot: ProductEditorSnapshot, platformCode: string): Edit
|
||||
tags: variant.tags,
|
||||
description: variant.description,
|
||||
physical_spec: variant.physical_spec,
|
||||
package_spec: variant.package_spec,
|
||||
package_spec: normalizedPackageSpec(variant.package_spec),
|
||||
offer_id: variant.listing.offer_id,
|
||||
media_asset_ids: variant.media.map((media) => media.asset_id),
|
||||
})),
|
||||
@@ -153,6 +156,55 @@ function nullable(value: string | null | undefined) {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizedPackageSpec<
|
||||
T extends {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
},
|
||||
>(spec: T): T {
|
||||
const integerOrOriginal = (value: string | null) =>
|
||||
nullable(normalizePackageInteger(value?.trim()));
|
||||
return {
|
||||
...spec,
|
||||
length_mm: integerOrOriginal(spec.length_mm),
|
||||
width_mm: integerOrOriginal(spec.width_mm),
|
||||
height_mm: integerOrOriginal(spec.height_mm),
|
||||
shipping_weight_g: integerOrOriginal(spec.shipping_weight_g),
|
||||
};
|
||||
}
|
||||
|
||||
/** 草稿可缺少包装规格,但已填写的重量和尺寸必须是正整数。 */
|
||||
function packageIntegerProblem(values: EditorFormValues): string | null {
|
||||
const check = (
|
||||
spec: Pick<
|
||||
EditorFormValues['common']['default_package_spec'],
|
||||
'shipping_weight_g' | 'length_mm' | 'width_mm' | 'height_mm'
|
||||
>,
|
||||
owner: string
|
||||
) => {
|
||||
for (const [key, label] of [
|
||||
['shipping_weight_g', '包装重量'],
|
||||
['length_mm', '包装长度'],
|
||||
['width_mm', '包装宽度'],
|
||||
['height_mm', '包装高度'],
|
||||
] as const) {
|
||||
const value = spec[key];
|
||||
if (value !== null && value !== '' && !isPositivePackageInteger(value))
|
||||
return `${owner}的${label}请输入大于 0 的整数`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const commonProblem = check(values.common.default_package_spec, '商品');
|
||||
if (commonProblem) return commonProblem;
|
||||
for (const [index, variant] of values.variants.entries()) {
|
||||
const problem = check(variant.package_spec, variant.display_name || `SKU ${index + 1}`);
|
||||
if (problem) return problem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const wait = (milliseconds: number) =>
|
||||
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
|
||||
|
||||
@@ -229,23 +281,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
height_mm: nullable(spec.height_mm),
|
||||
net_weight_g: nullable(spec.net_weight_g),
|
||||
});
|
||||
const packageSpec = <
|
||||
T extends {
|
||||
length_mm: string | null;
|
||||
width_mm: string | null;
|
||||
height_mm: string | null;
|
||||
shipping_weight_g: string | null;
|
||||
},
|
||||
>(
|
||||
spec: T
|
||||
): T => ({
|
||||
...spec,
|
||||
length_mm: nullable(spec.length_mm),
|
||||
width_mm: nullable(spec.width_mm),
|
||||
height_mm: nullable(spec.height_mm),
|
||||
shipping_weight_g: nullable(spec.shipping_weight_g),
|
||||
});
|
||||
const commonPackage = packageSpec(values.common.default_package_spec);
|
||||
const commonPackage = normalizedPackageSpec(values.common.default_package_spec);
|
||||
const weightByVariant = values.common.package_weight_mode === 'variant';
|
||||
const dimensionsByVariant = values.common.package_dimensions_mode === 'variant';
|
||||
const packageComplete = (
|
||||
@@ -278,6 +314,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
profile: {
|
||||
...values.profile,
|
||||
title: nullable(values.profile.title),
|
||||
title_mode: values.profile.title_mode ?? 'common',
|
||||
description: nullable(values.profile.description),
|
||||
tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean),
|
||||
},
|
||||
@@ -288,7 +325,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues {
|
||||
return attributeValues.length ? [{ ...attribute, values: attributeValues }] : [];
|
||||
}),
|
||||
variants: values.variants.map((variant) => {
|
||||
const normalizedPackage = packageSpec(variant.package_spec);
|
||||
const normalizedPackage = normalizedPackageSpec(variant.package_spec);
|
||||
if (weightByVariant || dimensionsByVariant) {
|
||||
const effectivePackage = {
|
||||
...normalizedPackage,
|
||||
@@ -359,7 +396,7 @@ function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSumma
|
||||
};
|
||||
return {
|
||||
price: facts.price ? `${facts.price.amount} ${facts.price.currency_code}` : '—',
|
||||
brand: facts.brand ?? find((label) => /brand|品牌/.test(label)),
|
||||
brand: displayBrandName(facts.brand ?? find((label) => /brand|品牌/.test(label))),
|
||||
physicalDimensions: dimensions(dimension(false)),
|
||||
physicalWeight: grams(weight(false)),
|
||||
packageDimensions: dimensions(dimension(true)),
|
||||
@@ -458,6 +495,7 @@ const ProductEditPage = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingSourceDescription, setSavingSourceDescription] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [publishStatusContainer, setPublishStatusContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
||||
const [usesVariantPackageWeight, setUsesVariantPackageWeight] = useState(false);
|
||||
const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false);
|
||||
@@ -497,10 +535,9 @@ const ProductEditPage = () => {
|
||||
: ([] as ProductAttributeSchema[]),
|
||||
[attributeSchema, attributeSchemaCategoryId, selectedCategoryId]
|
||||
);
|
||||
// 已由编辑器内建控件承载的 Ozon 属性不再重复展示或加载字典。
|
||||
const editableAttributeSchema = useMemo(
|
||||
() => activeAttributeSchema.filter((attribute) => !attribute.editor_field),
|
||||
[activeAttributeSchema]
|
||||
// 内建品牌字段同样使用当前类目的 Ozon 字典,其余内建字段无需加载字典。
|
||||
const brandDefinition = activeAttributeSchema.find(
|
||||
(attribute) => attribute.editor_field === 'brand'
|
||||
);
|
||||
|
||||
/** 拉取编辑器快照并重置表单;保存成功或冲突后也会调用,以合并服务端最新状态。 */
|
||||
@@ -519,7 +556,7 @@ const ProductEditPage = () => {
|
||||
setUsesVariantPackageDimensions(next.product.package_dimensions_mode === 'variant');
|
||||
setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0));
|
||||
setUsesVariantDescription(next.variants.some((variant) => !!variant.description));
|
||||
setUsesVariantTitle(next.variants.some((variant) => !!variant.title));
|
||||
setUsesVariantTitle(next.profile.title_mode === 'variant');
|
||||
setCopyTranslations(next.copy_translations);
|
||||
setDirty(false);
|
||||
} catch (error) {
|
||||
@@ -637,13 +674,16 @@ const ProductEditPage = () => {
|
||||
selectedCategoryId,
|
||||
snapshot,
|
||||
]);
|
||||
// 预取当前 schema 涉及的所有字典值,供属性下拉框展示与远程搜索兜底。
|
||||
// 预取类目属性字典;品牌初始只查询 Ozon 的无品牌候选,其他品牌按关键词加载。
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform || !selectedCategoryId) return;
|
||||
const attributeIds = [
|
||||
...new Set(
|
||||
editableAttributeSchema
|
||||
.filter((attribute) => !!attribute.dictionary_id)
|
||||
activeAttributeSchema
|
||||
.filter((attribute) =>
|
||||
!!attribute.dictionary_id &&
|
||||
(!attribute.editor_field || attribute.editor_field === 'brand')
|
||||
)
|
||||
.map((attribute) => attribute.attribute_id)
|
||||
),
|
||||
];
|
||||
@@ -662,7 +702,9 @@ const ProductEditPage = () => {
|
||||
snapshot.profile.id,
|
||||
currentPlatform.id,
|
||||
attributeId,
|
||||
undefined,
|
||||
attributeId === brandDefinition?.attribute_id
|
||||
? OZON_NO_BRAND_VALUE
|
||||
: undefined,
|
||||
selectedCategoryId
|
||||
),
|
||||
] as const
|
||||
@@ -698,7 +740,7 @@ const ProductEditPage = () => {
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [catalogRefresh, currentPlatform, editableAttributeSchema, selectedCategoryId, snapshot]);
|
||||
}, [catalogRefresh, currentPlatform, activeAttributeSchema, selectedCategoryId, snapshot]);
|
||||
useEffect(() => {
|
||||
if (!snapshot || !currentPlatform) return;
|
||||
let active = true;
|
||||
@@ -821,30 +863,63 @@ const ProductEditPage = () => {
|
||||
const useVariantPackageDimensions = () => changePackageMode('dimensions', 'variant');
|
||||
const useCommonPackageWeight = () => confirmCommonPackageMode('weight');
|
||||
const useCommonPackageDimensions = () => confirmCommonPackageMode('dimensions');
|
||||
/** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */
|
||||
/** SKU 标签和描述继续按覆盖值继承商品通用文案。 */
|
||||
const useVariantTags = () => setUsesVariantTags(true);
|
||||
const useVariantDescription = () => setUsesVariantDescription(true);
|
||||
const useVariantTitle = () => setUsesVariantTitle(true);
|
||||
/** 俄文标题模式切换时清空退出模式的值,避免隐藏文案继续参与发布。 */
|
||||
const changeTitleMode = (mode: 'common' | 'variant') => {
|
||||
if (mode === 'variant') {
|
||||
form.setFieldValue(['profile', 'title'], null);
|
||||
setCopyTranslations((current) =>
|
||||
current.filter((item) => item.field !== 'title' || item.variant_id !== null)
|
||||
);
|
||||
} else {
|
||||
const variants = (form.getFieldValue('variants') ?? []) as EditorFormValues['variants'];
|
||||
variants.forEach((_, index) => form.setFieldValue(['variants', index, 'title'], null));
|
||||
setCopyTranslations((current) =>
|
||||
current.filter((item) => item.field !== 'title' || item.variant_id === null)
|
||||
);
|
||||
}
|
||||
form.setFieldValue(['profile', 'title_mode'], mode);
|
||||
setUsesVariantTitle(mode === 'variant');
|
||||
setDirty(true);
|
||||
};
|
||||
const useVariantTitle = () => changeTitleMode('variant');
|
||||
const useCommonTitle = () =>
|
||||
modal.confirm({
|
||||
title: '将俄文标题设为通用?',
|
||||
content: '确认后将清空所有 SKU 的俄文标题,并恢复商品通用俄文标题的编辑。',
|
||||
okText: '确认调整',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: () => changeTitleMode('common'),
|
||||
});
|
||||
/** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */
|
||||
const save = async (values: EditorFormValues) => {
|
||||
values = { ...values, ...form.getFieldsValue(true) };
|
||||
if (!productId || !currentPlatform || !snapshot) return false;
|
||||
const draft = normalizedValues({
|
||||
...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)
|
||||
),
|
||||
});
|
||||
const invalidPackage = packageIntegerProblem(draft);
|
||||
if (invalidPackage) {
|
||||
message.error(invalidPackage);
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await productEditorApi.save(
|
||||
productId,
|
||||
currentPlatform.id,
|
||||
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||
normalizedValues({
|
||||
...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)
|
||||
),
|
||||
})
|
||||
draft
|
||||
);
|
||||
message.success('草稿已保存');
|
||||
setDirty(false);
|
||||
@@ -996,7 +1071,7 @@ const ProductEditPage = () => {
|
||||
variants[index] = { ...variants[index], offer_id: offer.offer_id };
|
||||
}
|
||||
const copy = result.spu_copy;
|
||||
if (copy?.title && !profile.title) {
|
||||
if (copy?.title && profile.title_mode !== 'variant' && !profile.title) {
|
||||
profile.title = copy.title.ru;
|
||||
addCopyTranslations(null, { title: copy.title }, taskId);
|
||||
}
|
||||
@@ -1082,6 +1157,7 @@ const ProductEditPage = () => {
|
||||
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
|
||||
if (index < 0) return;
|
||||
const copy = skuCopyResult;
|
||||
if (copy.title && !usesVariantTitle) changeTitleMode('variant');
|
||||
form.setFieldValue(
|
||||
'variants',
|
||||
variants.map((variant, current) =>
|
||||
@@ -1098,7 +1174,6 @@ const ProductEditPage = () => {
|
||||
// SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。
|
||||
if (skuCopyTaskId.current)
|
||||
addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current);
|
||||
setUsesVariantTitle(true);
|
||||
setUsesVariantTags(true);
|
||||
setUsesVariantDescription(true);
|
||||
setDirty(true);
|
||||
@@ -1406,6 +1481,7 @@ const ProductEditPage = () => {
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div ref={setPublishStatusContainer} className="editor-publish-status-slot" />
|
||||
</header>
|
||||
{snapshot.warnings.map((warning) => (
|
||||
<Alert key={warning.code} type="warning" message={warning.message} showIcon />
|
||||
@@ -1436,11 +1512,21 @@ const ProductEditPage = () => {
|
||||
usesVariantPackageDimensions={usesVariantPackageDimensions}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseVariantTitle={useVariantTitle}
|
||||
onUseCommonTitle={useCommonTitle}
|
||||
onCompareTitle={() => setComparison({ variantId: null, field: 'title' })}
|
||||
onUseVariantPackageWeight={useVariantPackageWeight}
|
||||
onUseVariantPackageDimensions={useVariantPackageDimensions}
|
||||
onUseCommonPackageWeight={useCommonPackageWeight}
|
||||
onUseCommonPackageDimensions={useCommonPackageDimensions}
|
||||
brandDefinition={brandDefinition}
|
||||
brandOptions={brandDefinition ? dictionaryValues[brandDefinition.attribute_id] ?? [] : []}
|
||||
onOpenBrandOptions={(attributeId) =>
|
||||
void loadDictionaryValues(attributeId, OZON_NO_BRAND_VALUE)
|
||||
}
|
||||
onSearchBrandOptions={(attributeId, query) =>
|
||||
void loadDictionaryValues(attributeId, query)
|
||||
}
|
||||
onLoadMoreBrandOptions={loadMoreDictionaryValues}
|
||||
/>
|
||||
|
||||
<ProductAttributeSection
|
||||
@@ -1488,6 +1574,7 @@ const ProductEditPage = () => {
|
||||
usesVariantTags={usesVariantTags}
|
||||
usesVariantDescription={usesVariantDescription}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseCommonTitle={useCommonTitle}
|
||||
onAiCopy={(variantId) => {
|
||||
setSkuCopyVariantId(variantId);
|
||||
setSkuCopyResult(null);
|
||||
@@ -1495,6 +1582,7 @@ const ProductEditPage = () => {
|
||||
skuCopyTaskId.current = null;
|
||||
}}
|
||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||
onAttributeChanged={() => setDirty(true)}
|
||||
onImagesChanged={() => setDirty(true)}
|
||||
/>
|
||||
|
||||
@@ -1539,13 +1627,27 @@ const ProductEditPage = () => {
|
||||
<Button onClick={leave}>取消并关闭</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
htmlType="submit"
|
||||
loading={saving}
|
||||
icon={<SaveOutlined />}
|
||||
>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button disabled>选择 SKU 并首次上架</Button>
|
||||
<ProductPublishActions
|
||||
snapshot={snapshot}
|
||||
platformId={currentPlatform.id}
|
||||
disabled={saving || aiAutofillLoading}
|
||||
statusContainer={publishStatusContainer}
|
||||
ensureSaved={async () => {
|
||||
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return null;
|
||||
return productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
}}
|
||||
onStatusChange={async () => {
|
||||
const next = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
|
||||
setSnapshot(current => current ? {...current, profile: {...current.profile, listing_summary: next.profile.listing_summary}, variants: current.variants.map(v => ({...v, listing: next.variants.find(n => n.id === v.id)?.listing ?? v.listing}))} : current);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</footer>
|
||||
<ProductCopyComparisonModal
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Ozon 品牌字典的无品牌值只在界面翻译,草稿与发布继续使用原始候选值。 */
|
||||
export const OZON_NO_BRAND_VALUE = 'Нет бренда';
|
||||
|
||||
export function displayBrandName(value: string): string {
|
||||
return value === OZON_NO_BRAND_VALUE ? '无品牌' : value;
|
||||
}
|
||||
@@ -212,8 +212,12 @@
|
||||
padding: 0 6px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
font-size: 11px;
|
||||
gap: 3px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.collected-image-edit .anticon-down {
|
||||
font-size: 10px;
|
||||
}
|
||||
@container (max-width: 1099px) {
|
||||
/* 主内容区不足 1360px 时,面板退化为工作区下方整行,不再固定高度。 */
|
||||
.editor-aside {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
|
||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||
type NamePath = Array<string | number>;
|
||||
const productColorValueLimit = 100;
|
||||
|
||||
/**
|
||||
@@ -118,6 +117,7 @@ type Props = {
|
||||
itemLayout?: 'horizontal' | 'vertical';
|
||||
forceRequiredNames?: string[];
|
||||
colorNameDefinition?: ProductAttributeSchema;
|
||||
onAttributeChanged?: () => void;
|
||||
};
|
||||
|
||||
/** 根据 schema 与归属找到顶层属性草稿的稳定数组位置。 */
|
||||
@@ -152,6 +152,7 @@ function DynamicAttributeField({
|
||||
scope,
|
||||
variantId,
|
||||
colorNameDefinition,
|
||||
onAttributeChanged,
|
||||
}: {
|
||||
definition: ProductAttributeSchema;
|
||||
index: number;
|
||||
@@ -167,6 +168,7 @@ function DynamicAttributeField({
|
||||
scope: Props['scope'];
|
||||
variantId: Props['variantId'];
|
||||
colorNameDefinition: Props['colorNameDefinition'];
|
||||
onAttributeChanged: Props['onAttributeChanged'];
|
||||
}) {
|
||||
const [dictionaryQuery, setDictionaryQuery] = useState('');
|
||||
const onSyncRef = useRef(onSyncDictionaryValues);
|
||||
@@ -181,12 +183,25 @@ function DynamicAttributeField({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [definition.attribute_id, definition.dictionary_id, dictionaryQuery]);
|
||||
|
||||
const valueName = ['attributes', index, 'values'] as NamePath;
|
||||
const scalarValueName = [...valueName, 0] as NamePath;
|
||||
const selectedProductColorValue = Form.useWatch(
|
||||
['attributes', index, 'values'],
|
||||
form
|
||||
) as unknown;
|
||||
const valueName: ['attributes', number, 'values'] = ['attributes', index, 'values'];
|
||||
const scalarValueName: ['attributes', number, 'values', 0] = [
|
||||
'attributes',
|
||||
index,
|
||||
'values',
|
||||
0,
|
||||
];
|
||||
// SKU 卡片位于 Form.List('variants') 内,命名 Form.Item 会自动附加 variants 前缀。
|
||||
const directVariantBinding = scope === 'variant';
|
||||
const watchedValues = Form.useWatch(valueName, { form, preserve: true }) as unknown;
|
||||
const attributeValues = Array.isArray(watchedValues)
|
||||
? watchedValues.filter((value): value is string => typeof value === 'string')
|
||||
: typeof watchedValues === 'string'
|
||||
? [watchedValues]
|
||||
: [];
|
||||
const setVariantValues = (values: string[]) => {
|
||||
form.setFieldValue(valueName, values);
|
||||
onAttributeChanged?.();
|
||||
};
|
||||
const lastColorSelection = useRef<string | null>(null);
|
||||
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
|
||||
const isRequired =
|
||||
@@ -204,11 +219,7 @@ function DynamicAttributeField({
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
const selectedColorValueIds = Array.isArray(selectedProductColorValue)
|
||||
? selectedProductColorValue.filter((value): value is string => typeof value === 'string')
|
||||
: typeof selectedProductColorValue === 'string'
|
||||
? [selectedProductColorValue]
|
||||
: [];
|
||||
const selectedColorValueIds = attributeValues;
|
||||
const colorSelectionKey = selectedColorValueIds.join('\u0000');
|
||||
/** 商品颜色变更时,将俄文表达立即写入同一 SKU 的颜色文字字段。 */
|
||||
const syncColorName = (selectedValue: unknown) => {
|
||||
@@ -254,16 +265,32 @@ function DynamicAttributeField({
|
||||
|
||||
const input =
|
||||
definition.value_type === 'dictionary' ? (
|
||||
<Form.Item name={allowsMultipleValues ? valueName : scalarValueName} noStyle>
|
||||
<Form.Item
|
||||
name={directVariantBinding ? undefined : allowsMultipleValues ? valueName : scalarValueName}
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
className={isProductColor(definition) ? 'product-color-select' : undefined}
|
||||
mode={allowsMultipleValues ? 'multiple' : undefined}
|
||||
maxCount={allowsMultipleValues ? maxValueCount : undefined}
|
||||
options={dictionaryOptions}
|
||||
value={
|
||||
directVariantBinding
|
||||
? allowsMultipleValues
|
||||
? attributeValues
|
||||
: attributeValues[0]
|
||||
: undefined
|
||||
}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onChange={(value) => syncColorName(value)}
|
||||
onChange={(value) => {
|
||||
if (directVariantBinding)
|
||||
setVariantValues(
|
||||
Array.isArray(value) ? value : value ? [value] : []
|
||||
);
|
||||
syncColorName(value);
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open || !definition.dictionary_id) return;
|
||||
setDictionaryQuery('');
|
||||
@@ -313,8 +340,14 @@ function DynamicAttributeField({
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'boolean' ? (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<Select
|
||||
value={directVariantBinding ? attributeValues[0] : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (value) => setVariantValues(value ? [value] : [])
|
||||
: undefined
|
||||
}
|
||||
options={[
|
||||
{ value: 'true', label: '是' },
|
||||
{ value: 'false', label: '否' },
|
||||
@@ -323,25 +356,42 @@ function DynamicAttributeField({
|
||||
/>
|
||||
</Form.Item>
|
||||
) : allowsMultipleValues ? (
|
||||
<Form.Item name={valueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : valueName} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
value={directVariantBinding ? attributeValues : undefined}
|
||||
onChange={directVariantBinding ? setVariantValues : undefined}
|
||||
maxCount={maxValueCount}
|
||||
tokenSeparators={[',']}
|
||||
placeholder={`输入后回车添加,最多 ${maxValueCount} 项`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : definition.value_type === 'integer' || definition.value_type === 'decimal' ? (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<InputNumber<string>
|
||||
value={directVariantBinding ? attributeValues[0] ?? null : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (value) => setVariantValues(value === null ? [] : [value])
|
||||
: undefined
|
||||
}
|
||||
step={definition.value_type === 'integer' ? '1' : '0.001'}
|
||||
stringMode
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name={scalarValueName} noStyle>
|
||||
<Input placeholder={`填写${definition.name}`} />
|
||||
<Form.Item name={directVariantBinding ? undefined : scalarValueName} noStyle>
|
||||
<Input
|
||||
value={directVariantBinding ? attributeValues[0] ?? '' : undefined}
|
||||
onChange={
|
||||
directVariantBinding
|
||||
? (event) =>
|
||||
setVariantValues(event.target.value ? [event.target.value] : [])
|
||||
: undefined
|
||||
}
|
||||
placeholder={`填写${definition.name}`}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
@@ -384,6 +434,7 @@ export function DynamicAttributeFields({
|
||||
itemLayout,
|
||||
forceRequiredNames,
|
||||
colorNameDefinition,
|
||||
onAttributeChanged,
|
||||
}: Props) {
|
||||
const attributes = (form.getFieldValue('attributes') ?? []) as AttributeDraft[];
|
||||
const fields = definitions.flatMap((definition) => {
|
||||
@@ -413,6 +464,7 @@ export function DynamicAttributeFields({
|
||||
scope={scope}
|
||||
variantId={variantId}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Card, Cascader, Form, Input, Radio, Select, Tooltip } from 'antd';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons';
|
||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||
import { TranslationOutlined } from '@ant-design/icons';
|
||||
import type {
|
||||
ProductAttributeDictionaryValue,
|
||||
ProductAttributeSchema,
|
||||
ProductEditorSnapshot,
|
||||
} from '@atlas/contracts';
|
||||
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
|
||||
import { displayBrandName, OZON_NO_BRAND_VALUE } from '../brandDisplay';
|
||||
import { SectionTitle } from './SectionTitle';
|
||||
import './ProductMainInfoSection.css';
|
||||
|
||||
@@ -110,7 +116,13 @@ type Props = {
|
||||
onUseCommonPackageDimensions: () => void;
|
||||
usesVariantTitle: boolean;
|
||||
onUseVariantTitle: () => void;
|
||||
onUseCommonTitle: () => void;
|
||||
onCompareTitle: () => void;
|
||||
brandDefinition: ProductAttributeSchema | undefined;
|
||||
brandOptions: ProductAttributeDictionaryValue[];
|
||||
onOpenBrandOptions: (attributeId: string) => void;
|
||||
onSearchBrandOptions: (attributeId: string, query: string) => void;
|
||||
onLoadMoreBrandOptions: (attributeId: string, query: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -136,8 +148,36 @@ export function ProductMainInfoSection({
|
||||
onUseCommonPackageDimensions,
|
||||
usesVariantTitle,
|
||||
onUseVariantTitle,
|
||||
onUseCommonTitle,
|
||||
onCompareTitle,
|
||||
brandDefinition,
|
||||
brandOptions,
|
||||
onOpenBrandOptions,
|
||||
onSearchBrandOptions,
|
||||
onLoadMoreBrandOptions,
|
||||
}: Props) {
|
||||
const publishingLocked = snapshot.variants.some(v => ['imported', 'submitting'].includes(v.listing.status));
|
||||
const [brandQuery, setBrandQuery] = useState('');
|
||||
const onSearchBrandOptionsRef = useRef(onSearchBrandOptions);
|
||||
onSearchBrandOptionsRef.current = onSearchBrandOptions;
|
||||
// 中文“无品牌”仅用于展示与搜索,表单始终保存用户选中的 Ozon 候选名称。
|
||||
const brandSearchQuery =
|
||||
brandQuery.trim() === '无品牌' ? OZON_NO_BRAND_VALUE : brandQuery.trim();
|
||||
const visibleBrandOptions = brandOptions.filter((option) =>
|
||||
brandSearchQuery
|
||||
? option.value.toLocaleLowerCase().includes(brandSearchQuery.toLocaleLowerCase())
|
||||
: option.value === OZON_NO_BRAND_VALUE
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!brandDefinition?.dictionary_id || !brandSearchQuery) return;
|
||||
const timer = window.setTimeout(
|
||||
() => onSearchBrandOptionsRef.current(brandDefinition.attribute_id, brandSearchQuery),
|
||||
400
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [brandDefinition?.attribute_id, brandDefinition?.dictionary_id, brandSearchQuery]);
|
||||
const usesBrandDictionary =
|
||||
brandDefinition?.value_type === 'dictionary' && !!brandDefinition.dictionary_id;
|
||||
return (
|
||||
<Card className="editor-section editor-horizontal-section" bordered={false}>
|
||||
<SectionTitle
|
||||
@@ -154,6 +194,7 @@ export function ProductMainInfoSection({
|
||||
>
|
||||
<Select
|
||||
className="editor-standard-select"
|
||||
disabled={snapshot.variants.some(v => v.listing.status === 'submitting')}
|
||||
options={snapshot.profile.available_target_stores.map((store) => ({
|
||||
value: store.id,
|
||||
label: `${store.name}(${store.currency_code})`,
|
||||
@@ -227,30 +268,33 @@ export function ProductMainInfoSection({
|
||||
>
|
||||
<Input placeholder="填写中文商品名" />
|
||||
</Form.Item>
|
||||
<Form.Item label="俄文发布标题" className="span-full" required>
|
||||
<Form.Item label="俄文标题" className="span-full" required={!usesVariantTitle}>
|
||||
<div className="profile-override-control">
|
||||
<Form.Item
|
||||
name={['profile', 'title']}
|
||||
noStyle
|
||||
rules={[{ max: 1000 }]}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input placeholder="AI 填报或手动填写俄文标题" maxLength={1000} />
|
||||
<Input
|
||||
placeholder="AI 填报或手动填写俄文标题"
|
||||
maxLength={500}
|
||||
disabled={usesVariantTitle}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!usesVariantTitle && (
|
||||
<Tooltip title="在 SKU 中分别编辑标题">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlusCircleOutlined />}
|
||||
aria-label="在 SKU 中分别编辑标题"
|
||||
onClick={onUseVariantTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
type="link"
|
||||
className="package-mode-button"
|
||||
aria-label={`${usesVariantTitle ? '设为通用' : '按 SKU 设置'}俄文标题`}
|
||||
onClick={usesVariantTitle ? onUseCommonTitle : onUseVariantTitle}
|
||||
>
|
||||
{usesVariantTitle ? '设为通用' : '按 SKU 设置'}
|
||||
</Button>
|
||||
<Tooltip title="查看标题中文对照">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<TranslationOutlined />}
|
||||
aria-label="查看标题中文对照"
|
||||
disabled={usesVariantTitle}
|
||||
onClick={onCompareTitle}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -260,15 +304,62 @@ export function ProductMainInfoSection({
|
||||
name={['common', 'model']}
|
||||
label="型号"
|
||||
required
|
||||
extra={publishingLocked ? "已有 SKU 导入或正在导入,型号已锁定" : undefined}
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="首次上架前需填写" />
|
||||
<Input className="editor-short-input" disabled={publishingLocked} placeholder="首次上架前需填写" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['common', 'brand']}
|
||||
label="品牌"
|
||||
required
|
||||
extra={
|
||||
usesBrandDictionary
|
||||
? '默认显示“无品牌”;输入关键词搜索其他品牌。'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
|
||||
{usesBrandDictionary ? (
|
||||
<Select
|
||||
className="editor-standard-select editor-short-input"
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
labelRender={({ value, label }) =>
|
||||
value === OZON_NO_BRAND_VALUE ? '无品牌' : label
|
||||
}
|
||||
options={visibleBrandOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: displayBrandName(option.value),
|
||||
key: option.value_id,
|
||||
}))}
|
||||
placeholder="选择 Ozon 品牌,输入名称搜索"
|
||||
notFoundContent={
|
||||
brandQuery.trim()
|
||||
? '暂无匹配的 Ozon 品牌候选'
|
||||
: '暂无无品牌候选,输入关键词搜索其他品牌'
|
||||
}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
setBrandQuery('');
|
||||
onOpenBrandOptions(brandDefinition.attribute_id);
|
||||
}}
|
||||
onSearch={(query) => {
|
||||
setBrandQuery(query);
|
||||
if (!query.trim())
|
||||
onOpenBrandOptions(brandDefinition.attribute_id);
|
||||
}}
|
||||
onPopupScroll={(event) => {
|
||||
const target = event.currentTarget;
|
||||
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32)
|
||||
onLoadMoreBrandOptions(
|
||||
brandDefinition.attribute_id,
|
||||
brandSearchQuery
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input className="editor-short-input" placeholder="填写品牌" />
|
||||
)}
|
||||
</Form.Item>
|
||||
</div>
|
||||
<PackageFields
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Alert, App, Button, Modal, Space, Table, Tag, Typography } from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
RightOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
ProductEditorSnapshot,
|
||||
ProductPublishJob,
|
||||
ProductPublishPreflight,
|
||||
} from '@atlas/contracts';
|
||||
import { productEditorApi } from '@/services/productEditor';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
|
||||
type Props = {
|
||||
snapshot: ProductEditorSnapshot;
|
||||
platformId: string;
|
||||
disabled: boolean;
|
||||
statusContainer: HTMLElement | null;
|
||||
ensureSaved: () => Promise<ProductEditorSnapshot | null>;
|
||||
onStatusChange: () => Promise<void>;
|
||||
};
|
||||
const terminal = (job: ProductPublishJob) =>
|
||||
['succeeded', 'partially_succeeded', 'failed'].includes(job.status);
|
||||
const itemLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
submitting: '提交中',
|
||||
polling: '等待 Ozon',
|
||||
imported: '已导入',
|
||||
failed: '失败',
|
||||
skipped: '已跳过',
|
||||
reconciling: '等待对账',
|
||||
};
|
||||
const jobLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
running: '处理中',
|
||||
succeeded: '已导入',
|
||||
partially_succeeded: '部分已导入',
|
||||
failed: '未导入',
|
||||
reconciling: '等待对账',
|
||||
};
|
||||
|
||||
/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */
|
||||
export function ProductPublishActions({
|
||||
snapshot,
|
||||
platformId,
|
||||
disabled,
|
||||
statusContainer,
|
||||
ensureSaved,
|
||||
onStatusChange,
|
||||
}: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [stage, setStage] = useState<'select' | 'check' | 'confirm' | 'result'>('select');
|
||||
const [saved, setSaved] = useState(snapshot);
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [preflight, setPreflight] = useState<ProductPublishPreflight | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [job, setJob] = useState<ProductPublishJob | null>(null);
|
||||
const requestKey = useRef<string | null>(null);
|
||||
const run = useRef(0);
|
||||
const statusChanged = useRef(onStatusChange);
|
||||
statusChanged.current = onStatusChange;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setJob(null);
|
||||
productEditorApi
|
||||
.publishJobs(snapshot.profile.id, platformId)
|
||||
.then((jobs) => {
|
||||
if (!cancelled) setJob(jobs.find((j) => !terminal(j)) ?? jobs[0] ?? null);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
run.current++;
|
||||
};
|
||||
}, [snapshot.profile.id, platformId]);
|
||||
useEffect(() => {
|
||||
if (!job || terminal(job)) return;
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await productEditorApi.publishJob(job.id, platformId);
|
||||
if (cancelled) return;
|
||||
setJob(next);
|
||||
await statusChanged.current();
|
||||
if (terminal(next)) return;
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(apiErrorMessage(e));
|
||||
}
|
||||
if (!cancelled) timer = setTimeout(() => void poll(), 5000);
|
||||
};
|
||||
timer = setTimeout(() => void poll(), 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [job?.id, job?.status, platformId]);
|
||||
const eligible = (data: ProductEditorSnapshot) =>
|
||||
data.variants.filter((v) => ['not_listed', 'failed'].includes(v.listing.status));
|
||||
const skipReason = (status: string) =>
|
||||
status === 'imported'
|
||||
? '已有成功导入记录,不能重复提交'
|
||||
: '正在提交或等待对账,不能重复排队';
|
||||
const check = async (data: ProductEditorSnapshot, selected: string[]) => {
|
||||
if (!selected.length) {
|
||||
message.warning('请至少选择 1 个待上架 SKU');
|
||||
return;
|
||||
}
|
||||
if (selected.length > 50) {
|
||||
message.warning('本次最多上架 50 个 SKU,请使用部分上架分批提交');
|
||||
return;
|
||||
}
|
||||
const thisRun = ++run.current;
|
||||
setStage('check');
|
||||
setBusy(true);
|
||||
setError('');
|
||||
setPreflight(null);
|
||||
requestKey.current = null;
|
||||
try {
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
const result = await productEditorApi.publishPreflight(
|
||||
data.profile.id,
|
||||
platformId,
|
||||
selected
|
||||
);
|
||||
if (thisRun !== run.current) return;
|
||||
setPreflight(result);
|
||||
if (result.status !== 'refreshing') {
|
||||
setStage(result.status === 'ready' ? 'confirm' : 'check');
|
||||
return;
|
||||
}
|
||||
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
}
|
||||
} catch (e) {
|
||||
if (thisRun === run.current) setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
if (thisRun === run.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
const start = async (partial: boolean) => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await ensureSaved();
|
||||
if (!data) return;
|
||||
setSaved(data);
|
||||
setPreflight(null);
|
||||
requestKey.current = null;
|
||||
const all = eligible(data).map((v) => v.id);
|
||||
setIds(partial ? [] : all);
|
||||
setOpen(true);
|
||||
setStage('select');
|
||||
if (!partial) {
|
||||
if (all.length > 50) {
|
||||
setError('待上架 SKU 超过 50 个,请选择本次需要上架的 SKU,分批提交。');
|
||||
} else if (!all.length) {
|
||||
setError('没有可首次上架的 SKU。已导入的 SKU 请前往在线商品查看。');
|
||||
} else await check(data, all);
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
requestKey.current ??= crypto.randomUUID();
|
||||
try {
|
||||
const result = await productEditorApi.createPublishJob(
|
||||
saved.profile.id,
|
||||
platformId,
|
||||
ids,
|
||||
{ product: saved.product.revision, profile: saved.profile.revision },
|
||||
requestKey.current
|
||||
);
|
||||
setJob(result.publish_job);
|
||||
setStage('result');
|
||||
await onStatusChange();
|
||||
} catch (e) {
|
||||
setError(apiErrorMessage(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const chosen = saved.variants.filter((v) => ids.includes(v.id));
|
||||
const issues = preflight
|
||||
? [
|
||||
...preflight.validation.profile.issues.map((i) => ({ ...i, scope: '商品资料' })),
|
||||
...preflight.validation.variants.flatMap((v) =>
|
||||
v.issues.map((i) => ({
|
||||
...i,
|
||||
scope:
|
||||
v.display_name ||
|
||||
saved.variants.find((s) => s.id === v.variant_id)?.listing.offer_id ||
|
||||
'SKU',
|
||||
}))
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const statusTone = job?.status === 'succeeded'
|
||||
? 'success'
|
||||
: job?.status === 'failed'
|
||||
? 'error'
|
||||
: job?.status === 'partially_succeeded' || job?.status === 'reconciling'
|
||||
? 'warning'
|
||||
: 'progress';
|
||||
const statusIcon = statusTone === 'success'
|
||||
? <CheckCircleOutlined />
|
||||
: statusTone === 'progress'
|
||||
? <ClockCircleOutlined />
|
||||
: <ExclamationCircleOutlined />;
|
||||
return (
|
||||
<>
|
||||
{/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */}
|
||||
{job && statusContainer && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-publish-status editor-publish-status--${statusTone}`}
|
||||
onClick={() => {
|
||||
setStage('result');
|
||||
setOpen(true);
|
||||
setError('');
|
||||
}}
|
||||
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
|
||||
>
|
||||
<span className="editor-publish-status-icon" aria-hidden="true">
|
||||
{statusIcon}
|
||||
</span>
|
||||
<span className="editor-publish-status-content">
|
||||
<span className="editor-publish-status-title">
|
||||
发布任务 · {jobLabels[job.status]}
|
||||
</span>
|
||||
<span className="editor-publish-status-count">{job.item_count} 个 SKU</span>
|
||||
</span>
|
||||
<span className="editor-publish-status-link">
|
||||
查看详情 <RightOutlined />
|
||||
</span>
|
||||
</button>,
|
||||
statusContainer
|
||||
)}
|
||||
<Space size={8} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={disabled || busy}
|
||||
loading={busy && !open}
|
||||
onClick={() => void start(false)}
|
||||
>
|
||||
上架至 OZON
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
disabled={disabled || busy}
|
||||
onClick={() => void start(true)}
|
||||
>
|
||||
部分上架
|
||||
</Button>
|
||||
</Space>
|
||||
<Modal
|
||||
title={
|
||||
stage === 'result'
|
||||
? 'Ozon 导入结果'
|
||||
: stage === 'confirm'
|
||||
? '确认上架至 OZON'
|
||||
: '选择与校验上架 SKU'
|
||||
}
|
||||
open={open}
|
||||
width={900}
|
||||
onCancel={() => {
|
||||
run.current++;
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
}}
|
||||
footer={
|
||||
stage === 'result' ? (
|
||||
<Button onClick={() => setOpen(false)}>关闭</Button>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
run.current++;
|
||||
setOpen(false);
|
||||
setBusy(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
{stage === 'confirm' ? (
|
||||
<Button type="primary" loading={busy} onClick={() => void submit()}>
|
||||
提交 {ids.length} 个 SKU
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={busy}
|
||||
disabled={!ids.length || ids.length > 50}
|
||||
onClick={() => void check(saved, ids)}
|
||||
>
|
||||
校验所选 SKU
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<Alert type="error" showIcon title={error} style={{ marginBottom: 16 }} />
|
||||
)}
|
||||
{stage === 'result' && job ? (
|
||||
<>
|
||||
<Alert
|
||||
type={
|
||||
job.status === 'failed'
|
||||
? 'error'
|
||||
: job.status === 'reconciling'
|
||||
? 'warning'
|
||||
: terminal(job)
|
||||
? 'success'
|
||||
: 'info'
|
||||
}
|
||||
showIcon
|
||||
title={`${jobLabels[job.status]} · ${job.item_count} 个 SKU`}
|
||||
description={
|
||||
job.status === 'reconciling'
|
||||
? '平台受理结果尚未确认,系统将继续对账,请勿重复导入。'
|
||||
: '已导入表示商品资料已进入 Ozon;审核、可见性及库存请前往在线商品或 Ozon 后台查看。'
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={job.items}
|
||||
columns={[
|
||||
{ title: 'SKU', render: (_, i) => i.display_name || '未命名 SKU' },
|
||||
{ title: '货号', dataIndex: 'offer_id' },
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, i) => (
|
||||
<Tag
|
||||
color={
|
||||
i.status === 'imported'
|
||||
? 'green'
|
||||
: i.status === 'failed'
|
||||
? 'red'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{itemLabels[i.status]}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果说明',
|
||||
render: (_, i) =>
|
||||
i.errors.map((e) => (
|
||||
<div key={`${e.code}:${e.message}`}>{e.message}</div>
|
||||
)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Paragraph>
|
||||
目标店铺:{saved.profile.target_store.name}。本次最多 50 个
|
||||
SKU,仅首次导入商品资料;库存需在 Ozon 后台设置。
|
||||
</Typography.Paragraph>
|
||||
{saved.variants.some(
|
||||
(v) => !['not_listed', 'failed'].includes(v.listing.status)
|
||||
) && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
title="以下 SKU 本次不重复提交"
|
||||
description={saved.variants
|
||||
.filter(
|
||||
(v) => !['not_listed', 'failed'].includes(v.listing.status)
|
||||
)
|
||||
.map((v) => (
|
||||
<div key={v.id}>
|
||||
{v.display_name || v.listing.offer_id || '未命名 SKU'}:
|
||||
{skipReason(v.listing.status)}
|
||||
</div>
|
||||
))}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
{stage === 'select' ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
dataSource={saved.variants}
|
||||
rowSelection={{
|
||||
selectedRowKeys: ids,
|
||||
onChange: (keys) => {
|
||||
setIds(keys as string[]);
|
||||
setPreflight(null);
|
||||
},
|
||||
getCheckboxProps: (v) => ({
|
||||
disabled: !['not_listed', 'failed'].includes(
|
||||
v.listing.status
|
||||
),
|
||||
}),
|
||||
}}
|
||||
columns={[
|
||||
{ title: 'SKU', dataIndex: 'display_name' },
|
||||
{
|
||||
title: '货号',
|
||||
render: (_, v) => v.listing.offer_id || '未填写',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, v) =>
|
||||
v.listing.status === 'imported'
|
||||
? '已导入'
|
||||
: v.listing.status === 'submitting'
|
||||
? '提交中'
|
||||
: v.listing.status === 'failed'
|
||||
? '可修正后重试'
|
||||
: '待首次上架',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={chosen}
|
||||
columns={[
|
||||
{ title: 'SKU', dataIndex: 'display_name' },
|
||||
{ title: '货号', render: (_, v) => v.listing.offer_id },
|
||||
{
|
||||
title: '售价',
|
||||
render: (_, v) =>
|
||||
`${v.listing.selling_price} ${saved.profile.target_store.currency_code}`,
|
||||
},
|
||||
{ title: '图片', render: (_, v) => `${v.media.length} 张` },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{preflight?.status === 'refreshing' && (
|
||||
<Alert
|
||||
type="info"
|
||||
title="正在更新 Ozon 类目与属性目录,请稍候或重新校验"
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
{!!issues.length && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="请完善以下资料后重新上架"
|
||||
description={issues.map((i, index) => (
|
||||
<div key={`${i.field}:${index}`}>
|
||||
<strong>{i.scope}</strong>:{i.message}
|
||||
</div>
|
||||
))}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
{stage === 'confirm' && (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
title={`预检通过,将提交 ${ids.length} 个 SKU`}
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,16 @@
|
||||
color: #90889c;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.sku-main-thumbnail:hover {
|
||||
border-color: #8956f5;
|
||||
}
|
||||
.sku-main-thumbnail:focus-visible {
|
||||
outline: 2px solid #8956f5;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sku-main-thumbnail img {
|
||||
width: 100%;
|
||||
@@ -212,6 +222,9 @@
|
||||
color: #292433;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sku-expand-button .anticon-down {
|
||||
font-size: 12px;
|
||||
}
|
||||
.sku-card-expanded {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 3;
|
||||
@@ -393,6 +406,9 @@
|
||||
.sku-info-panel .sku-info-package-row .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-info-panel .sku-info-variant-title .ant-form-item-label > label {
|
||||
width: 100%;
|
||||
}
|
||||
.sku-package-label {
|
||||
display: inline-flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -101,8 +101,10 @@ type Props = {
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onUseCommonTitle: () => void;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onAttributeChanged: () => void;
|
||||
onImagesChanged: () => void;
|
||||
};
|
||||
|
||||
@@ -130,8 +132,10 @@ export function ProductVariantSection({
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onUseCommonTitle,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
onAttributeChanged,
|
||||
onImagesChanged,
|
||||
}: Props) {
|
||||
const [pricingAuto, setPricingAuto] = useState(
|
||||
@@ -432,6 +436,7 @@ export function ProductVariantSection({
|
||||
imageUrl={displayImages[0]?.url}
|
||||
imageCount={selectedAssetIds.length}
|
||||
sourcePreview={!!sourceMainCandidate}
|
||||
onPick={pickImages}
|
||||
/>
|
||||
<div className="sku-card-main">
|
||||
<div className="sku-card-content">
|
||||
@@ -541,6 +546,7 @@ export function ProductVariantSection({
|
||||
usesVariantDescription
|
||||
}
|
||||
usesVariantTitle={usesVariantTitle}
|
||||
onUseCommonTitle={onUseCommonTitle}
|
||||
skuInfoAspectDefinitions={
|
||||
skuInfoAspectDefinitions
|
||||
}
|
||||
@@ -561,6 +567,7 @@ export function ProductVariantSection({
|
||||
onLoadMoreDictionaryValues={
|
||||
onLoadMoreDictionaryValues
|
||||
}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
onAiCopy={onAiCopy}
|
||||
onCompareCopy={onCompareCopy}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,21 @@ type Props = {
|
||||
imageUrl?: string;
|
||||
imageCount: number;
|
||||
sourcePreview: boolean;
|
||||
onPick: () => void;
|
||||
};
|
||||
|
||||
/** 收起态主图沿用 SKU 图片顺序,来源候选图不计入已选图片数量。 */
|
||||
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
|
||||
export function SkuCardPreview({ imageUrl, imageCount, sourcePreview, onPick }: Props) {
|
||||
const imageLabel = sourcePreview ? '来源图' : '主图';
|
||||
|
||||
return (
|
||||
<div className="sku-main-preview">
|
||||
<div
|
||||
<button
|
||||
className="sku-main-thumbnail"
|
||||
role="img"
|
||||
aria-label={`SKU ${sourcePreview ? '来源图片' : '主图'},已选 ${imageCount} 张图片`}
|
||||
type="button"
|
||||
onClick={onPick}
|
||||
aria-label={`选择 SKU 图片,当前已选 ${imageCount} 张`}
|
||||
title="选择 SKU 图片"
|
||||
>
|
||||
{imageUrl ? (
|
||||
<>
|
||||
@@ -28,7 +31,7 @@ export function SkuCardPreview({ imageUrl, imageCount, sourcePreview }: Props) {
|
||||
<span className="sku-main-count" aria-hidden="true">
|
||||
<PictureOutlined /> {sourcePreview ? '已选 0 张' : `${imageCount} 张图`}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ type Props = {
|
||||
usesVariantTags: boolean;
|
||||
usesVariantDescription: boolean;
|
||||
usesVariantTitle: boolean;
|
||||
onUseCommonTitle: () => void;
|
||||
skuInfoAspectDefinitions: ProductAttributeSchema[];
|
||||
colorNameDefinition: ProductAttributeSchema | undefined;
|
||||
catalogSyncing: string | null;
|
||||
@@ -30,6 +31,7 @@ type Props = {
|
||||
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onOpenDictionaryValues: (dictionaryId: string) => void;
|
||||
onLoadMoreDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||
onAttributeChanged: () => void;
|
||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||
};
|
||||
@@ -66,6 +68,7 @@ export function SkuInfoPanel({
|
||||
usesVariantTags,
|
||||
usesVariantDescription,
|
||||
usesVariantTitle,
|
||||
onUseCommonTitle,
|
||||
skuInfoAspectDefinitions,
|
||||
colorNameDefinition,
|
||||
catalogSyncing,
|
||||
@@ -74,6 +77,7 @@ export function SkuInfoPanel({
|
||||
onSyncDictionaryValues,
|
||||
onOpenDictionaryValues,
|
||||
onLoadMoreDictionaryValues,
|
||||
onAttributeChanged,
|
||||
onAiCopy,
|
||||
onCompareCopy,
|
||||
}: Props) {
|
||||
@@ -86,7 +90,7 @@ export function SkuInfoPanel({
|
||||
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
|
||||
<Input placeholder="填写 SKU 名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical">
|
||||
<Form.Item name={[fieldName, 'offer_id']} label="货号" layout="vertical" rules={[{max: 50, message: '货号最多 50 字符'}]}>
|
||||
<Input placeholder="首次上架前填写" />
|
||||
</Form.Item>
|
||||
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
|
||||
@@ -144,30 +148,39 @@ export function SkuInfoPanel({
|
||||
</>
|
||||
)}
|
||||
<div className="sku-info-group-title">发布文案与类目属性</div>
|
||||
<Form.Item
|
||||
label="俄文发布标题"
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required={usesVariantTitle}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name={[fieldName, '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>
|
||||
{usesVariantTitle && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="sku-package-label">
|
||||
<span>俄文标题</span>
|
||||
<Button type="link" size="small" onClick={onUseCommonTitle}>
|
||||
设为通用
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
layout="vertical"
|
||||
className="sku-info-variant-title"
|
||||
required
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name={[fieldName, 'title']} noStyle rules={[{ max: 500 }]}>
|
||||
<Input placeholder="填写当前 SKU 俄文标题" maxLength={500} />
|
||||
</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 label="主题标签" layout="vertical" className="sku-info-variant-tags">
|
||||
<Space.Compact
|
||||
@@ -244,6 +257,7 @@ export function SkuInfoPanel({
|
||||
itemLayout="vertical"
|
||||
forceRequiredNames={['商品颜色']}
|
||||
colorNameDefinition={colorNameDefinition}
|
||||
onAttributeChanged={onAttributeChanged}
|
||||
/>
|
||||
)}
|
||||
{skuInfoAspectDefinitions.length > 0 && !sourceId && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProductAiTask,
|
||||
ProductEditorValidation,
|
||||
ProductPublishPreflight,
|
||||
ProductPublishJob,
|
||||
Operation,
|
||||
SaveProductEditorDraftRequest,
|
||||
SaveProductEditorDraftResponse,
|
||||
@@ -118,6 +119,10 @@ export const productEditorApi = {
|
||||
{ variant_ids: variantIds },
|
||||
{ headers: { 'X-Atlas-Platform-Id': platformId } }
|
||||
),
|
||||
createPublishJob: (profileId: string, platformId: string, variantIds: string[], revisions: {product: number; profile: number}, key: string) =>
|
||||
api.post<{publish_job: ProductPublishJob}>(`/product-platform-profiles/${profileId}/publish-jobs`, {variant_ids: variantIds}, {headers: {'X-Atlas-Platform-Id': platformId, 'Idempotency-Key': key, 'If-Match': `"${revisions.product}:${revisions.profile}"`}}),
|
||||
publishJob: (id: string, platformId: string) => api.get<ProductPublishJob>(`/product-publish-jobs/${id}`, {headers: {'X-Atlas-Platform-Id': platformId}}),
|
||||
publishJobs: (profileId: string, platformId: string) => api.get<ProductPublishJob[]>(`/product-platform-profiles/${profileId}/publish-jobs`, {headers: {'X-Atlas-Platform-Id': platformId}}),
|
||||
syncAttributeSchema: (profileId: string, platformId: string, categoryId: string) =>
|
||||
api.post<{ run: ProductCatalogRefreshRun }>(
|
||||
`/product-platform-profiles/${profileId}/attribute-schema-syncs`,
|
||||
|
||||
Reference in New Issue
Block a user