feat(edit): 编辑上报细节优化

This commit is contained in:
2026-10-06 18:30:05 +08:00
parent e20cd7547f
commit 79b310efe7
92 changed files with 2764 additions and 368 deletions
@@ -0,0 +1,19 @@
import { App, Button } from 'antd';
import { CopyOutlined } from '@ant-design/icons';
/** 直接复制表单或预览原文,保留换行和空行,不从页面排版提取文本。 */
export function CopyTextButton({ getText, label }: {
getText: () => string | null | undefined;
label: string;
}) {
const { message } = App.useApp();
return <Button type="text" icon={<CopyOutlined />} aria-label={label} title={label}
onClick={async () => {
const text = getText();
if (!text) { message.info('暂无可复制的内容'); return; }
try {
await navigator.clipboard.writeText(text);
message.success('已复制,换行格式已保留');
} catch { message.error('复制失败,请选中文本后复制'); }
}} />;
}
@@ -685,7 +685,7 @@ export function PricingCalculator({
other_cny: '其他',
} as Record<string, string>
)[k],
children: v,
children: Number(v).toFixed(2),
}))}
/>
</>
+74 -48
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { Button, Form, InputNumber, Modal, Typography } from 'antd';
import { App, Button, Form, InputNumber, Modal, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type {
PricingCatalog,
@@ -22,7 +22,8 @@ export function skuPricingInput(
values: Values,
index: number,
profileId: string,
parameters: PricingParameters
parameters: PricingParameters,
storeId?: string
): PricingPreviewRequest {
const v = values.variants[index],
base = values.common.default_package_spec,
@@ -34,7 +35,7 @@ export function skuPricingInput(
height_mm: normalizePackageInteger(pkg.height_mm ?? base.height_mm),
};
return {
store_id: values.profile.target_store_id,
store_id: storeId ?? values.profile.target_store_id,
platform_product_category_id: values.profile.platform_product_category_id ?? '',
fulfillment: values.profile.fulfillment as 'rfbs',
binding: {
@@ -78,12 +79,17 @@ export function SkuPricing({
onChanged: () => void;
children: (slots: PricingSlots) => ReactNode;
}) {
const { modal: confirmation } = App.useApp();
Form.useWatch('variants', { form, preserve: true });
Form.useWatch('common', { form, preserve: true });
Form.useWatch('profile', { form, preserve: true });
const values = form.getFieldsValue(true) as Values,
v = values.variants[index],
original = snapshot.variants.find((x) => x.id === v?.id);
const imported = original?.listing.status === 'imported';
const pricingStore = imported
? original.listing.store_id
: values.profile?.target_store_id;
const [catalog, setCatalog] = useState<PricingCatalog | null>(null),
[error, setError] = useState(''),
[busy, setBusy] = useState(false),
@@ -98,7 +104,7 @@ export function SkuPricing({
controller = useRef<AbortController | null>(null),
lastRequest = useRef(''),
lastBatch = useRef(batch);
const store = values.profile?.target_store_id;
const store = pricingStore;
useEffect(() => {
let active = true;
setCatalog(null);
@@ -126,7 +132,9 @@ export function SkuPricing({
const params = fulfillmentSupported
? (v?.pricing_draft ?? (catalog ? defaultPricing(catalog) : null))
: null;
const live = params && v ? skuPricingInput(values, index, snapshot.profile.id, params) : null;
const live = params && v
? skuPricingInput(values, index, snapshot.profile.id, params, pricingStore)
: null;
const liveKey = live ? keyFor(live) : '';
const [now, setNow] = useState(Date.now());
useEffect(() => {
@@ -143,7 +151,7 @@ export function SkuPricing({
Date.parse(result.preview.expires_at) > now;
const savedInput = original?.listing.pricing_draft
? {
store_id: snapshot.profile.target_store.id,
store_id: imported ? original.listing.store_id : snapshot.profile.target_store.id,
platform_product_category_id: snapshot.profile.category.id ?? '',
fulfillment: snapshot.profile.fulfillment as 'rfbs',
binding: {
@@ -173,6 +181,21 @@ export function SkuPricing({
form.setFieldValue(['variants', index, 'pricing_draft'], p);
onChanged();
};
const localPriceNotice =
'该 SKU 已上架。本次价格调整仅保存为本地记录,不会同步至 Ozon;如需修改线上价格,请前往 Ozon 后台。';
function confirmPriceChange(action: () => void | Promise<void>) {
if (!imported) {
void action();
return;
}
confirmation.confirm({
title: '调整已上架 SKU 的本地价格',
content: localPriceNotice,
okText: '仅更新本地记录',
cancelText: '取消',
onOk: action,
});
}
function apply(
input: PricingPreviewRequest,
preview: PricingPreview,
@@ -216,7 +239,7 @@ export function SkuPricing({
catalogOverride?: PricingCatalog
) {
const sourceCatalog = catalogOverride ?? catalog;
if (!sourceCatalog || !v || original?.listing.status === 'imported') return;
if (!sourceCatalog || !v) return;
if (!fulfillmentSupported) {
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
return;
@@ -231,7 +254,8 @@ export function SkuPricing({
form.getFieldsValue(true),
index,
snapshot.profile.id,
parameters
parameters,
pricingStore
),
inputKey = keyFor(input);
const problem = pricingInputProblem(input);
@@ -260,8 +284,9 @@ export function SkuPricing({
const latestParameters = latest.variants[index]?.pricing_draft;
if (
!latestParameters ||
keyFor(skuPricingInput(latest, index, snapshot.profile.id, latestParameters)) !==
inputKey
keyFor(
skuPricingInput(latest, index, snapshot.profile.id, latestParameters, pricingStore)
) !== inputKey
)
return;
const recommended = p.options.find((o) => o.id === p.recommended_option_id);
@@ -292,12 +317,12 @@ export function SkuPricing({
}
}
async function calculateFromDefault() {
if (!v || original?.listing.status === 'imported') return;
if (!v) return;
if (!fulfillmentSupported) {
setError('当前仅支持 Ozon rFBS 定价;请先确认商品的履约方式');
return;
}
const requestedStore = form.getFieldValue(['profile', 'target_store_id']);
const requestedStore = pricingStore;
controller.current?.abort();
const seq = ++sequence.current;
setBusy(true);
@@ -307,7 +332,9 @@ export function SkuPricing({
const fresh = await pricingApi.catalog(requestedStore);
if (
seq !== sequence.current ||
form.getFieldValue(['profile', 'target_store_id']) !== requestedStore
(imported
? original.listing.store_id
: form.getFieldValue(['profile', 'target_store_id'])) !== requestedStore
)
return;
setCatalog(fresh);
@@ -345,7 +372,7 @@ export function SkuPricing({
sequence.current++;
controller.current?.abort();
setBusy(false);
if (auto && live && result?.inputKey !== liveKey) {
if (auto && !imported && live && result?.inputKey !== liveKey) {
const timer = setTimeout(() => void calculate(true), 400);
return () => clearTimeout(timer);
}
@@ -353,10 +380,9 @@ export function SkuPricing({
useEffect(() => {
if (batch === lastBatch.current) return;
lastBatch.current = batch;
void calculateFromDefault();
if (!imported) void calculateFromDefault();
}, [batch]);
if (!v) return null;
const imported = original?.listing.status === 'imported';
const currency = catalog?.store.currency_code ?? original?.listing.currency_code ?? 'CNY';
const saleCny =
params?.mode === 'fixed_sale' && currency === 'CNY'
@@ -454,33 +480,30 @@ export function SkuPricing({
<div className="sku-panel-heading">
<h5>定价</h5>
<div className="sku-panel-actions">
{!imported && (
<Button
loading={busy}
type="primary"
onClick={() => void calculateFromDefault()}
>
重新试算
</Button>
)}
{!imported && (
<Button
disabled={!catalog || !params}
onClick={() => {
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!
)
);
}}
>
试算详情
</Button>
)}
<Button
loading={busy}
type="primary"
onClick={() => confirmPriceChange(calculateFromDefault)}
>
重新试算
</Button>
<Button
disabled={!catalog || !params}
onClick={() => {
restoreInheritance.current = false;
setModal(
skuPricingInput(
form.getFieldsValue(true),
index,
snapshot.profile.id,
params!,
pricingStore
)
);
}}
>
试算详情
</Button>
</div>
</div>
<div className="sku-pricing-fields">
@@ -502,7 +525,7 @@ export function SkuPricing({
}}
onPressEnter={(event) => {
event.preventDefault();
void calculate(true);
confirmPriceChange(() => calculate(true));
}}
/>
</label>
@@ -562,6 +585,7 @@ export function SkuPricing({
<span>{feedback}</span>
</div>
<div className="sku-pricing-meta">
{imported && <span>{localPriceNotice}</span>}
<span>
{policyName}
{option ? ` · ${option.transport_name}` : ''}
@@ -631,10 +655,12 @@ export function SkuPricing({
previous={result?.option ?? original?.listing.pricing_result}
catalog={catalog}
onCancel={() => setModal(null)}
onApply={(input, p, id) => {
apply(input, p, id, true);
setModal(null);
}}
onApply={(input, p, id) =>
confirmPriceChange(() => {
apply(input, p, id, true);
setModal(null);
})
}
/>
</>
)}
@@ -231,6 +231,10 @@
.editor-short-input.ant-input {
width: 200px;
}
.editor-supply-url .ant-btn {
height: auto;
align-self: stretch;
}
/* 「采集信息变化」弹窗内容(页面 modal 渲染) */
.source-diff-list {
+99 -17
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useBlocker, useNavigate, useParams } from 'react-router-dom';
import { useBlocker, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd';
import {
ArrowLeftOutlined,
@@ -41,6 +41,7 @@ import { ProductImageTaskCenter } from './components/ProductImageTaskCenter';
import { ProductImageSuiteDrawer } from './components/ProductImageSuiteDrawer';
import type { SkuEditorImage } from './components/SkuImageCard';
import { displayBrandName, OZON_NO_BRAND_VALUE } from './brandDisplay';
import { composeSkuOfferId, lockedSkuOfferIds, skuOfferSuffix, syncSkuOfferIds } from './skuOfferId';
import {
ProductCopyComparisonModal,
SkuCopyAiModal,
@@ -455,10 +456,13 @@ function detectedSpecs(attributes: CollectedAttribute[]) {
*/
const ProductEditPage = () => {
const { id: productId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const collectionItemId = searchParams.get('collection_item_id');
const { currentPlatform } = usePlatform();
const { message, modal } = AntdApp.useApp();
const navigate = useNavigate();
const [form] = Form.useForm<EditorFormValues>();
const offerModelRef = useRef<string | null>(null);
// 关键表单值的实时监听:驱动类目/属性数据联动与货源链接打开按钮。
const supplyUrl = Form.useWatch(['common', 'purchase_url'], form);
const selectedCategoryId = Form.useWatch(['profile', 'platform_product_category_id'], form);
@@ -520,6 +524,9 @@ const ProductEditPage = () => {
const [skuCopyLoading, setSkuCopyLoading] = useState(false);
const [skuCopyResult, setSkuCopyResult] = useState<AiCopyOutput | null>(null);
const [skuCopyError, setSkuCopyError] = useState<string | null>(null);
const [skuCopyRequirements, setSkuCopyRequirements] = useState<Record<string, string>>({});
const skuCopyTaskId = useRef<string | null>(null);
const skuCopyRequestId = useRef(0);
const blocker = useBlocker(dirty);
const sourceSpecsApplied = useRef(false);
const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]);
@@ -555,26 +562,31 @@ const ProductEditPage = () => {
if (!productId || !currentPlatform) return;
setLoading(true);
try {
const next = await productEditorApi.read(productId, currentPlatform.id);
const next = await productEditorApi.read(productId, currentPlatform.id, collectionItemId);
setSnapshot(next);
setCollectionRecord(null);
setSourceDraft(next.source_material);
setSourceOverriddenFields(next.source_material_state.overridden_fields);
sourceSpecsApplied.current = false;
form.setFieldsValue(formValues(next, currentPlatform.code));
const values = formValues(next, currentPlatform.code);
const variants = syncSkuOfferIds(
values.variants, values.common.model, values.common.model, lockedSkuOfferIds(next)
);
offerModelRef.current = values.common.model;
form.setFieldsValue({ ...values, variants });
setUsesVariantPackageWeight(next.product.package_weight_mode === 'variant');
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.profile.title_mode === 'variant');
setCopyTranslations(next.copy_translations);
setDirty(false);
setDirty(variants.some((variant, index) => variant.offer_id !== values.variants[index].offer_id));
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setLoading(false);
}
}, [currentPlatform, form, message, productId]);
}, [collectionItemId, currentPlatform, form, message, productId]);
useEffect(() => {
void load();
}, [load]);
@@ -906,6 +918,10 @@ const ProductEditPage = () => {
message.error(invalidPackage);
return false;
}
if (draft.variants.some((variant) => (variant.offer_id?.length ?? 0) > 50)) {
message.error('完整货号(含型号前缀)最多 50 字符,请缩短型号或 SKU 后缀');
return false;
}
setSaving(true);
try {
await productEditorApi.save(
@@ -1029,7 +1045,8 @@ const ProductEditPage = () => {
const values = form.getFieldsValue(true) as EditorFormValues;
const profile = { ...values.profile };
const common = { ...values.common };
const variants = [...values.variants];
let variants = [...values.variants];
const lockedOffers = lockedSkuOfferIds(snapshot);
if (result.category && !profile.platform_product_category_id) {
profile.platform_product_category_id = result.category.category_id;
profile.category_source = 'ai_suggestion';
@@ -1060,9 +1077,16 @@ const ProductEditPage = () => {
}
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 };
if (index >= 0 && !variants[index].offer_id && !lockedOffers.has(offer.variant_id))
variants[index] = {
...variants[index],
offer_id: composeSkuOfferId(
values.common.model, skuOfferSuffix(offer.offer_id, result.model ?? common.model)
),
};
}
variants = syncSkuOfferIds(variants, values.common.model, common.model, lockedOffers);
offerModelRef.current = common.model;
const copy = result.spu_copy;
if (copy?.title && profile.title_mode !== 'variant' && !profile.title) {
profile.title = copy.title.ru;
@@ -1118,36 +1142,49 @@ const ProductEditPage = () => {
};
const generateSkuCopy = async () => {
if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return;
const variantId = skuCopyVariantId;
const hint = (skuCopyRequirements[variantId] ?? '').trim();
const requestId = ++skuCopyRequestId.current;
setSkuCopyLoading(true);
setSkuCopyError(null);
setSkuCopyResult(null);
skuCopyTaskId.current = null;
try {
if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return;
if (requestId !== skuCopyRequestId.current) return;
// 保存重载后补齐的空属性控件不属于人工修改,按实际保存内容比较。
const formAtStart = JSON.stringify(
normalizedValues(form.getFieldsValue(true) as EditorFormValues)
);
const task = await productEditorApi.aiSkuCopy(
snapshot.profile.id,
skuCopyVariantId,
currentPlatform.id
variantId,
currentPlatform.id,
hint ? { hint } : {}
);
if (requestId !== skuCopyRequestId.current) return;
const completed = await waitForAiTask(task.ai_task_id);
if (requestId !== skuCopyRequestId.current) return;
if (completed.status === 'failed' || !completed.result)
throw new Error(completed.error_message ?? 'SKU 文案生成失败');
if ((completed.result as ProductAiSkuCopyResult).variant_id !== variantId)
throw new Error('生成结果与当前 SKU 不匹配,请重新生成');
const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id);
if (requestId !== skuCopyRequestId.current) return;
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(normalizedValues(form.getFieldsValue(true) as EditorFormValues)) !== formAtStart
)
throw new Error('商品已被其他操作更新,请重新生成');
skuCopyTaskId.current = completed.id;
setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy);
} catch (error) {
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
if (requestId === skuCopyRequestId.current)
setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error));
} finally {
setSkuCopyLoading(false);
if (requestId === skuCopyRequestId.current) setSkuCopyLoading(false);
}
};
const applySkuCopy = () => {
@@ -1156,12 +1193,29 @@ const ProductEditPage = () => {
const index = variants.findIndex((variant) => variant.id === skuCopyVariantId);
if (index < 0) return;
const copy = skuCopyResult;
if (copy.title && !usesVariantTitle) changeTitleMode('variant');
const switchesTitleMode = !!copy.title && !usesVariantTitle;
const inheritedTitle = switchesTitleMode
? form.getFieldValue(['profile', 'title']) as string | null
: null;
if (switchesTitleMode) {
const inheritedTranslation = copyTranslations.find((item) =>
item.variant_id === null && item.field === 'title' && item.russian_value === inheritedTitle
);
changeTitleMode('variant');
// 只替换当前 SKU;其他 SKU 原先继承的标题与对照在模式切换后继续保留。
if (inheritedTranslation) {
const inheritedVariants = variants.filter((variant, current) => current !== index && variant.id && !variant.title);
setCopyTranslations((previous) => [
...previous.filter((item) => item.field !== 'title' || !inheritedVariants.some((variant) => variant.id === item.variant_id)),
...inheritedVariants.map((variant) => ({ ...inheritedTranslation, variant_id: variant.id })),
]);
}
}
form.setFieldValue(
'variants',
variants.map((variant, current) =>
current !== index
? variant
? inheritedTitle && !variant.title ? { ...variant, title: inheritedTitle } : variant
: {
...variant,
title: copy.title?.ru ?? variant.title,
@@ -1179,7 +1233,15 @@ const ProductEditPage = () => {
setSkuCopyVariantId(null);
message.success('已应用到当前 SKU,保存草稿后生效');
};
const skuCopyTaskId = useRef<string | null>(null);
const closeSkuCopy = () => {
// 关闭后仍在运行的任务可以完成,但不能写入后来打开的另一个 SKU 弹窗。
skuCopyRequestId.current += 1;
skuCopyTaskId.current = null;
setSkuCopyVariantId(null);
setSkuCopyLoading(false);
setSkuCopyResult(null);
setSkuCopyError(null);
};
const validSupplyUrl =
typeof supplyUrl === 'string' && /^https?:\/\//i.test(supplyUrl) ? supplyUrl : null;
const canOpenSupplyUrl = !!validSupplyUrl;
@@ -1448,7 +1510,17 @@ const ProductEditPage = () => {
wrapperCol={{ flex: '0 1 680px' }}
onFinish={save}
requiredMark
onValuesChange={() => setDirty(true)}
onValuesChange={(changed: Partial<EditorFormValues>) => {
if (changed.common && 'model' in changed.common) {
// 收起的 SKU 没有挂载字段,必须从完整表单读取,避免同步时丢失其资料。
const values = form.getFieldsValue(true) as EditorFormValues;
form.setFieldValue('variants', syncSkuOfferIds(
values.variants, offerModelRef.current, values.common.model, lockedSkuOfferIds(snapshot)
));
offerModelRef.current = values.common.model;
}
setDirty(true);
}}
>
<header className="editor-header">
<Button
@@ -1569,6 +1641,8 @@ const ProductEditPage = () => {
usesVariantTitle={usesVariantTitle}
onUseCommonTitle={useCommonTitle}
onAiCopy={(variantId) => {
skuCopyRequestId.current += 1;
setSkuCopyLoading(false);
setSkuCopyVariantId(variantId);
setSkuCopyResult(null);
setSkuCopyError(null);
@@ -1677,12 +1751,20 @@ const ProductEditPage = () => {
.map((item) => `${item.axis_key}: ${item.value_name}`)
.join(';') ?? ''
}
requirements={skuCopyVariantId ? skuCopyRequirements[skuCopyVariantId] ?? '' : ''}
loading={skuCopyLoading}
result={skuCopyResult}
error={skuCopyError}
onGenerate={() => void generateSkuCopy()}
onRequirementsChange={(value) => {
if (!skuCopyVariantId || skuCopyLoading) return;
setSkuCopyRequirements((previous) => ({ ...previous, [skuCopyVariantId]: value }));
setSkuCopyResult(null);
setSkuCopyError(null);
skuCopyTaskId.current = null;
}}
onApply={applySkuCopy}
onClose={() => setSkuCopyVariantId(null)}
onClose={closeSkuCopy}
/>
<Modal
className="ai-category-modal"
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { Button, Form, Input, InputNumber, Select, Tooltip, Typography } from 'antd';
import type { FormInstance } from 'antd';
import { InfoCircleTwoTone } from '@ant-design/icons';
import { isOzonProductColorAttribute as isProductColor, productAttributeValueRules } from '@atlas/contracts';
import type {
ProductAttributeDictionaryValue,
ProductAttributeSchema,
@@ -10,7 +11,6 @@ import type {
type EditorFormValues = SaveProductEditorDraftRequest;
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
const productColorValueLimit = 100;
/**
* Ozon 商品颜色字典(attribute_id=10096)的 value_id → SVG 色值与俄文颜色名。
@@ -75,11 +75,6 @@ const ozonProductColorByValueId: Record<string, { color: string | null; russianN
'61574': { color: '#000000', russianName: 'чёрный' },
};
/** 商品颜色允许一个 SKU 同时选择多个颜色,作为类目单值配置的业务例外。 */
function isProductColor(definition: ProductAttributeSchema) {
return definition.name.trim() === '商品颜色';
}
/** 颜色展示严格按 Ozon 字典值 ID 取色,未知 ID 使用中性色以避免根据名称臆测。 */
function colorSwatch(valueId: string) {
const definition = ozonProductColorByValueId[valueId];
@@ -171,9 +166,31 @@ function DynamicAttributeField({
onAttributeChanged: Props['onAttributeChanged'];
}) {
const [dictionaryQuery, setDictionaryQuery] = useState('');
const [colorLabels, setColorLabels] = useState<{
attributeId: string;
values: Record<string, string>;
}>({ attributeId: definition.attribute_id, values: {} });
const onLoadRef = useRef(onLoadDictionaryValues);
onLoadRef.current = onLoadDictionaryValues;
const onSyncRef = useRef(onSyncDictionaryValues);
onSyncRef.current = onSyncDictionaryValues;
useEffect(() => setDictionaryQuery(''), [definition.attribute_id]);
useEffect(() => {
if (isProductColor(definition) && definition.dictionary_id)
onLoadRef.current(definition.attribute_id, '');
}, [definition.attribute_id, definition.dictionary_id, definition.platform_attribute_id]);
useEffect(() => {
if (!isProductColor(definition)) return;
// 搜索结果会替换候选页,已选颜色名称必须独立保留,且不能沿用其他类目的字典。
setColorLabels(current => ({
attributeId: definition.attribute_id,
values: {
...(current.attributeId === definition.attribute_id ? current.values : {}),
...Object.fromEntries((dictionaryValues[definition.attribute_id] ?? [])
.map(value => [value.value_id, value.value])),
},
}));
}, [definition.attribute_id, definition.platform_attribute_id, dictionaryValues]);
useEffect(() => {
if (!definition.dictionary_id || dictionaryQuery.trim().length < 2) return;
const timer = window.setTimeout(
@@ -203,14 +220,22 @@ function DynamicAttributeField({
onAttributeChanged?.();
};
const lastColorSelection = useRef<string | null>(null);
const allowsMultipleValues = definition.is_collection || isProductColor(definition);
const { allowsMultipleValues, maxValueCount } = productAttributeValueRules(definition);
const isRequired =
definition.is_required || forceRequiredNames?.includes(definition.name.trim()) === true;
const maxValueCount = isProductColor(definition)
? productColorValueLimit
: definition.max_value_count;
const currentDictionaryValues = dictionaryValues[definition.attribute_id] ?? [];
const displayDictionaryValues = isProductColor(definition)
? [...currentDictionaryValues, ...attributeValues
.filter(valueId => !currentDictionaryValues.some(value => value.value_id === valueId))
.map(valueId => ({
value_id: valueId,
value: (colorLabels.attributeId === definition.attribute_id ? colorLabels.values[valueId] : undefined)
?? ozonProductColorByValueId[valueId]?.russianName
?? '颜色名称待加载',
}))]
: currentDictionaryValues;
const dictionaryOptions = definition.dictionary_id
? (dictionaryValues[definition.attribute_id] ?? []).map((value) => ({
? displayDictionaryValues.map((value) => ({
value: value.value_id,
label: isProductColor(definition) ? (
<ProductColorOption valueId={value.value_id} value={value.value} />
@@ -40,7 +40,7 @@ export function ImageSuiteReferencePicker({
null
);
const upload = async (file: File): Promise<SkuEditorImage> => {
const asset = await uploadProductImage(file),
const asset = await uploadProductImage(file, productId),
access = await mediaApi.createPreviewUrl(asset.id);
return {
id: `asset:${asset.id}`,
@@ -50,7 +50,7 @@ export function ImageSuiteReferencePicker({
origin: 'upload',
};
};
const library = useSkuImageLibrary(open, variantId, draft, upload);
const library = useSkuImageLibrary(open, productId, variantId, draft, upload);
useEffect(() => {
if (open) {
setDraft(selected);
@@ -58,7 +58,7 @@ export function ImageSuiteReferencePicker({
setPreview(null);
dragIndex.current = null;
}
}, [open, variantId]);
}, [open, productId, variantId]);
useEffect(() => {
if (!open) return;
let active = true;
@@ -11,6 +11,7 @@ import type {
} from '@atlas/contracts';
import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import { CopyTextButton } from '@/components/CopyTextButton';
import './ProductAttributeSection.css';
type EditorFormValues = SaveProductEditorDraftRequest;
@@ -136,6 +137,7 @@ export function ProductAttributeSection({
<Form.Item name={['profile', 'description']} noStyle>
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
</Form.Item>
<CopyTextButton label="复制通用产品描述" getText={() => form.getFieldValue(['profile', 'description'])} />
{!usesVariantDescription && (
<Button
type="text"
@@ -37,6 +37,65 @@
min-height: 80px;
}
.sku-copy-ai-context {
display: flex;
gap: 8px;
align-items: center;
margin: 4px 0 18px;
overflow-wrap: anywhere;
}
.sku-copy-ai-requirements {
padding: 16px;
border: 1px solid #e7ddff;
border-radius: 12px;
background: #faf8ff;
}
.sku-copy-ai-requirements-heading {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
font-weight: 600;
}
.sku-copy-ai-help {
color: #77718a;
font-size: 13px;
line-height: 1.6;
margin: 10px 0;
}
.sku-copy-ai-requirements .ant-input-textarea-show-count {
margin-bottom: 24px;
}
.sku-copy-ai-reference {
margin-top: 16px;
}
.sku-copy-ai-result-field {
padding: 14px 16px;
border: 1px solid #eeeaf5;
border-radius: 10px;
margin-bottom: 12px;
}
.sku-copy-ai-language {
color: #77718a;
font-size: 12px;
margin: 10px 0 4px;
}
.sku-copy-ai-result-field p:not(.sku-copy-ai-language) {
margin: 0;
}
.sku-copy-ai-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
text-align: left;
}
.sku-copy-ai-modal .ant-modal-body {
max-height: 68vh;
overflow-y: auto;
}
@media (max-width: 700px) {
.copy-comparison-head,
.copy-comparison-row,
@@ -1,6 +1,8 @@
import { Alert, Button, Modal, Space, Spin, Tag, Typography } from 'antd';
import { Alert, Button, Collapse, Input, Modal, Space, Spin, Tag, Typography } from 'antd';
import { PRODUCT_AI_HINT_MAX_LENGTH } from '@atlas/contracts';
import type { AiCopyOutput, ProductCopyField } from '@atlas/contracts';
import './ProductCopyAiDialogs.css';
import { CopyTextButton } from '@/components/CopyTextButton';
type ComparisonRow = {
field: ProductCopyField;
@@ -52,11 +54,13 @@ export function ProductCopyComparisonModal({
<Typography.Text strong>{labels[row.field]}</Typography.Text>
{row.inherited && <Tag color="blue">继承 SPU</Tag>}
<div className="copy-comparison-text">{row.russian || '—'}</div>
{row.field === 'description' && <CopyTextButton label="复制俄文产品描述" getText={() => row.russian} />}
</div>
<div>
<Typography.Text strong>{labels[row.field]}</Typography.Text>
{row.stale && <Tag color="orange">对照待更新</Tag>}
<div className="copy-comparison-text">{row.chinese || '暂无中文对照'}</div>
{row.field === 'description' && <CopyTextButton label="复制中文产品描述" getText={() => row.chinese} />}
</div>
</div>
))}
@@ -71,10 +75,12 @@ export function SkuCopyAiModal({
sourceTitle,
sourceDescription,
specifications,
requirements,
loading,
result,
error,
onGenerate,
onRequirementsChange,
onApply,
onClose,
}: {
@@ -83,46 +89,81 @@ export function SkuCopyAiModal({
sourceTitle: string;
sourceDescription: string;
specifications: string;
requirements: string;
loading: boolean;
result: AiCopyOutput | null;
error: string | null;
onGenerate: () => void;
onRequirementsChange: (value: string) => void;
onApply: () => void;
onClose: () => void;
}) {
return (
<Modal
open={open}
title={`AI 生成当前 SKU 文案 · ${variantName}`}
title="AI 生成当前 SKU 文案"
className="sku-copy-ai-modal"
onCancel={onClose}
width={1000}
width={980}
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>
<div className="sku-copy-ai-footer">
<Typography.Text type="secondary">仅应用到当前 SKU,保存草稿后生效。</Typography.Text>
<Space wrap>
<Button onClick={onClose}>关闭</Button>
<Button type={result ? 'default' : 'primary'} onClick={onGenerate} loading={loading}>
{loading ? '正在生成' : result ? '重新生成' : 'AI 生成'}
</Button>
<Button type="primary" disabled={!result || loading} onClick={onApply}>
应用到当前 SKU
</Button>
</Space>
</div>
}
>
<div className="sku-copy-ai-context">
<Tag color="blue">当前 SKU</Tag>
<Typography.Text strong>{variantName}</Typography.Text>
</div>
{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 className="sku-copy-ai-requirements">
<div className="sku-copy-ai-requirements-heading">
<label htmlFor="sku-copy-requirements">用户描述 / 生成要求</label>
<Tag color="purple">优先采用</Tag>
</div>
<p className="sku-copy-ai-help">你的描述与要求将优先采用,采集资料仅用于补充。</p>
<Input.TextArea
id="sku-copy-requirements"
aria-label="用户描述 / 生成要求"
value={requirements}
disabled={loading}
maxLength={PRODUCT_AI_HINT_MAX_LENGTH}
showCount
autoSize={{ minRows: 6, maxRows: 10 }}
placeholder="例如:这是红色款,高度 25cm;标题突出收藏用途,不要出现赠品和包邮。描述用简洁俄文,分点说明。"
onChange={(event) => onRequirementsChange(event.target.value)}
/>
<p className="sku-copy-ai-help">可补充或修正商品信息,指定卖点、写作风格和禁用词。留空则依据当前 SKU 与采集资料生成。</p>
</div>
<Collapse
className="sku-copy-ai-reference"
items={[{
key: 'source',
label: '采集资料与 SKU 信息 · 辅助参考',
children: <>
<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>
@@ -133,22 +174,26 @@ export function SkuCopyAiModal({
)}
{!loading && !result && (
<Typography.Text type="secondary">
点击下方「AI 生成」后在此预览。
填写描述或要求后点击「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>
{[
{ field: 'title', ru: result.title?.ru, zh: result.title?.zh },
{ field: 'tags', ru: result.tags?.map((tag) => tag.ru).join(' '), zh: result.tags?.map((tag) => tag.zh).join('、') },
{ field: 'description', ru: result.description?.ru, zh: result.description?.zh },
].map((row) => (
<div className="sku-copy-ai-result-field" key={row.field}>
<Typography.Text strong>{labels[row.field as ProductCopyField]}</Typography.Text>
<p className="sku-copy-ai-language">俄文</p>
<p>{row.ru || '未生成'}</p>
{row.field === 'description' && <CopyTextButton label="复制生成的俄文产品描述" getText={() => row.ru} />}
<p className="sku-copy-ai-language">中文对照</p>
<p className="sku-copy-ai-zh">{row.zh || '未生成'}</p>
{row.field === 'description' && <CopyTextButton label="复制生成的中文产品描述" getText={() => row.zh} />}
</div>
))}
</div>
)}
</div>
@@ -21,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
</div>
<div className="editor-form-grid">
<Form.Item label="货源链接" className="span-two">
<Space.Compact block>
<Space.Compact block className="editor-supply-url">
<Form.Item
name={['common', 'purchase_url']}
noStyle
@@ -32,6 +32,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
<Tooltip title="打开货源链接">
<Button
style={{ minWidth: 40 }}
aria-label="打开货源链接"
icon={<ExportOutlined />}
disabled={!canOpenSupplyUrl}
onClick={onOpenSupplyUrl}
@@ -5,6 +5,7 @@ import {
CheckCircleOutlined,
ClockCircleOutlined,
ExclamationCircleOutlined,
LoadingOutlined,
RightOutlined,
} from '@ant-design/icons';
import type {
@@ -43,6 +44,15 @@ const jobLabels: Record<string, string> = {
reconciling: '等待对账',
};
function activeJobLabel(job: ProductPublishJob) {
if (job.status !== 'running') return jobLabels[job.status];
if (job.items.some((item) => item.status === 'reconciling')) return '等待对账';
if (job.items.some((item) => item.status === 'submitting')) return '提交中';
if (job.items.some((item) => item.status === 'polling')) return '等待 Ozon';
if (job.items.some((item) => item.status === 'queued')) return '排队中';
return jobLabels.running;
}
/** 两个发布入口共用保存、指定 SKU 预检、确认与可恢复任务状态。 */
export function ProductPublishActions({
snapshot,
@@ -218,6 +228,13 @@ export function ProductPublishActions({
: statusTone === 'progress'
? <ClockCircleOutlined />
: <ExclamationCircleOutlined />;
const activeJob = job && !terminal(job) ? job : null;
const progressLabel = activeJob ? activeJobLabel(activeJob) : null;
const showJob = () => {
setStage('result');
setOpen(true);
setError('');
};
return (
<>
{/* 发布状态与结果弹窗共用任务数据,状态入口展示在页头。 */}
@@ -225,11 +242,7 @@ export function ProductPublishActions({
<button
type="button"
className={`editor-publish-status editor-publish-status--${statusTone}`}
onClick={() => {
setStage('result');
setOpen(true);
setError('');
}}
onClick={showJob}
aria-label={`查看发布任务详情:${jobLabels[job.status]},${job.item_count} 个 SKU`}
>
<span className="editor-publish-status-icon" aria-hidden="true">
@@ -259,10 +272,26 @@ export function ProductPublishActions({
<Button
type="primary"
ghost
disabled={disabled || busy}
onClick={() => void start(true)}
disabled={busy || (!activeJob && disabled)}
icon={
activeJob
? progressLabel === '等待对账'
? <ExclamationCircleOutlined />
: progressLabel === '排队中'
? <ClockCircleOutlined />
: <LoadingOutlined spin />
: undefined
}
title={activeJob ? `${activeJob.item_count} 个 SKU 正在上架,点击查看详情` : undefined}
onClick={() => {
// 进行中的按钮作为详情入口,避免用户把同一任务再次提交。
if (activeJob) showJob();
else void start(true);
}}
>
部分上架
<span aria-live="polite">
{progressLabel ? `部分上架 · ${progressLabel}` : '部分上架'}
</span>
</Button>
</Space>
<Modal
@@ -646,3 +646,28 @@
flex-wrap: wrap;
}
}
.sku-info-basic-row {
display: grid;
grid-column: 1 / -1;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.sku-info-offer-id .ant-input-group-addon {
border-radius: 7px 0 0 7px;
}
.sku-info-offer-id .ant-input {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.sku-offer-prefix {
display: inline-block;
max-width: min(180px, 16cqw);
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
@@ -542,6 +542,7 @@ export function ProductVariantSection({
form={form}
fieldName={field.name}
sourceId={sourceId}
offerIdLocked={['imported', 'submitting'].includes(original?.listing.status ?? '')}
usesVariantPackageWeight={
usesVariantPackageWeight
}
@@ -651,8 +652,17 @@ export function ProductVariantSection({
onImagesChanged();
setImageEditorTarget(null);
}}
onImageDeleted={(image) => {
const variants = form.getFieldValue('variants') as VariantValues[];
if (image.assetId) variants.forEach((variant, index) => {
form.setFieldValue(['variants', index, 'media_asset_ids'], (variant.media_asset_ids ?? []).filter(id => id !== image.assetId));
});
setLocalImagesByVariant(current => Object.fromEntries(Object.entries(current).map(([key, items]) =>
[key, items.filter(item => item.id !== image.id && (!image.assetId || item.assetId !== image.assetId))])));
onImagesChanged();
}}
onUploadFile={async (file) => {
const asset = await uploadProductImage(file);
const asset = await uploadProductImage(file, snapshot.product.id);
const url = URL.createObjectURL(file);
localObjectUrls.current.push(url);
return {
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
import { App as AntdApp, Alert, Button, Dropdown, Empty, Image, Modal, Spin } from 'antd';
import type { MenuProps } from 'antd';
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
import type { CollectionItemInspection, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
import type { CollectionItemInspection, ProductImageDeletion, ProductImageDerivativeRecord, WatermarkTemplate } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import { watermarksApi } from '@/services/watermarks';
@@ -31,6 +31,7 @@ type Props = {
onMakeThreeFour: (image: SkuEditorImage) => Promise<SkuEditorImage>;
onApplyWatermark: (image: SkuEditorImage, template: WatermarkTemplate) => Promise<SkuEditorImage>;
onResolveAsset: (image: SkuEditorImage) => Promise<string>;
onImageDeleted?: (image: SkuEditorImage) => void;
onDownloadImage: (image: SkuEditorImage) => Promise<void>;
};
@@ -56,11 +57,27 @@ export function SkuImageEditorModal({
onApplyWatermark,
onResolveAsset,
onDownloadImage,
onImageDeleted,
}: Props) {
const { message } = AntdApp.useApp();
const { message, modal } = AntdApp.useApp();
const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
const [markedIds, setMarkedIds] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState('collection');
const [deletionJobs, setDeletionJobs] = useState<ProductImageDeletion[]>([]);
useEffect(() => {
if (!open) return;
let active = true;
setDeletionJobs([]);
const refresh = () => void mediaApi.listPendingImageDeletions(productId)
.then(jobs => { if (active) setDeletionJobs(jobs); }).catch(() => undefined);
refresh();
const interval = window.setInterval(refresh, 4000);
return () => { active = false; window.clearInterval(interval); };
}, [open, productId]);
const [deleting, setDeleting] = useState(false);
const [removedImages, setRemovedImages] = useState<SkuEditorImage[]>([]);
const deleteDialog = useRef<{ destroy: () => void } | null>(null);
useEffect(() => { setRemovedImages([]); }, [productId]);
const [confirming, setConfirming] = useState(false);
const [processingThreeFour, setProcessingThreeFour] = useState(false);
const [processingWatermark, setProcessingWatermark] = useState(false);
@@ -92,6 +109,7 @@ export function SkuImageEditorModal({
const editorSession = useRef(0);
const downloadLock = useRef(false);
const {
removeAsset,
networkUrls,
setNetworkUrls,
networkAdding,
@@ -101,7 +119,7 @@ export function SkuImageEditorModal({
uploadFile,
uploadToRight,
addNetworkImages,
} = useSkuImageLibrary(open, skuKey, images, onUploadFile);
} = useSkuImageLibrary(open, productId, skuKey, images, onUploadFile);
const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>(
null
);
@@ -122,10 +140,12 @@ export function SkuImageEditorModal({
return () => {
if (editorSession.current === session) {
editorSession.current += 1;
deleteDialog.current?.destroy();
deleteDialog.current = null;
defaultWatermarkRequest.current = null;
}
};
}, [open, skuKey]);
}, [open, productId, skuKey]);
useEffect(() => {
if (!open) return;
let active = true;
@@ -394,7 +414,54 @@ export function SkuImageEditorModal({
{ key: 'delete', label: '移出已选', danger: true },
],
};
const imageMenu = (image: SkuEditorImage): MenuProps => ({
const requestImageDelete = (image: SkuEditorImage) => {
const session = editorSession.current;
let deletionId: string | null = null;
deleteDialog.current = modal.confirm({
title: '删除这张图片?',
content: '删除后将从当前商品图片库和已选区域移除,七牛中已存储的图片文件也会永久删除。此操作无法撤销。',
okText: '删除', cancelText: '取消', okButtonProps: { danger: true },
onOk: async () => {
if (session !== editorSession.current) return;
processingLock.current = true; setDeleting(true);
try {
let result = deletionId
? await mediaApi.retryImageDeletion(productId, deletionId)
: await mediaApi.deleteProductImage(productId, image.assetId ? { asset_id: image.assetId } : { collection_image_id: image.transferId });
deletionId = result.id;
if (session === editorSession.current) {
setRemovedImages(current => [...current, image]);
setImages(current => current.filter(item => !sameImage(item, image)));
setMarkedIds(current => current.filter(id => id !== image.id));
if (image.assetId) {
removeAsset(image.assetId);
setAiImages(current => current.filter(item => item.assetId !== image.assetId));
setProcessedRecords(current => current.filter(item => item.output_asset.id !== image.assetId));
}
setPreview(null);
setWatermarkUndo(current => Object.fromEntries(Object.entries(current).filter(([id, value]) =>
id !== image.id && (!image.assetId || value?.assetId !== image.assetId))));
onImageDeleted?.(image);
}
for (let attempt = 0; result.status === 'queued' && attempt < 60; attempt += 1) {
await new Promise<void>(resolve => window.setTimeout(resolve, 1000));
result = await mediaApi.readImageDeletion(productId, result.id);
}
if (result.status === 'failed') throw new Error('七牛图片清理失败,请点击删除按钮重试');
if (session === editorSession.current) {
if (result.status === 'succeeded') message.success('图片已删除,存储文件已清理');
else message.info('图片删除已提交,存储文件仍在清理中');
}
} catch (error) {
if (session === editorSession.current) message.error(apiErrorMessage(error));
throw error;
} finally {
if (session === editorSession.current) { processingLock.current = false; setDeleting(false); }
}
},
});
};
const imageMenu = (image: SkuEditorImage, canDelete = false): MenuProps => ({
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (processingLock.current) return;
@@ -404,6 +471,7 @@ export function SkuImageEditorModal({
else if (key === 'watermark') void addWatermark([image]);
else if (key === 'AI 编辑') setAiTarget(image);
else if (key === 'undo_watermark') undoWatermark(image);
else if (key === 'delete_image') requestImageDelete(image);
else pendingAction(String(key));
},
items: [
@@ -413,10 +481,12 @@ export function SkuImageEditorModal({
{ key: 'three_four', label: '一键 3:4' },
{ key: 'cover', label: '设为封图' },
{ key: 'download', label: '下载' },
...(canDelete ? [{ type: 'divider' as const }, { key: 'delete_image', label: '删除', danger: true }] : []),
],
});
const uploadFiles = async (files: FileList | null) => {
if (!files || processingLock.current) return;
const session = editorSession.current;
const available = MAX_IMAGES - images.length;
const accepted = Array.from(files).filter((file) => {
const error = uploadFileError(file);
@@ -425,14 +495,17 @@ export function SkuImageEditorModal({
});
if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
for (const file of accepted.slice(0, available)) {
if (session !== editorSession.current) return;
try {
const image = await uploadFile(file);
if (session !== editorSession.current) return;
setImages((current) =>
current.some((item) => item.assetId === image.assetId)
? current
: [...current, image]
);
} catch (error) {
if (session !== editorSession.current) return;
message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`);
}
}
@@ -440,7 +513,7 @@ export function SkuImageEditorModal({
};
const sourceImages = (role: string): SkuEditorImage[] =>
collectionImages
.filter((image) => image.role === role)
.filter((image) => image.role === role && !removedImages.some(removed => removed.transferId === image.id || (removed.assetId && removed.assetId === image.asset_id)))
.map((image) => ({
id: `collection:${image.id}`,
url: (image.asset_id && previewUrls[image.asset_id]) ?? image.source_url,
@@ -456,8 +529,9 @@ export function SkuImageEditorModal({
}));
const showPreview = (items: SkuEditorImage[], index: number) =>
setPreview({ images: items, index });
const renderSourceImages = (items: SkuEditorImage[]) =>
items.length ? (
const renderSourceImages = (candidates: SkuEditorImage[]) => {
const items = candidates.filter(image => !removedImages.some(removed => sameImage(removed, image)));
return items.length ? (
<div className="sku-image-source-grid">
{items.map((image, index) => {
const selected = images.some((item) => sameImage(item, image));
@@ -467,7 +541,7 @@ export function SkuImageEditorModal({
image={image}
mode="source"
selected={selected}
menu={imageMenu(image)}
menu={imageMenu(image, true)}
onToggle={() => addImage(image)}
onPreview={() => showPreview(items, index)}
/>
@@ -477,7 +551,9 @@ export function SkuImageEditorModal({
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
);
const processedImages = processedRecords.map((record): SkuEditorImage => ({
};
const visibleProcessedRecords = processedRecords.filter(record => !removedImages.some(image => image.assetId === record.output_asset.id));
const processedImages = visibleProcessedRecords.map((record): SkuEditorImage => ({
id: `asset:${record.output_asset.id}`,
url: record.output_asset.preview_url,
label: record.kind === 'watermark' ? '水印处理图' : '3:4 处理图',
@@ -489,16 +565,16 @@ export function SkuImageEditorModal({
if (processedError) return <Empty description="图片处理记录加载失败">
<Button onClick={() => void refreshProcessedImages().catch(() => setProcessedError(true))}>重试</Button>
</Empty>;
if (!processedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
if (!visibleProcessedRecords.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片处理记录" />;
return <div className="sku-image-source-grid">
{processedRecords.map((record, index) => {
{visibleProcessedRecords.map((record, index) => {
const image = processedImages[index];
return <div className="sku-image-processed-entry" key={record.id}>
<SkuImageCard
image={image}
mode="source"
selected={images.some((item) => sameImage(item, image))}
menu={imageMenu(image)}
menu={imageMenu(image, true)}
onToggle={() => addImage(image)}
onPreview={() => showPreview(processedImages, index)}
/>
@@ -535,19 +611,19 @@ export function SkuImageEditorModal({
}
width="min(1480px, 96vw)"
onCancel={() => {
if (!confirming && !processingThreeFour && !processingWatermark) onCancel();
if (!confirming && !deleting && !processingThreeFour && !processingWatermark) onCancel();
}}
closable={!confirming && !processingThreeFour && !processingWatermark}
maskClosable={!confirming && !processingThreeFour && !processingWatermark}
closable={!confirming && !deleting && !processingThreeFour && !processingWatermark}
maskClosable={!confirming && !deleting && !processingThreeFour && !processingWatermark}
footer={[
<Button key="cancel" onClick={onCancel} disabled={confirming || processingThreeFour || processingWatermark}>
<Button key="cancel" onClick={onCancel} disabled={confirming || deleting || processingThreeFour || processingWatermark}>
取消
</Button>,
<Button
key="confirm"
type="primary"
loading={confirming}
disabled={uploading || processingThreeFour || processingWatermark}
disabled={uploading || deleting || processingThreeFour || processingWatermark}
onClick={() => void confirmSelection()}
>
确认
@@ -555,6 +631,14 @@ export function SkuImageEditorModal({
]}
centered
>
{deletionJobs.length > 0 && <Alert type={deletionJobs.some(job => job.status === 'failed') ? 'warning' : 'info'}
title={deletionJobs.some(job => job.status === 'failed') ? '部分图片的存储文件清理失败,可重试' : '图片存储文件正在清理中'}
action={<>{deletionJobs.filter(job => job.status === 'failed').map(job => <Button key={job.id} size="small" onClick={async () => {
try {
const result = await mediaApi.retryImageDeletion(productId, job.id);
setDeletionJobs(current => current.map(item => item.id === job.id ? result : item));
} catch (error) { message.error(apiErrorMessage(error)); }
}}>重试清理</Button>)}</>} style={{ marginBottom: 12 }} />}
<div className="sku-image-editor-columns">
<section className="sku-image-editor-left" aria-label="已选 SKU 图片">
<div className="sku-image-editor-toolbar">
@@ -9,6 +9,8 @@ import type {
} from '@atlas/contracts';
import { PackageIntegerInput, packageIntegerRule } from '@/components/PackageIntegerInput';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import { CopyTextButton } from '@/components/CopyTextButton';
import { SkuOfferIdInput } from './SkuOfferIdInput';
type EditorFormValues = SaveProductEditorDraftRequest;
type NamePath = Array<string | number>;
@@ -16,6 +18,7 @@ type Props = {
form: FormInstance<EditorFormValues>;
fieldName: number;
sourceId: string | null;
offerIdLocked: boolean;
usesVariantPackageWeight: boolean;
usesVariantPackageDimensions: boolean;
onUseCommonPackageWeight: () => void;
@@ -62,6 +65,7 @@ export function SkuInfoPanel({
form,
fieldName,
sourceId,
offerIdLocked,
usesVariantPackageWeight,
usesVariantPackageDimensions,
onUseCommonPackageWeight,
@@ -86,6 +90,7 @@ export function SkuInfoPanel({
const profile = Form.useWatch('profile', { form, preserve: true }) as
EditorFormValues['profile'] | undefined;
const ownTags = Form.useWatch(['variants', fieldName, 'tags'], { form, preserve: true }) ?? [];
const model = Form.useWatch(['common', 'model'], { form, preserve: true });
const tagsProblem = ozonHashtagProblem(
usesVariantTags && ownTags.length ? ownTags : profile?.tags ?? []
);
@@ -95,17 +100,21 @@ export function SkuInfoPanel({
<h5>规格与发布信息</h5>
</div>
<div className="sku-info-group-title">SKU 基础资料</div>
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
<Input placeholder="填写 SKU 名称" />
</Form.Item>
<Form.Item
name={[fieldName, 'offer_id']}
label="货号"
layout="vertical"
rules={[{ max: 50, message: '货号最多 50 字符' }]}
>
<Input placeholder="首次上架前填写" />
</Form.Item>
<div className="sku-info-basic-row">
<Form.Item name={[fieldName, 'display_name']} label="SKU 名称" layout="vertical">
<Input placeholder="填写 SKU 名称" />
</Form.Item>
<Form.Item
name={[fieldName, 'offer_id']}
label="货号"
className="sku-info-offer-id"
layout="vertical"
rules={[{ max: 50, message: '完整货号(含型号前缀)最多 50 字符' }]}
extra={offerIdLocked ? '已上架或正在上架,货号已锁定' : '型号为自动前缀,修改型号时后缀保留;完整货号最多 50 字符。'}
>
<SkuOfferIdInput model={model} locked={offerIdLocked} />
</Form.Item>
</div>
{(usesVariantPackageWeight || usesVariantPackageDimensions) && (
<>
<div className="sku-info-group-title">包装规格</div>
@@ -271,6 +280,12 @@ export function SkuInfoPanel({
}
/>
</Form.Item>
<CopyTextButton label="复制当前 SKU 产品描述" getText={() => {
const own = usesVariantDescription
? form.getFieldValue(['variants', fieldName, 'description']) as string | null | undefined
: null;
return own?.trim() ? own : form.getFieldValue(['profile', 'description']);
}} />
<Button
icon={<RobotOutlined />}
aria-label="AI 生成当前 SKU 描述"
@@ -0,0 +1,27 @@
import { Input } from 'antd';
import { composeSkuOfferId, skuOfferPrefix, skuOfferSuffix } from '../skuOfferId';
type Props = {
value?: string | null;
onChange?: (value: string | null) => void;
model: string | null | undefined;
locked: boolean;
id?: string;
};
/** 控件仅编辑后缀,表单和现有 API 始终持有完整货号。 */
export function SkuOfferIdInput({ value, onChange, model, locked, id }: Props) {
if (locked) return <Input id={id} value={value ?? ''} readOnly aria-label="已锁定货号" />;
const prefix = skuOfferPrefix(model);
return (
<Input
id={id}
aria-label="货号后缀"
addonBefore={<span className="sku-offer-prefix" title={prefix || '请先填写型号'}>{prefix || '待填写型号'}</span>}
value={skuOfferSuffix(value, model)}
maxLength={Math.max(0, 50 - prefix.length)}
placeholder="只需输入 SKU 后缀"
onChange={(event) => onChange?.(composeSkuOfferId(model, event.target.value))}
/>
);
}
@@ -24,9 +24,10 @@ function networkImageUrl(value: string) {
}
}
/** 工作空间候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
/** 当前商品候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
export function useSkuImageLibrary(
open: boolean,
productId: string,
skuKey: string,
selectedImages: SkuEditorImage[],
onUploadFile: (file: File) => Promise<SkuEditorImage>
@@ -38,22 +39,40 @@ export function useSkuImageLibrary(
const [uploadedImages, setUploadedImages] = useState<SkuEditorImage[]>([]);
const [uploadCount, setUploadCount] = useState(0);
const fingerprints = useRef(new Set<string>());
const removedIds = useRef(new Set<string>());
const networkRequest = useRef(0);
const scope = useRef({ productId, generation: 0 });
const [libraryProductId, setLibraryProductId] = useState(productId);
// 在商品切换的首帧就隔离旧结果,也防止快速切回同一商品时接收更早的请求。
if (scope.current.productId !== productId) {
scope.current = { productId, generation: scope.current.generation + 1 };
}
const generation = scope.current.generation;
const isCurrentProduct = () => scope.current.generation === generation;
useEffect(() => {
setLibraryProductId(productId);
setNetworkImages([]);
setUploadedImages([]);
setUploadCount(0);
fingerprints.current.clear();
removedIds.current.clear();
}, [productId]);
useEffect(() => {
networkRequest.current += 1;
setNetworkUrls('');
setNetworkAdding(false);
}, [open, skuKey]);
}, [open, productId, skuKey]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = () =>
void mediaApi
.listProductUploads()
.listProductUploads(productId)
.then((uploads) => {
if (!active) return;
const loaded = uploads.map((asset) => ({
if (!active || !isCurrentProduct()) return;
const loaded = uploads.filter(asset => !removedIds.current.has(asset.id)).map((asset) => ({
id: `asset:${asset.id}`,
url: asset.preview_url,
label: '上传图片',
@@ -68,7 +87,7 @@ export function useSkuImageLibrary(
]);
})
.catch((error) => {
if (active) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
if (active && isCurrentProduct()) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
});
refresh();
const interval = window.setInterval(refresh, 8 * 60_000);
@@ -76,16 +95,16 @@ export function useSkuImageLibrary(
active = false;
window.clearInterval(interval);
};
}, [open, message]);
}, [open, productId, message]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = () =>
void mediaApi
.listProductImports()
.listProductImports(productId)
.then((imports) => {
if (!active) return;
const loaded = imports.map((asset) => ({
if (!active || !isCurrentProduct()) return;
const loaded = imports.filter(asset => !removedIds.current.has(asset.id)).map((asset) => ({
id: `asset:${asset.id}`,
url: asset.preview_url,
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
@@ -101,7 +120,7 @@ export function useSkuImageLibrary(
]);
})
.catch((error) => {
if (active) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
if (active && isCurrentProduct()) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
});
refresh();
const interval = window.setInterval(refresh, 8 * 60_000);
@@ -109,14 +128,14 @@ export function useSkuImageLibrary(
active = false;
window.clearInterval(interval);
};
}, [open, message]);
}, [open, productId, message]);
const uploadFile = async (file: File) => {
setUploadCount((count) => count + 1);
try {
return await onUploadFile(file);
} finally {
setUploadCount((count) => count - 1);
if (isCurrentProduct()) setUploadCount((count) => Math.max(0, count - 1));
}
};
const uploadToRight = async (file: File) => {
@@ -133,12 +152,14 @@ export function useSkuImageLibrary(
fingerprints.current.add(fingerprint);
try {
const image = await uploadFile(file);
if (!isCurrentProduct()) return Upload.LIST_IGNORE;
setUploadedImages((current) =>
current.some((item) => item.assetId === image.assetId)
? current
: [image, ...current]
);
} catch (uploadError) {
if (!isCurrentProduct()) return Upload.LIST_IGNORE;
fingerprints.current.delete(fingerprint);
message.error(`${file.name} 上传失败:${apiErrorMessage(uploadError)}`);
}
@@ -171,9 +192,9 @@ export function useSkuImageLibrary(
}
setNetworkAdding(true);
const results = await Promise.allSettled(
candidates.map((url) => mediaApi.importProductImage({ url }))
candidates.map((url) => mediaApi.importProductImage({ url, product_id: productId }))
);
if (request !== networkRequest.current) return;
if (request !== networkRequest.current || !isCurrentProduct()) return;
const valid = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
);
@@ -202,11 +223,17 @@ export function useSkuImageLibrary(
};
return {
removeAsset: (id: string) => {
removedIds.current.add(id);
setUploadedImages(current => current.filter(image => image.assetId !== id));
setNetworkImages(current => current.filter(image => image.assetId !== id));
fingerprints.current.clear();
},
networkUrls,
setNetworkUrls,
networkAdding,
networkImages,
uploadedImages,
networkImages: libraryProductId === productId ? networkImages : [],
uploadedImages: libraryProductId === productId ? uploadedImages : [],
uploading: uploadCount > 0,
uploadFile,
uploadToRight,
+38
View File
@@ -0,0 +1,38 @@
import type { ProductEditorSnapshot } from '@atlas/contracts';
export function skuOfferPrefix(model: string | null | undefined): string {
const value = model?.trim();
return value ? `${value}-` : '';
}
/** 只移除明确匹配的型号前缀,旧自定义货号的内容完整保留为后缀。 */
export function skuOfferSuffix(offerId: string | null | undefined, model: string | null | undefined): string {
const value = offerId?.trim() ?? '';
const prefix = skuOfferPrefix(model);
return prefix && value.startsWith(prefix) ? value.slice(prefix.length) : value;
}
export function composeSkuOfferId(model: string | null | undefined, suffix: string): string | null {
const value = suffix.trim();
return value ? `${skuOfferPrefix(model)}${value}` : null;
}
export function lockedSkuOfferIds(snapshot: ProductEditorSnapshot | null): Set<string> {
return new Set((snapshot?.variants ?? [])
.filter((variant) => ['imported', 'submitting'].includes(variant.listing.status))
.map((variant) => variant.id));
}
/** 收起的 SKU 同样同步前缀;已导入或进行中的货号属于发布事实,不改写。 */
export function syncSkuOfferIds<T extends { id: string | null; offer_id: string | null }>(
variants: T[],
previousModel: string | null | undefined,
nextModel: string | null | undefined,
lockedIds: ReadonlySet<string>,
): T[] {
return variants.map((variant) => {
if (variant.id && lockedIds.has(variant.id)) return variant;
const offerId = composeSkuOfferId(nextModel, skuOfferSuffix(variant.offer_id, previousModel));
return offerId === variant.offer_id ? variant : { ...variant, offer_id: offerId };
});
}
+16 -5
View File
@@ -2,6 +2,8 @@ import type {
CreateMediaUploadIntentRequest,
MediaAccessUrl,
MediaAsset,
DeleteProductImageRequest,
ProductImageDeletion,
MediaUploadIntent,
ProductUploadImage,
ProductImportedImage,
@@ -21,6 +23,13 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
export const mediaApi = {
listPendingImageDeletions: (productId: string) => api.get<ProductImageDeletion[]>(`/products/${productId}/image-deletions`),
deleteProductImage: (productId: string, payload: DeleteProductImageRequest) =>
api.post<ProductImageDeletion>(`/products/${productId}/image-deletions`, payload,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readImageDeletion: (productId: string, id: string) => api.get<ProductImageDeletion>(`/products/${productId}/image-deletions/${id}`),
retryImageDeletion: (productId: string, id: string) => api.post<ProductImageDeletion>(`/products/${productId}/image-deletions/${id}/retry`, {},
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
api.post<MediaAiEdit>(`/media-assets/${assetId}/ai-edits`, payload,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
@@ -47,8 +56,8 @@ export const mediaApi = {
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readWatermarkDerivative: (derivativeId: string) =>
api.get<WatermarkDerivative>(`/media-watermark-derivatives/${derivativeId}`),
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
listProductUploads: (productId: string) => api.get<ProductUploadImage[]>(`/media-assets/product-uploads?product_id=${encodeURIComponent(productId)}`),
listProductImports: (productId: string) => api.get<ProductImportedImage[]>(`/media-assets/product-imports?product_id=${encodeURIComponent(productId)}`),
importProductImage: (payload: ImportProductImageRequest) =>
api.post<ProductImportedImage>('/media-assets/product-imports', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
@@ -165,17 +174,19 @@ export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
}
/** 商品图片沿用素材直传协议,完成服务端校验后才可写入 SKU 草稿。 */
export async function uploadProductImage(file: File): Promise<MediaAsset> {
return uploadImage(file, 'product_image');
export async function uploadProductImage(file: File, productId: string): Promise<MediaAsset> {
return uploadImage(file, 'product_image', productId);
}
async function uploadImage(
file: File,
purpose: CreateMediaUploadIntentRequest['purpose']
purpose: CreateMediaUploadIntentRequest['purpose'],
productId?: string
): Promise<MediaAsset> {
const checksumSha256 = await sha256Hex(file);
const intent = await mediaApi.createUploadIntent({
purpose,
...(productId ? { product_id: productId } : {}),
filename: file.name,
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
byte_size: file.size,
+17 -5
View File
@@ -6,6 +6,7 @@ import type {
ProductEditorContext,
ProductEditorSnapshot,
ProductAiTask,
RequestProductAiTask,
ProductEditorValidation,
ProductPublishPreflight,
ProductPublishJob,
@@ -29,10 +30,21 @@ export const productEditorApi = {
},
}
),
read: (productId: string, platformId: string) =>
api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
read: async (
productId: string,
platformId: string,
collectionItemId?: string | null
): Promise<ProductEditorSnapshot> => {
// 插件首次进入时尚无平台配置,沿用采集箱的初始化流程并校验商品归属。
if (collectionItemId) {
const context = await productEditorApi.enter(collectionItemId, platformId);
if (context.product_id !== productId)
throw new Error('采集记录与商品不匹配,请从采集箱重新进入编辑页');
}
return api.get<ProductEditorSnapshot>(`/products/${productId}/editor`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
});
},
aiAutofill: (profileId: string, platformId: string) =>
api.post<{ ai_task_id: string; status: 'queued' }>(
`/product-platform-profiles/${profileId}/ai/autofills`,
@@ -44,10 +56,10 @@ export const productEditorApi = {
},
}
),
aiSkuCopy: (profileId: string, variantId: string, platformId: string) =>
aiSkuCopy: (profileId: string, variantId: string, platformId: string, input: RequestProductAiTask = {}) =>
api.post<{ ai_task_id: string; status: 'queued' }>(
`/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy`,
{},
input,
{
headers: {
'X-Atlas-Platform-Id': platformId,