feat(edit): 编辑页开发

This commit is contained in:
R524809
2026-09-11 17:28:58 +08:00
parent 1b03ea5871
commit da7342b466
21 changed files with 1910 additions and 857 deletions
+38
View File
@@ -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') @Post(':storeId/warehouse-syncs')
@ApiOperation({ summary: '请求异步同步门店仓库' }) @ApiOperation({ summary: '请求异步同步门店仓库' })
@ApiAcceptedResponse({ description: '仓库同步操作已入队' }) @ApiAcceptedResponse({ description: '仓库同步操作已入队' })
+339 -1
View File
@@ -11,7 +11,7 @@ import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js'; import { IdempotencyService } from '../common/idempotency.service.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js'; import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.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<Store, keyof { created_at: never; updated_at: never }> & { type StoreRow = Omit<Store, keyof { created_at: never; updated_at: never }> & {
credential_checked_at: Date | string | null; credential_checked_at: Date | string | null;
@@ -46,6 +46,23 @@ type WarehouseRow = {
status: string; status: string;
synced_at: Date | 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() @Injectable()
export class StoresService { 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<unknown> {
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( public async requestWarehouseSync(
workspaceId: string, workspaceId: string,
userId: string, userId: string,
@@ -661,6 +842,163 @@ export class StoresService {
} }
} }
async function requestOzonCategoryTree(
clientId: string,
apiKey: string,
): Promise<OzonCategoryNode[]> {
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 = ` const storeSelect = `
SELECT s.id, s.platform_id, s.name, s.platform_client_id, s.currency_code, 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, s.is_default, s.note, s.status, s.credential_status, s.credential_checked_at,
+109
View File
@@ -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<Array<{ id: string; code: string }>>(
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 () => { it('更新版本、切换默认门店、删除和工作空间隔离都遵守契约', async () => {
const owner = await register('stores-lifecycle'); const owner = await register('stores-lifecycle');
const otherOwner = await register('stores-isolation'); const otherOwner = await register('stores-isolation');
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, it } from 'vitest';
import { DATABASE } from '../src/database/database.module.js'; import { DATABASE } from '../src/database/database.module.js';
import { createApplication } from '../src/main.js'; import { createApplication } from '../src/main.js';
import { OutboxDispatcher } from '../../worker/src/outbox-dispatcher.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'; import { scheduleStoreCategoryTreeSync } from '../../worker/src/store-category-tree-scheduler.js';
let app: NestExpressApplication; let app: NestExpressApplication;
@@ -42,6 +43,56 @@ async function scheduleWithStoreLock() {
} }
describe('门店类目树 Worker 调度集成测试', () => { 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 () => { beforeAll(async () => {
Object.assign(process.env, { Object.assign(process.env, {
NODE_ENV: 'test', NODE_ENV: 'test',
+58
View File
@@ -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。
+14
View File
@@ -1,5 +1,6 @@
import { import {
CalculatorOutlined, CalculatorOutlined,
BugOutlined,
CodeSandboxOutlined, CodeSandboxOutlined,
EditOutlined, EditOutlined,
GlobalOutlined, GlobalOutlined,
@@ -37,6 +38,13 @@ export const routeMenuConfig: RouteMenuConfig[] = [
icon: <InboxOutlined />, icon: <InboxOutlined />,
label: '采集箱', label: '采集箱',
}, },
{
// 放在 /shops 之前,避免前缀匹配时被“店铺管理”抢先选中。
path: '/shops/category-tree-debug',
key: '/shops/category-tree-debug',
icon: <BugOutlined />,
label: '调试',
},
{ {
path: '/shops', path: '/shops',
key: '/shops', key: '/shops',
@@ -70,6 +78,12 @@ export const getBreadcrumbItems = (path: string): BreadcrumbCrumb[] => {
{ icon: <EditOutlined />, label: '商品编辑' }, { icon: <EditOutlined />, label: '商品编辑' },
]; ];
} }
if (path === '/shops/category-tree-debug') {
return [
{ icon: <ShopOutlined />, label: '店铺管理' },
{ icon: <BugOutlined />, label: 'Ozon 类目树调试' },
];
}
if (path === '/settings/profile') { if (path === '/settings/profile') {
return [ return [
{ icon: <SettingOutlined />, label: '设置' }, { icon: <SettingOutlined />, label: '设置' },
+36 -126
View File
@@ -104,47 +104,21 @@
color: #344054; color: #344054;
font-weight: 600; 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 { .package-fields {
margin-top: 22px; margin-top: 22px;
padding-top: 20px; padding-top: 20px;
border-top: 1px solid #f0eef4; border-top: 1px solid #f0eef4;
} }
.package-fields > .ant-form-item:first-child { .package-fields > .package-weight-field {
width: max-content; width: max-content;
max-width: 100%; max-width: 100%;
} }
.package-fields > .ant-form-item:first-child .ant-input { .package-fields > .package-weight-field .ant-input-group-wrapper {
width: 150px; width: 150px;
} }
.package-fields > .package-weight-field .ant-input {
width: 100%;
}
.package-dimensions { .package-dimensions {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -163,30 +137,18 @@
} }
.package-dimension-unit { .package-dimension-unit {
color: #667085; color: #667085;
font-size: 16px; line-height: 32px;
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;
} }
.editor-category-popup .ant-cascader-menu { .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 { .editor-category-popup .ant-cascader-menu-item {
display: flex; display: flex;
min-height: 40px;
align-items: center; align-items: center;
font-size: 16px;
line-height: 24px;
} }
.supply-link-control .ant-btn { .supply-link-control .ant-btn {
height: 40px;
min-width: 40px; min-width: 40px;
} }
.category-row { .category-row {
@@ -220,79 +182,46 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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 { .category-select-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 4px 8px 6px; padding: 4px 8px 6px;
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0); 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 { .dynamic-attributes {
margin-top: 24px; margin-top: 24px;
padding-top: 22px; padding-top: 22px;
border-top: 1px solid #eeeaf4; border-top: 1px solid #eeeaf4;
} }
.dynamic-attributes h5.ant-typography { .dynamic-attributes h5.ant-typography {
margin: 0 0 14px; margin: 0;
font-size: 16px; 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 { .dynamic-attribute-list {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -318,23 +247,16 @@
} }
.sku-section .ant-input { .sku-section .ant-input {
border-radius: 8px; border-radius: 8px;
font-size: 14px;
} }
.sku-section textarea.ant-input { .sku-section textarea.ant-input {
min-height: 58px;
resize: none; resize: none;
} }
.sku-section .ant-input-group-addon { .sku-section .ant-input-group-addon {
border-radius: 0 8px 8px 0; border-radius: 0 8px 8px 0;
font-size: 13px;
} }
.sku-section .ant-input-group .ant-input { .sku-section .ant-input-group .ant-input {
border-radius: 8px 0 0 8px; border-radius: 8px 0 0 8px;
} }
.sku-section .ant-input:not(textarea) {
height: 32px;
min-height: 32px;
}
.sku-card-list { .sku-card-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -502,8 +424,6 @@
flex: 0 0 86px !important; flex: 0 0 86px !important;
} }
.sku-spec-panel .ant-form-item-label > label { .sku-spec-panel .ant-form-item-label > label {
height: 32px;
font-size: 14px;
font-weight: 600; font-weight: 600;
} }
.sku-spec-panel .ant-form-item-control { .sku-spec-panel .ant-form-item-control {
@@ -570,13 +490,6 @@
.source-update-alert .ant-alert-description { .source-update-alert .ant-alert-description {
font-size: 12px; 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 { .collected-field {
margin-top: 16px; margin-top: 16px;
} }
@@ -691,9 +604,6 @@
.other-section .editor-section-heading { .other-section .editor-section-heading {
margin-bottom: 8px; margin-bottom: 8px;
} }
.supply-open-button {
height: 40px;
}
.editor-footer { .editor-footer {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -740,7 +650,7 @@
.editor-form-grid .span-two { .editor-form-grid .span-two {
grid-column: span 1; grid-column: span 1;
} }
.package-fields > .ant-form-item:first-child, .package-fields > .package-weight-field,
.package-dimensions-controls { .package-dimensions-controls {
width: 100%; width: 100%;
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,130 @@
import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd';
import type {
ProductAiSourceField,
ProductAiSourceMaterial,
ProductEditorSnapshot,
} from '@atlas/contracts';
export type CollectedProductSummary = {
price: string;
brand: string;
physicalDimensions: string;
physicalWeight: string;
packageDimensions: string;
packageWeight: string;
};
type Props = {
snapshot: ProductEditorSnapshot;
collected: ProductAiSourceMaterial | null;
summary: CollectedProductSummary;
overriddenFields: ProductAiSourceField[];
onRestore: (field: Extract<ProductAiSourceField, 'title' | 'description'>) => void;
onUpdate: (field: Extract<ProductAiSourceField, 'title' | 'description'>, value: string) => void;
onShowDifferences: () => void;
onAcceptLatest: () => void;
};
export function CollectedProductInfoPanel({
snapshot,
collected,
summary,
overriddenFields,
onRestore,
onUpdate,
onShowDifferences,
onAcceptLatest,
}: Props) {
return (
<aside className="editor-aside">
<Card className="collected-info-panel" bordered={false}>
<div className="collected-info-heading">
<div>
<Typography.Title level={4}>采集的商品信息</Typography.Title>
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
</div>
<Tag>第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集</Tag>
</div>
{snapshot.source_material_state.has_source_updates && (
<Alert
className="source-update-alert"
type="warning"
showIcon
message="检测到新的采集信息"
description="未编辑字段已采用最新值;用户修改过的字段仍保持不变。"
action={
<Space direction="vertical" size={4}>
<Button size="small" onClick={onShowDifferences}>
查看变化
</Button>
<Button size="small" type="primary" onClick={onAcceptLatest}>
全部采用最新
</Button>
</Space>
}
/>
)}
<div className="collected-field">
<div className="collected-field-label">
<span>标题</span>
{overriddenFields.includes('title') && (
<Button type="link" size="small" onClick={() => onRestore('title')}>
恢复来源值
</Button>
)}
</div>
<Input
value={collected?.title ?? snapshot.product.common.source_title ?? ''}
placeholder="采集标题"
onChange={(event) => onUpdate('title', event.target.value)}
/>
</div>
<div className="collected-field">
<div className="collected-field-label">
<span>商品描述</span>
{overriddenFields.includes('description') && (
<Button
type="link"
size="small"
onClick={() => onRestore('description')}
>
恢复来源值
</Button>
)}
</div>
<Input.TextArea
value={collected?.description ?? ''}
rows={12}
placeholder="本次采集未取得商品描述。可补充后用于 AI 生成。"
onChange={(event) => onUpdate('description', event.target.value)}
/>
</div>
<div className="collected-field">
<span>采集基础数据</span>
<div className="collected-summary">
<div><b>价格</b><strong>{summary.price}</strong></div>
<div><b>品牌</b><strong>{summary.brand}</strong></div>
<div><b>商品尺寸</b><strong>{summary.physicalDimensions}</strong></div>
<div><b>商品重量</b><strong>{summary.physicalWeight}</strong></div>
<div><b>包装尺寸</b><strong>{summary.packageDimensions}</strong></div>
<div><b>包装重量</b><strong>{summary.packageWeight}</strong></div>
</div>
</div>
<div className="collected-field">
<span>规格 / 参数</span>
{collected?.attributes.length ? (
<div className="collected-attributes">
{collected.attributes.map((attribute) => (
<div key={`${attribute.key}:${attribute.value}`}>
<b>{attribute.name}</b><span>{attribute.value}</span>
</div>
))}
</div>
) : (
<Typography.Text type="secondary">本次采集未取得规格或参数。</Typography.Text>
)}
</div>
</Card>
</aside>
);
}
@@ -0,0 +1,156 @@
import { Card, Cascader, Form, Input, Select, Typography } from 'antd';
import type { FormInstance } from 'antd';
import type { ProductEditorSnapshot } from '@atlas/contracts';
type NamePath = Array<string | number>;
type CategoryOption = {
value: string;
label: string;
isLeaf?: boolean;
children?: CategoryOption[];
};
function SectionTitle() {
return (
<div className="editor-section-heading">
<div>
<Typography.Title level={4}>
<span className="editor-section-number">01</span>主要信息
</Typography.Title>
<Typography.Text type="secondary">
上架所需的核心资料。包装信息用于定价、物流与发布。
</Typography.Text>
</div>
</div>
);
}
function PackageFields({ name }: { name: NamePath }) {
return (
<div className="package-fields">
<Form.Item
name={[...name, 'shipping_weight_g']}
label="包装重量"
rules={[{ required: true, message: '请填写包装重量' }]}
>
<Input type="number" min="0" step="any" addonAfter="g" style={{ width: 150 }} />
</Form.Item>
<Form.Item label="包装尺寸" required className="package-dimensions">
<div className="package-dimensions-controls">
<Form.Item name={[...name, 'length_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="长" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'width_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="宽" />
</Form.Item>
<span className="package-dimension-separator">×</span>
<Form.Item name={[...name, 'height_mm']} noStyle>
<Input type="number" min="0" step="any" placeholder="高" />
</Form.Item>
<span className="package-dimension-unit">mm</span>
</div>
</Form.Item>
</div>
);
}
type Props = {
form: FormInstance;
snapshot: ProductEditorSnapshot;
selectedCategoryId: string | null | undefined;
categoryOptions: CategoryOption[];
categoryPaths: Map<string, string[]>;
categoryNotFoundContent: string;
onCategorySearch: (query: string) => void;
};
export function ProductMainInfoSection({
form,
snapshot,
selectedCategoryId,
categoryOptions,
categoryPaths,
categoryNotFoundContent,
onCategorySearch,
}: Props) {
return (
<Card className="editor-section" bordered={false}>
<SectionTitle />
<div className="editor-form-grid">
<Form.Item
name={['profile', 'target_store_id']}
label="上架店铺"
className="span-full"
rules={[{ required: true, message: '请选择上架店铺' }]}
>
<Select
className="editor-standard-select"
options={snapshot.profile.available_target_stores.map((store) => ({
value: store.id,
label: `${store.name}(${store.currency_code})`,
}))}
onChange={() => {
form.setFieldValue(['profile', 'store_product_category_id'], null);
form.setFieldValue(['profile', 'category_source'], null);
form.setFieldValue('attributes', []);
}}
/>
</Form.Item>
<Form.Item
name={['profile', 'store_product_category_id']}
label="产品类目"
className="span-full"
rules={[{ required: true, message: '请选择产品类目' }]}
getValueProps={(categoryId: string | null) => ({
value: categoryId ? (categoryPaths.get(categoryId) ?? [categoryId]) : [],
})}
getValueFromEvent={(value: string[]) => value.at(-1) ?? null}
>
<Cascader
className="editor-standard-cascader"
classNames={{ popup: { root: 'editor-category-popup' } }}
showSearch
onSearch={onCategorySearch}
placeholder="选择当前店铺可发布的产品类目"
options={categoryOptions}
notFoundContent={categoryNotFoundContent}
getPopupContainer={(node) => node.parentElement ?? document.body}
onChange={(value) => {
const categoryId = value.at(-1) ?? null;
if (categoryId !== selectedCategoryId)
form.setFieldValue('attributes', []);
form.setFieldValue(
['profile', 'category_source'],
categoryId ? 'manual' : null
);
}}
/>
</Form.Item>
<Form.Item
name={['common', 'source_title']}
label="标题"
className="span-full"
rules={[{ required: true, message: '请填写标题' }, { max: 1000 }]}
>
<Input placeholder="整理后的商品标题" />
</Form.Item>
<Form.Item
name={['common', 'model']}
label="型号"
rules={[{ required: true, message: '请填写型号' }]}
>
<Input className="editor-short-input" placeholder="首次上架前需填写" />
</Form.Item>
<Form.Item
name={['common', 'brand']}
label="品牌"
rules={[{ required: true, message: '请填写品牌;无品牌请填写“无品牌”' }]}
>
<Input className="editor-short-input" placeholder="无品牌请填写“无品牌”" />
</Form.Item>
</div>
<PackageFields name={['common', 'default_package_spec']} />
</Card>
);
}
@@ -0,0 +1,50 @@
import { Button, Card, Form, Input, Space, Tooltip, Typography } from 'antd';
import { ExportOutlined } from '@ant-design/icons';
type Props = {
canOpenSupplyUrl: boolean;
platformName: string;
onOpenSupplyUrl: () => void;
};
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
return (
<Card className="editor-section other-section" bordered={false}>
<div className="editor-section-heading">
<div>
<Typography.Title level={4}>
<span className="editor-section-number">04</span>其它
</Typography.Title>
<Typography.Text type="secondary">与供货和发布相关的补充信息。</Typography.Text>
</div>
</div>
<div className="editor-form-grid">
<Form.Item label="货源链接" className="span-two">
<Space.Compact block className="supply-link-control">
<Form.Item
name={['common', 'purchase_url']}
noStyle
rules={[{ type: 'url', message: '请输入有效 URL' }]}
>
<Input placeholder="https://..." />
</Form.Item>
<Tooltip title="打开货源链接">
<Button
className="supply-open-button"
icon={<ExportOutlined />}
disabled={!canOpenSupplyUrl}
onClick={onOpenSupplyUrl}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
<Form.Item name={['common', 'supply_note']} label="备注" className="span-full">
<Input.TextArea rows={2} maxLength={4000} placeholder="供应商、采购注意事项等" />
</Form.Item>
</div>
<Typography.Text type="secondary">
已上架 SKU 的编辑仅保存 Atlas 草稿,不会自动更新 {platformName} 商品。
</Typography.Text>
</Card>
);
}
@@ -0,0 +1,49 @@
.category-debug-page {
display: grid;
gap: 16px;
}
.category-debug-intro {
width: 100%;
}
.category-debug-intro .ant-typography {
margin-bottom: 0;
}
.category-debug-alert {
margin-bottom: 0;
}
.category-debug-result .ant-card-body {
min-height: 280px;
}
.category-debug-loading {
display: grid;
min-height: 240px;
place-items: center;
}
.category-debug-tree {
max-height: 640px;
overflow: auto;
font-size: 14px;
}
.category-debug-node-meta {
font-size: 12px;
}
.category-debug-json pre {
max-height: 720px;
margin: 0;
overflow: auto;
padding: 16px;
border-radius: 8px;
background: #0f172a;
color: #e2e8f0;
font-size: 12px;
line-height: 1.5;
white-space: pre;
}
@@ -0,0 +1,154 @@
import { useMemo, useState } from 'react';
import { Alert, Button, Card, Collapse, Empty, Space, Spin, Tree, Typography } from 'antd';
import type { DataNode } from 'antd/es/tree';
import { ApiOutlined, BugOutlined, ReloadOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { categoryTreeDebugApi } from '@/services/categoryTreeDebug';
import { usePlatform } from '@/services/platform';
import './CategoryTreeDebugPage.css';
type OzonCategoryNode = {
description_category_id?: number | string;
category_name?: string;
type_id?: number | string | null;
type_name?: string | null;
disabled?: boolean;
children?: OzonCategoryNode[];
};
function categoryNodes(value: unknown): OzonCategoryNode[] {
if (!value || typeof value !== 'object') return [];
const result = (value as { result?: unknown }).result;
return Array.isArray(result) ? (result as OzonCategoryNode[]) : [];
}
function toTreeData(nodes: OzonCategoryNode[], ancestry = ''): DataNode[] {
return nodes.map((node, index) => {
const id = String(node.description_category_id ?? `unknown-${index}`);
const key = `${ancestry}/${id}:${String(node.type_id ?? 0)}:${index}`;
const children = Array.isArray(node.children) ? node.children : [];
const isTypeLeaf = !node.category_name?.trim() && Boolean(node.type_name?.trim());
const name = node.category_name?.trim() || node.type_name?.trim() || '未命名类目';
return {
key,
title: (
<Space size={6} wrap>
<Typography.Text>{name}</Typography.Text>
<Typography.Text type="secondary" className="category-debug-node-meta">
{isTypeLeaf
? `类型 ID=${String(node.type_id ?? 0)}`
: `类目 ID=${id} · 类型 ID=${String(node.type_id ?? 0)}`}
</Typography.Text>
{node.disabled === true && <Typography.Text type="warning">不可建品</Typography.Text>}
</Space>
),
children: children.length ? toTreeData(children, key) : undefined,
};
});
}
/**
* Ozon 类目树诊断页。请求始终由 API 服务端发起,浏览器没有也不会收到门店密钥。
*/
const CategoryTreeDebugPage = () => {
const { currentPlatform, context, contextLoading } = usePlatform();
const [rawResponse, setRawResponse] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const nodes = useMemo(() => categoryNodes(rawResponse), [rawResponse]);
const treeData = useMemo(() => toTreeData(nodes), [nodes]);
const rawJson = useMemo(
() => (rawResponse === null ? '' : JSON.stringify(rawResponse, null, 2)),
[rawResponse],
);
const fetchTree = async () => {
if (!currentPlatform) return;
setLoading(true);
setError(null);
try {
const response = await categoryTreeDebugApi.fetchDefaultStoreTree(currentPlatform.id);
setRawResponse(response);
} catch (requestError) {
setRawResponse(null);
setError(apiErrorMessage(requestError));
} finally {
setLoading(false);
}
};
const unavailable = !currentPlatform || !context?.default_store;
return (
<div className="category-debug-page">
<Card
title={
<Space>
<BugOutlined />
Ozon 类目树调试
</Space>
}
extra={
<Button
type="primary"
icon={<ReloadOutlined />}
loading={loading}
disabled={unavailable || contextLoading}
onClick={() => void fetchTree()}
>
获取类目
</Button>
}
>
<Space direction="vertical" size="middle" className="category-debug-intro">
<Typography.Paragraph>
使用当前平台的默认门店请求 Ozon 官方
<Typography.Text code>/v1/description-category/tree</Typography.Text>,并原样展示响应。
</Typography.Paragraph>
<Alert
type="info"
showIcon
icon={<ApiOutlined />}
message={
context?.default_store
? `默认门店:${context.default_store.name}`
: '当前平台未设置默认门店'
}
description="请求由 Atlas 服务端代发;Client ID 与 API Key 不会发送到浏览器、日志或响应内容。"
/>
</Space>
</Card>
{error && <Alert className="category-debug-alert" type="error" showIcon message={error} />}
<Card className="category-debug-result" title="类目树">
{loading ? (
<div className="category-debug-loading"><Spin tip="正在向 Ozon 获取完整类目树…" /></div>
) : treeData.length ? (
<Tree
className="category-debug-tree"
showLine={{ showLeafIcon: false }}
defaultExpandParent={false}
treeData={treeData}
/>
) : (
<Empty description="点击“获取类目”查看 Ozon 返回的类目树" />
)}
</Card>
{rawResponse !== null && (
<Collapse
className="category-debug-json"
items={[
{
key: 'raw-json',
label: `原始 JSON 响应(${rawJson.length.toLocaleString()} 字符)`,
children: <pre>{rawJson}</pre>,
},
]}
/>
)}
</div>
);
};
export default CategoryTreeDebugPage;
+16
View File
@@ -19,6 +19,7 @@ import {
import type { MenuProps, TableProps } from 'antd'; import type { MenuProps, TableProps } from 'antd';
import { import {
ApiOutlined, ApiOutlined,
BugOutlined,
CloudDownloadOutlined, CloudDownloadOutlined,
DeleteOutlined, DeleteOutlined,
MoreOutlined, MoreOutlined,
@@ -361,6 +362,14 @@ const ShopsPage = () => {
if (result?.operation) void pollOperation(result.operation.id); if (result?.operation) void pollOperation(result.operation.id);
}); });
const handleSyncCategoryTree = (store: Store) =>
withPending(store, async () => {
const result = await storesApi.requestCategoryTreeSync(store.id);
message.success(
`门店类目已刷新:${result.active_category_count}/${result.category_count} 个可发布类目`
);
});
const handleSyncWarehouses = (store: Store) => const handleSyncWarehouses = (store: Store) =>
withPending(store, async () => { withPending(store, async () => {
const result = await storesApi.requestWarehouseSync(store.id); const result = await storesApi.requestWarehouseSync(store.id);
@@ -401,6 +410,7 @@ const ShopsPage = () => {
disabled: store.is_default, disabled: store.is_default,
}, },
{ key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' }, { key: 'verify', icon: <ReloadOutlined />, label: '重新验证凭据' },
{ key: 'sync-category-tree', icon: <CloudDownloadOutlined />, label: '刷新门店类目' },
{ key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' }, { key: 'sync', icon: <CloudDownloadOutlined />, label: '同步仓库' },
{ type: 'divider' }, { type: 'divider' },
{ key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true }, { key: 'delete', icon: <DeleteOutlined />, label: '删除店铺', danger: true },
@@ -411,6 +421,7 @@ const ShopsPage = () => {
({ key }) => { ({ key }) => {
if (key === 'set-default') void handleSetDefault(store); if (key === 'set-default') void handleSetDefault(store);
else if (key === 'verify') void handleVerify(store); else if (key === 'verify') void handleVerify(store);
else if (key === 'sync-category-tree') void handleSyncCategoryTree(store);
else if (key === 'sync') void handleSyncWarehouses(store); else if (key === 'sync') void handleSyncWarehouses(store);
else if (key === 'delete') handleDelete(store); else if (key === 'delete') handleDelete(store);
}; };
@@ -549,6 +560,10 @@ const ShopsPage = () => {
<Card <Card
title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`} title={`店铺管理${currentPlatform ? ` · ${currentPlatform.name}` : ''}`}
extra={ extra={
<Space>
<Button href="/shops/category-tree-debug" icon={<BugOutlined />}>
类目调试
</Button>
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
@@ -557,6 +572,7 @@ const ShopsPage = () => {
> >
添加店铺 添加店铺
</Button> </Button>
</Space>
} }
> >
{platforms.length === 0 && !currentPlatform ? ( {platforms.length === 0 && !currentPlatform ? (
+2
View File
@@ -12,6 +12,7 @@ import ProductEditPage from '@/pages/products/ProductEditPage';
import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage'; import ProfileSettingsPage from '@/pages/settings/ProfileSettingsPage';
import RegisterPage from '@/pages/auth/RegisterPage'; import RegisterPage from '@/pages/auth/RegisterPage';
import ShopsPage from '@/pages/shops/ShopsPage'; import ShopsPage from '@/pages/shops/ShopsPage';
import CategoryTreeDebugPage from '@/pages/shops/CategoryTreeDebugPage';
import WatermarksPage from '@/pages/watermarks/WatermarksPage'; import WatermarksPage from '@/pages/watermarks/WatermarksPage';
import { PlatformProvider } from '@/services/platform'; import { PlatformProvider } from '@/services/platform';
import { useSession } from '@/services/session'; import { useSession } from '@/services/session';
@@ -59,6 +60,7 @@ export const router = createBrowserRouter([
{ path: 'collection', element: <CollectionPage /> }, { path: 'collection', element: <CollectionPage /> },
{ path: 'collection-test/:id', element: <CollectionInspectionPage /> }, { path: 'collection-test/:id', element: <CollectionInspectionPage /> },
{ path: 'shops', element: <ShopsPage /> }, { path: 'shops', element: <ShopsPage /> },
{ path: 'shops/category-tree-debug', element: <CategoryTreeDebugPage /> },
{ path: 'watermarks', element: <WatermarksPage /> }, { path: 'watermarks', element: <WatermarksPage /> },
{ path: 'pricing', element: <PricingPage /> }, { path: 'pricing', element: <PricingPage /> },
// 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现 // 商品 AI 编辑页:从采集箱/在线商品进入,不在左侧菜单中出现
@@ -0,0 +1,9 @@
import { api } from '@/services/api';
/** 仅调试页使用:服务端代表当前平台默认门店读取 Ozon 原始响应。 */
export const categoryTreeDebugApi = {
fetchDefaultStoreTree: (platformId: string) =>
api.post<unknown>('/stores/default/category-tree-debug', undefined, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
};
+11
View File
@@ -17,6 +17,11 @@ export interface UpdateStoreOptions {
revision: number; revision: number;
} }
export interface CategoryTreeRefreshResult {
category_count: number;
active_category_count: number;
}
/** /**
* 门店管理接口,契约见 docs/api/stores-and-platforms.md。 * 门店管理接口,契约见 docs/api/stores-and-platforms.md。
* 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。 * 列表必须用 platform_id 查询参数(管理列表禁止携带 X-Atlas-Platform-Id header)。
@@ -57,6 +62,12 @@ export const storesApi = {
headers: { 'Idempotency-Key': crypto.randomUUID() }, headers: { 'Idempotency-Key': crypto.randomUUID() },
}), }),
/** 同步重新拉取并写入该门店完整类目树;密钥仅在服务端短暂使用。 */
requestCategoryTreeSync: (storeId: string) =>
api.post<CategoryTreeRefreshResult>(`/stores/${storeId}/category-tree-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
requestWarehouseSync: (storeId: string) => requestWarehouseSync: (storeId: string) =>
api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, { api.post<StoreOperationResponse>(`/stores/${storeId}/warehouse-syncs`, undefined, {
headers: { 'Idempotency-Key': crypto.randomUUID() }, headers: { 'Idempotency-Key': crypto.randomUUID() },
+25 -6
View File
@@ -369,28 +369,47 @@ async function persistCategoryTree(
}; };
} }
function flattenCategories( export function flattenCategories(
nodes: OzonCategoryNode[], nodes: OzonCategoryNode[],
path: string[] = [], path: string[] = [],
parentKey: string | null = null, parentKey: string | null = null,
inheritedCategory?: { id: string; name: string },
): FlatCategory[] { ): FlatCategory[] {
const flattened: FlatCategory[] = []; const flattened: FlatCategory[] = [];
for (const node of nodes) { for (const node of nodes) {
const categoryId = String(node.description_category_id ?? '').trim(); // Ozon's last level may contain only type_name/type_id. It is still a
if (!categoryId || !node.category_name?.trim()) continue; // publishable option for the closest description category, so inherit
// its category ID while retaining the type's own display name and ID.
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 typeId = String(node.type_id ?? 0);
const nextPath = [...path, node.category_name.trim()]; const typeName = node.type_name?.trim() ?? '';
const name = explicitCategoryName || typeName || categoryName;
if (!categoryId || !name) continue;
const nextPath = [...path, name];
const key = categoryKey(categoryId, typeId); const key = categoryKey(categoryId, typeId);
flattened.push({ flattened.push({
external_category_id: categoryId, external_category_id: categoryId,
external_type_id: typeId, external_type_id: typeId,
parent_key: parentKey, parent_key: parentKey,
name: node.category_name.trim(), name,
path: nextPath.join(' › '), path: nextPath.join(' › '),
is_active: node.disabled !== true && typeId !== '0', is_active: node.disabled !== true && typeId !== '0',
source_payload: node, source_payload: node,
}); });
flattened.push(...flattenCategories(node.children ?? [], nextPath, key)); const nextInheritedCategory = explicitCategoryId && explicitCategoryName
? { id: explicitCategoryId, name: explicitCategoryName }
: inheritedCategory;
flattened.push(
...flattenCategories(
node.children ?? [],
nextPath,
key,
nextInheritedCategory,
),
);
} }
return flattened; return flattened;
} }
+3 -2
View File
@@ -13,8 +13,9 @@ export class OzonRequestError extends Error {
export class OzonRetryableError extends Error {} export class OzonRetryableError extends Error {}
export type OzonCategoryNode = { export type OzonCategoryNode = {
description_category_id: number | string; /** 类型叶子会省略这两个字段,继承最近的父类目。 */
category_name: string; description_category_id?: number | string;
category_name?: string | null;
type_id?: number | string | null; type_id?: number | string | null;
type_name?: string | null; type_name?: string | null;
disabled?: boolean; disabled?: boolean;
+4
View File
@@ -148,6 +148,10 @@ Headers:`Idempotency-Key` 必填。路径店铺在其平台下成为默认店
Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。 Headers:`Idempotency-Key` 必填。手动重试凭据校验,返回 `202` 与 operation。店铺停用或删除时返回 `422`。
`POST /api/stores/{store_id}/category-tree-syncs`
Headers:`Idempotency-Key` 必填。同步请求 Ozon `POST /v1/description-category/tree` 并写入目标门店缓存;只有完整响应成功落库后才返回 `200` 与类目数量。必须是凭据已验证的 Ozon 门店,否则返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。Ozon 请求或落库失败返回脱敏错误,旧缓存保持不变。此手动操作不经 Worker 或队列,API Key 仅在服务端请求期间短暂存在。
`POST /api/stores/{store_id}/warehouse-syncs` `POST /api/stores/{store_id}/warehouse-syncs`
Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。 Headers:`Idempotency-Key` 必填。触发 Ozon `POST /v1/warehouse/list` 同步,返回 `202` 与 operation。凭据无效时不调用平台,返回 `422 STORE_CREDENTIAL_UNAVAILABLE`。
@@ -41,25 +41,24 @@
{ {
"result": [ "result": [
{ {
"description_category_id": 17033876, "description_category_id": 17000001,
"category_name": "Термокружки", "category_name": "Дом и сад",
"type_id": 97114,
"type_name": "Термокружка",
"disabled": false,
"children": []
},
{
"description_category_id": 17028922,
"category_name": "Посуда",
"type_id": 0, "type_id": 0,
"type_name": "", "type_name": "",
"disabled": true, "disabled": true,
"children": [ "children": [
{ {
"description_category_id": 17033876, "description_category_id": 17000002,
"category_name": "Термокружки", "category_name": "Сувениры и подарки",
"type_id": 97114, "type_id": 0,
"type_name": "Термокружка", "type_name": "",
"disabled": true,
"children": [
{
"description_category_id": 17032010,
"category_name": "Копилки",
"type_id": 93770,
"type_name": "Копилка",
"disabled": false, "disabled": false,
"children": [] "children": []
} }
@@ -67,6 +66,8 @@
} }
] ]
} }
]
}
``` ```
### 字段说明 ### 字段说明
@@ -77,16 +78,18 @@
| category_name | string | 类目名称 | | category_name | string | 类目名称 |
| type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) | | type_id | integer | **商品类型 ID**(发布商品时必填,与 category_id 配对) |
| type_name | string | 商品类型名称 | | type_name | string | 商品类型名称 |
| disabled | boolean | **是否禁用**。`true` = 不可建品(父类目),`false` = 可建品(末级类目) | | disabled | boolean | **是否可建品**。`true` = 当前节点不可用于建品;`false` = 可用于建品(通常是末级类目) |
| children | array | 子类目(递归结构) | | children | array | 子类目(递归结构) |
> **类型叶子例外**:Ozon 的最后一层可能只返回 `type_name`、`type_id`、`disabled` 和空 `children`,而不重复返回 `description_category_id`、`category_name`。此时它属于最近的父类目:缓存时应继承父类目的类目 ID,展示名称使用 `type_name`,发布时使用“父类目 `description_category_id` + 叶子 `type_id`”。
--- ---
## 关键约束 ## 关键约束
1. **只有末级类目可建品**:`disabled=false` 的类目才能用于发布商品 1. **只有可发布的末级类目可建品**:选择结果必须是没有 `children` 且 `disabled=false` 的节点
2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id` 2. **必须配对使用**:发布时需同时提供 `description_category_id` + `type_id`
3. **层级结构**:类目可能有多层嵌套(最多 5-6 层),需递归遍历找到末级 3. **层级结构**:`children` 是递归结构,类目可有三级或更深层级;不能假定只有两层,也不能只展开一层
--- ---
@@ -97,15 +100,16 @@
``` ```
① 请求类目树(language=RU,给俄文用户看) ① 请求类目树(language=RU,给俄文用户看)
② 递归展开树形结构 ② 递归展开树形结构
③ 用户选择类目后,校验 disabled=false(若 true 则禁止选择或自动展开子级) ③ 所有有子节点的目录都保持可展开;不要把目录节点映射为组件的 disabled
④ 选中后保存 description_category_id + type_id ④ 仅允许选择没有子节点且 disabled=false 的节点
⑤ 选中后保存 description_category_id + type_id
``` ```
### 场景 2:服务端门店缓存 ### 场景 2:服务端门店缓存
``` ```
① 门店 `Client-Id` + `Api-Key` 验证成功后拉取类目树(Atlas 使用 `ZH_HANS`) ① 门店 `Client-Id` + `Api-Key` 验证成功后拉取完整类目树(Atlas 使用 `ZH_HANS`)
② 按门店存入本地类目缓存;不与其他门店混用 ② 递归保存每一层及其父子关系,按门店存入本地类目缓存;不与其他门店混用
③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效 ③ 同步失败由后台任务重试,已验证的门店凭据不因此改为无效
④ 用户选类目时直接查当前目标门店缓存,不频繁调 API ④ 用户选类目时直接查当前目标门店缓存,不频繁调 API
``` ```
@@ -141,7 +145,7 @@ async def fetch_category_tree(
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
# 递归扁平化 # 递归扁平化;不要限制层级数。
def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0): def flatten(nodes: List[Dict], level: int = 0, parent_id: int = 0):
flat = [] flat = []
for node in nodes: for node in nodes:
@@ -184,19 +188,24 @@ async function fetchCategoryTree(language = 'RU'): Promise<CategoryNode[]> {
return data.result; return data.result;
} }
// 转为 antd Tree 数据结构 // 转为 antd Cascader 数据结构。目录永远不能设为 disabled,
// 否则用户无法继续展开到下一级。
function toTreeData(nodes: CategoryNode[]): any[] { function toTreeData(nodes: CategoryNode[]): any[] {
return nodes.map(node => ({ return nodes.map(node => {
key: `${node.description_category_id}-${node.type_id}`, const hasChildren = node.children.length > 0;
title: node.category_name, return {
disabled: node.disabled, // 父类目禁止选择 value: `${node.description_category_id}-${node.type_id}`,
children: node.children.length > 0 ? toTreeData(node.children) : undefined, label: node.category_name,
// 保存原始数据,选中时取用 isLeaf: !hasChildren,
// 仅不可发布的叶子不可选择;目录节点必须可展开。
disabled: !hasChildren && node.disabled,
children: hasChildren ? toTreeData(node.children) : undefined,
data: { data: {
description_category_id: node.description_category_id, description_category_id: node.description_category_id,
type_id: node.type_id type_id: node.type_id
} }
})); };
});
} }
``` ```
@@ -204,48 +213,29 @@ function toTreeData(nodes: CategoryNode[]): any[] {
## 缓存策略 ## 缓存策略
### 全局缓存(推荐) ### 按门店缓存(Atlas 方案)
```python ```python
# 类目树与店铺无关,所有店铺共用一份 # 类目数据的读取凭证属于门店。即使结构通常相近,也必须按 store_id 隔离缓存。
# 启动时拉取,存内存 + 数据库 # 验证成功或缓存失效时,由 Worker 用该门店凭证拉取完整树并递归写库。
# TTL 24h(类目变化不频繁) # 前端只读取该门店的缓存,绝不携带或读取 Client-Id / Api-Key。
async def get_store_category_tree(store_id: str):
cached = await load_store_category_cache(store_id)
if cached and cached.is_valid:
return cached.tree
from functools import lru_cache await enqueue_store_catalog_sync(store_id)
from datetime import datetime, timedelta raise CatalogCachePreparingError(store_id)
_category_tree_cache = None
_cache_time = None
@lru_cache(maxsize=1)
async def get_category_tree_cached(language: str = "DEFAULT"):
global _category_tree_cache, _cache_time
now = datetime.utcnow()
if _category_tree_cache and _cache_time and (now - _cache_time) < timedelta(hours=24):
return _category_tree_cache
# 从任意店铺拉(类目树全局一致)
tree = await fetch_category_tree(any_client_id, any_api_key, language)
_category_tree_cache = tree
_cache_time = now
# 同时写数据库
await save_to_db(tree)
return tree
``` ```
### 按需更新 ### 按需更新
```python ```python
# 用户反馈「找不到某类目」时手动刷新 # 用户在门店管理中手动刷新时,API 同步请求 Ozon 并在成功后原子替换目标门店缓存。
async def refresh_category_tree(): # 请求或落库失败会保留旧缓存并向用户返回错误,不经 Worker 队列。
global _category_tree_cache, _cache_time async def refresh_store_category_tree(store_id: str):
_category_tree_cache = None tree = await fetch_category_tree_for_store(store_id)
_cache_time = None return await replace_store_category_cache_atomically(store_id, tree)
get_category_tree_cached.cache_clear()
return await get_category_tree_cached()
``` ```
--- ---
@@ -254,7 +244,7 @@ async def refresh_category_tree():
### Q1: 类目树很大吗? ### Q1: 类目树很大吗?
**A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。首次拉取需几秒,后续从缓存读取。 **A**: 约 **1-2 万个类目节点**,JSON 约 3-5MB。完整树在一次响应中以递归 `children` 返回,首次拉取需几秒,后续从门店缓存读取。
### Q2: 多久更新一次? ### Q2: 多久更新一次?
@@ -262,67 +252,20 @@ async def refresh_category_tree():
### Q3: 不同语言的类目树结构一样吗? ### Q3: 不同语言的类目树结构一样吗?
**A**: 结构一致(`description_category_id` / `type_id` 相同),仅 `category_name` / `type_name` 翻译不同。建议: **A**: 通常 `description_category_id` / `type_id` 相同,仅 `category_name` / `type_name` 翻译不同。Atlas 统一由 Worker 为每个门店缓存 `ZH_HANS`,前端直接读取该门店缓存,不能为展示语言绕过缓存直接携带门店密钥请求 Ozon。
- 服务端缓存 `DEFAULT`(英文,便于代码处理)
- 前端按用户语言拉取 `RU`(俄文,展示用)
### Q4: `type_id=0` 是什么意思? ### Q4: `type_id=0` 是什么意思?
**A**: 父类目(`disabled=true`)的 `type_id` 为 0,表示该节点不是商品类型,只是分类层级。只有末级类目的 `type_id > 0`。 **A**: 目录节点通常 `type_id=0`,但前端不能据此把所有目录设为 disabled,否则它们无法展开。是否允许提交必须同时判断:它是叶子节点、`disabled=false`,并且有可用的 `type_id`。
--- ---
## V2 项目集成 ## Atlas 当前集成
### API 层(已实现) - **Worker**:以门店凭证调用 Ozon,递归持久化完整 `children` 树及父子关系;缓存命中时不重复调用 Ozon。
- **API**:按当前目标门店返回缓存的扁平节点(包含 `parent_id`);不会将 `Client-Id` 或 `Api-Key` 返回给浏览器。
```python - **前端**:根据 `parent_id` 重建任意深度的 Cascader;目录可展开,只有可发布的叶子能写入商品草稿。
# server/api/categories.py - **调试**:若需要查看 Ozon 原始响应,应由受权限保护的服务端调试能力获取并脱敏展示;浏览器不得直接用门店密钥请求 Ozon。
@router.get("/categories/tree")
async def get_tree(
lang: str = Query("RU"),
shop_id: str = Query(...), # 需要店铺凭证
db: AsyncSession = Depends(get_db)
):
shop = await get_shop(db, shop_id)
tree = await ozon_client.get_category_tree(
shop.client_id_dec,
shop.api_key_dec,
lang
)
return {"result": tree}
```
### 前端(待实现)
```tsx
// studio/src/pages/product/components/CategoryPicker.tsx
import { Tree } from 'antd';
import { useEffect, useState } from 'react';
export function CategoryPicker({ shopId, onChange }) {
const [treeData, setTreeData] = useState([]);
useEffect(() => {
fetch(`/api/categories/tree?shop_id=${shopId}&lang=RU`)
.then(r => r.json())
.then(data => setTreeData(toTreeData(data.result)));
}, [shopId]);
return (
<Tree
treeData={treeData}
onSelect={(keys, { node }) => {
if (!node.disabled) {
onChange(node.data); // { description_category_id, type_id }
}
}}
/>
);
}
```
--- ---