diff --git a/.env.example b/.env.example index 36c6827..ddf5a0a 100644 --- a/.env.example +++ b/.env.example @@ -84,3 +84,8 @@ MINIO_CONSOLE_PORT=9001 WEB_PORT=8100 # Local WXT browser-extension development port. EXTENSION_PORT=8110 + +# DeepSeek API Key +DEEPSEEK_API_KEY= +# DeepSeek Model Name +DEEPSEEK_MODEL_NAME=deepseek-flash \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c33ad89..7928144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,10 @@ - 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。 - 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。 -## 2. 不要启动服务端和 Worker +## 2. 服务端、Worker 与前端验证 -- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。 -- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。 +- API(NestJS)和 Worker 可在开发、联调或验证需要时由 AI 启动、重启或停止;操作前应先确认目标进程与端口,避免误杀无关进程。 +- API 或 Worker 已由维护者启动时,可在本任务需要时重启,但应在完成后说明实际操作和运行状态。 - 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。 ## 3. 代码注释 diff --git a/apps/api/src/product-editor/product-ai.service.ts b/apps/api/src/product-editor/product-ai.service.ts new file mode 100644 index 0000000..8add99c --- /dev/null +++ b/apps/api/src/product-editor/product-ai.service.ts @@ -0,0 +1,233 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ProductAiTask, ProductEditorSnapshot } from '@atlas/contracts'; +import type { Database } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { DATABASE } from '../database/database.module.js'; +import { ProductEditorService } from './product-editor.service.js'; + +@Injectable() +export class ProductAiService { + public constructor( + @Inject(DATABASE) private readonly database: Database, + @Inject(IdempotencyService) + private readonly idempotency: IdempotencyService, + @Inject(ProductEditorService) + private readonly editor: ProductEditorService, + ) {} + + /** 固化当前编辑快照并在同一事务写入任务与 Outbox。 */ + async create( + workspaceId: string, + userId: string, + profileId: string, + platformId: string, + variantId: string | null, + key: string, + hint: string | undefined, + ) { + const profile = await this.database.workspaceTransaction( + workspaceId, + async (tx) => { + const row = await tx.query<{ product_id: string }>( + `SELECT product_id FROM atlas.product_platform_profiles + WHERE id = $1 AND platform_id = $2`, + [profileId, platformId], + ); + if (!row.rows[0]) + throw new ApiError( + 404, + 'PROFILE_NOT_FOUND', + '商品发布配置不存在', + ); + return row.rows[0]; + }, + ); + const snapshot = await this.editor.read( + workspaceId, + profile.product_id, + platformId, + ); + if (snapshot.profile.id !== profileId) + throw new ApiError( + 409, + 'PROFILE_CHANGED', + '商品发布配置已变化,请刷新页面', + ); + if ( + variantId && + !snapshot.variants.some((variant) => variant.id === variantId) + ) + throw new ApiError(404, 'VARIANT_NOT_FOUND', 'SKU 不属于当前商品'); + const kind = variantId ? 'sku_copy' : 'autofill'; + const path = variantId + ? `/api/product-platform-profiles/${profileId}/variants/${variantId}/ai/copy` + : `/api/product-platform-profiles/${profileId}/ai/autofills`; + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path, + key, + request: { platform_id: platformId, hint: hint ?? null }, + }, + async (tx) => { + const current = await tx.query<{ + product_revision: number; + profile_revision: number; + source_snapshot_revision: number; + }>( + `SELECT product.revision AS product_revision, + profile.revision AS profile_revision, + item.source_snapshot_revision + FROM atlas.product_platform_profiles profile + JOIN atlas.products product ON product.id = profile.product_id + JOIN atlas.collection_items item ON item.id = product.collection_item_id + WHERE profile.id = $1 AND profile.platform_id = $2 + FOR UPDATE OF profile, product, item`, + [profileId, platformId], + ); + const row = current.rows[0]; + if ( + !row || + Number(row.product_revision) !== + snapshot.product.revision || + Number(row.profile_revision) !== + snapshot.profile.revision || + Number(row.source_snapshot_revision) !== + snapshot.source_material.source_snapshot_revision + ) { + throw new ApiError( + 409, + 'AI_INPUT_CHANGED', + '商品资料已变化,请重新点击 AI 填报', + ); + } + const input = taskInput(snapshot, variantId, hint); + const inserted = await tx.query<{ id: string }>( + `INSERT INTO atlas.product_ai_tasks + (workspace_id, product_id, profile_id, variant_id, kind, input_snapshot, created_by_user_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5::jsonb, $6) + RETURNING id`, + [ + profile.product_id, + profileId, + variantId, + kind, + JSON.stringify(input), + userId, + ], + ); + const taskId = inserted.rows[0].id; + await tx.query( + `INSERT INTO atlas.outbox_events + (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) + VALUES (current_setting('app.workspace_id')::uuid, 'product_ai_task', $1, + 'product_ai_task.requested', $2::jsonb, $3)`, + [ + taskId, + JSON.stringify({ task_id: taskId }), + `product_ai_task:${taskId}`, + ], + ); + return { + status: 202, + body: { data: { ai_task_id: taskId, status: 'queued' } }, + }; + }, + ); + } + + /** 只返回已校验结果和输入版本,不暴露采集快照与模型提示词。 */ + async read( + workspaceId: string, + taskId: string, + platformId: string, + ): Promise { + return this.database.workspaceTransaction(workspaceId, async (tx) => { + const result = await tx.query<{ + id: string; + kind: 'autofill' | 'sku_copy' | 'attribute_fill'; + status: ProductAiTask['status']; + variant_id: string | null; + input_snapshot: Record; + result: ProductAiTask['result']; + error_code: string | null; + error_message: string | null; + }>( + `SELECT task.id, task.kind, task.status, task.variant_id, + task.input_snapshot, task.result, task.error_code, task.error_message + FROM atlas.product_ai_tasks task + JOIN atlas.product_platform_profiles profile ON profile.id = task.profile_id + WHERE task.id = $1 AND profile.platform_id = $2`, + [taskId, platformId], + ); + const row = result.rows[0]; + if (!row) + throw new ApiError(404, 'AI_TASK_NOT_FOUND', 'AI 任务不存在'); + const input = row.input_snapshot as { + revision?: ProductAiTask['input_revision']; + }; + return { + id: row.id, + kind: row.kind, + status: row.status, + input_revision: input.revision ?? { + product: 0, + profile: 0, + source_snapshot: 0, + }, + variant_id: row.variant_id, + result: + row.status === 'succeeded' || row.status === 'partial' + ? row.result + : null, + error_code: row.error_code, + error_message: row.error_message, + }; + }); + } +} + +function taskInput( + snapshot: ProductEditorSnapshot, + variantId: string | null, + hint?: string, +) { + return { + revision: { + product: snapshot.product.revision, + profile: snapshot.profile.revision, + source_snapshot: snapshot.source_material.source_snapshot_revision, + }, + platform_id: snapshot.profile.platform_id, + product_id: snapshot.product.id, + profile_id: snapshot.profile.id, + variant_id: variantId, + hint: hint ?? null, + source_material: snapshot.source_material, + common: snapshot.product.common, + profile: { + title: snapshot.profile.title, + tags: snapshot.profile.tags, + description: snapshot.profile.description, + category: snapshot.profile.category, + target_store_id: snapshot.profile.target_store.id, + }, + variants: snapshot.variants.map((variant) => ({ + id: variant.id, + display_name: variant.display_name, + source_sku: variant.source_sku, + specification_values: variant.specification_values, + listing: { + status: variant.listing.status, + offer_id: variant.listing.offer_id, + }, + title: variant.title, + tags: variant.tags, + description: variant.description, + package_spec: variant.effective_package_spec, + })), + }; +} diff --git a/apps/api/src/product-editor/product-editor.controller.ts b/apps/api/src/product-editor/product-editor.controller.ts index 5c4033d..8eac262 100644 --- a/apps/api/src/product-editor/product-editor.controller.ts +++ b/apps/api/src/product-editor/product-editor.controller.ts @@ -14,6 +14,7 @@ import { import { z } from 'zod'; import { productPublishPreflightRequestSchema, + requestProductAiTaskSchema, saveProductEditorDraftRequestSchema, saveProductSourceDescriptionRequestSchema, type Workspace, @@ -22,6 +23,7 @@ import { ApiError } from '../common/api-error.js'; import type { AuthenticatedRequest } from '../auth/auth.guard.js'; import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; import { ProductEditorService } from './product-editor.service.js'; +import { ProductAiService } from './product-ai.service.js'; type Response = { status: (value: number) => Response }; @@ -31,6 +33,8 @@ export class ProductEditorController { public constructor( @Inject(ProductEditorService) private readonly editor: ProductEditorService, + @Inject(ProductAiService) + private readonly productAi: ProductAiService, ) {} /** 为采集记录创建或复用当前平台的商品编辑上下文。 */ @@ -70,6 +74,85 @@ export class ProductEditorController { }; } + /** 用户主动创建 SPU AI 填报任务。 */ + @Post('product-platform-profiles/:profileId/ai/autofills') + async aiAutofill( + @Param('profileId') profileId: string, + @Body() body: unknown, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: Response, + ) { + const parsed = requestProductAiTaskSchema.safeParse(body ?? {}); + if (!parsed.success) + throw new ApiError( + 422, + 'VALIDATION_FAILED', + 'AI 请求参数不符合要求', + ); + const result = await this.productAi.create( + workspace.id, + request.auth.userId, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + null, + requiredUuid('Idempotency-Key', key), + parsed.data.hint, + ); + response.status(result.status); + return result.body; + } + + /** 用户主动创建单个 SKU 的文案任务,不影响其他 SKU。 */ + @Post('product-platform-profiles/:profileId/variants/:variantId/ai/copy') + async aiSkuCopy( + @Param('profileId') profileId: string, + @Param('variantId') variantId: string, + @Body() body: unknown, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @Headers('idempotency-key') key: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: Response, + ) { + const parsed = requestProductAiTaskSchema.safeParse(body ?? {}); + if (!parsed.success) + throw new ApiError( + 422, + 'VALIDATION_FAILED', + 'AI 请求参数不符合要求', + ); + const result = await this.productAi.create( + workspace.id, + request.auth.userId, + requiredUuid('profile_id', profileId), + requiredUuid('X-Atlas-Platform-Id', platformId), + requiredUuid('variant_id', variantId), + requiredUuid('Idempotency-Key', key), + parsed.data.hint, + ); + response.status(result.status); + return result.body; + } + + /** 查询当前工作空间和平台的 AI 任务状态。 */ + @Get('product-ai-tasks/:taskId') + async aiTask( + @Param('taskId') taskId: string, + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + return { + data: await this.productAi.read( + workspace.id, + requiredUuid('ai_task_id', taskId), + requiredUuid('X-Atlas-Platform-Id', platformId), + ), + }; + } + /** 返回当前平台共享的类目树,供编辑器选择。 */ @Get('product-platform-profiles/:profileId/category-options') async categoryOptions( @@ -288,7 +371,8 @@ export class ProductEditorController { @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, ) { - const parsed = saveProductSourceDescriptionRequestSchema.safeParse(body); + const parsed = + saveProductSourceDescriptionRequestSchema.safeParse(body); if (!parsed.success) { throw new ApiError( 422, diff --git a/apps/api/src/product-editor/product-editor.module.ts b/apps/api/src/product-editor/product-editor.module.ts index 3c7dafc..cd7f57b 100644 --- a/apps/api/src/product-editor/product-editor.module.ts +++ b/apps/api/src/product-editor/product-editor.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { IdempotencyService } from '../common/idempotency.service.js'; import { ProductEditorController } from './product-editor.controller.js'; import { ProductEditorService } from './product-editor.service.js'; +import { ProductAiService } from './product-ai.service.js'; @Module({ controllers: [ProductEditorController], - providers: [IdempotencyService, ProductEditorService], + providers: [IdempotencyService, ProductEditorService, ProductAiService], }) export class ProductEditorModule {} diff --git a/apps/api/src/product-editor/product-editor.service.ts b/apps/api/src/product-editor/product-editor.service.ts index cc30cd4..35c4411 100644 --- a/apps/api/src/product-editor/product-editor.service.ts +++ b/apps/api/src/product-editor/product-editor.service.ts @@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { ProductEditorContext, ProductEditorSnapshot, + ProductCopyTranslation, SaveProductEditorDraftRequest, SaveProductSourceDescriptionRequest, } from '@atlas/contracts'; @@ -214,6 +215,7 @@ export class ProductEditorService { purchase_cost_cny: string | null; listing_id: string; offer_id: string | null; + title: string | null; description: string | null; tags: unknown; status: string; @@ -236,7 +238,7 @@ export class ProductEditorService { }>( `SELECT v.id, v.source_variant_key, v.source_sku, v.display_name, v.sort_order, v.purchase_cost_cny::text AS purchase_cost_cny, l.id AS listing_id, l.offer_id, - l.description, l.tags, l.status, l.selling_price::text, + l.title, l.description, l.tags, l.status, l.selling_price::text, l.list_price::text, l.currency_code, v.specification_values, v.physical_length_mm::text, v.physical_width_mm::text, v.physical_height_mm::text, v.net_weight_g::text, v.physical_spec_status, @@ -350,6 +352,17 @@ export class ProductEditorService { WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`, [profile.id], ); + const translations = await transaction.query<{ + variant_id: string | null; + field: 'title' | 'tags' | 'description'; + russian_value: string | string[]; + chinese_value: string | string[]; + source_task_id: string | null; + }>( + `SELECT variant_id, field, russian_value, chinese_value, source_task_id + FROM atlas.product_copy_translations WHERE profile_id = $1`, + [profile.id], + ); const defaultPhysicalSpec = mapProductPhysicalSpec(product); const defaultPackageSpec = mapProductPackageSpec(product); const sourceMaterialResult = resolveSourceMaterial(product); @@ -362,6 +375,7 @@ export class ProductEditorService { display_name: variant.display_name, sort_order: variant.sort_order, purchase_cost_cny: variant.purchase_cost_cny, + title: variant.title, tags: Array.isArray(variant.tags) ? variant.tags.filter( (value): value is string => @@ -466,6 +480,7 @@ export class ProductEditorService { source_material: sourceMaterial, source_material_state: sourceMaterialResult.state, captured_category_match: capturedCategoryMatch, + copy_translations: translations.rows, ai_input: { source_material: sourceMaterial, default_physical_spec: defaultPhysicalSpec, @@ -883,13 +898,16 @@ export class ProductEditorService { await transaction.query( `UPDATE atlas.product_variant_platform_listings SET offer_id = $1, store_id = $2, tags = $3::jsonb, description = $4, + title = CASE WHEN $5::boolean THEN $6 ELSE title END, revision = revision + 1, updated_at = now() - WHERE profile_id = $5 AND product_variant_id = $6 AND status <> 'imported'`, + WHERE profile_id = $7 AND product_variant_id = $8 AND status <> 'imported'`, [ variant.offer_id, input.profile.target_store_id, JSON.stringify(variant.tags), variant.description, + variant.title !== undefined, + variant.title ?? null, profile.id, variant.id, ], @@ -974,9 +992,9 @@ export class ProductEditorService { await transaction.query( `INSERT INTO atlas.product_variant_platform_listings (workspace_id, product_id, profile_id, platform_id, product_variant_id, store_id, offer_id, - currency_code, tags, description) + currency_code, tags, description, title) SELECT current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, $5, $6, s.currency_code, - $7::jsonb, $8 + $7::jsonb, $8, $9 FROM atlas.stores s WHERE s.id = $5`, [ productId, @@ -987,6 +1005,7 @@ export class ProductEditorService { variant.offer_id, JSON.stringify(variant.tags), variant.description, + variant.title ?? null, ], ); } @@ -1019,6 +1038,15 @@ export class ProductEditorService { profile, variants: variantMedia, }); + if (input.copy_translations) { + await this.persistCopyTranslations( + transaction, + productId, + profile.id, + retainedIds, + input.copy_translations, + ); + } return { status: 200, body: { @@ -1038,6 +1066,68 @@ export class ProductEditorService { ); } + /** 中俄对照与草稿同一事务保存,继承的 SKU 字段不创建副本。 */ + private async persistCopyTranslations( + transaction: DatabaseTransaction, + productId: string, + profileId: string, + retainedVariantIds: string[], + translations: ProductCopyTranslation[], + ) { + const retained = new Set(retainedVariantIds); + const seen = new Set(); + for (const item of translations) { + if (item.variant_id && !retained.has(item.variant_id)) + throw new ApiError( + 422, + 'INVALID_TRANSLATION_VARIANT', + '中文对照关联了无效 SKU', + ); + const identity = `${item.variant_id ?? 'spu'}:${item.field}`; + if (seen.has(identity)) + throw new ApiError( + 422, + 'DUPLICATE_TRANSLATION', + '同一字段不能重复保存中文对照', + ); + seen.add(identity); + if (item.source_task_id) { + const task = await transaction.query<{ id: string }>( + `SELECT id FROM atlas.product_ai_tasks WHERE id = $1 AND profile_id = $2`, + [item.source_task_id, profileId], + ); + if (!task.rows[0]) + throw new ApiError( + 422, + 'INVALID_TRANSLATION_TASK', + '中文对照来源任务不属于当前商品', + ); + } + } + await transaction.query( + `DELETE FROM atlas.product_copy_translations WHERE profile_id = $1`, + [profileId], + ); + for (const item of translations) { + await transaction.query( + `INSERT INTO atlas.product_copy_translations + (workspace_id, product_id, profile_id, variant_id, field, + russian_value, chinese_value, source_task_id) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3, $4, + $5::jsonb, $6::jsonb, $7)`, + [ + productId, + profileId, + item.variant_id, + item.field, + JSON.stringify(item.russian_value), + JSON.stringify(item.chinese_value), + item.source_task_id, + ], + ); + } + } + /** 保存采集描述覆盖,避免未完成的整页草稿阻塞该独立编辑动作。 */ async saveSourceDescription( workspaceId: string, @@ -1063,8 +1153,16 @@ export class ProductEditorService { }, async (transaction) => { await this.requireTargetPlatform(transaction, platformId); - const product = await this.requireProduct(transaction, productId, true); - const profile = await this.findProfile(transaction, productId, platformId); + const product = await this.requireProduct( + transaction, + productId, + true, + ); + const profile = await this.findProfile( + transaction, + productId, + platformId, + ); if (!profile) throw new ApiError( 409, @@ -1096,24 +1194,36 @@ export class ProductEditorService { 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, + 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) { + 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 }, + { + ...currentSource.effective, + description: input.description, + }, [...overriddenFields], ); - const updatedProduct = await transaction.query<{ revision: number }>( + const updatedProduct = await transaction.query<{ + revision: number; + }>( `UPDATE atlas.products SET ai_source_material_overrides = $1::jsonb, ai_source_material_base_revision = $2, @@ -1136,13 +1246,16 @@ export class ProductEditorService { const savedSource = resolveSourceMaterial({ ...product, ai_source_material_overrides: overrides, - ai_source_material_base_revision: input.source_snapshot_revision, + ai_source_material_base_revision: + input.source_snapshot_revision, }); return { status: 200, body: { data: { - product_revision: Number(updatedProduct.rows[0].revision), + product_revision: Number( + updatedProduct.rows[0].revision, + ), profile_revision: Number(profile.revision), source_material: savedSource.effective, source_material_state: savedSource.state, @@ -1796,7 +1909,8 @@ export class ProductEditorService { WHERE item.id = $1 AND item.deleted_at IS NULL`, [product.collection_item_id], ); - if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') return unavailable; + if (sourcePlatform.rows[0]?.code.toUpperCase() !== 'OZON') + return unavailable; const signals = capturedOzonCategorySignals( latestProductAiSourceMaterial(product), @@ -1851,11 +1965,12 @@ export class ProductEditorService { LIMIT 20`, [platformId, signals.typeName, signals.rootCategoryName], ); - const status = candidates.rows.length === 1 - ? 'matched' - : candidates.rows.length - ? 'ambiguous' - : 'unmatched'; + const status = + candidates.rows.length === 1 + ? 'matched' + : candidates.rows.length + ? 'ambiguous' + : 'unmatched'; return { status, root_category_name: signals.rootCategoryName, diff --git a/apps/api/src/product-editor/product-editor.validation.ts b/apps/api/src/product-editor/product-editor.validation.ts index c05e780..6356226 100644 --- a/apps/api/src/product-editor/product-editor.validation.ts +++ b/apps/api/src/product-editor/product-editor.validation.ts @@ -205,7 +205,13 @@ export class ProductEditorValidationOperations { issues.push({ code: 'TITLE_REQUIRED', field: 'common.source_title', - message: '请填写标题', + message: '请填写中文商品名', + }); + if (!profile.title?.trim()) + issues.push({ + code: 'PUBLISH_TITLE_REQUIRED', + field: 'profile.title', + message: '请填写俄文发布标题', }); if (!product.model?.trim()) issues.push({ diff --git a/apps/api/test/product-editor.e2e.test.ts b/apps/api/test/product-editor.e2e.test.ts index 0afc2d4..4dbcd7b 100644 --- a/apps/api/test/product-editor.e2e.test.ts +++ b/apps/api/test/product-editor.e2e.test.ts @@ -452,6 +452,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { }, ], purchase_cost_cny: '88.50', + tags: [], + description: null, physical_spec: { length_mm: null, width_mm: null, @@ -476,6 +478,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { source_sku: null, specification_values: [], purchase_cost_cny: '99.00', + tags: [], + description: null, physical_spec: { length_mm: null, width_mm: null, @@ -707,6 +711,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { source_sku: null, specification_values: [], purchase_cost_cny: '99.00', + tags: [], + description: null, physical_spec: { length_mm: null, width_mm: null, @@ -805,6 +811,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { ] : variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + tags: variant.tags, + description: variant.description, physical_spec: index === 0 ? { @@ -994,6 +1002,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { source_sku: variant.source_sku, specification_values: variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + tags: variant.tags, + description: variant.description, physical_spec: variant.physical_spec, package_spec: variant.package_spec, offer_id: variant.listing.offer_id, @@ -1862,6 +1872,8 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { source_sku: item.source_sku, specification_values: item.specification_values, purchase_cost_cny: item.purchase_cost_cny, + tags: item.tags, + description: item.description, physical_spec: item.physical_spec, package_spec: item.package_spec, offer_id: item.listing.offer_id, @@ -1955,4 +1967,66 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => { '独立持久化后的采集商品描述', ); }); + + it('俄文发布标题为空时发布就绪校验提示必填,填写后移除提示', async () => { + await database.workspaceTransaction(workspaceId, async (tx) => { + const store = await tx.query<{ id: string }>( + `SELECT id FROM atlas.stores + WHERE workspace_id = current_setting('app.workspace_id')::uuid + AND platform_id = $1 AND is_default = true + LIMIT 1`, + [platformId], + ); + if (!store.rows.length) + await tx.query( + `INSERT INTO atlas.stores + (workspace_id, platform_id, name, platform_client_id, + currency_code, is_default, status, credential_status) + VALUES (current_setting('app.workspace_id')::uuid, $1, + '俄文标题测试店铺', $2, 'RUB', true, 'active', 'valid')`, + [platformId, `editor-title-${randomUUID()}`], + ); + }); + 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 profileId = entered.body.data.profile_id as string; + const missing = await request( + `/api/product-platform-profiles/${profileId}/validation`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal(missing.response.status, 200); + assert.ok( + missing.body.data.profile.issues.some( + (issue: { code: string; field: string }) => + issue.code === 'PUBLISH_TITLE_REQUIRED' && + issue.field === 'profile.title', + ), + ); + await database.workspaceTransaction(workspaceId, (tx) => + tx.query( + `UPDATE atlas.product_platform_profiles SET title = $1 WHERE id = $2`, + ['Название товара', profileId], + ), + ); + const filled = await request( + `/api/product-platform-profiles/${profileId}/validation`, + { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }, + ); + assert.equal(filled.response.status, 200); + assert.ok( + !filled.body.data.profile.issues.some( + (issue: { code: string }) => issue.code === 'PUBLISH_TITLE_REQUIRED', + ), + ); + }); }); diff --git a/apps/web/HANDOFF.md b/apps/web/HANDOFF.md index cbb873f..5eb3441 100644 --- a/apps/web/HANDOFF.md +++ b/apps/web/HANDOFF.md @@ -1,10 +1,20 @@ -# Web 前端交接:商品编辑器与门店类目 +# Web 前端交接:商品编辑器与 AI 填报 -更新日期:2026-09-11 +更新日期:2026-09-23 -本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。 +本文记录 Web 编辑页的现行状态;跨领域总体进度以 [`../../docs/project/HANDOFF.md`](../../docs/project/HANDOFF.md) 的 1.39 为准。开始继续开发前请查看仓库根目录的 `git status --short`,保留目前 API、Worker、contracts、迁移及 Web 中未提交和未跟踪的改动。 -## 当前完成状态 +## 当前状态(2026-09-23) + +- 编辑页 `/products/:id/editor` 的 `ProductEditPage.tsx` 将底部操作条固定在视口底部,提供「AI 填报」和「保存草稿」;AI 生图、首次上架仍是占位/不可用入口。全局 AI 填报会先保存未保存草稿,再创建异步任务并轮询;核对商品、profile、来源版本和本地表单未变化后,只把合格结果填进空字段,不直接持久化。用户仍须检查并保存。 +- 全局 AI 只回填 SPU 发布标题、标签、描述和中文对照,以及合格的类目、型号、未导入 SKU 货号与有证据的包装建议。类目有多个候选时,「确认 AI 推荐类目」弹窗用单选卡片让用户选择;选择前不自动写入类目。类目一旦确定,页面按现有机制加载动态属性,但不调用 AI 填写 Ozon 类目属性。 +- SPU 的「中文商品名」和「俄文发布标题」在页面中均标为必填;发布就绪校验也要求俄文标题。标题、标签、描述各有中文对照入口;标题旁有加号,可开启 SKU 标题覆盖。SKU 默认继承 SPU 文案,用户可在当前 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-11 历史前端记录(以下内容仅供追溯) ### 商品编辑器 @@ -51,8 +61,8 @@ git diff --check 两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。 -## 前端边界 +## 当前工作约定 -- 仅修改 `apps/web/` 及前端相关文档。 -- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。 +- 当前任务可按仓库 `AGENTS.md` 修改相关项目代码;跨领域改动需遵守 [`../../docs/project/development-collaboration-contract.md`](../../docs/project/development-collaboration-contract.md) 并说明影响范围。 +- 验证需要时可在确认目标进程和端口后启动、停止或重启 API/Worker;完成后说明实际操作与状态。上方 2026-09-11 的验证记录不能替代新改动的检查。 - 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。 diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index 37e316a..ebd6681 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -9,7 +9,8 @@ .product-editor { max-width: 1500px; margin: 0 auto; - padding-bottom: 92px; + /* 固定操作条不参与文档流,末尾内容需保留可滚动的安全空间。 */ + padding-bottom: 132px; } /* 编辑器快照加载前占满内容可视区,避免加载图标受页面留白影响而偏移。 */ @@ -165,16 +166,63 @@ font-size: 13px; } +/* AI 类目候选为整行单选项,确认按钮只在用户明确选择后可用。 */ +.ai-category-modal .ant-modal-title { + font-size: 18px; +} +.ai-category-modal-intro { + margin: 0 0 18px; + color: #667085; + line-height: 1.6; +} +.ai-category-candidates.ant-radio-group { + display: grid; + gap: 10px; + width: 100%; + max-height: min(50vh, 400px); + overflow-y: auto; +} +.ai-category-candidate.ant-radio-wrapper { + display: flex; + align-items: flex-start; + width: 100%; + margin: 0; + padding: 13px 16px; + border: 1px solid #e4e7ec; + border-radius: 10px; + color: #344054; + line-height: 1.5; + white-space: normal; + overflow-wrap: anywhere; + transition: border-color 0.2s, background-color 0.2s; +} +.ai-category-candidate.ant-radio-wrapper:hover { + border-color: #a99bea; + background: #faf9ff; +} +.ai-category-candidate.ant-radio-wrapper:has(.ant-radio-checked) { + border-color: #7958e5; + background: #f7f4ff; +} +.ai-category-candidate .ant-radio { + margin-top: 3px; +} +.ai-category-candidate > span:last-child { + min-width: 0; +} + /* 底部操作条 */ .editor-footer { - position: sticky; - bottom: 0; - z-index: 10; + position: fixed; + right: 20px; + bottom: max(16px, env(safe-area-inset-bottom)); + left: 220px; + z-index: 50; display: flex; align-items: center; justify-content: space-between; gap: 16px; - margin-top: 16px; + margin-top: 0; padding: 13px 16px; border: 1px solid #e4e0eb; border-radius: 12px; @@ -182,6 +230,11 @@ box-shadow: 0 -8px 24px rgba(38, 27, 56, 0.08); backdrop-filter: blur(8px); } + +/* 左侧导航折叠后,让固定操作条与实际内容区的左边缘对齐。 */ +.main-sider.ant-layout-sider-collapsed ~ .main-content-layout .editor-footer { + left: 80px; +} .footer-ai-actions { display: flex; } @@ -220,7 +273,17 @@ flex: 0 0 auto !important; } } +/* 移动端导航改为抽屉,固定条回到完整视口宽度。 */ +@media (max-width: 767px) { + .editor-footer { + right: 16px; + left: 16px; + } +} @media (max-width: 860px) { + .product-editor { + padding-bottom: 188px; + } .editor-header-main, .editor-footer { align-items: flex-start; diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 6c2445b..9a5f403 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -1,19 +1,24 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useBlocker, useNavigate, useParams } from 'react-router-dom'; -import { Alert, App as AntdApp, Button, Form, Space, Spin, Tag, Typography } from 'antd'; +import { Alert, App as AntdApp, Button, Form, Modal, Radio, Space, Spin, Tag, Typography } from 'antd'; import { ArrowLeftOutlined, FileTextOutlined, RobotOutlined, SaveOutlined, - TagsOutlined, } from '@ant-design/icons'; import type { + AiCopyOutput, CollectionItemInspection, ProductAttributeDictionaryValue, ProductAttributeSchema, ProductAiSourceField, ProductAiSourceMaterial, + ProductAiTask, + ProductAiAutofillResult, + ProductAiSkuCopyResult, + ProductCopyField, + ProductCopyTranslation, ProductEditorSnapshot, ProductEditorCategoryOption, SaveProductEditorDraftRequest, @@ -30,6 +35,11 @@ import { ProductAttributeSection } from './components/ProductAttributeSection'; import { ProductMainInfoSection } from './components/ProductMainInfoSection'; import { ProductOtherSection } from './components/ProductOtherSection'; import { ProductVariantSection } from './components/ProductVariantSection'; +import { + ProductCopyComparisonModal, + SkuCopyAiModal, + type ComparisonRow, +} from './components/ProductCopyAiDialogs'; import './ProductEditPage.css'; type EditorFormValues = SaveProductEditorDraftRequest; @@ -137,6 +147,7 @@ function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { source_sku: variant.source_sku, specification_values: variant.specification_values, purchase_cost_cny: variant.purchase_cost_cny, + title: variant.title, tags: variant.tags, description: variant.description, physical_spec: variant.physical_spec, @@ -274,6 +285,7 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { source_sku: nullable(variant.source_sku), purchase_cost_cny: nullable(variant.purchase_cost_cny), offer_id: nullable(variant.offer_id), + title: nullable(variant.title), specification_values: variant.specification_values.map((item) => ({ axis_key: item.axis_key.trim(), value_key: item.value_key.trim(), @@ -424,6 +436,22 @@ const ProductEditPage = () => { const [usesVariantPackageDimensions, setUsesVariantPackageDimensions] = useState(false); const [usesVariantTags, setUsesVariantTags] = useState(false); const [usesVariantDescription, setUsesVariantDescription] = useState(false); + const [usesVariantTitle, setUsesVariantTitle] = useState(false); + const [copyTranslations, setCopyTranslations] = useState([]); + const [aiAutofillLoading, setAiAutofillLoading] = useState(false); + const [aiAutofillStatus, setAiAutofillStatus] = useState(''); + const [categoryCandidates, setCategoryCandidates] = useState< + NonNullable | null + >(null); + const [selectedCandidateId, setSelectedCandidateId] = useState(null); + const [comparison, setComparison] = useState<{ + variantId: string | null; + field: ProductCopyField; + } | null>(null); + const [skuCopyVariantId, setSkuCopyVariantId] = useState(null); + const [skuCopyLoading, setSkuCopyLoading] = useState(false); + const [skuCopyResult, setSkuCopyResult] = useState(null); + const [skuCopyError, setSkuCopyError] = useState(null); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); @@ -470,6 +498,8 @@ const ProductEditPage = () => { ); setUsesVariantTags(next.variants.some((variant) => variant.tags.length > 0)); setUsesVariantDescription(next.variants.some((variant) => !!variant.description)); + setUsesVariantTitle(next.variants.some((variant) => !!variant.title)); + setCopyTranslations(next.copy_translations); setDirty(false); } catch (error) { message.error(apiErrorMessage(error)); @@ -765,35 +795,13 @@ const ProductEditPage = () => { setUsesVariantPackageDimensions(true); setDirty(true); }; - /** 将 SPU 主题标签复制到各 SKU;后续可在每个 SKU 内独立维护。 */ - const useVariantTags = () => { - const tags = (form.getFieldValue(['profile', 'tags']) ?? []) as string[]; - const variants = form.getFieldValue('variants') as EditorFormValues['variants']; - form.setFieldsValue({ - variants: variants.map((variant) => ({ - ...variant, - tags: variant.tags.length ? variant.tags : tags, - })), - }); - setUsesVariantTags(true); - setDirty(true); - }; - /** 将 SPU 产品描述复制到各 SKU;后续可在每个 SKU 内独立维护。 */ - const useVariantDescription = () => { - const description = form.getFieldValue(['profile', 'description']) as string | null; - const variants = form.getFieldValue('variants') as EditorFormValues['variants']; - form.setFieldsValue({ - variants: variants.map((variant) => ({ - ...variant, - description: variant.description ?? description, - })), - }); - setUsesVariantDescription(true); - setDirty(true); - }; + /** SKU 文案默认继承 SPU;加号仅显示覆盖输入框,不复制共用值。 */ + const useVariantTags = () => setUsesVariantTags(true); + const useVariantDescription = () => setUsesVariantDescription(true); + const useVariantTitle = () => setUsesVariantTitle(true); /** 保存草稿:携带快照版本号做乐观锁;来源更新/版本冲突时重新加载合并结果。 */ const save = async (values: EditorFormValues) => { - if (!productId || !currentPlatform || !snapshot) return; + if (!productId || !currentPlatform || !snapshot) return false; setSaving(true); try { await productEditorApi.save( @@ -804,11 +812,17 @@ const ProductEditPage = () => { ...values, ai_source_material: sourceDraft ?? snapshot.source_material, ai_source_overridden_fields: sourceOverriddenFields, + copy_translations: copyTranslations.filter( + (item) => + !item.variant_id || + values.variants.some((variant) => variant.id === item.variant_id) + ), }) ); message.success('草稿已保存'); setDirty(false); await load(); + return true; } catch (error) { if (apiErrorCode(error) === 'SOURCE_SNAPSHOT_CHANGED') { message.warning('采集来源已更新,已重新加载合并结果,请确认后再次保存'); @@ -817,10 +831,234 @@ const ProductEditPage = () => { message.warning('商品已在其他位置更新,已重新加载最新内容'); await load(); } else message.error(apiErrorMessage(error)); + return false; } finally { setSaving(false); } }; + const upsertTranslation = ( + previous: ProductCopyTranslation[], + variantId: string | null, + field: ProductCopyField, + russian: string | string[], + chinese: string | string[], + taskId: string + ) => [ + ...previous.filter((item) => !(item.variant_id === variantId && item.field === field)), + { + variant_id: variantId, + field, + russian_value: russian, + chinese_value: chinese, + source_task_id: taskId, + }, + ]; + const addCopyTranslations = (variantId: string | null, copy: AiCopyOutput, taskId: string) => { + setCopyTranslations((current) => { + let next = current; + if (copy.title) + next = upsertTranslation( + next, + variantId, + 'title', + copy.title.ru, + copy.title.zh, + taskId + ); + if (copy.tags?.length) + next = upsertTranslation( + next, + variantId, + 'tags', + copy.tags.map((tag) => tag.ru), + copy.tags.map((tag) => tag.zh), + taskId + ); + if (copy.description) + next = upsertTranslation( + next, + variantId, + 'description', + copy.description.ru, + copy.description.zh, + taskId + ); + return next; + }); + }; + /** 任务输出必须与发起时的版本一致,避免异步结果覆盖后续人工编辑。 */ + const waitForAiTask = async (taskId: string): Promise => { + if (!currentPlatform) throw new Error('平台未选择'); + for (let attempt = 0; attempt < 60; attempt += 1) { + if (attempt) await wait(2_000); + const task = await productEditorApi.aiTask(taskId, currentPlatform.id); + if (['succeeded', 'partial', 'failed'].includes(task.status)) return task; + } + throw new Error('AI 任务仍在处理,请稍后重试'); + }; + const selectCategoryCandidate = ( + candidates: NonNullable + ) => { + setSelectedCandidateId(null); + setCategoryCandidates(candidates); + }; + const confirmCategoryCandidate = () => { + if (!selectedCandidateId) return; + form.setFieldsValue({ + profile: { + platform_product_category_id: selectedCandidateId, + category_source: 'ai_suggestion', + }, + attributes: [], + }); + setDirty(true); + setCategoryCandidates(null); + setSelectedCandidateId(null); + }; + const applyAutofill = (result: ProductAiAutofillResult, taskId: string) => { + const values = form.getFieldsValue(true) as EditorFormValues; + const profile = { ...values.profile }; + const common = { ...values.common }; + const variants = [...values.variants]; + if (result.category && !profile.platform_product_category_id) { + profile.platform_product_category_id = result.category.category_id; + profile.category_source = 'ai_suggestion'; + } else if (result.category_candidates?.length) + selectCategoryCandidate(result.category_candidates); + if (result.model && !common.model) common.model = result.model; + if (result.package_spec) { + const spec = { ...common.default_package_spec }; + let packageFilled = false; + for (const key of [ + 'length_mm', + 'width_mm', + 'height_mm', + 'shipping_weight_g', + ] as const) { + if (!spec[key]) { + spec[key] = result.package_spec[key]; + packageFilled = true; + } + } + if (packageFilled && spec.status !== 'confirmed') spec.status = 'ambiguous'; + common.default_package_spec = spec; + } + for (const offer of result.offers ?? []) { + const index = variants.findIndex((variant) => variant.id === offer.variant_id); + if (index >= 0 && !variants[index].offer_id) + variants[index] = { ...variants[index], offer_id: offer.offer_id }; + } + const copy = result.spu_copy; + if (copy?.title && !profile.title) { + profile.title = copy.title.ru; + addCopyTranslations(null, { title: copy.title }, taskId); + } + if (copy?.tags?.length && !profile.tags.length) { + profile.tags = copy.tags.map((tag) => tag.ru); + addCopyTranslations(null, { tags: copy.tags }, taskId); + } + if (copy?.description && !profile.description) { + profile.description = copy.description.ru; + addCopyTranslations(null, { description: copy.description }, taskId); + } + form.setFieldsValue({ profile, common, variants }); + setDirty(true); + if (result.warnings.length) message.warning(result.warnings.join(';')); + else message.success('AI 结果已填入表单,请检查后保存'); + }; + const startAutofill = async () => { + if (!snapshot || !currentPlatform || aiAutofillLoading) return; + setAiAutofillLoading(true); + try { + if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return; + const formAtStart = JSON.stringify(form.getFieldsValue(true)); + setAiAutofillStatus('正在分析采集信息…'); + const task = await productEditorApi.aiAutofill(snapshot.profile.id, currentPlatform.id); + const completed = await waitForAiTask(task.ai_task_id); + if (completed.status === 'failed' || !completed.result) + throw new Error(completed.error_message ?? 'AI 生成失败'); + const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id); + if ( + latest.product.revision !== completed.input_revision.product || + latest.profile.revision !== completed.input_revision.profile || + latest.source_material.source_snapshot_revision !== + completed.input_revision.source_snapshot || + JSON.stringify(form.getFieldsValue(true)) !== formAtStart + ) { + message.warning( + '商品或采集信息在 AI 处理期间已变化,结果未自动回填,请重新点击 AI 填报' + ); + return; + } + applyAutofill(completed.result as ProductAiAutofillResult, completed.id); + } catch (error) { + message.error(error instanceof Error ? error.message : apiErrorMessage(error)); + } finally { + setAiAutofillLoading(false); + setAiAutofillStatus(''); + } + }; + const generateSkuCopy = async () => { + if (!snapshot || !currentPlatform || !skuCopyVariantId || skuCopyLoading) return; + setSkuCopyLoading(true); + setSkuCopyError(null); + setSkuCopyResult(null); + try { + if (dirty && !(await save(form.getFieldsValue(true) as EditorFormValues))) return; + const formAtStart = JSON.stringify(form.getFieldsValue(true)); + const task = await productEditorApi.aiSkuCopy( + snapshot.profile.id, + skuCopyVariantId, + currentPlatform.id + ); + const completed = await waitForAiTask(task.ai_task_id); + if (completed.status === 'failed' || !completed.result) + throw new Error(completed.error_message ?? 'SKU 文案生成失败'); + const latest = await productEditorApi.read(snapshot.product.id, currentPlatform.id); + if ( + latest.product.revision !== completed.input_revision.product || + latest.profile.revision !== completed.input_revision.profile || + JSON.stringify(form.getFieldsValue(true)) !== formAtStart + ) + throw new Error('商品已被其他操作更新,请重新生成'); + skuCopyTaskId.current = completed.id; + setSkuCopyResult((completed.result as ProductAiSkuCopyResult).copy); + } catch (error) { + setSkuCopyError(error instanceof Error ? error.message : apiErrorMessage(error)); + } finally { + setSkuCopyLoading(false); + } + }; + const applySkuCopy = () => { + if (!skuCopyVariantId || !skuCopyResult) return; + const variants = form.getFieldValue('variants') as EditorFormValues['variants']; + const index = variants.findIndex((variant) => variant.id === skuCopyVariantId); + if (index < 0) return; + const copy = skuCopyResult; + form.setFieldValue( + 'variants', + variants.map((variant, current) => + current !== index + ? variant + : { + ...variant, + title: copy.title?.ru ?? variant.title, + tags: copy.tags?.map((tag) => tag.ru) ?? variant.tags, + description: copy.description?.ru ?? variant.description, + } + ) + ); + // SKU 生成结果只在用户明确应用后写入;中文对照与俄文同时保存。 + if (skuCopyTaskId.current) + addCopyTranslations(skuCopyVariantId, copy, skuCopyTaskId.current); + setUsesVariantTitle(true); + setUsesVariantTags(true); + setUsesVariantDescription(true); + setDirty(true); + setSkuCopyVariantId(null); + message.success('已应用到当前 SKU,保存草稿后生效'); + }; + const skuCopyTaskId = useRef(null); const showFutureFeature = (name: string) => modal.info({ title: `${name}将在下一阶段接入`, @@ -861,8 +1099,8 @@ const ProductEditPage = () => { { product: snapshot.product.revision, profile: snapshot.profile.revision }, { description, - source_snapshot_revision: - (sourceDraft ?? snapshot.source_material).source_snapshot_revision, + source_snapshot_revision: (sourceDraft ?? snapshot.source_material) + .source_snapshot_revision, } ); setSnapshot((current) => @@ -891,8 +1129,7 @@ const ProductEditPage = () => { ? { ...current, description: saved.source_material.description, - source_snapshot_revision: - saved.source_material.source_snapshot_revision, + source_snapshot_revision: saved.source_material.source_snapshot_revision, } : saved.source_material ); @@ -1072,8 +1309,45 @@ const ProductEditPage = () => { /> ); - const hasSourceCategory = snapshot.profile.category.source === 'source_capture'; const summary = collectedSummary(collected ?? snapshot.source_material); + const currentValues = form.getFieldsValue(true) as EditorFormValues; + const activeSku = skuCopyVariantId + ? currentValues.variants?.find((variant) => variant.id === skuCopyVariantId) + : null; + const comparisonVariant = comparison?.variantId + ? currentValues.variants?.find((variant) => variant.id === comparison.variantId) + : null; + const comparisonRows: ComparisonRow[] = (['title', 'tags', 'description'] as const).map( + (field) => { + const ownValue = comparisonVariant?.[field]; + const inherited = + !!comparisonVariant && + (field === 'tags' ? !Array.isArray(ownValue) || !ownValue.length : !ownValue); + const russianValue = + inherited || !comparisonVariant ? currentValues.profile?.[field] : ownValue; + const russian = Array.isArray(russianValue) + ? russianValue.join('、') + : (russianValue ?? ''); + const translation = copyTranslations.find( + (item) => + item.variant_id === (inherited ? null : (comparison?.variantId ?? null)) && + item.field === field + ); + const savedRussian = Array.isArray(translation?.russian_value) + ? translation.russian_value.join('、') + : (translation?.russian_value ?? ''); + const chinese = Array.isArray(translation?.chinese_value) + ? translation.chinese_value.join('、') + : (translation?.chinese_value ?? ''); + return { + field, + russian, + chinese: savedRussian === russian ? chinese : '', + inherited, + stale: !!translation && savedRussian !== russian, + }; + } + ); return (
{ }} usesVariantPackageWeight={usesVariantPackageWeight} usesVariantPackageDimensions={usesVariantPackageDimensions} + usesVariantTitle={usesVariantTitle} + onUseVariantTitle={useVariantTitle} + onCompareTitle={() => setComparison({ variantId: null, field: 'title' })} onUseVariantPackageWeight={useVariantPackageWeight} onUseVariantPackageDimensions={useVariantPackageDimensions} /> @@ -1165,6 +1442,7 @@ const ProductEditPage = () => { usesVariantDescription={usesVariantDescription} onUseVariantTags={useVariantTags} onUseVariantDescription={useVariantDescription} + onCompareCopy={(field) => setComparison({ variantId: null, field })} /> { usesVariantPackageDimensions={usesVariantPackageDimensions} usesVariantTags={usesVariantTags} usesVariantDescription={usesVariantDescription} + usesVariantTitle={usesVariantTitle} + onAiCopy={(variantId) => { + setSkuCopyVariantId(variantId); + setSkuCopyResult(null); + setSkuCopyError(null); + skuCopyTaskId.current = null; + }} + onCompareCopy={(variantId, field) => setComparison({ variantId, field })} /> { - + setComparison(null)} + /> + `${item.axis_key}: ${item.value_name}`) + .join(';') ?? '' + } + loading={skuCopyLoading} + result={skuCopyResult} + error={skuCopyError} + onGenerate={() => void generateSkuCopy()} + onApply={applySkuCopy} + onClose={() => setSkuCopyVariantId(null)} + /> + { + setCategoryCandidates(null); + setSelectedCandidateId(null); + }} + > +

