feat(edit): 采集插件、编辑页SKU 属性动态展示、颜色展示开发
This commit is contained in:
@@ -15,6 +15,7 @@ import { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
productPublishPreflightRequestSchema,
|
productPublishPreflightRequestSchema,
|
||||||
saveProductEditorDraftRequestSchema,
|
saveProductEditorDraftRequestSchema,
|
||||||
|
saveProductSourceDescriptionRequestSchema,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
@@ -275,6 +276,43 @@ export class ProductEditorController {
|
|||||||
)
|
)
|
||||||
).body;
|
).body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 独立保存右侧采集面板的商品描述,不要求整页草稿处于可保存状态。 */
|
||||||
|
@Put('products/:productId/source-description')
|
||||||
|
async saveSourceDescription(
|
||||||
|
@Param('productId') productId: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Headers('if-match') ifMatch: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const parsed = saveProductSourceDescriptionRequestSchema.safeParse(body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
parsed.error.issues.map((issue) => ({
|
||||||
|
field: issue.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: issue.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
await this.editor.saveSourceDescription(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requiredUuid('product_id', productId),
|
||||||
|
requiredUuid('X-Atlas-Platform-Id', platformId),
|
||||||
|
requiredUuid('Idempotency-Key', key),
|
||||||
|
parseEditorRevision(ifMatch),
|
||||||
|
parsed.data,
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
|
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ProductEditorContext,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
|
SaveProductSourceDescriptionRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
@@ -1031,6 +1032,121 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 保存采集描述覆盖,避免未完成的整页草稿阻塞该独立编辑动作。 */
|
||||||
|
async saveSourceDescription(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
productId: string,
|
||||||
|
platformId: string,
|
||||||
|
key: string,
|
||||||
|
expected: { product: number; profile: number },
|
||||||
|
input: SaveProductSourceDescriptionRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PUT',
|
||||||
|
path: `/api/products/${productId}/source-description`,
|
||||||
|
key,
|
||||||
|
request: {
|
||||||
|
if_match: expected,
|
||||||
|
platform_id: platformId,
|
||||||
|
...input,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (transaction) => {
|
||||||
|
await this.requireTargetPlatform(transaction, platformId);
|
||||||
|
const product = await this.requireProduct(transaction, productId, true);
|
||||||
|
const profile = await this.findProfile(transaction, productId, platformId);
|
||||||
|
if (!profile)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'EDITOR_CONTEXT_REQUIRED',
|
||||||
|
'请先从采集箱进入商品编辑器',
|
||||||
|
);
|
||||||
|
if (Number(product.revision) !== expected.product)
|
||||||
|
await this.throwProductRevisionConflict(
|
||||||
|
transaction,
|
||||||
|
productId,
|
||||||
|
expected.product,
|
||||||
|
);
|
||||||
|
if (Number(profile.revision) !== expected.profile)
|
||||||
|
await this.throwProfileRevisionConflict(
|
||||||
|
transaction,
|
||||||
|
profile.id,
|
||||||
|
expected.profile,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
input.source_snapshot_revision !==
|
||||||
|
Number(product.source_snapshot_revision)
|
||||||
|
) {
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'SOURCE_SNAPSHOT_CHANGED',
|
||||||
|
'采集来源已更新,请重新加载并确认变更',
|
||||||
|
[
|
||||||
|
{
|
||||||
|
field: 'source_snapshot_revision',
|
||||||
|
code: 'SOURCE_SNAPSHOT_CHANGED',
|
||||||
|
message: `当前采集版本为 ${product.source_snapshot_revision}`,
|
||||||
|
current_revision: Number(product.source_snapshot_revision),
|
||||||
|
expected_revision: input.source_snapshot_revision,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentSource = resolveSourceMaterial(product);
|
||||||
|
const overriddenFields = new Set(currentSource.state.overridden_fields);
|
||||||
|
if (input.description === currentSource.state.latest.description) {
|
||||||
|
overriddenFields.delete('description');
|
||||||
|
} else {
|
||||||
|
overriddenFields.add('description');
|
||||||
|
}
|
||||||
|
const overrides = selectSourceMaterialOverrides(
|
||||||
|
{ ...currentSource.effective, description: input.description },
|
||||||
|
[...overriddenFields],
|
||||||
|
);
|
||||||
|
const updatedProduct = await transaction.query<{ revision: number }>(
|
||||||
|
`UPDATE atlas.products
|
||||||
|
SET ai_source_material_overrides = $1::jsonb,
|
||||||
|
ai_source_material_base_revision = $2,
|
||||||
|
revision = revision + 1, updated_at = now()
|
||||||
|
WHERE id = $3 AND revision = $4
|
||||||
|
RETURNING revision`,
|
||||||
|
[
|
||||||
|
JSON.stringify(overrides),
|
||||||
|
input.source_snapshot_revision,
|
||||||
|
productId,
|
||||||
|
expected.product,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!updatedProduct.rows[0])
|
||||||
|
await this.throwProductRevisionConflict(
|
||||||
|
transaction,
|
||||||
|
productId,
|
||||||
|
expected.product,
|
||||||
|
);
|
||||||
|
const savedSource = resolveSourceMaterial({
|
||||||
|
...product,
|
||||||
|
ai_source_material_overrides: overrides,
|
||||||
|
ai_source_material_base_revision: input.source_snapshot_revision,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
product_revision: Number(updatedProduct.rows[0].revision),
|
||||||
|
profile_revision: Number(profile.revision),
|
||||||
|
source_material: savedSource.effective,
|
||||||
|
source_material_state: savedSource.state,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */
|
/** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */
|
||||||
private async ensureListings(
|
private async ensureListings(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
|
|||||||
@@ -1844,4 +1844,70 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
[asset],
|
[asset],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||||
|
const created = await createCollectionItem('独立保存描述测试商品');
|
||||||
|
const entered = await request(
|
||||||
|
`/api/collection-items/${created.collectionItemId}/editor-contexts`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers({
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': randomUUID(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(entered.response.status, 201);
|
||||||
|
const before = await request(`/api/products/${created.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
const key = randomUUID();
|
||||||
|
const body = {
|
||||||
|
description: '独立持久化后的采集商品描述',
|
||||||
|
source_snapshot_revision:
|
||||||
|
before.body.data.source_material.source_snapshot_revision,
|
||||||
|
};
|
||||||
|
const saved = await request(
|
||||||
|
`/api/products/${created.productId}/source-description`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers({
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': key,
|
||||||
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(saved.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
saved.body.data.source_material.description,
|
||||||
|
'独立持久化后的采集商品描述',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
saved.body.data.source_material_state.overridden_fields.includes(
|
||||||
|
'description',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const replay = await request(
|
||||||
|
`/api/products/${created.productId}/source-description`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: headers({
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': key,
|
||||||
|
'If-Match': `"${before.body.data.product.revision}:${before.body.data.profile.revision}"`,
|
||||||
|
}),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(replay.body, saved.body);
|
||||||
|
const after = await request(`/api/products/${created.productId}/editor`, {
|
||||||
|
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
after.body.data.source_material.description,
|
||||||
|
'独立持久化后的采集商品描述',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* 将采集到的描述规范为可阅读的纯文本。保留原有段落和列表边界;只有来源
|
||||||
|
* 已把结构压成一行时,才依据常见商品段落标题补出最小必要的换行。
|
||||||
|
*/
|
||||||
|
export function formatCollectedDescription(value: string): string {
|
||||||
|
const withoutMarkup = value
|
||||||
|
.replace(/\r\n?/g, '\n')
|
||||||
|
.replace(/<\s*br\s*\/?\s*>/gi, '\n')
|
||||||
|
.replace(/<\s*\/\s*(?:p|div|section|article|li|h[1-6])\s*>/gi, '\n')
|
||||||
|
.replace(/<\s*li\b[^>]*>/gi, '\n- ')
|
||||||
|
.replace(/<[^>]*>/g, '');
|
||||||
|
const formatted = withoutMarkup
|
||||||
|
.replace(
|
||||||
|
/\s*(Описание(?:\s+товара)?|Характеристики|Преимущества|Особенности|Комплектация|Технические\s+характеристики|Specifications?|Features?|Package\s+contents?|商品描述|产品描述|商品信息|规格参数|产品参数|产品特点|包装清单)\s*([::])\s*/gi,
|
||||||
|
'\n\n$1$2\n',
|
||||||
|
)
|
||||||
|
.replace(/([^\n])\s*[-•·]\s+(?=[\p{L}\p{N}])/gu, '$1\n- ')
|
||||||
|
.replace(/[\t\f\v ]*\n[\t\f\v ]*/g, '\n')
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { CollectedProductPayload } from '@atlas/contracts';
|
import type { CollectedProductPayload } from '@atlas/contracts';
|
||||||
import { dedupeImageUrl, normaliseImageUrl } from './product-media.js';
|
import { dedupeImageUrl, normaliseImageUrl } from './product-media.js';
|
||||||
|
import { formatCollectedDescription } from './description-format.js';
|
||||||
|
|
||||||
export type OzonPageJsonRequest = {
|
export type OzonPageJsonRequest = {
|
||||||
kind: 'modal-aspects' | 'product' | 'product-detail';
|
kind: 'modal-aspects' | 'product' | 'product-detail';
|
||||||
@@ -341,7 +342,7 @@ export async function collectOzonProduct(
|
|||||||
itemKey: currentSku,
|
itemKey: currentSku,
|
||||||
canonicalUrl: canonical,
|
canonicalUrl: canonical,
|
||||||
collectedAt: new Date().toISOString(),
|
collectedAt: new Date().toISOString(),
|
||||||
parserVersion: 'ozon-public-2.3.0',
|
parserVersion: 'ozon-public-2.3.1',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
...(title
|
...(title
|
||||||
@@ -650,10 +651,7 @@ export function extractOzonDescription(
|
|||||||
} catch {
|
} catch {
|
||||||
// Some older page responses already contain plain text here.
|
// Some older page responses already contain plain text here.
|
||||||
}
|
}
|
||||||
value = value
|
value = formatCollectedDescription(value);
|
||||||
.replace(/\s+\n/g, '\n')
|
|
||||||
.replace(/\n{3,}/g, '\n\n')
|
|
||||||
.trim();
|
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
return {
|
return {
|
||||||
value: value.slice(0, 100_000),
|
value: value.slice(0, 100_000),
|
||||||
@@ -678,7 +676,7 @@ export function extractOzonJsonLdDescription(
|
|||||||
: '';
|
: '';
|
||||||
if (!raw) continue;
|
if (!raw) continue;
|
||||||
return {
|
return {
|
||||||
value: raw.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').slice(0, 100_000),
|
value: formatCollectedDescription(raw).slice(0, 100_000),
|
||||||
rawValue: raw.slice(0, 120_000),
|
rawValue: raw.slice(0, 120_000),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
type OzonPageJsonFetcher,
|
type OzonPageJsonFetcher,
|
||||||
} from './ozon-parser.js';
|
} from './ozon-parser.js';
|
||||||
import { collectPlatformDomAssets } from './product-media.js';
|
import { collectPlatformDomAssets } from './product-media.js';
|
||||||
|
import { formatCollectedDescription } from './description-format.js';
|
||||||
|
|
||||||
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
type SourcePlatform = '1688' | 'taobao' | 'tmall' | 'ozon';
|
||||||
type Json = Record<string, unknown>;
|
type Json = Record<string, unknown>;
|
||||||
@@ -112,7 +113,7 @@ function collect1688(root: Document, pageUrl: string, bridgedState?: unknown): C
|
|||||||
axes,
|
axes,
|
||||||
uniqueVariants(variants),
|
uniqueVariants(variants),
|
||||||
evidence,
|
evidence,
|
||||||
'1688-structured-2.0.0',
|
'1688-structured-2.0.1',
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -187,7 +188,7 @@ function collectTaobao(
|
|||||||
axes,
|
axes,
|
||||||
uniqueVariants(variants),
|
uniqueVariants(variants),
|
||||||
evidence,
|
evidence,
|
||||||
`${platform}-structured-2.0.0`,
|
`${platform}-structured-2.0.1`,
|
||||||
state,
|
state,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -357,7 +358,11 @@ function extractMarketplaceFacts(root: Document, states: unknown[], platform: Ex
|
|||||||
return {
|
return {
|
||||||
title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined),
|
title: (isRecord(item) ? stringAt(item, ['title', 'name']) : undefined) ?? (isRecord(temp) ? stringAt(temp, ['offerTitle']) : undefined) ?? (product ? stringAt(product, ['name']) : undefined),
|
||||||
price,
|
price,
|
||||||
description: (product ? stringAt(product, ['description']) : undefined) ?? scopedDescription(root, platform),
|
description: formatCollectedDescription(
|
||||||
|
(product ? stringAt(product, ['description']) : undefined) ??
|
||||||
|
scopedDescription(root, platform) ??
|
||||||
|
'',
|
||||||
|
) || undefined,
|
||||||
attributes,
|
attributes,
|
||||||
categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })),
|
categories: [...new Set(categoryNames)].map((name) => ({ key: `${platform}:category:${name.toLowerCase()}`.slice(0, 300), name })),
|
||||||
};
|
};
|
||||||
@@ -368,7 +373,10 @@ function scopedDescription(root: Document, platform: Exclude<SourcePlatform, 'oz
|
|||||||
? ['.de-description-detail', '#detailContentContainer', '.html-description']
|
? ['.de-description-detail', '#detailContentContainer', '.html-description']
|
||||||
: ['[class*="tabDetailWrap--"]', '[class*="detailInfo--"]'];
|
: ['[class*="tabDetailWrap--"]', '[class*="detailInfo--"]'];
|
||||||
for (const selector of selectors) {
|
for (const selector of selectors) {
|
||||||
const value = root.querySelector(selector)?.textContent?.replace(/\s+/g, ' ').trim();
|
const element = root.querySelector<HTMLElement>(selector);
|
||||||
|
const value = formatCollectedDescription(
|
||||||
|
element?.innerText ?? element?.textContent ?? '',
|
||||||
|
);
|
||||||
if (value && value.length >= 10) return value.slice(0, 100_000);
|
if (value && value.length >= 10) return value.slice(0, 100_000);
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
extractOzonGalleryImageUrls,
|
extractOzonGalleryImageUrls,
|
||||||
extractOzonGalleryVideos,
|
extractOzonGalleryVideos,
|
||||||
} from '../lib/ozon-parser.ts';
|
} from '../lib/ozon-parser.ts';
|
||||||
|
import { formatCollectedDescription } from '../lib/description-format.ts';
|
||||||
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
|
import { dedupeImageUrl, normaliseImageUrl } from '../lib/product-media.ts';
|
||||||
|
|
||||||
test('Ozon gallery fixture only returns product-gallery images', async () => {
|
test('Ozon gallery fixture only returns product-gallery images', async () => {
|
||||||
@@ -93,6 +94,15 @@ test('Ozon JSON-LD Product description is the fallback when rich content is abse
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('采集描述保留段落和列表,并为压平的 Ozon 结构标题补出换行', () => {
|
||||||
|
assert.equal(
|
||||||
|
formatCollectedDescription(
|
||||||
|
'Описание товара:适合户外使用。Характеристики:- 容量:25 L- 材质:尼龙Преимущества:- 防水\n\nКомплектация:<br>背包',
|
||||||
|
),
|
||||||
|
'Описание товара:\n适合户外使用。\n\nХарактеристики:\n- 容量:25 L\n- 材质:尼龙\n\nПреимущества:\n- 防水\n\nКомплектация:\n背包',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('Ozon full characteristics merge with the five short header characteristics', () => {
|
test('Ozon full characteristics merge with the five short header characteristics', () => {
|
||||||
const attributes = extractOzonCharacteristics([
|
const attributes = extractOzonCharacteristics([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -416,6 +416,7 @@ const ProductEditPage = () => {
|
|||||||
const [catalogRefresh, setCatalogRefresh] = useState(0);
|
const [catalogRefresh, setCatalogRefresh] = useState(0);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savingSourceDescription, setSavingSourceDescription] = useState(false);
|
||||||
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
const [materializingTransferId, setMaterializingTransferId] = useState<string | null>(null);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false);
|
||||||
@@ -849,6 +850,68 @@ const ProductEditPage = () => {
|
|||||||
});
|
});
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
};
|
};
|
||||||
|
/** 独立持久化采集描述,避免要求页面上其他未完成字段一并通过整页校验。 */
|
||||||
|
const saveSourceDescription = async (description: string) => {
|
||||||
|
if (!productId || !currentPlatform || !snapshot) return;
|
||||||
|
setSavingSourceDescription(true);
|
||||||
|
try {
|
||||||
|
const saved = await productEditorApi.saveSourceDescription(
|
||||||
|
productId,
|
||||||
|
currentPlatform.id,
|
||||||
|
{ product: snapshot.product.revision, profile: snapshot.profile.revision },
|
||||||
|
{
|
||||||
|
description,
|
||||||
|
source_snapshot_revision:
|
||||||
|
(sourceDraft ?? snapshot.source_material).source_snapshot_revision,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setSnapshot((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
product: {
|
||||||
|
...current.product,
|
||||||
|
revision: saved.product_revision,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
...current.profile,
|
||||||
|
revision: saved.profile_revision,
|
||||||
|
},
|
||||||
|
source_material: saved.source_material,
|
||||||
|
source_material_state: saved.source_material_state,
|
||||||
|
ai_input: {
|
||||||
|
...current.ai_input,
|
||||||
|
source_material: saved.source_material,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
);
|
||||||
|
setSourceDraft((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
description: saved.source_material.description,
|
||||||
|
source_snapshot_revision:
|
||||||
|
saved.source_material.source_snapshot_revision,
|
||||||
|
}
|
||||||
|
: saved.source_material
|
||||||
|
);
|
||||||
|
setSourceOverriddenFields((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (saved.source_material_state.overridden_fields.includes('description')) {
|
||||||
|
next.add('description');
|
||||||
|
} else {
|
||||||
|
next.delete('description');
|
||||||
|
}
|
||||||
|
return [...next];
|
||||||
|
});
|
||||||
|
message.success('商品描述已保存');
|
||||||
|
} catch (error) {
|
||||||
|
message.error(`商品描述保存失败:${apiErrorMessage(error)}`);
|
||||||
|
} finally {
|
||||||
|
setSavingSourceDescription(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const restoreSourceField = (field: Extract<ProductAiSourceField, 'title' | 'description'>) => {
|
const restoreSourceField = (field: Extract<ProductAiSourceField, 'title' | 'description'>) => {
|
||||||
if (!snapshot) return;
|
if (!snapshot) return;
|
||||||
setSourceDraft({
|
setSourceDraft({
|
||||||
@@ -1130,6 +1193,8 @@ const ProductEditPage = () => {
|
|||||||
overriddenFields={sourceOverriddenFields}
|
overriddenFields={sourceOverriddenFields}
|
||||||
onRestore={restoreSourceField}
|
onRestore={restoreSourceField}
|
||||||
onUpdate={updateSourceField}
|
onUpdate={updateSourceField}
|
||||||
|
savingDescription={savingSourceDescription}
|
||||||
|
onSaveDescription={(description) => void saveSourceDescription(description)}
|
||||||
onShowDifferences={showSourceDifferences}
|
onShowDifferences={showSourceDifferences}
|
||||||
onAcceptLatest={acceptLatestSource}
|
onAcceptLatest={acceptLatestSource}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type Props = {
|
|||||||
field: Extract<ProductAiSourceField, 'title' | 'description'>,
|
field: Extract<ProductAiSourceField, 'title' | 'description'>,
|
||||||
value: string
|
value: string
|
||||||
) => void;
|
) => void;
|
||||||
|
savingDescription: boolean;
|
||||||
|
onSaveDescription: (value: string) => void;
|
||||||
onShowDifferences: () => void;
|
onShowDifferences: () => void;
|
||||||
onAcceptLatest: () => void;
|
onAcceptLatest: () => void;
|
||||||
};
|
};
|
||||||
@@ -48,6 +50,8 @@ export function CollectedProductInfoPanel({
|
|||||||
overriddenFields,
|
overriddenFields,
|
||||||
onRestore,
|
onRestore,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
savingDescription,
|
||||||
|
onSaveDescription,
|
||||||
onShowDifferences,
|
onShowDifferences,
|
||||||
onAcceptLatest,
|
onAcceptLatest,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -137,14 +141,16 @@ export function CollectedProductInfoPanel({
|
|||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
value={descriptionDraft}
|
value={descriptionDraft}
|
||||||
rows={12}
|
rows={12}
|
||||||
|
disabled={savingDescription}
|
||||||
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
|
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
|
||||||
onChange={(event) => setDescriptionDraft(event.target.value)}
|
onChange={(event) => setDescriptionDraft(event.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="collected-description-actions">
|
<div className="collected-description-actions">
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
|
loading={savingDescription}
|
||||||
disabled={descriptionDraft === sourceDescription}
|
disabled={descriptionDraft === sourceDescription}
|
||||||
onClick={() => onUpdate('description', descriptionDraft)}
|
onClick={() => onSaveDescription(descriptionDraft)}
|
||||||
>
|
>
|
||||||
保存描述
|
保存描述
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import type {
|
|||||||
Operation,
|
Operation,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
SaveProductEditorDraftResponse,
|
SaveProductEditorDraftResponse,
|
||||||
|
SaveProductSourceDescriptionRequest,
|
||||||
|
SaveProductSourceDescriptionResponse,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
|
||||||
@@ -42,6 +44,23 @@ export const productEditorApi = {
|
|||||||
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
saveSourceDescription: (
|
||||||
|
productId: string,
|
||||||
|
platformId: string,
|
||||||
|
revisions: { product: number; profile: number },
|
||||||
|
payload: SaveProductSourceDescriptionRequest
|
||||||
|
) =>
|
||||||
|
api.put<SaveProductSourceDescriptionResponse>(
|
||||||
|
`/products/${productId}/source-description`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'X-Atlas-Platform-Id': platformId,
|
||||||
|
'Idempotency-Key': crypto.randomUUID(),
|
||||||
|
'If-Match': `"${revisions.product}:${revisions.profile}"`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
categoryOptions: (profileId: string, platformId: string, search?: string) =>
|
||||||
api.get<ProductEditorCategoryOption[]>(
|
api.get<ProductEditorCategoryOption[]>(
|
||||||
`/product-platform-profiles/${profileId}/category-options`,
|
`/product-platform-profiles/${profileId}/category-options`,
|
||||||
|
|||||||
@@ -126,6 +126,18 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
|
|
||||||
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
|
成功返回新 `product.revision`、`profile.revision`、创建的 SKU ID/`client_key` 映射和 warnings。任一字段、属性或媒体无效时返回 `422` 且不部分保存;版本冲突返回 `409 VERSION_CONFLICT`,客户端应重新读取编辑快照。
|
||||||
|
|
||||||
|
### 2.1 独立保存采集商品描述
|
||||||
|
|
||||||
|
`PUT /api/products/{product_id}/source-description`
|
||||||
|
|
||||||
|
右侧“采集的商品信息 → 商品描述”的“保存描述”按钮使用该接口,不依赖整页草稿可通过校验。Headers:`X-Atlas-Platform-Id`、`If-Match: "{product_revision}:{profile_revision}"` 和 `Idempotency-Key` 均必填。请求体为:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "description": "用户补充后的来源描述", "source_snapshot_revision": 3 }
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端仅更新 `ai_source_material_overrides.description`,保留其他来源字段已有的覆盖值,并递增 `product.revision`;`profile.revision` 不变。若描述与当前最新采集描述完全相同,则移除该字段覆盖,使它继续跟随后续复采。接口返回最新双版本号及合并后的 `source_material` / `source_material_state`,页面据此立即更新右侧面板;刷新编辑页后仍能读取已保存描述。采集版本或编辑版本不一致时返回 `409`,客户端显示保存失败提示,不得以旧描述覆盖新采集快照。
|
||||||
|
|
||||||
## 3. 目标店铺、类目与属性
|
## 3. 目标店铺、类目与属性
|
||||||
|
|
||||||
`PUT /api/product-platform-profiles/{profile_id}/target-store`
|
`PUT /api/product-platform-profiles/{profile_id}/target-store`
|
||||||
|
|||||||
@@ -422,6 +422,25 @@ export const productAiSourceMaterialOverridesSchema =
|
|||||||
.partial()
|
.partial()
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
/** 右侧采集面板独立保存商品描述时的最小请求体。 */
|
||||||
|
export const saveProductSourceDescriptionRequestSchema = z
|
||||||
|
.object({
|
||||||
|
description: nullableText(100_000),
|
||||||
|
source_snapshot_revision: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type SaveProductSourceDescriptionRequest = z.infer<
|
||||||
|
typeof saveProductSourceDescriptionRequestSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type SaveProductSourceDescriptionResponse = {
|
||||||
|
product_revision: number;
|
||||||
|
profile_revision: number;
|
||||||
|
source_material: ProductAiSourceMaterial;
|
||||||
|
source_material_state: ProductAiSourceMaterialState;
|
||||||
|
};
|
||||||
|
|
||||||
export const productPublishPreflightRequestSchema = z
|
export const productPublishPreflightRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
variant_ids: z
|
variant_ids: z
|
||||||
|
|||||||
Reference in New Issue
Block a user