From da7342b466092ff65ef14c4fff7050928180c842 Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 11 Sep 2026 17:28:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(edit):=20=E7=BC=96=E8=BE=91=E9=A1=B5?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/stores/stores.controller.ts | 38 + apps/api/src/stores/stores.service.ts | 340 ++++- apps/api/test/stores.e2e.test.ts | 109 ++ .../test/worker-catalog-scheduler.e2e.test.ts | 51 + apps/web/HANDOFF.md | 58 + apps/web/src/layouts/menuConfig.tsx | 14 + .../src/pages/products/ProductEditPage.css | 162 +-- .../src/pages/products/ProductEditPage.tsx | 1169 ++++++++--------- .../components/CollectedProductInfoPanel.tsx | 130 ++ .../components/ProductMainInfoSection.tsx | 156 +++ .../components/ProductOtherSection.tsx | 50 + .../src/pages/shops/CategoryTreeDebugPage.css | 49 + .../src/pages/shops/CategoryTreeDebugPage.tsx | 154 +++ apps/web/src/pages/shops/ShopsPage.tsx | 32 +- apps/web/src/router/index.tsx | 2 + apps/web/src/services/categoryTreeDebug.ts | 9 + apps/web/src/services/stores.ts | 11 + apps/worker/src/ozon-catalog-sync.ts | 31 +- apps/worker/src/ozon-client.ts | 5 +- docs/api/stores-and-platforms.md | 4 + .../ozon-seller-api/02-category-tree.md | 193 +-- 21 files changed, 1910 insertions(+), 857 deletions(-) create mode 100644 apps/web/HANDOFF.md create mode 100644 apps/web/src/pages/products/components/CollectedProductInfoPanel.tsx create mode 100644 apps/web/src/pages/products/components/ProductMainInfoSection.tsx create mode 100644 apps/web/src/pages/products/components/ProductOtherSection.tsx create mode 100644 apps/web/src/pages/shops/CategoryTreeDebugPage.css create mode 100644 apps/web/src/pages/shops/CategoryTreeDebugPage.tsx create mode 100644 apps/web/src/services/categoryTreeDebug.ts diff --git a/apps/api/src/stores/stores.controller.ts b/apps/api/src/stores/stores.controller.ts index 6fdf7cf..8ac859f 100644 --- a/apps/api/src/stores/stores.controller.ts +++ b/apps/api/src/stores/stores.controller.ts @@ -207,6 +207,44 @@ export class StoresController { ); } + @Post(':storeId/category-tree-syncs') + @ApiOperation({ summary: '同步刷新门店类目树' }) + @ApiOkResponse({ description: 'Ozon 类目树已成功写入门店缓存' }) + public async categoryTreeSync( + @Param('storeId') storeId: string, + @Headers('idempotency-key') idempotencyKey: string | undefined, + @Req() request: AuthenticatedRequest, + @CurrentWorkspace() workspace: Workspace, + @Res({ passthrough: true }) response: HttpResponse, + ) { + return withStatus( + response, + await this.storesService.requestCategoryTreeSync( + workspace.id, + request.auth.userId, + requireUuid('store_id', storeId), + requireIdempotencyKey(idempotencyKey), + ), + ); + } + + @Post('default/category-tree-debug') + @ApiOperation({ summary: '读取当前平台默认门店的 Ozon 原始类目树(调试)' }) + public async defaultCategoryTreeDebug( + @Headers('x-atlas-platform-id') platformId: string | undefined, + @CurrentWorkspace() workspace: Workspace, + ) { + if (!platformId) { + throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id'); + } + return { + data: await this.storesService.fetchDefaultCategoryTreeDebug( + workspace.id, + requireUuid('X-Atlas-Platform-Id', platformId), + ), + }; + } + @Post(':storeId/warehouse-syncs') @ApiOperation({ summary: '请求异步同步门店仓库' }) @ApiAcceptedResponse({ description: '仓库同步操作已入队' }) diff --git a/apps/api/src/stores/stores.service.ts b/apps/api/src/stores/stores.service.ts index ceb7f73..243201d 100644 --- a/apps/api/src/stores/stores.service.ts +++ b/apps/api/src/stores/stores.service.ts @@ -11,7 +11,7 @@ import { ApiError } from '../common/api-error.js'; import { IdempotencyService } from '../common/idempotency.service.js'; import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; import { DATABASE } from '../database/database.module.js'; -import { encryptStoreSecret } from './store-secret.crypto.js'; +import { decryptStoreSecret, encryptStoreSecret } from './store-secret.crypto.js'; type StoreRow = Omit & { credential_checked_at: Date | string | null; @@ -46,6 +46,23 @@ type WarehouseRow = { status: string; synced_at: Date | string; }; +type OzonCategoryNode = { + description_category_id?: number | string; + category_name?: string | null; + type_id?: number | string | null; + type_name?: string | null; + disabled?: boolean; + children?: OzonCategoryNode[]; +}; +type FlatCategory = { + external_category_id: string; + external_type_id: string; + parent_key: string | null; + name: string; + path: string; + is_active: boolean; + source_payload: OzonCategoryNode; +}; @Injectable() export class StoresService { @@ -335,6 +352,170 @@ export class StoresService { ); } + /** 手动刷新是同步请求:Ozon 完整响应成功落库后才向浏览器返回成功。 */ + public async requestCategoryTreeSync( + workspaceId: string, + userId: string, + storeId: string, + idempotencyKey: string, + ) { + return this.idempotency.run( + { + workspaceId, + userId, + method: 'POST', + path: `/api/stores/${storeId}/category-tree-syncs`, + key: idempotencyKey, + request: {}, + }, + async (transaction) => { + const store = await this.requireStore(transaction, storeId); + if (store.status === 'disabled') { + throw new ApiError(422, 'STORE_DISABLED', '门店已停用'); + } + if (store.credential_status !== 'valid') { + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '门店凭据尚未验证通过,无法刷新类目', + ); + } + + // 保持门店行锁直到完成写入,防止同一门店的两次刷新交错覆盖缓存。 + await transaction.query( + 'SELECT id FROM atlas.stores WHERE id = $1 FOR UPDATE', + [storeId], + ); + const source = await transaction.query<{ + platform_id: string; + platform_code: string; + platform_client_id: string; + secret_ciphertext: Buffer; + secret_nonce: Buffer; + }>( + `SELECT store.platform_id, platform.code AS platform_code, store.platform_client_id, + secret.secret_ciphertext, secret.secret_nonce + FROM atlas.stores store + JOIN atlas.platforms platform ON platform.id = store.platform_id + JOIN atlas.store_secrets secret ON secret.store_id = store.id + WHERE store.id = $1 AND store.deleted_at IS NULL + AND secret.kind = 'seller_api_key' AND secret.revoked_at IS NULL + AND secret.status = 'valid' + LIMIT 1`, + [storeId], + ); + const current = source.rows[0]; + if (!current) { + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '门店凭据尚未验证通过,无法刷新类目', + ); + } + if (current.platform_code.toUpperCase() !== 'OZON') { + throw new ApiError( + 422, + 'PLATFORM_CATALOG_UNSUPPORTED', + '当前平台暂不支持类目缓存同步', + ); + } + const apiKey = decryptStoreSecret( + current.secret_ciphertext, + current.secret_nonce, + this.config.STORE_SECRET_ENCRYPTION_KEY, + ); + const tree = await requestOzonCategoryTree( + current.platform_client_id, + apiKey, + ); + const result = await persistCategoryTreeInTransaction( + transaction, + storeId, + current.platform_id, + tree, + ); + return { status: 200, body: { data: result } }; + }, + ); + } + + /** + * Debug-only read-through for the current platform's default store. The + * credential is decrypted only for this outbound request and is never + * placed in the browser response, logs, operations, or outbox payloads. + */ + public async fetchDefaultCategoryTreeDebug( + workspaceId: string, + platformId: string, + ): Promise { + const source = await this.database.workspaceTransaction( + workspaceId, + async (transaction) => { + const rows = await transaction.query<{ + platform_client_id: string; + secret_ciphertext: Buffer; + secret_nonce: Buffer; + }>( + `SELECT store.platform_client_id, secret.secret_ciphertext, secret.secret_nonce + FROM atlas.stores store + JOIN atlas.platforms platform ON platform.id = store.platform_id + JOIN atlas.store_secrets secret ON secret.store_id = store.id + WHERE store.platform_id = $1 AND store.is_default + AND store.deleted_at IS NULL AND store.status <> 'disabled' + AND store.credential_status = 'valid' + AND platform.code = 'OZON' + AND secret.kind = 'seller_api_key' AND secret.status = 'valid' + AND secret.revoked_at IS NULL + LIMIT 1`, + [platformId], + ); + return rows.rows[0] ?? null; + }, + ); + if (!source) { + throw new ApiError( + 422, + 'STORE_CREDENTIAL_UNAVAILABLE', + '当前平台没有凭据已验证的默认 Ozon 门店', + ); + } + + const apiKey = decryptStoreSecret( + source.secret_ciphertext, + source.secret_nonce, + this.config.STORE_SECRET_ENCRYPTION_KEY, + ); + try { + const response = await fetch( + 'https://api-seller.ozon.ru/v1/description-category/tree', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Client-Id': source.platform_client_id, + 'Api-Key': apiKey, + }, + body: JSON.stringify({ language: 'ZH_HANS' }), + signal: AbortSignal.timeout(30_000), + }, + ); + const body = (await response.json()) as unknown; + if (response.ok) return body; + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_DEBUG_FAILED', + `Ozon 类目树请求失败(HTTP ${response.status})`, + ); + } catch (error) { + if (error instanceof ApiError) throw error; + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_DEBUG_UNAVAILABLE', + 'Ozon 类目树请求暂时不可用,请稍后重试', + ); + } + } + public async requestWarehouseSync( workspaceId: string, userId: string, @@ -661,6 +842,163 @@ export class StoresService { } } +async function requestOzonCategoryTree( + clientId: string, + apiKey: string, +): Promise { + let response: Response; + try { + response = await fetch('https://api-seller.ozon.ru/v1/description-category/tree', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Client-Id': clientId, + 'Api-Key': apiKey, + }, + body: JSON.stringify({ language: 'ZH_HANS' }), + signal: AbortSignal.timeout(30_000), + }); + } catch { + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_UNAVAILABLE', + 'Ozon 类目请求暂时不可用,请稍后重试', + ); + } + if (!response.ok) { + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_REFRESH_FAILED', + `Ozon 类目请求失败(HTTP ${response.status})`, + ); + } + let body: { result?: unknown }; + try { + body = (await response.json()) as { result?: unknown }; + } catch { + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_INVALID', + 'Ozon 返回的类目树格式无效', + ); + } + if (!Array.isArray(body?.result)) { + throw new ApiError( + 502, + 'OZON_CATEGORY_TREE_INVALID', + 'Ozon 返回的类目树格式无效', + ); + } + return body.result as OzonCategoryNode[]; +} + +async function persistCategoryTreeInTransaction( + transaction: DatabaseTransaction, + storeId: string, + platformId: string, + roots: OzonCategoryNode[], +) { + const records = [ + ...new Map( + flattenCategoryTree(roots).map((record) => [ + `${record.external_category_id}:${record.external_type_id}`, + record, + ]), + ).values(), + ]; + const syncTime = new Date(); + await transaction.query( + `WITH incoming AS ( + SELECT * FROM jsonb_to_recordset($3::jsonb) AS value( + external_category_id text, external_type_id text, parent_key text, + name text, path text, is_active boolean, source_payload jsonb + ) + ) + INSERT INTO atlas.store_product_categories + (workspace_id, store_id, platform_id, external_category_id, external_type_id, name, path, + is_active, source_payload, synced_at) + SELECT current_setting('app.workspace_id')::uuid, $1, $2, external_category_id, external_type_id, + name, path, is_active, source_payload, $4::timestamptz + FROM incoming + ON CONFLICT (store_id, external_category_id, external_type_id) DO UPDATE + SET name = EXCLUDED.name, path = EXCLUDED.path, is_active = EXCLUDED.is_active, + source_payload = EXCLUDED.source_payload, synced_at = EXCLUDED.synced_at, + updated_at = now()`, + [storeId, platformId, JSON.stringify(records), syncTime], + ); + await transaction.query( + `WITH incoming AS ( + SELECT * FROM jsonb_to_recordset($2::jsonb) AS value( + external_category_id text, external_type_id text, parent_key text, + name text, path text, is_active boolean, source_payload jsonb + ) + ), resolved AS ( + SELECT child.id AS child_id, parent.id AS parent_id + FROM incoming + JOIN atlas.store_product_categories child + ON child.store_id = $1 + AND child.external_category_id = incoming.external_category_id + AND child.external_type_id = incoming.external_type_id + LEFT JOIN atlas.store_product_categories parent + ON parent.store_id = $1 + AND parent.external_category_id || ':' || parent.external_type_id = incoming.parent_key + ) + UPDATE atlas.store_product_categories category + SET parent_id = resolved.parent_id, updated_at = now() + FROM resolved WHERE category.id = resolved.child_id`, + [storeId, JSON.stringify(records)], + ); + await transaction.query( + `UPDATE atlas.store_product_categories SET is_active = false, updated_at = now() + WHERE store_id = $1 AND synced_at IS DISTINCT FROM $2::timestamptz`, + [storeId, syncTime], + ); + return { + category_count: records.length, + active_category_count: records.filter((record) => record.is_active).length, + }; +} + +function flattenCategoryTree( + nodes: OzonCategoryNode[], + path: string[] = [], + parentKey: string | null = null, + inheritedCategory?: { id: string; name: string }, +): FlatCategory[] { + const records: FlatCategory[] = []; + for (const node of nodes) { + const explicitCategoryId = String(node.description_category_id ?? '').trim(); + const explicitCategoryName = node.category_name?.trim() ?? ''; + const categoryId = explicitCategoryId || inheritedCategory?.id || ''; + const categoryName = explicitCategoryName || inheritedCategory?.name || ''; + const typeId = String(node.type_id ?? 0); + const name = explicitCategoryName || node.type_name?.trim() || categoryName; + if (!categoryId || !name) continue; + const key = `${categoryId}:${typeId}`; + const nextPath = [...path, name]; + records.push({ + external_category_id: categoryId, + external_type_id: typeId, + parent_key: parentKey, + name, + path: nextPath.join(' › '), + is_active: node.disabled !== true && typeId !== '0', + source_payload: node, + }); + records.push( + ...flattenCategoryTree( + node.children ?? [], + nextPath, + key, + explicitCategoryId && explicitCategoryName + ? { id: explicitCategoryId, name: explicitCategoryName } + : inheritedCategory, + ), + ); + } + return records; +} + const storeSelect = ` SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code, s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at, diff --git a/apps/api/test/stores.e2e.test.ts b/apps/api/test/stores.e2e.test.ts index 07a91e5..230de88 100644 --- a/apps/api/test/stores.e2e.test.ts +++ b/apps/api/test/stores.e2e.test.ts @@ -231,6 +231,115 @@ describe('门店 HTTP 集成测试', () => { ); }); + it('同步刷新门店类目:等待 Ozon 返回并完整落库后才成功', async () => { + const owner = await register('stores-category-refresh'); + const platforms = await request('/api/platforms', { + headers: headers(owner), + }); + const platform = success>( + platforms.body, + ).data.find((item) => item.code === 'OZON'); + assert.ok(platform); + const store = await createStore(owner, platform.id, '类目刷新测试店'); + await database.workspaceTransaction(owner.workspace.id, async (tx) => { + await tx.query( + `UPDATE atlas.stores + SET status = 'active', credential_status = 'valid' + WHERE id = $1`, + [store.id], + ); + await tx.query( + `UPDATE atlas.store_secrets SET status = 'valid' + WHERE store_id = $1 AND kind = 'seller_api_key' AND revoked_at IS NULL`, + [store.id], + ); + await tx.query( + `INSERT INTO atlas.store_product_categories + (workspace_id, store_id, platform_id, external_category_id, external_type_id, + name, path, synced_at) + VALUES (current_setting('app.workspace_id')::uuid, $1, $2, + 'cached-category', 'cached-type', '已有缓存', '已有缓存', now())`, + [store.id, platform.id], + ); + }); + + const originalFetch = globalThis.fetch; + let ozonCalls = 0; + globalThis.fetch = async (input, init) => { + if (String(input).startsWith('https://api-seller.ozon.ru/')) { + ozonCalls += 1; + assert.equal(init?.method, 'POST'); + return new Response( + JSON.stringify({ + result: [ + { + description_category_id: 17028750, + category_name: '汽车脚垫和车座套', + disabled: false, + children: [ + { + type_name: '汽车罩', + type_id: 93587, + disabled: false, + children: [], + }, + ], + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + return originalFetch(input, init); + }; + try { + const idempotencyKey = randomUUID(); + const first = await request(`/api/stores/${store.id}/category-tree-syncs`, { + method: 'POST', + headers: headers(owner, idempotencyKey), + }); + assert.equal(first.response.status, 200); + assert.deepEqual( + success<{ category_count: number; active_category_count: number }>(first.body).data, + { category_count: 2, active_category_count: 1 }, + ); + + const replay = await request(`/api/stores/${store.id}/category-tree-syncs`, { + method: 'POST', + headers: headers(owner, idempotencyKey), + }); + assert.equal(replay.response.status, 200); + assert.deepEqual(replay.body, first.body); + assert.equal(ozonCalls, 1); + + await database.workspaceTransaction(owner.workspace.id, async (tx) => { + const categories = await tx.query<{ + external_category_id: string; + external_type_id: string; + name: string; + path: string; + is_active: boolean; + }>( + `SELECT external_category_id, external_type_id, name, path, is_active + FROM atlas.store_product_categories + WHERE store_id = $1 ORDER BY path`, + [store.id], + ); + assert.deepEqual(categories.rows.filter((row) => row.is_active), [ + { + external_category_id: '17028750', + external_type_id: '93587', + name: '汽车罩', + path: '汽车脚垫和车座套 › 汽车罩', + is_active: true, + }, + ]); + }); + } finally { + globalThis.fetch = originalFetch; + } + }); + it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => { const owner = await register('stores-lifecycle'); const otherOwner = await register('stores-isolation'); diff --git a/apps/api/test/worker-catalog-scheduler.e2e.test.ts b/apps/api/test/worker-catalog-scheduler.e2e.test.ts index 46f4a0a..fe4e4bb 100644 --- a/apps/api/test/worker-catalog-scheduler.e2e.test.ts +++ b/apps/api/test/worker-catalog-scheduler.e2e.test.ts @@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, it } from 'vitest'; import { DATABASE } from '../src/database/database.module.js'; import { createApplication } from '../src/main.js'; import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.js'; +import { flattenCategories } from '../../worker/src/ozon-catalog-sync.js'; import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js'; let app: NestExpressApplication; @@ -42,6 +43,56 @@ async function scheduleWithStoreLock() { } describe('门店类目树 Worker 调度集成测试', () => { + it('类型叶子继承父类目 ID,并使用 type_name 作为可选项名称', () => { + const records = flattenCategories([ + { + description_category_id: 17027495, + category_name: '汽车用品', + disabled: false, + children: [ + { + description_category_id: 17028750, + category_name: '汽车脚垫和车座套', + disabled: false, + children: [ + { + type_name: '汽车罩', + type_id: 93587, + disabled: false, + children: [], + }, + ], + }, + ], + }, + ]); + + assert.deepEqual( + records.map((record) => ({ + category: record.external_category_id, + type: record.external_type_id, + name: record.name, + path: record.path, + parent: record.parent_key, + active: record.is_active, + })), + [ + { + category: '17027495', type: '0', name: '汽车用品', path: '汽车用品', + parent: null, active: false, + }, + { + category: '17028750', type: '0', name: '汽车脚垫和车座套', + path: '汽车用品 › 汽车脚垫和车座套', parent: '17027495:0', active: false, + }, + { + category: '17028750', type: '93587', name: '汽车罩', + path: '汽车用品 › 汽车脚垫和车座套 › 汽车罩', parent: '17028750:0', active: true, + }, + ], + ); + }); + beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', diff --git a/apps/web/HANDOFF.md b/apps/web/HANDOFF.md new file mode 100644 index 0000000..cbb873f --- /dev/null +++ b/apps/web/HANDOFF.md @@ -0,0 +1,58 @@ +# Web 前端交接:商品编辑器与门店类目 + +更新日期:2026-09-11 + +本文只覆盖 `apps/web/` 当前未提交的前端改动。开始继续开发前,请先查看仓库根目录的 `git status --short`;工作区同时存在服务端、Worker 和文档改动,它们不属于前端接手者的修改范围。 + +## 当前完成状态 + +### 商品编辑器 + +- 编辑页为 `/products/:id/editor`,主文件为 `src/pages/products/ProductEditPage.tsx`。 +- 已接入门店级类目 Cascader:保存时只提交末级类目 ID;父级节点可展开,只有叶子节点可选。类目下拉菜单高度为 324px(相对 Ant Design 默认菜单高度提高 80%)。 +- “更多特征”位于 **02 产品属性** Card 内,不再单独使用 Card;展开后按当前选定类目加载 schema,折叠再展开且类目未变时复用已加载的 schema。加载、空 schema、同步类目属性、字典值搜索、清除字段和旧属性告警均在该区处理。 +- 动态属性字段按 schema 渲染:普通值为输入框、布尔值为选择框、字典值为可搜索下拉、多值为多选/标签输入;保存时会过滤空的可选属性值,避免提交无效空数组。 +- “主题标签”和“JSON 富内容”属于产品属性的普通字段,不属于“更多特征”。 +- 编辑页表单已去除页面级的字号、控件高度覆盖,恢复 Ant Design 主题默认规格。仍保留业务性宽度:型号/品牌/条码 170px,包装重量 150px,包装尺寸各 130px。 +- 包装重量通过 `package-weight-field` 单独约束输入组宽度,避免 `addonAfter="g"` 与输入框分离;父表单的标签列为 190px、控件区域为可收缩 680px,避免控件被换到标签下一行。 + +### 组件拆分现状 + +已拆出的组件: + +- `src/pages/products/components/ProductMainInfoSection.tsx`:主要信息、目标门店、类目、标题、品牌、型号、父商品包装规格。 +- `src/pages/products/components/ProductOtherSection.tsx`:货源链接和备注。 +- `src/pages/products/components/CollectedProductInfoPanel.tsx`:右侧“采集的商品信息”侧栏。 + +仍在 `ProductEditPage.tsx` 中的视图:产品属性/更多特征,以及变体设置。后续若继续组件化,应将它们分别迁为展示组件;父页面只保留快照读取、草稿保存、类目/schema/字典同步和状态编排。迁移时不要改变 `Form.List` 字段路径、乐观锁保存流程或动态属性的按需加载缓存条件。 + +### 门店与类目调试入口 + +- 店铺列表的“刷新门店类目”按钮在 `src/pages/shops/ShopsPage.tsx`;前端会等待请求完成才结束 loading,并展示成功或失败反馈。 +- 左侧菜单新增“调试 → Ozon 类目树调试”,路由为 `/shops/category-tree-debug`。 +- 调试页文件:`src/pages/shops/CategoryTreeDebugPage.tsx`、`CategoryTreeDebugPage.css`;服务封装为 `src/services/categoryTreeDebug.ts`。该页只负责调用已存在的前端 API 封装并以 Ant Design 树展示响应,不能在前端处理或持久化 API Key。 + +## 关键前端行为 + +1. 选定目标门店后才读取该门店的类目缓存;加载期间 Cascader 显示“正在读取该店铺的类目缓存”。 +2. 切换类目会清空草稿属性;展开“更多特征”后才读取新类目的 schema。 +3. 动态属性的字典搜索输入至少两个字符后,用户可显式选择“从 Ozon 搜索”;前端不传递或记录 API Key。 +4. 页面 API、错误码和返回结构以 `docs/api/` 与 `@atlas/contracts` 为准。前端不要自行扩展接口字段或模拟同步完成状态。 + +## 已执行验证 + +本次前端改动已执行: + +```bash +cd apps/web +../../node_modules/.bin/tsc -p tsconfig.json --noEmit +git diff --check +``` + +两项均通过。后续对交互或样式作变更时,应在已启动的 Web 环境中回归:类目选择、更多特征展开/收起与加载状态、字典查询、包装重量同一行展示、SKU 展开编辑、货源链接按钮对齐,以及调试页的树形渲染。 + +## 前端边界 + +- 仅修改 `apps/web/` 及前端相关文档。 +- 不启动、停止或修改 API/Worker;若类目刷新或 schema 请求失败,应先检查服务端运行状态与 API 契约,再交由服务端维护者处理。 +- 不在浏览器持久化、日志或页面状态中保存 Ozon API Key。 diff --git a/apps/web/src/layouts/menuConfig.tsx b/apps/web/src/layouts/menuConfig.tsx index 315dbc3..c680a47 100644 --- a/apps/web/src/layouts/menuConfig.tsx +++ b/apps/web/src/layouts/menuConfig.tsx @@ -1,5 +1,6 @@ import { CalculatorOutlined, + BugOutlined, CodeSandboxOutlined, EditOutlined, GlobalOutlined, @@ -37,6 +38,13 @@ export const routeMenuConfig: RouteMenuConfig[] = [ icon: , label: '采集箱', }, + { + // 放在 /shops 之前,避免前缀匹配时被“店铺管理”抢先选中。 + path: '/shops/category-tree-debug', + key: '/shops/category-tree-debug', + icon: , + label: '调试', + }, { path: '/shops', key: '/shops', @@ -70,6 +78,12 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => { { icon: , label: '商品编辑' }, ]; } + if (path === '/shops/category-tree-debug') { + return [ + { icon: , label: '店铺管理' }, + { icon: , label: 'Ozon 类目树调试' }, + ]; + } if (path === '/settings/profile') { return [ { icon: , label: '设置' }, diff --git a/apps/web/src/pages/products/ProductEditPage.css b/apps/web/src/pages/products/ProductEditPage.css index c81fab6..619467f 100644 --- a/apps/web/src/pages/products/ProductEditPage.css +++ b/apps/web/src/pages/products/ProductEditPage.css @@ -104,47 +104,21 @@ color: #344054; font-weight: 600; } -.product-editor .ant-form-item-label > label { - font-size: 16px; -} -.product-editor .ant-input, -.product-editor .ant-select-selection-item, -.product-editor .ant-select-selection-placeholder { - font-size: 16px; -} -.product-editor .ant-input:not(textarea) { - min-height: 40px; -} -.product-editor .ant-input-group-addon { - font-size: 16px; -} -.product-editor .ant-select .ant-select-selector { - min-height: 40px !important; - font-size: 16px; -} -.product-editor .ant-select-single .ant-select-selector { - height: 40px !important; -} -.product-editor .ant-select-single .ant-select-selection-item, -.product-editor .ant-select-single .ant-select-selection-placeholder { - line-height: 38px !important; -} -.product-editor .ant-select-multiple .ant-select-selector { - padding-top: 3px; - padding-bottom: 3px; -} .package-fields { margin-top: 22px; padding-top: 20px; border-top: 1px solid #f0eef4; } -.package-fields > .ant-form-item:first-child { +.package-fields > .package-weight-field { width: max-content; max-width: 100%; } -.package-fields > .ant-form-item:first-child .ant-input { +.package-fields > .package-weight-field .ant-input-group-wrapper { width: 150px; } +.package-fields > .package-weight-field .ant-input { + width: 100%; +} .package-dimensions { margin-bottom: 0; } @@ -163,30 +137,18 @@ } .package-dimension-unit { color: #667085; - font-size: 16px; - line-height: 40px; -} -.editor-standard-cascader .ant-select-selector { - min-height: 40px !important; - font-size: 16px !important; -} -.editor-standard-cascader .ant-select-selection-placeholder, -.editor-standard-cascader .ant-select-selection-item { - line-height: 38px !important; - font-size: 16px !important; + line-height: 32px; } .editor-category-popup .ant-cascader-menu { - max-height: 320px; + /* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */ + height: 324px !important; + max-height: 324px; } .editor-category-popup .ant-cascader-menu-item { display: flex; - min-height: 40px; align-items: center; - font-size: 16px; - line-height: 24px; } .supply-link-control .ant-btn { - height: 40px; min-width: 40px; } .category-row { @@ -220,79 +182,46 @@ text-overflow: ellipsis; white-space: nowrap; } -.editor-standard-select { - min-height: 40px !important; - font-size: 16px !important; -} -.editor-standard-select .ant-select-content { - min-height: 38px; - font-size: 16px !important; -} -.editor-standard-select .ant-select-content-has-value, -.editor-standard-select .ant-select-placeholder, -.editor-standard-select .ant-select-input { - font-size: 16px !important; -} - .category-select-footer { display: flex; justify-content: flex-end; padding: 4px 8px 6px; border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0); } -.editor-standard-select.ant-select-single { - height: 40px !important; -} -.editor-standard-select.ant-select-single .ant-select-content { - height: 38px; - line-height: 38px; -} -.editor-standard-select.ant-select-single .ant-select-input { - height: 38px; - line-height: 38px !important; -} -.editor-standard-select.ant-select-multiple .ant-select-content { - min-height: 40px !important; - padding: 3px 0; -} -.editor-standard-select.ant-select-multiple .ant-select-content-item { - min-height: 32px; - display: flex; - align-items: center; -} -.editor-standard-select.ant-select-multiple .ant-select-placeholder, -.editor-standard-select.ant-select-multiple .ant-select-input { - font-size: 16px !important; -} -.editor-standard-select.ant-select-multiple .ant-select-placeholder, -.editor-standard-select.ant-select-multiple .ant-select-input { - line-height: 32px !important; -} -/* 产品类目 Cascader:控件高度与字号对齐页面文本框(40px / 16px) */ -.editor-standard-cascader.ant-select { - --ant-select-height: 40px; - --ant-select-font-size: 16px; - --ant-select-line-height: 24px; - --ant-select-font-height: 24px; -} -.editor-standard-cascader .ant-select-input { - font-size: 16px; -} -.editor-standard-cascader .ant-select-placeholder { - font-size: 16px; -} -.product-editor .ant-cascader-dropdown .ant-cascader-menu-item { - font-size: 16px; -} .dynamic-attributes { margin-top: 24px; padding-top: 22px; border-top: 1px solid #eeeaf4; } .dynamic-attributes h5.ant-typography { - margin: 0 0 14px; + margin: 0; font-size: 16px; } +.dynamic-attributes-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} +.dynamic-attributes-heading .ant-typography-secondary { + display: block; + margin-top: 4px; +} +.dynamic-attributes-heading .ant-btn { + flex: none; +} +.editor-short-input.ant-input { + width: 170px; +} +.dynamic-attributes-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 132px; + color: #7a7485; +} .dynamic-attribute-list { display: grid; grid-template-columns: 1fr; @@ -318,23 +247,16 @@ } .sku-section .ant-input { border-radius: 8px; - font-size: 14px; } .sku-section textarea.ant-input { - min-height: 58px; resize: none; } .sku-section .ant-input-group-addon { border-radius: 0 8px 8px 0; - font-size: 13px; } .sku-section .ant-input-group .ant-input { border-radius: 8px 0 0 8px; } -.sku-section .ant-input:not(textarea) { - height: 32px; - min-height: 32px; -} .sku-card-list { display: flex; flex-direction: column; @@ -502,8 +424,6 @@ flex: 0 0 86px !important; } .sku-spec-panel .ant-form-item-label > label { - height: 32px; - font-size: 14px; font-weight: 600; } .sku-spec-panel .ant-form-item-control { @@ -570,13 +490,6 @@ .source-update-alert .ant-alert-description { font-size: 12px; } -.collected-info-panel .ant-input { - font-size: 14px; -} -.collected-info-panel .ant-input:not(textarea) { - height: 32px; - min-height: 32px; -} .collected-field { margin-top: 16px; } @@ -691,9 +604,6 @@ .other-section .editor-section-heading { margin-bottom: 8px; } -.supply-open-button { - height: 40px; -} .editor-footer { position: sticky; bottom: 0; @@ -740,7 +650,7 @@ .editor-form-grid .span-two { grid-column: span 1; } - .package-fields > .ant-form-item:first-child, + .package-fields > .package-weight-field, .package-dimensions-controls { width: 100%; } diff --git a/apps/web/src/pages/products/ProductEditPage.tsx b/apps/web/src/pages/products/ProductEditPage.tsx index 52e33b9..f513309 100644 --- a/apps/web/src/pages/products/ProductEditPage.tsx +++ b/apps/web/src/pages/products/ProductEditPage.tsx @@ -45,13 +45,57 @@ import { apiErrorCode, apiErrorMessage } from '@/services/api'; import { productEditorApi } from '@/services/productEditor'; import { usePlatform } from '@/services/platform'; import { collectionApi } from '@/services/collection'; +import { + CollectedProductInfoPanel, + type CollectedProductSummary, +} from './components/CollectedProductInfoPanel'; +import { ProductMainInfoSection } from './components/ProductMainInfoSection'; +import { ProductOtherSection } from './components/ProductOtherSection'; import './ProductEditPage.css'; type VariantValues = SaveProductEditorDraftRequest['variants'][number]; type EditorFormValues = SaveProductEditorDraftRequest; +type AttributeDraft = NonNullable[number]; type NamePath = Array; type SnapshotVariant = ProductEditorSnapshot['variants'][number]; +function missingAttributeDrafts( + attributes: AttributeDraft[], + schema: ProductAttributeSchema[], + snapshot: ProductEditorSnapshot +) { + const missing: AttributeDraft[] = []; + for (const definition of schema) { + const scopes = definition.is_aspect + ? snapshot.variants.map((variant) => ({ + scope: 'variant' as const, + variant_id: variant.id, + })) + : [{ scope: 'common' as const, variant_id: null }]; + for (const scope of scopes) { + const exists = attributes.some( + (attribute) => + attribute.scope === scope.scope && + attribute.variant_id === scope.variant_id && + attribute.platform_attribute_id === definition.platform_attribute_id && + attribute.complex_id === definition.complex_id + ); + if (!exists) { + missing.push({ + ...scope, + platform_attribute_id: definition.platform_attribute_id, + complex_id: definition.complex_id, + values: [], + language_code: null, + source: 'manual', + confirmation_status: 'confirmed', + }); + } + } + } + return missing; +} + function formValues(snapshot: ProductEditorSnapshot): EditorFormValues { return { ai_source_material: snapshot.source_material, @@ -128,9 +172,7 @@ function categoryTree(options: ProductEditorCategoryOption[]) { const roots: CategoryTreeOption[] = []; for (const category of options) { const node = nodes.get(category.id)!; - const parent = category.parent_id - ? nodes.get(category.parent_id) - : undefined; + const parent = category.parent_id ? nodes.get(category.parent_id) : undefined; if (parent) parent.children!.push(node); else roots.push(node); } @@ -208,6 +250,13 @@ function normalizedValues(values: EditorFormValues): EditorFormValues { description: nullable(values.profile.description), tags: values.profile.tags.map((tag) => tag.trim()).filter(Boolean), }, + // Schema fields are rendered eagerly so the user can fill them immediately. + // Do not persist untouched optional fields: the API correctly requires every + // stored attribute to contain at least one non-empty value. + attributes: (values.attributes ?? []).flatMap((attribute) => { + const attributeValues = attribute.values.map((value) => value.trim()).filter(Boolean); + return attributeValues.length ? [{ ...attribute, values: attributeValues }] : []; + }), variants: values.variants.map((variant) => ({ ...variant, display_name: nullable(variant.display_name), @@ -249,7 +298,7 @@ function sourceValue(value: unknown): string { type CollectedAttribute = { key: string; name: string; value: string }; -function collectedSummary(facts: ProductAiSourceMaterial) { +function collectedSummary(facts: ProductAiSourceMaterial): CollectedProductSummary { const find = (matches: (label: string) => boolean) => facts.attributes.find((attribute) => matches(`${attribute.key} ${attribute.name}`.toLowerCase()) @@ -329,7 +378,7 @@ function detectedSpecs(attributes: CollectedAttribute[]) { function variantImageCandidates( recordValue: CollectionItemInspection | null, - variant: SnapshotVariant, + variant: SnapshotVariant ) { if (!recordValue) return []; const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item])); @@ -344,7 +393,7 @@ function variantImageCandidates( }) .filter( (transfer, index, transfers) => - transfers.findIndex((item) => item.id === transfer.id) === index, + transfers.findIndex((item) => item.id === transfer.id) === index ); } @@ -354,6 +403,7 @@ function PackageFields({ name }: { name: NamePath }) { @@ -444,7 +494,6 @@ const ProductEditPage = () => { const supplyUrl = Form.useWatch(['common', 'purchase_url'], form); const selectedCategoryId = Form.useWatch(['profile', 'store_product_category_id'], form); const selectedStoreId = Form.useWatch(['profile', 'target_store_id'], form); - const draftAttributes = Form.useWatch('attributes', form) ?? []; const draftVariants = Form.useWatch('variants', form) ?? []; const [snapshot, setSnapshot] = useState(null); const [collectionRecord, setCollectionRecord] = useState(null); @@ -454,7 +503,15 @@ const ProductEditPage = () => { ); const [richContent, setRichContent] = useState(''); const [categoryOptions, setCategoryOptions] = useState([]); + const [categoryOptionsLoading, setCategoryOptionsLoading] = useState(false); + const [categorySearch, setCategorySearch] = useState(''); const [attributeSchema, setAttributeSchema] = useState([]); + const [attributeSchemaCategoryId, setAttributeSchemaCategoryId] = useState(null); + const [attributeSchemaCatalogRefresh, setAttributeSchemaCatalogRefresh] = useState< + number | null + >(null); + const [attributeSchemaLoading, setAttributeSchemaLoading] = useState(false); + const [attributeSchemaError, setAttributeSchemaError] = useState(null); const [dictionaryValues, setDictionaryValues] = useState< Record >({}); @@ -466,9 +523,24 @@ const ProductEditPage = () => { const [materializingTransferId, setMaterializingTransferId] = useState(null); const [dirty, setDirty] = useState(false); const [expandedVariantKeys, setExpandedVariantKeys] = useState>(() => new Set()); + const [moreFeaturesExpanded, setMoreFeaturesExpanded] = useState(false); const blocker = useBlocker(dirty); const sourceSpecsApplied = useRef(false); const categories = useMemo(() => categoryTree(categoryOptions), [categoryOptions]); + const categoryNotFoundContent = categoryOptionsLoading + ? '正在读取该店铺的类目缓存…' + : categoryOptions.length === 0 + ? '该店铺还没有可用类目缓存,请先验证店铺或稍后刷新' + : categorySearch.trim() + ? '未找到匹配的类目' + : '暂无可选择的类目'; + const activeAttributeSchema = useMemo( + () => + attributeSchemaCategoryId === selectedCategoryId + ? attributeSchema + : ([] as ProductAttributeSchema[]), + [attributeSchema, attributeSchemaCategoryId, selectedCategoryId] + ); const load = useCallback(async () => { if (!productId || !currentPlatform) return; @@ -494,13 +566,22 @@ const ProductEditPage = () => { useEffect(() => { if (!snapshot || !currentPlatform) return; let active = true; + setCategoryOptionsLoading(true); + setCategoryOptions([]); + setCategorySearch(''); void productEditorApi .categoryOptions(snapshot.profile.id, currentPlatform.id, undefined, selectedStoreId) .then((options) => { - if (active) setCategoryOptions(options); + if (active) { + setCategoryOptions(options); + setCategoryOptionsLoading(false); + } }) .catch(() => { - if (active) setCategoryOptions([]); + if (active) { + setCategoryOptions([]); + setCategoryOptionsLoading(false); + } }); return () => { active = false; @@ -509,27 +590,77 @@ const ProductEditPage = () => { useEffect(() => { if (!snapshot || !currentPlatform || !selectedCategoryId) { setAttributeSchema([]); + setAttributeSchemaCategoryId(null); + setAttributeSchemaCatalogRefresh(null); + setAttributeSchemaLoading(false); + setAttributeSchemaError(null); setDictionaryValues({}); return; } + // Attributes are an on-demand panel. Once loaded for the selected + // category, collapsing and reopening it must reuse the same schema. + if ( + !moreFeaturesExpanded || + (attributeSchemaCategoryId === selectedCategoryId && + attributeSchemaCatalogRefresh === catalogRefresh) + ) + return; let active = true; + setAttributeSchemaLoading(true); + setAttributeSchemaError(null); + setAttributeSchema([]); + setAttributeSchemaCategoryId(null); + setDictionaryValues({}); + setDictionarySearches({}); void productEditorApi - .attributeSchema(snapshot.profile.id, currentPlatform.id, selectedCategoryId, selectedStoreId) + .attributeSchema( + snapshot.profile.id, + currentPlatform.id, + selectedCategoryId, + selectedStoreId + ) .then((schema) => { - if (active) setAttributeSchema(schema); + if (active) { + const currentAttributes = (form.getFieldValue('attributes') ?? + []) as AttributeDraft[]; + const missing = missingAttributeDrafts(currentAttributes, schema, snapshot); + if (missing.length) { + form.setFieldValue('attributes', [...currentAttributes, ...missing]); + } + setAttributeSchema(schema); + setAttributeSchemaCategoryId(selectedCategoryId); + setAttributeSchemaCatalogRefresh(catalogRefresh); + setAttributeSchemaLoading(false); + } }) - .catch(() => { - if (active) setAttributeSchema([]); + .catch((error) => { + if (active) { + setAttributeSchema([]); + setAttributeSchemaCategoryId(null); + setAttributeSchemaCatalogRefresh(null); + setAttributeSchemaLoading(false); + setAttributeSchemaError(apiErrorMessage(error)); + } }); return () => { active = false; }; - }, [catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]); + }, [ + attributeSchemaCategoryId, + attributeSchemaCatalogRefresh, + catalogRefresh, + currentPlatform, + form, + moreFeaturesExpanded, + selectedCategoryId, + selectedStoreId, + snapshot, + ]); useEffect(() => { if (!snapshot || !currentPlatform || !selectedCategoryId) return; const dictionaryIds = [ ...new Set( - attributeSchema + activeAttributeSchema .map((attribute) => attribute.dictionary_id) .filter((dictionaryId): dictionaryId is string => !!dictionaryId) ), @@ -550,7 +681,7 @@ const ProductEditPage = () => { dictionaryId, undefined, selectedCategoryId, - selectedStoreId, + selectedStoreId ), ] as const ) @@ -567,7 +698,14 @@ const ProductEditPage = () => { return () => { active = false; }; - }, [attributeSchema, catalogRefresh, currentPlatform, selectedCategoryId, selectedStoreId, snapshot]); + }, [ + activeAttributeSchema, + catalogRefresh, + currentPlatform, + selectedCategoryId, + selectedStoreId, + snapshot, + ]); useEffect(() => { if (!snapshot || !currentPlatform) return; let active = true; @@ -790,7 +928,7 @@ const ProductEditPage = () => { currentPlatform.id, selectedCategoryId ), - '该类目的属性已同步', + '该类目的属性已同步' ); }; const loadDictionaryValues = async (dictionaryId: string, query = '') => { @@ -801,7 +939,7 @@ const ProductEditPage = () => { dictionaryId, query, selectedCategoryId, - selectedStoreId, + selectedStoreId ); setDictionaryValues((current) => ({ ...current, [dictionaryId]: page.items })); }; @@ -822,7 +960,7 @@ const ProductEditPage = () => { selectedCategoryId, query ), - '字典值已同步', + '字典值已同步' ).then(() => void loadDictionaryValues(dictionaryId, query)); }; const materializeCandidate = async (transferId: string) => { @@ -870,8 +1008,8 @@ const ProductEditPage = () => { form={form} layout="horizontal" labelAlign="right" - labelCol={{ flex: '112px' }} - wrapperCol={{ flex: '1' }} + labelCol={{ flex: '190px' }} + wrapperCol={{ flex: '0 1 680px' }} onFinish={save} requiredMark onValuesChange={() => setDirty(true)} @@ -907,116 +1045,21 @@ const ProductEditPage = () => {
- - -
- - - - - - - - - -
- -
+ } - onClick={() => - document - .getElementById('dynamic-attributes') - ?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }) - } - > - 填写更多属性 - - } /> + { />
- 类目属性 - {!selectedCategoryId ? ( - - ) : !attributeSchema.length ? ( - - 该类目的属性 schema 尚未同步 - - - } - /> - ) : ( - - {(fields, { add, remove }) => ( -
- {attributeSchema.flatMap((definition) => { - const scopes = definition.is_aspect - ? snapshot.variants.map((variant) => ({ - scope: 'variant' as const, - variantId: variant.id, - suffix: - variant.display_name || '未命名 SKU', - })) - : [ - { - scope: 'common' as const, - variantId: null, - suffix: null, - }, - ]; - return scopes.map( - ({ scope, variantId, suffix }) => { - const field = fields.find((candidate) => { - const draft = - draftAttributes[candidate.name]; - return ( - draft?.scope === scope && - draft?.variant_id === variantId && - draft?.platform_attribute_id === - definition.platform_attribute_id && - draft?.complex_id === - definition.complex_id +
+
+ 更多特征 + + 根据所选类目填写 Ozon 平台属性 + +
+ +
+ {moreFeaturesExpanded && + (!selectedCategoryId ? ( + + ) : attributeSchemaLoading ? ( +
+ + 正在读取类目属性… +
+ ) : !activeAttributeSchema.length ? ( + + + {attributeSchemaError ?? + '该类目的属性 schema 尚未同步'} + + + + } + /> + ) : ( + + {(fields) => ( +
+ {activeAttributeSchema.flatMap((definition) => { + const scopes = definition.is_aspect + ? snapshot.variants.map((variant) => ({ + scope: 'variant' as const, + variantId: variant.id, + suffix: + variant.display_name || + '未命名 SKU', + })) + : [ + { + scope: 'common' as const, + variantId: null, + suffix: null, + }, + ]; + return scopes.map( + ({ scope, variantId, suffix }) => { + const field = fields.find( + (candidate) => { + const draft = + form.getFieldValue([ + 'attributes', + candidate.name, + ]) as + | AttributeDraft + | undefined; + return ( + draft?.scope === scope && + draft?.variant_id === + variantId && + draft?.platform_attribute_id === + definition.platform_attribute_id && + draft?.complex_id === + definition.complex_id + ); + } ); - }); - const label = suffix - ? `${definition.name} · ${suffix}` - : definition.name; - if (!field) + const label = suffix + ? `${definition.name} · ${suffix}` + : definition.name; + // Attribute rows are created together + // with the schema response. A transient + // render may still occur before Form.List + // receives the new field metadata. + if (!field) return null; + const dictionaryOptions = + definition.dictionary_id + ? ( + dictionaryValues[ + definition + .dictionary_id + ] ?? [] + ).map((value) => ({ + value: value.value_id, + label: value.value, + })) + : []; + const dictionaryQuery = + definition.dictionary_id + ? (dictionarySearches[ + definition.dictionary_id + ] ?? '') + : ''; + const valueName = [ + field.name, + 'values', + ] as NamePath; + const scalarValueName = [ + ...valueName, + 0, + ] as NamePath; + const valueRules = + definition.is_required + ? [ + { + validator: ( + _rule: unknown, + value: unknown + ) => { + const entries = + Array.isArray( + value + ) + ? value + : [value]; + return entries.some( + (entry) => + typeof entry === + 'string' && + entry.trim() + ) + ? Promise.resolve() + : Promise.reject( + new Error( + `请填写${definition.name}` + ) + ); + }, + }, + ] + : undefined; + const input = + definition.value_type === + 'dictionary' ? ( + + + + ) : definition.is_collection ? ( + + + + ); return ( - + + {input} + + ); - const dictionaryOptions = - definition.dictionary_id - ? ( - dictionaryValues[ - definition.dictionary_id - ] ?? [] - ).map((value) => ({ - value: value.value_id, - label: value.value, - })) - : []; - const dictionaryQuery = definition.dictionary_id - ? dictionarySearches[ - definition.dictionary_id - ] ?? '' - : ''; - const valueName = [ - field.name, - 'values', - ] as NamePath; - const input = - definition.value_type === - 'dictionary' ? ( - - - - ) : definition.is_collection ? ( - - - - ); - return ( - - - {input} - - - - ); - } - ); - })} -
- )} -
- )} + } + ); + })} +
+ )} +
+ ))}
{snapshot.unmapped_attributes.length > 0 && ( { .filter((url): url is string => !!url); const toggleImage = (assetId: string) => { const next = selectedAssetIds.includes(assetId) - ? selectedAssetIds.filter((id) => id !== assetId) + ? selectedAssetIds.filter( + (id) => id !== assetId + ) : [...selectedAssetIds, assetId]; form.setFieldValue( ['variants', field.name, 'media_asset_ids'], @@ -1574,65 +1704,82 @@ const ProductEditPage = () => { {imageCandidates.length ? (
- {imageCandidates.map((candidate) => { - const selectable = - candidate.status === 'succeeded' && - !!candidate.asset_id; - const selected = - selectable && - selectedAssetIds.includes( - candidate.asset_id! - ); - return ( -
- 采集候选图片 - {selectable ? ( - - ) : ( - + ) : ( + - )} -
- ); - })} + } + disabled={ + candidate.status === + 'queued' || + candidate.status === + 'running' || + candidate.status === + 'failed' + } + onClick={() => + void materializeCandidate( + candidate.id + ) + } + > + {candidate.status === + 'pending_selection' + ? '转存后选择' + : candidate.status === + 'failed' + ? '转存失败' + : '转存中'} + + )} +
+ ); + } + )}
) : (
@@ -1772,178 +1919,22 @@ const ProductEditPage = () => { - - -
- - - - - - -
- - 已上架 SKU 的编辑仅保存 Atlas 草稿,不会自动更新 {currentPlatform.name}{' '} - 商品。 - -
+ - +