+ 根据采集信息找到以下可能的类目。请选择最符合商品的一个,确认后会填入表单。 +

+ setSelectedCandidateId(event.target.value)} + > + {categoryCandidates?.map((candidate) => ( + + {candidate.path} + + ))} + +
); }; diff --git a/apps/web/src/pages/products/components/ProductAttributeSection.tsx b/apps/web/src/pages/products/components/ProductAttributeSection.tsx index fbfc49f..5d690e4 100644 --- a/apps/web/src/pages/products/components/ProductAttributeSection.tsx +++ b/apps/web/src/pages/products/components/ProductAttributeSection.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from 'react'; import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd'; import type { FormInstance } from 'antd'; -import { EditOutlined, PlusCircleOutlined } from '@ant-design/icons'; +import { EditOutlined, PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons'; import type { ProductAttributeDictionaryValue, ProductAttributeSchema, @@ -41,6 +41,7 @@ type Props = { usesVariantDescription: boolean; onUseVariantTags: () => void; onUseVariantDescription: () => void; + onCompareCopy: (field: 'tags' | 'description') => void; }; /** @@ -70,6 +71,7 @@ export function ProductAttributeSection({ usesVariantDescription, onUseVariantTags, onUseVariantDescription, + onCompareCopy, }: Props) { // 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。 const [richContent, setRichContent] = useState(''); @@ -95,41 +97,53 @@ export function ProductAttributeSection({ title="产品属性" description="用于当前平台发布与 AI 文案生成。" /> - {!usesVariantTags && ( - -
- - + + {!usesVariantTags && (
-
- )} - {!usesVariantDescription && ( - -
- - - + )} +
+
+ +
+ + + + {!usesVariantDescription && (
-
- )} + )} + } + width={880} + destroyOnHidden + > +
+ 俄文 + 中文对照 +
+ {rows.map((row) => ( +
+
+ {labels[row.field]} + {row.inherited && 继承 SPU} +
{row.russian || '—'}
+
+
+ {labels[row.field]} + {row.stale && 对照待更新} +
{row.chinese || '暂无中文对照'}
+
+
+ ))} + + ); +} + +/** 当前 SKU 生成弹窗只预览结果;点击应用后才写入该 SKU 的未保存表单。 */ +export function SkuCopyAiModal({ + open, + variantName, + sourceTitle, + sourceDescription, + specifications, + loading, + result, + error, + onGenerate, + onApply, + onClose, +}: { + open: boolean; + variantName: string; + sourceTitle: string; + sourceDescription: string; + specifications: string; + loading: boolean; + result: AiCopyOutput | null; + error: string | null; + onGenerate: () => void; + onApply: () => void; + onClose: () => void; +}) { + return ( + + + + + + } + > + {error && ( + + )} +
+
+ 中文来源与 SKU 信息 + SKU 名称 +

{variantName}

+ 来源标题 +

{sourceTitle || '未采集'}

+ 来源描述 +

{sourceDescription || '未采集'}

+ SKU 规格 +

{specifications || '未采集'}

+
+
+ 当前 SKU 的生成结果 + {loading && ( + +
+ + )} + {!loading && !result && ( + + 点击下方「AI 生成」后在此预览。 + + )} + {result && ( +
+ 标题 +

{result.title?.ru || '未生成'}

+

{result.title?.zh || ''}

+ 主题标签 +

{result.tags?.map((tag) => tag.ru).join('、') || '未生成'}

+

+ {result.tags?.map((tag) => tag.zh).join('、') || ''} +

+ 产品描述 +

{result.description?.ru || '未生成'}

+

{result.description?.zh || ''}

+
+ )} +
+
+ + ); +} + +export type { ComparisonRow }; diff --git a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx index 85c2d00..8a2125d 100644 --- a/apps/web/src/pages/products/components/ProductMainInfoSection.tsx +++ b/apps/web/src/pages/products/components/ProductMainInfoSection.tsx @@ -1,6 +1,6 @@ import { Button, Card, Cascader, Form, Input, InputNumber, Radio, Select, Tooltip } from 'antd'; import type { FormInstance } from 'antd'; -import { PlusCircleOutlined } from '@ant-design/icons'; +import { PlusCircleOutlined, TranslationOutlined } from '@ant-design/icons'; import type { ProductEditorSnapshot } from '@atlas/contracts'; import { SectionTitle } from './SectionTitle'; import './ProductMainInfoSection.css'; @@ -93,6 +93,9 @@ type Props = { usesVariantPackageDimensions: boolean; onUseVariantPackageWeight: () => void; onUseVariantPackageDimensions: () => void; + usesVariantTitle: boolean; + onUseVariantTitle: () => void; + onCompareTitle: () => void; }; /** @@ -114,6 +117,9 @@ export function ProductMainInfoSection({ usesVariantPackageDimensions, onUseVariantPackageWeight, onUseVariantPackageDimensions, + usesVariantTitle, + onUseVariantTitle, + onCompareTitle, }: Props) { return ( @@ -170,7 +176,10 @@ export function ProductMainInfoSection({ getValueProps={(categoryId: string | null) => ({ value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [], })} - getValueFromEvent={(value: string[]) => value.at(-1) ?? null} + // Cascader 清除时会传入 undefined,统一折算为空类目避免读取空值。 + getValueFromEvent={(value: unknown) => + Array.isArray(value) ? (value.at(-1) ?? null) : null + } > node.parentElement ?? document.body} onChange={(value) => { - const categoryId = value.at(-1) ?? null; + const categoryId = Array.isArray(value) ? (value.at(-1) ?? null) : null; if (categoryId !== selectedCategoryId) form.setFieldValue('attributes', []); form.setFieldValue( @@ -194,11 +203,47 @@ export function ProductMainInfoSection({ - + + + +
+ + + + {!usesVariantTitle && ( + +
label { - justify-content: flex-start; -} .sku-card-value strong { min-height: 32px; padding-top: 5px; @@ -338,7 +318,7 @@ .sku-info-variant-description { grid-column: span 2; } -/* 动态 SKU 属性也属于同一信息网格,不能再形成独立的横向属性区。 */ +/* 所有动态 SKU 属性与包装、文案字段共用“SKU 信息”网格。 */ .sku-info-panel .dynamic-attribute-list { display: contents; } @@ -384,9 +364,6 @@ } @media (max-width: 560px) { - .sku-attribute-panel { - padding-left: 0; - } .sku-inline-dimensions { grid-template-columns: 1fr; } diff --git a/apps/web/src/pages/products/components/ProductVariantSection.tsx b/apps/web/src/pages/products/components/ProductVariantSection.tsx index c1a1e28..ffa63b5 100644 --- a/apps/web/src/pages/products/components/ProductVariantSection.tsx +++ b/apps/web/src/pages/products/components/ProductVariantSection.tsx @@ -13,7 +13,14 @@ import { Typography, } from 'antd'; import type { FormInstance } from 'antd'; -import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons'; +import { + CopyOutlined, + DeleteOutlined, + DownOutlined, + PlusOutlined, + RobotOutlined, + TranslationOutlined, +} from '@ant-design/icons'; import type { CollectionItemInspection, ProductAttributeDictionaryValue, @@ -124,6 +131,9 @@ type Props = { usesVariantPackageDimensions: boolean; usesVariantTags: boolean; usesVariantDescription: boolean; + usesVariantTitle: boolean; + onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; + onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void; }; /** @@ -151,23 +161,19 @@ export function ProductVariantSection({ usesVariantPackageDimensions, usesVariantTags, usesVariantDescription, + usesVariantTitle, + onAiCopy, + onCompareCopy, }: Props) { // 实时监听变体草稿:驱动主图回显与候选图片的选中态。 const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[]; // 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。 const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => new Set()); - const requiredAspectDefinitions = activeAttributeSchema.filter( - (definition) => - definition.is_aspect && - definition.is_required && - definition.name.trim() !== '商品颜色' && - !definition.editor_field - ); + // SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。 const skuInfoAspectDefinitions = activeAttributeSchema .filter( (definition) => definition.is_aspect && - (!definition.is_required || definition.name.trim() === '商品颜色') && !definition.editor_field ) .sort((left, right) => skuInfoAttributeOrder(left) - skuInfoAttributeOrder(right)); @@ -196,6 +202,7 @@ export function ProductVariantSection({ source_sku: null, specification_values: [], purchase_cost_cny: null, + title: null, tags: [], description: null, physical_spec: { @@ -269,6 +276,9 @@ export function ProductVariantSection({ display_name: variantValue.display_name ? `${variantValue.display_name} 副本` : 'SKU 副本', + title: null, + tags: [], + description: null, offer_id: null, media_asset_ids: [], }); @@ -425,39 +435,6 @@ export function ProductVariantSection({
- {requiredAspectDefinitions.length > 0 && ( -
- - Ozon 变体属性 - - {sourceId ? ( - - ) : ( - - 保存新 SKU 后即可填写其 Ozon 变体属性。 - - )} -
- )} {expanded && (
@@ -569,6 +546,7 @@ export function ProductVariantSection({ usesVariantPackageDimensions || usesVariantTags || usesVariantDescription || + usesVariantTitle || (skuInfoAspectDefinitions.length > 0 && sourceId)) && (
@@ -615,31 +593,146 @@ export function ProductVariantSection({ )}
)} + {usesVariantTitle && ( + + + + + +