Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a66b07652b | |||
| 7f68aad9f3 | |||
| 3156a7516f |
@@ -1,14 +1,18 @@
|
|||||||
# AGENTS.md — 工作区工作约定
|
# AGENTS.md — 工作区工作约定
|
||||||
|
|
||||||
本工作区(`atlas-seller-kit-frontend` worktree,`zcode/frontend` 分支)的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
|
本工作区的 AI 会话必须遵守以下规则,优先级高于会话内的自行判断。
|
||||||
|
|
||||||
## 1. 只改前端
|
## 1. 可修改全部项目代码
|
||||||
|
|
||||||
- 只修改 `apps/web/` 及前端相关文档(即协作约定中「前端体验」领域,见 `docs/project/development-collaboration-contract.md` §2)。
|
- 可按任务需要修改 `apps/web/`、`apps/api/`、`apps/worker/`、`apps/extension/`、`packages/` 及相关文档。
|
||||||
- `apps/api/`、`apps/worker/`、`packages/database/`、`packages/contracts/` 等服务端领域的代码一律不改;发现问题时在回复中说明,由维护者/后端处理。
|
- 修改应遵守 `docs/project/development-collaboration-contract.md` 中的领域边界、API 契约、数据安全与验证要求;跨领域改动须明确说明影响范围。
|
||||||
|
|
||||||
## 2. 不要启动服务端和 Worker
|
## 2. 不要启动服务端和 Worker
|
||||||
|
|
||||||
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
|
- API(NestJS)和 Worker 由维护者自行在其他地方启动,AI 不需要也不应该启动、重启或杀死它们。
|
||||||
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
|
- 开发/验证时若检测到 API 或 Worker 未运行,直接在回复中说明即可,不要尝试代为启动。
|
||||||
- 前端 vite dev server 属于前端领域,可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
- 前端 Vite dev server 可为验证目的临时启动,用完后应停掉,并避免占用维护者已在使用的端口(如 5173/8200)。
|
||||||
|
|
||||||
|
## 3. 代码注释
|
||||||
|
|
||||||
|
- 新增或修改的代码注释必须使用中文;注释应说明职责、边界或不易从代码直接看出的业务约束,避免逐行复述实现。
|
||||||
|
|||||||
@@ -1,6 +1,27 @@
|
|||||||
import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { bulkDeleteCollectionItemsRequestSchema, clearCollectionItemsRequestSchema, createCollectionItemRequestSchema, listCollectionItemsQuerySchema, materializeCollectionAssetsRequestSchema, retryCollectionAssetsRequestSchema, updateCollectionItemRequestSchema, type Workspace } from '@atlas/contracts';
|
import {
|
||||||
|
bulkDeleteCollectionItemsRequestSchema,
|
||||||
|
clearCollectionItemsRequestSchema,
|
||||||
|
createCollectionItemRequestSchema,
|
||||||
|
listCollectionItemsQuerySchema,
|
||||||
|
materializeCollectionAssetsRequestSchema,
|
||||||
|
retryCollectionAssetsRequestSchema,
|
||||||
|
updateCollectionItemRequestSchema,
|
||||||
|
type Workspace,
|
||||||
|
} from '@atlas/contracts';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
import type { AuthenticatedRequest } from '../auth/auth.guard.js';
|
||||||
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js';
|
||||||
@@ -9,34 +30,199 @@ import { CollectionService } from './collection.service.js';
|
|||||||
type Response = { status: (value: number) => Response };
|
type Response = { status: (value: number) => Response };
|
||||||
@Controller('collection-items')
|
@Controller('collection-items')
|
||||||
export class CollectionController {
|
export class CollectionController {
|
||||||
public constructor(@Inject(CollectionService) private readonly collection: CollectionService) {}
|
/** 注入采集箱接口共用的应用服务。 */
|
||||||
@Get() async list(@Query() query: unknown, @Headers('x-atlas-platform-id') platformId: string | undefined, @CurrentWorkspace() workspace: Workspace) {
|
public constructor(
|
||||||
if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
@Inject(CollectionService)
|
||||||
return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query));
|
private readonly collection: CollectionService,
|
||||||
}
|
) {}
|
||||||
@Get(':id') async detail(@Param('id') id: string, @CurrentWorkspace() workspace: Workspace) {
|
/** 按页头平台上下文查询当前工作空间的采集箱。 */
|
||||||
return { data: await this.collection.detail(workspace.id, requireUuid('collection_item_id', id)) };
|
@Get() async list(
|
||||||
}
|
@Query() query: unknown,
|
||||||
@Post() async create(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
@Headers('x-atlas-platform-id') platformId: string | undefined,
|
||||||
const result = await this.collection.create(workspace.id, request.auth.userId, requireKey(key), parse(createCollectionItemRequestSchema, body)); response.status(result.status); return result.body;
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
}
|
) {
|
||||||
@Patch(':id') async update(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Headers('if-match') match: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace) {
|
if (!platformId)
|
||||||
return (await this.collection.update(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), revision(match), parse(updateCollectionItemRequestSchema, body))).body;
|
throw new ApiError(
|
||||||
}
|
400,
|
||||||
@Post('bulk-delete') async bulkDelete(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
const result = await this.collection.bulkDelete(workspace.id, request.auth.userId, requireKey(key), parse(bulkDeleteCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
'请求缺少 X-Atlas-Platform-Id',
|
||||||
}
|
);
|
||||||
@Post('clear') async clear(@Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
return this.collection.list(
|
||||||
const result = await this.collection.clear(workspace.id, request.auth.userId, requireKey(key), parse(clearCollectionItemsRequestSchema, body)); response.status(result.status); return result.body;
|
workspace.id,
|
||||||
}
|
requireUuid('X-Atlas-Platform-Id', platformId),
|
||||||
@Post(':id/asset-retries') async retry(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
parse(listCollectionItemsQuerySchema, query),
|
||||||
const result = await this.collection.retryAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(retryCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
);
|
||||||
}
|
}
|
||||||
@Post(':id/asset-materializations') async materialize(@Param('id') id: string, @Body() body: unknown, @Headers('idempotency-key') key: string | undefined, @Req() request: AuthenticatedRequest, @CurrentWorkspace() workspace: Workspace, @Res({ passthrough: true }) response: Response) {
|
/** 返回采集记录、解析证据、SKU 与当前快照的素材任务。 */
|
||||||
const result = await this.collection.materializeAssets(workspace.id, request.auth.userId, requireUuid('collection_item_id', id), requireKey(key), parse(materializeCollectionAssetsRequestSchema, body)); response.status(result.status); return result.body;
|
@Get(':id') async detail(
|
||||||
}
|
@Param('id') id: string,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
data: await this.collection.detail(
|
||||||
|
workspace.id,
|
||||||
|
requireUuid('collection_item_id', id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 幂等地创建手工采集草稿及其默认商品和 SKU。 */
|
||||||
|
@Post() async create(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.collection.create(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireKey(key),
|
||||||
|
parse(createCollectionItemRequestSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
/** 通过 If-Match 乐观锁更新当前允许编辑的采集记录字段。 */
|
||||||
|
@Patch(':id') async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Headers('if-match') match: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await this.collection.update(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('collection_item_id', id),
|
||||||
|
requireKey(key),
|
||||||
|
revision(match),
|
||||||
|
parse(updateCollectionItemRequestSchema, body),
|
||||||
|
)
|
||||||
|
).body;
|
||||||
|
}
|
||||||
|
/** 幂等地软删除用户指定的多条采集记录。 */
|
||||||
|
@Post('bulk-delete') async bulkDelete(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.collection.bulkDelete(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireKey(key),
|
||||||
|
parse(bulkDeleteCollectionItemsRequestSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
/** 在数量确认未过期时,幂等地清空当前工作空间的采集箱。 */
|
||||||
|
@Post('clear') async clear(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.collection.clear(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireKey(key),
|
||||||
|
parse(clearCollectionItemsRequestSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
/** 将当前采集快照中失败或取消的素材任务重新投递给 Worker。 */
|
||||||
|
@Post(':id/asset-retries') async retry(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.collection.retryAssets(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('collection_item_id', id),
|
||||||
|
requireKey(key),
|
||||||
|
parse(retryCollectionAssetsRequestSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
/** 将当前采集快照中用户选定的来源素材请求转存为 Atlas 素材。 */
|
||||||
|
@Post(':id/asset-materializations') async materialize(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Req() request: AuthenticatedRequest,
|
||||||
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
|
@Res({ passthrough: true }) response: Response,
|
||||||
|
) {
|
||||||
|
const result = await this.collection.materializeAssets(
|
||||||
|
workspace.id,
|
||||||
|
request.auth.userId,
|
||||||
|
requireUuid('collection_item_id', id),
|
||||||
|
requireKey(key),
|
||||||
|
parse(materializeCollectionAssetsRequestSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 使用契约 Schema 校验请求体或查询参数,并转换为统一错误响应。 */
|
||||||
|
function parse<
|
||||||
|
T extends {
|
||||||
|
safeParse: (input: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
>(schema: T, input: unknown) {
|
||||||
|
const result = schema.safeParse(input);
|
||||||
|
if (result.success) return result.data as never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((x) => ({
|
||||||
|
field: x.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: x.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 校验路径、请求头中的 UUID 标识。 */
|
||||||
|
function requireUuid(field: string, value: string) {
|
||||||
|
if (z.uuid().safeParse(value).success) return value;
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
|
{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
/** 要求关键写操作提供可追踪的幂等键。 */
|
||||||
|
function requireKey(value: string | undefined) {
|
||||||
|
if (!value)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少 Idempotency-Key',
|
||||||
|
);
|
||||||
|
return requireUuid('Idempotency-Key', value);
|
||||||
|
}
|
||||||
|
/** 解析单资源更新使用的 If-Match 版本号。 */
|
||||||
|
function revision(value: string | undefined) {
|
||||||
|
const valueMatch = value?.match(/^"([1-9]\d*)"$/);
|
||||||
|
if (!valueMatch)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IF_MATCH_REQUIRED',
|
||||||
|
'请求缺少或格式错误的 If-Match',
|
||||||
|
);
|
||||||
|
return Number(valueMatch[1]);
|
||||||
}
|
}
|
||||||
function parse<T extends { safeParse: (input: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, input: unknown) { const result = schema.safeParse(input); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((x) => ({ field: x.path.join('.'), code: 'INVALID_FIELD', message: x.message }))); }
|
|
||||||
function requireUuid(field: string, value: string) { if (z.uuid().safeParse(value).success) return value; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', [{ field, code: 'INVALID_FIELD', message: `${field} 必须是 UUID` }]); }
|
|
||||||
function requireKey(value: string | undefined) { if (!value) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少 Idempotency-Key'); return requireUuid('Idempotency-Key', value); }
|
|
||||||
function revision(value: string | undefined) { const valueMatch = value?.match(/^"([1-9]\d*)"$/); if (!valueMatch) throw new ApiError(400, 'IF_MATCH_REQUIRED', '请求缺少或格式错误的 If-Match'); return Number(valueMatch[1]); }
|
|
||||||
|
|||||||
@@ -3,5 +3,8 @@ import { IdempotencyService } from '../common/idempotency.service.js';
|
|||||||
import { CollectionController } from './collection.controller.js';
|
import { CollectionController } from './collection.controller.js';
|
||||||
import { CollectionService } from './collection.service.js';
|
import { CollectionService } from './collection.service.js';
|
||||||
|
|
||||||
@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] })
|
@Module({
|
||||||
|
controllers: [CollectionController],
|
||||||
|
providers: [IdempotencyService, CollectionService],
|
||||||
|
})
|
||||||
export class CollectionModule {}
|
export class CollectionModule {}
|
||||||
|
|||||||
@@ -1,67 +1,547 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import type { CreateCollectionItemRequest, ListCollectionItemsQuery, MaterializeCollectionAssetsRequest, UpdateCollectionItemRequest } from '@atlas/contracts';
|
import type {
|
||||||
|
CreateCollectionItemRequest,
|
||||||
|
ListCollectionItemsQuery,
|
||||||
|
MaterializeCollectionAssetsRequest,
|
||||||
|
UpdateCollectionItemRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
import { throwRevisionConflict } from '../common/revision.js';
|
import { throwRevisionConflict } from '../common/revision.js';
|
||||||
import { DATABASE } from '../database/database.module.js';
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
|
||||||
type Row = { id: string; product_id: string; source_kind: 'platform' | 'manual'; platform_code: string | null; source_url: string | null; source_title: string | null; source_min_price: string | null; source_max_price: string | null; source_currency_code: string | null; note: string | null; status: string; created_at: Date | string; updated_at: Date | string; revision: number; variant_count: string; transfer_status: string | null };
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
product_id: string;
|
||||||
|
source_kind: 'platform' | 'manual';
|
||||||
|
platform_code: string | null;
|
||||||
|
source_url: string | null;
|
||||||
|
source_title: string | null;
|
||||||
|
source_min_price: string | null;
|
||||||
|
source_max_price: string | null;
|
||||||
|
source_currency_code: string | null;
|
||||||
|
note: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
updated_at: Date | string;
|
||||||
|
revision: number;
|
||||||
|
variant_count: string;
|
||||||
|
transfer_status: string | null;
|
||||||
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollectionService {
|
export class CollectionService {
|
||||||
public constructor(@Inject(IdempotencyService) private readonly idempotency: IdempotencyService, @Inject(DATABASE) private readonly database: Database) {}
|
/** 注入工作空间事务数据库与关键写操作的幂等边界。 */
|
||||||
async list(workspaceId: string, platformId: string, query: ListCollectionItemsQuery) {
|
public constructor(
|
||||||
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
@Inject(IdempotencyService)
|
||||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
private readonly idempotency: IdempotencyService,
|
||||||
await this.requireHeaderPlatform(tx, platformId);
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
const rows = await tx.query<Row>(`${selectRow}
|
) {}
|
||||||
|
/** 分页查询未删除的采集记录,并返回当前工作空间的配额摘要。 */
|
||||||
|
async list(
|
||||||
|
workspaceId: string,
|
||||||
|
platformId: string,
|
||||||
|
query: ListCollectionItemsQuery,
|
||||||
|
) {
|
||||||
|
const cursor = query.cursor ? decodeCursor(query.cursor) : null;
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
await this.requireHeaderPlatform(tx, platformId);
|
||||||
|
const rows = await tx.query<Row>(
|
||||||
|
`${selectRow}
|
||||||
WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%')
|
WHERE c.deleted_at IS NULL AND ($1::text IS NULL OR c.source_title ILIKE '%' || $1 || '%')
|
||||||
AND ($2::text IS NULL OR p.code = $2) AND ($3::text IS NULL OR $3 = 'not_listed')
|
AND ($2::text IS NULL OR p.code = $2) AND ($3::text IS NULL OR $3 = 'not_listed')
|
||||||
AND ($4::timestamptz IS NULL OR (c.updated_at, c.id) < ($4, $5::uuid))
|
AND ($4::timestamptz IS NULL OR (c.updated_at, c.id) < ($4, $5::uuid))
|
||||||
${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`, [query.q ?? null, query.source_platform_code ?? null, query.listing_status ?? null, cursor?.at ?? null, cursor?.id ?? null, query.limit + 1]);
|
${collectionGroupBy} ORDER BY c.updated_at DESC, c.id DESC LIMIT $6`,
|
||||||
const items = rows.rows.slice(0, query.limit).map(toItem); const last = items.at(-1);
|
[
|
||||||
const count = await tx.query<{ count: string; collection_item_limit: number }>('SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL');
|
query.q ?? null,
|
||||||
return { data: items, meta: { has_more: rows.rows.length > query.limit, next_cursor: rows.rows.length > query.limit && last ? encodeCursor(last.updated_at, last.id) : null, active_count: Number(count.rows[0]?.count ?? 0), collection_limit: count.rows[0]?.collection_item_limit ?? 1000 } };
|
query.source_platform_code ?? null,
|
||||||
});
|
query.listing_status ?? null,
|
||||||
}
|
cursor?.at ?? null,
|
||||||
async create(workspaceId: string, userId: string, key: string, input: CreateCollectionItemRequest) {
|
cursor?.id ?? null,
|
||||||
return this.idempotency.run({ workspaceId, userId, method: 'POST', path: '/api/collection-items', key, request: input }, async (tx) => {
|
query.limit + 1,
|
||||||
const item = await tx.query<{ id: string }>(`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`, [randomUUID(), input.title ?? null, input.note ?? null]);
|
],
|
||||||
const product = await tx.query<{ id: string }>(`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`, [item.rows[0].id, input.title ?? null]);
|
);
|
||||||
await tx.query(`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`, [product.rows[0].id]);
|
const items = rows.rows.slice(0, query.limit).map(toItem);
|
||||||
return { status: 201, body: { data: { collection_item: await this.requireItem(tx, item.rows[0].id), product_id: product.rows[0].id, editor_entry_path: `/products/${product.rows[0].id}` } } };
|
const last = items.at(-1);
|
||||||
});
|
const count = await tx.query<{
|
||||||
}
|
count: string;
|
||||||
async detail(workspaceId: string, id: string) {
|
collection_item_limit: number;
|
||||||
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
}>(
|
||||||
const item = await this.requireItem(tx, id);
|
'SELECT count(*)::text AS count, max(w.collection_item_limit)::int AS collection_item_limit FROM atlas.collection_items c CROSS JOIN atlas.workspaces w WHERE c.workspace_id = w.id AND c.deleted_at IS NULL',
|
||||||
const source = await tx.query<{ normalized_payload: unknown; raw_evidence: unknown; parser_version: string | null; collected_at: Date | string | null; source_snapshot_revision: string | number }>('SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]);
|
);
|
||||||
const variants = await tx.query<{ id: string; source_variant_key: string | null; source_sku: string | null; display_name: string | null; sort_order: number }>('SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id', [item.product_id]);
|
return {
|
||||||
const transfers = await tx.query<{ id: string; source_variant_id: string | null; media_type: 'image' | 'video'; poster_url: string | null; role: string; source_url: string; status: string; asset_id: string | null; error_payload: unknown; created_at: Date | string; completed_at: Date | string | null }>(`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`, [id, source.rows[0]?.source_snapshot_revision ?? 1]);
|
data: items,
|
||||||
return { ...item, normalized_payload: source.rows[0]?.normalized_payload ?? {}, raw_evidence: source.rows[0]?.raw_evidence ?? {}, parser_version: source.rows[0]?.parser_version ?? null, collected_at: source.rows[0]?.collected_at ? iso(source.rows[0].collected_at) : null, variants: variants.rows, asset_transfers: transfers.rows.map((transfer) => ({ ...transfer, created_at: iso(transfer.created_at), completed_at: transfer.completed_at ? iso(transfer.completed_at) : null })) };
|
meta: {
|
||||||
});
|
has_more: rows.rows.length > query.limit,
|
||||||
}
|
next_cursor:
|
||||||
async update(workspaceId: string, userId: string, id: string, key: string, expected: number, input: UpdateCollectionItemRequest) {
|
rows.rows.length > query.limit && last
|
||||||
return this.idempotency.run({ workspaceId, userId, method: 'PATCH', path: `/api/collection-items/${id}`, key, request: { if_match: expected, ...input } }, async (tx) => {
|
? encodeCursor(last.updated_at, last.id)
|
||||||
const result = await tx.query(`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`, [input.note, id, expected]);
|
: null,
|
||||||
if (!result.rowCount) await throwRevisionConflict(tx, 'collection_item', id, expected);
|
active_count: Number(count.rows[0]?.count ?? 0),
|
||||||
return { status: 200, body: { data: await this.requireItem(tx, id) } };
|
collection_limit:
|
||||||
});
|
count.rows[0]?.collection_item_limit ?? 1000,
|
||||||
}
|
},
|
||||||
async bulkDelete(workspaceId: string, userId: string, key: string, ids: { ids: string[] }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/bulk-delete', ids, false); }
|
};
|
||||||
async clear(workspaceId: string, userId: string, key: string, input: { confirm: true; expected_active_count: number }) { return this.removeMany(workspaceId, userId, key, '/api/collection-items/clear', input, true); }
|
});
|
||||||
async retryAssets(workspaceId: string, userId: string, id: string, key: string, input: { transfer_ids: string[] }) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-retries`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_RETRY_INVALID', '只能重试当前采集快照中失败或已取消的素材任务'); const operation = await this.operation(tx, userId, 'collection_asset_retry', id); await this.event(tx, id, 'collection.assets.retry.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
}
|
||||||
async materializeAssets(workspaceId: string, userId: string, id: string, key: string, input: MaterializeCollectionAssetsRequest) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path: `/api/collection-items/${id}/asset-materializations`, key, request: input }, async (tx) => { await this.requireItem(tx, id); const result = await tx.query<{ source_snapshot_revision: string | number }>(`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`, [id, input.transfer_ids]); if (result.rowCount !== input.transfer_ids.length) throw new ApiError(422, 'ASSET_MATERIALIZATION_INVALID', '只能转存当前采集快照中尚未转存的来源媒体'); const operation = await this.operation(tx, userId, 'collection_asset_materialization', id); await this.event(tx, operation.id, 'collection.assets.materialize.requested', { operation_id: operation.id, collection_item_id: id, source_snapshot_revision: Number(result.rows[0].source_snapshot_revision) }); return { status: 202, body: { data: { operation } } }; }); }
|
/** 原子创建手工采集记录、商品和默认 SKU,供后续编辑流程使用。 */
|
||||||
private async removeMany(workspaceId: string, userId: string, key: string, path: string, input: unknown, clear: boolean) { return this.idempotency.run({ workspaceId, userId, method: 'POST', path, key, request: input }, async (tx) => { const requested = input as { ids?: string[]; expected_active_count?: number }; const count = await tx.query<{ count: string }>('SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL'); if (clear && Number(count.rows[0].count) !== requested.expected_active_count) throw new ApiError(409, 'COLLECTION_COUNT_CHANGED', '采集箱数量已变化,请重新确认'); const deleted = await tx.query<{ id: string }>(clear ? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id` : `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`, clear ? [] : [requested.ids]); if (!clear && deleted.rows.length !== requested.ids?.length) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '部分采集记录不存在'); const operation = await this.operation(tx, userId, 'collection_cleanup', deleted.rows[0]?.id ?? null); await this.event(tx, operation.id, 'collection.cleanup.requested', { operation_id: operation.id, collection_item_ids: deleted.rows.map((row) => row.id) }); return { status: 202, body: { data: { deleted_count: deleted.rows.length, operation } } }; }); }
|
async create(
|
||||||
private async operation(tx: DatabaseTransaction, userId: string, kind: string, resourceId: string | null) { const row = await tx.query<{ id: string; status: string; created_at: Date | string }>(`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`, [kind, resourceId, userId]); return { id: row.rows[0].id, kind, status: row.rows[0].status, created_at: iso(row.rows[0].created_at), completed_at: null, error: null }; }
|
workspaceId: string,
|
||||||
private async event(tx: DatabaseTransaction, aggregateId: string, type: string, payload: unknown) { await tx.query(`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`, [aggregateId, type, payload, `${type}:${aggregateId}`]); }
|
userId: string,
|
||||||
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) { const row = await tx.query(`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`, [id]); if (!row.rows[0]) throw new ApiError(422, 'PLATFORM_UNAVAILABLE', '当前平台不可用于 ERP 上下文'); }
|
key: string,
|
||||||
private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query<Row>(`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`, [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); return toItem(row.rows[0]); }
|
input: CreateCollectionItemRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/collection-items',
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const item = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.collection_items (workspace_id, source_kind, source_item_key, source_title, note, status) VALUES (current_setting('app.workspace_id')::uuid, 'manual', $1, $2, $3, 'ready_for_edit') RETURNING id`,
|
||||||
|
[randomUUID(), input.title ?? null, input.note ?? null],
|
||||||
|
);
|
||||||
|
const product = await tx.query<{ id: string }>(
|
||||||
|
`INSERT INTO atlas.products (workspace_id, collection_item_id, source_title) VALUES (current_setting('app.workspace_id')::uuid, $1, $2) RETURNING id`,
|
||||||
|
[item.rows[0].id, input.title ?? null],
|
||||||
|
);
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.product_variants (workspace_id, product_id, display_name, sort_order) VALUES (current_setting('app.workspace_id')::uuid, $1, '默认 SKU', 0)`,
|
||||||
|
[product.rows[0].id],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 201,
|
||||||
|
body: {
|
||||||
|
data: {
|
||||||
|
collection_item: await this.requireItem(
|
||||||
|
tx,
|
||||||
|
item.rows[0].id,
|
||||||
|
),
|
||||||
|
product_id: product.rows[0].id,
|
||||||
|
editor_entry_path: `/products/${product.rows[0].id}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 返回当前采集快照的来源证据、SKU 与待转存素材任务。 */
|
||||||
|
async detail(workspaceId: string, id: string) {
|
||||||
|
return this.database.workspaceTransaction(workspaceId, async (tx) => {
|
||||||
|
const item = await this.requireItem(tx, id);
|
||||||
|
const source = await tx.query<{
|
||||||
|
normalized_payload: unknown;
|
||||||
|
raw_evidence: unknown;
|
||||||
|
parser_version: string | null;
|
||||||
|
collected_at: Date | string | null;
|
||||||
|
source_snapshot_revision: string | number;
|
||||||
|
}>(
|
||||||
|
'SELECT normalized_payload, raw_evidence, parser_version, collected_at, source_snapshot_revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
const variants = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
source_variant_key: string | null;
|
||||||
|
source_sku: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
}>(
|
||||||
|
'SELECT id, source_variant_key, source_sku, display_name, sort_order FROM atlas.product_variants WHERE product_id = $1 AND deleted_at IS NULL ORDER BY sort_order, id',
|
||||||
|
[item.product_id],
|
||||||
|
);
|
||||||
|
const transfers = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
source_variant_id: string | null;
|
||||||
|
media_type: 'image' | 'video';
|
||||||
|
poster_url: string | null;
|
||||||
|
role: string;
|
||||||
|
source_url: string;
|
||||||
|
status: string;
|
||||||
|
asset_id: string | null;
|
||||||
|
error_payload: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
completed_at: Date | string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, source_variant_id, media_type::text AS media_type, poster_url, role::text AS role, source_url, status::text AS status, asset_id, error_payload, created_at, completed_at FROM atlas.collection_asset_transfers WHERE collection_item_id = $1 AND source_snapshot_revision = $2 ORDER BY coalesce((error_payload->>'sort_order')::integer, 0), created_at, id`,
|
||||||
|
[id, source.rows[0]?.source_snapshot_revision ?? 1],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
normalized_payload: source.rows[0]?.normalized_payload ?? {},
|
||||||
|
raw_evidence: source.rows[0]?.raw_evidence ?? {},
|
||||||
|
parser_version: source.rows[0]?.parser_version ?? null,
|
||||||
|
collected_at: source.rows[0]?.collected_at
|
||||||
|
? iso(source.rows[0].collected_at)
|
||||||
|
: null,
|
||||||
|
variants: variants.rows,
|
||||||
|
asset_transfers: transfers.rows.map((transfer) => ({
|
||||||
|
...transfer,
|
||||||
|
created_at: iso(transfer.created_at),
|
||||||
|
completed_at: transfer.completed_at
|
||||||
|
? iso(transfer.completed_at)
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/** 使用乐观锁更新采集记录备注,避免覆盖并发编辑。 */
|
||||||
|
async update(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
expected: number,
|
||||||
|
input: UpdateCollectionItemRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'PATCH',
|
||||||
|
path: `/api/collection-items/${id}`,
|
||||||
|
key,
|
||||||
|
request: { if_match: expected, ...input },
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
const result = await tx.query(
|
||||||
|
`UPDATE atlas.collection_items SET note = $1, revision = revision + 1, updated_at = now() WHERE id = $2 AND deleted_at IS NULL AND revision = $3`,
|
||||||
|
[input.note, id, expected],
|
||||||
|
);
|
||||||
|
if (!result.rowCount)
|
||||||
|
await throwRevisionConflict(
|
||||||
|
tx,
|
||||||
|
'collection_item',
|
||||||
|
id,
|
||||||
|
expected,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: { data: await this.requireItem(tx, id) },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 请求软删除指定采集记录,并投递异步清理任务。 */
|
||||||
|
async bulkDelete(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
ids: { ids: string[] },
|
||||||
|
) {
|
||||||
|
return this.removeMany(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
'/api/collection-items/bulk-delete',
|
||||||
|
ids,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 确认采集箱数量未变化后,软删除全部活动记录。 */
|
||||||
|
async clear(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
input: { confirm: true; expected_active_count: number },
|
||||||
|
) {
|
||||||
|
return this.removeMany(
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
'/api/collection-items/clear',
|
||||||
|
input,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 重新投递当前来源快照中失败或取消的素材转存任务。 */
|
||||||
|
async retryAssets(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
input: { transfer_ids: string[] },
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/collection-items/${id}/asset-retries`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.requireItem(tx, id);
|
||||||
|
const result = await tx.query<{
|
||||||
|
source_snapshot_revision: string | number;
|
||||||
|
}>(
|
||||||
|
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status IN ('failed', 'cancelled') RETURNING c.source_snapshot_revision`,
|
||||||
|
[id, input.transfer_ids],
|
||||||
|
);
|
||||||
|
if (result.rowCount !== input.transfer_ids.length)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'ASSET_RETRY_INVALID',
|
||||||
|
'只能重试当前采集快照中失败或已取消的素材任务',
|
||||||
|
);
|
||||||
|
const operation = await this.operation(
|
||||||
|
tx,
|
||||||
|
userId,
|
||||||
|
'collection_asset_retry',
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await this.event(tx, id, 'collection.assets.retry.requested', {
|
||||||
|
operation_id: operation.id,
|
||||||
|
collection_item_id: id,
|
||||||
|
source_snapshot_revision: Number(
|
||||||
|
result.rows[0].source_snapshot_revision,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return { status: 202, body: { data: { operation } } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 将用户选中的候选来源素材转为可供编辑和发布的 Atlas 素材。 */
|
||||||
|
async materializeAssets(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
input: MaterializeCollectionAssetsRequest,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
method: 'POST',
|
||||||
|
path: `/api/collection-items/${id}/asset-materializations`,
|
||||||
|
key,
|
||||||
|
request: input,
|
||||||
|
},
|
||||||
|
async (tx) => {
|
||||||
|
await this.requireItem(tx, id);
|
||||||
|
const result = await tx.query<{
|
||||||
|
source_snapshot_revision: string | number;
|
||||||
|
}>(
|
||||||
|
`UPDATE atlas.collection_asset_transfers t SET status = 'queued', error_payload = NULL, completed_at = NULL, updated_at = now() FROM atlas.collection_items c WHERE t.collection_item_id = c.id AND t.collection_item_id = $1 AND t.id = ANY($2::uuid[]) AND t.source_snapshot_revision = c.source_snapshot_revision AND t.status = 'pending_selection' RETURNING c.source_snapshot_revision`,
|
||||||
|
[id, input.transfer_ids],
|
||||||
|
);
|
||||||
|
if (result.rowCount !== input.transfer_ids.length)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'ASSET_MATERIALIZATION_INVALID',
|
||||||
|
'只能转存当前采集快照中尚未转存的来源媒体',
|
||||||
|
);
|
||||||
|
const operation = await this.operation(
|
||||||
|
tx,
|
||||||
|
userId,
|
||||||
|
'collection_asset_materialization',
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
await this.event(
|
||||||
|
tx,
|
||||||
|
operation.id,
|
||||||
|
'collection.assets.materialize.requested',
|
||||||
|
{
|
||||||
|
operation_id: operation.id,
|
||||||
|
collection_item_id: id,
|
||||||
|
source_snapshot_revision: Number(
|
||||||
|
result.rows[0].source_snapshot_revision,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { status: 202, body: { data: { operation } } };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 复用批量删除和清空流程,并以 Outbox 触发后续素材清理。 */
|
||||||
|
private async removeMany(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
path: string,
|
||||||
|
input: unknown,
|
||||||
|
clear: boolean,
|
||||||
|
) {
|
||||||
|
return this.idempotency.run(
|
||||||
|
{ workspaceId, userId, method: 'POST', path, key, request: input },
|
||||||
|
async (tx) => {
|
||||||
|
const requested = input as {
|
||||||
|
ids?: string[];
|
||||||
|
expected_active_count?: number;
|
||||||
|
};
|
||||||
|
const count = await tx.query<{ count: string }>(
|
||||||
|
'SELECT count(*)::text AS count FROM atlas.collection_items WHERE deleted_at IS NULL',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
clear &&
|
||||||
|
Number(count.rows[0].count) !==
|
||||||
|
requested.expected_active_count
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
'COLLECTION_COUNT_CHANGED',
|
||||||
|
'采集箱数量已变化,请重新确认',
|
||||||
|
);
|
||||||
|
const deleted = await tx.query<{ id: string }>(
|
||||||
|
clear
|
||||||
|
? `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE deleted_at IS NULL RETURNING id`
|
||||||
|
: `UPDATE atlas.collection_items SET status = 'deleted', deleted_at = now(), revision = revision + 1, updated_at = now() WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL RETURNING id`,
|
||||||
|
clear ? [] : [requested.ids],
|
||||||
|
);
|
||||||
|
if (!clear && deleted.rows.length !== requested.ids?.length)
|
||||||
|
throw new ApiError(
|
||||||
|
404,
|
||||||
|
'RESOURCE_NOT_FOUND',
|
||||||
|
'部分采集记录不存在',
|
||||||
|
);
|
||||||
|
const operation = await this.operation(
|
||||||
|
tx,
|
||||||
|
userId,
|
||||||
|
'collection_cleanup',
|
||||||
|
deleted.rows[0]?.id ?? null,
|
||||||
|
);
|
||||||
|
await this.event(
|
||||||
|
tx,
|
||||||
|
operation.id,
|
||||||
|
'collection.cleanup.requested',
|
||||||
|
{
|
||||||
|
operation_id: operation.id,
|
||||||
|
collection_item_ids: deleted.rows.map((row) => row.id),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 202,
|
||||||
|
body: {
|
||||||
|
data: { deleted_count: deleted.rows.length, operation },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 创建供客户端轮询的异步操作记录。 */
|
||||||
|
private async operation(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
userId: string,
|
||||||
|
kind: string,
|
||||||
|
resourceId: string | null,
|
||||||
|
) {
|
||||||
|
const row = await tx.query<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
}>(
|
||||||
|
`INSERT INTO atlas.operations (workspace_id, kind, resource_type, resource_id, created_by_user_id) VALUES (current_setting('app.workspace_id')::uuid, $1, 'collection_item', $2, $3) RETURNING id, status, created_at`,
|
||||||
|
[kind, resourceId, userId],
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: row.rows[0].id,
|
||||||
|
kind,
|
||||||
|
status: row.rows[0].status,
|
||||||
|
created_at: iso(row.rows[0].created_at),
|
||||||
|
completed_at: null,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 在同一事务写入 Outbox 事件,避免业务事实与异步任务脱节。 */
|
||||||
|
private async event(
|
||||||
|
tx: DatabaseTransaction,
|
||||||
|
aggregateId: string,
|
||||||
|
type: string,
|
||||||
|
payload: unknown,
|
||||||
|
) {
|
||||||
|
await tx.query(
|
||||||
|
`INSERT INTO atlas.outbox_events (workspace_id, aggregate_type, aggregate_id, event_type, event_payload, deduplication_key) VALUES (current_setting('app.workspace_id')::uuid, 'collection_item', $1, $2, $3, $4)`,
|
||||||
|
[aggregateId, type, payload, `${type}:${aggregateId}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 确认页头平台已启用且允许作为 ERP 上下文。 */
|
||||||
|
private async requireHeaderPlatform(tx: DatabaseTransaction, id: string) {
|
||||||
|
const row = await tx.query(
|
||||||
|
`SELECT id FROM atlas.platforms WHERE id = $1 AND status = 'active' AND coalesce((capabilities->>'can_be_header_context')::boolean, false)`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!row.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'PLATFORM_UNAVAILABLE',
|
||||||
|
'当前平台不可用于 ERP 上下文',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** 读取工作空间内未删除的采集记录;RLS 负责隔离其他工作空间数据。 */
|
||||||
|
private async requireItem(tx: DatabaseTransaction, id: string) {
|
||||||
|
const row = await tx.query<Row>(
|
||||||
|
`${selectRow} WHERE c.id = $1 AND c.deleted_at IS NULL ${collectionGroupBy}`,
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
if (!row.rows[0])
|
||||||
|
throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在');
|
||||||
|
return toItem(row.rows[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
/** 采集箱列表和单项读取共用的聚合查询,素材状态只取当前来源快照。 */
|
||||||
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
|
const selectRow = `SELECT c.id, product.id AS product_id, c.source_kind, p.code AS platform_code, c.source_url, c.source_title, c.source_min_price::text, c.source_max_price::text, c.source_currency_code, c.note, c.status, c.created_at, c.updated_at, c.revision, count(DISTINCT v.id)::text AS variant_count, min(t.status) AS transfer_status FROM atlas.collection_items c JOIN atlas.products product ON product.collection_item_id = c.id LEFT JOIN atlas.platforms p ON p.id = c.source_platform_id LEFT JOIN atlas.product_variants v ON v.product_id = product.id AND v.deleted_at IS NULL LEFT JOIN atlas.collection_asset_transfers t ON t.collection_item_id = c.id AND t.source_snapshot_revision = c.source_snapshot_revision`;
|
||||||
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
const collectionGroupBy = 'GROUP BY c.id, product.id, p.code';
|
||||||
function toItem(row: Row) { return { id: row.id, product_id: row.product_id, source: { kind: row.source_kind, platform_code: row.platform_code, url: row.source_url }, title: row.source_title, thumbnail: { asset_id: null, preview_url: null, status: row.transfer_status ?? 'none' }, source_price: { min: row.source_min_price, max: row.source_max_price, currency_code: row.source_currency_code }, variant_summary: { count: Number(row.variant_count), needs_review: row.status === 'needs_review' }, note: row.note, collection_status: row.status, listing_summary: { status: 'not_listed' as const, detail: '等待首次上架', last_error: null }, created_at: iso(row.created_at), updated_at: iso(row.updated_at), revision: row.revision }; }
|
/** 将数据库聚合行转换为冻结的采集箱列表读模型。 */
|
||||||
function iso(value: Date | string) { return value instanceof Date ? value.toISOString() : value; }
|
function toItem(row: Row) {
|
||||||
function encodeCursor(at: string, id: string) { return Buffer.from(JSON.stringify({ at, id })).toString('base64url'); }
|
return {
|
||||||
function decodeCursor(cursor: string): { at: string; id: string } { try { const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { at?: string; id?: string }; if (!value.at || !value.id || Number.isNaN(Date.parse(value.at))) throw new Error(); return { at: value.at, id: value.id }; } catch { throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效'); } }
|
id: row.id,
|
||||||
|
product_id: row.product_id,
|
||||||
|
source: {
|
||||||
|
kind: row.source_kind,
|
||||||
|
platform_code: row.platform_code,
|
||||||
|
url: row.source_url,
|
||||||
|
},
|
||||||
|
title: row.source_title,
|
||||||
|
thumbnail: {
|
||||||
|
asset_id: null,
|
||||||
|
preview_url: null,
|
||||||
|
status: row.transfer_status ?? 'none',
|
||||||
|
},
|
||||||
|
source_price: {
|
||||||
|
min: row.source_min_price,
|
||||||
|
max: row.source_max_price,
|
||||||
|
currency_code: row.source_currency_code,
|
||||||
|
},
|
||||||
|
variant_summary: {
|
||||||
|
count: Number(row.variant_count),
|
||||||
|
needs_review: row.status === 'needs_review',
|
||||||
|
},
|
||||||
|
note: row.note,
|
||||||
|
collection_status: row.status,
|
||||||
|
listing_summary: {
|
||||||
|
status: 'not_listed' as const,
|
||||||
|
detail: '等待首次上架',
|
||||||
|
last_error: null,
|
||||||
|
},
|
||||||
|
created_at: iso(row.created_at),
|
||||||
|
updated_at: iso(row.updated_at),
|
||||||
|
revision: row.revision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
/** 统一将数据库时间值序列化为 ISO 8601 字符串。 */
|
||||||
|
function iso(value: Date | string) {
|
||||||
|
return value instanceof Date ? value.toISOString() : value;
|
||||||
|
}
|
||||||
|
/** 将排序键编码为不透明游标,避免客户端拼接 SQL 条件。 */
|
||||||
|
function encodeCursor(at: string, id: string) {
|
||||||
|
return Buffer.from(JSON.stringify({ at, id })).toString('base64url');
|
||||||
|
}
|
||||||
|
/** 校验并解析分页游标,拒绝损坏或不完整的客户端输入。 */
|
||||||
|
function decodeCursor(cursor: string): { at: string; id: string } {
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(
|
||||||
|
Buffer.from(cursor, 'base64url').toString('utf8'),
|
||||||
|
) as { at?: string; id?: string };
|
||||||
|
if (!value.at || !value.id || Number.isNaN(Date.parse(value.at)))
|
||||||
|
throw new Error();
|
||||||
|
return { at: value.at, id: value.id };
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(422, 'VALIDATION_FAILED', '分页游标无效');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { CanActivate, ExecutionContext, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { Database } from '@atlas/database';
|
import type { Database } from '@atlas/database';
|
||||||
import { AuthService } from '../auth/auth.service.js';
|
import { AuthService } from '../auth/auth.service.js';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
@@ -6,32 +12,61 @@ import { DATABASE } from '../database/database.module.js';
|
|||||||
|
|
||||||
export type ExtensionAuthenticatedRequest = {
|
export type ExtensionAuthenticatedRequest = {
|
||||||
header: (name: string) => string | undefined;
|
header: (name: string) => string | undefined;
|
||||||
extensionAuth: { userId: string; workspaceId: string; sessionId: string; clientId: string };
|
extensionAuth: {
|
||||||
|
userId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
sessionId: string;
|
||||||
|
clientId: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExtensionAccessGuard implements CanActivate {
|
export class ExtensionAccessGuard implements CanActivate {
|
||||||
public constructor(@Inject(AuthService) private readonly auth: AuthService, @Inject(DATABASE) private readonly database: Database) {}
|
public constructor(
|
||||||
|
@Inject(AuthService) private readonly auth: AuthService,
|
||||||
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
|
) {}
|
||||||
public async canActivate(context: ExecutionContext) {
|
public async canActivate(context: ExecutionContext) {
|
||||||
const request = context.switchToHttp().getRequest<ExtensionAuthenticatedRequest>();
|
const request = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest<ExtensionAuthenticatedRequest>();
|
||||||
// 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。
|
// 毛子 ERP 风格:插件携带从 Atlas Web 同步而来的短期 Web access token。
|
||||||
// AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。
|
// AuthService 同时核对 JWT 和未撤销的 Web session,不能仅信任 token 声明。
|
||||||
let authenticated: { userId: string; sessionId: string };
|
let authenticated: { userId: string; sessionId: string };
|
||||||
try {
|
try {
|
||||||
authenticated = await this.auth.authenticate(request.header('authorization'));
|
authenticated = await this.auth.authenticate(
|
||||||
|
request.header('authorization'),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
|
// AuthService 的通用认证守卫会抛 Nest UnauthorizedException;扩展接口
|
||||||
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
|
// 应返回稳定、可操作的错误信息,不能在 UI 中退化成“请求无法处理”。
|
||||||
if (error instanceof UnauthorizedException)
|
if (error instanceof UnauthorizedException)
|
||||||
throw new ApiError(401, 'SESSION_EXPIRED', 'Atlas ERP 登录已过期,请重新登录');
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
'SESSION_EXPIRED',
|
||||||
|
'Atlas ERP 登录已过期,请重新登录',
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
const workspace = await this.database.query<{ id: string }>(`SELECT w.id FROM atlas.workspace_memberships m
|
const workspace = await this.database.query<{ id: string }>(
|
||||||
|
`SELECT w.id FROM atlas.workspace_memberships m
|
||||||
JOIN atlas.workspaces w ON w.id = m.workspace_id
|
JOIN atlas.workspaces w ON w.id = m.workspace_id
|
||||||
WHERE m.user_id = $1 AND w.status = 'active'
|
WHERE m.user_id = $1 AND w.status = 'active'
|
||||||
ORDER BY m.joined_at LIMIT 1`, [authenticated.userId]);
|
ORDER BY m.joined_at LIMIT 1`,
|
||||||
if (!workspace.rows[0]) throw new ApiError(403, 'WORKSPACE_MEMBERSHIP_REQUIRED', '当前账户没有可用工作空间');
|
[authenticated.userId],
|
||||||
request.extensionAuth = { userId: authenticated.userId, workspaceId: workspace.rows[0].id, sessionId: authenticated.sessionId, clientId: 'atlas-web' };
|
);
|
||||||
|
if (!workspace.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
'WORKSPACE_MEMBERSHIP_REQUIRED',
|
||||||
|
'当前账户没有可用工作空间',
|
||||||
|
);
|
||||||
|
request.extensionAuth = {
|
||||||
|
userId: authenticated.userId,
|
||||||
|
workspaceId: workspace.rows[0].id,
|
||||||
|
sessionId: authenticated.sessionId,
|
||||||
|
clientId: 'atlas-web',
|
||||||
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,90 @@
|
|||||||
import { Body, Controller, Get, Headers, Inject, Post, Req, Res, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
Inject,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
Res,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { collectedProductPayloadSchema } from '@atlas/contracts';
|
import { collectedProductPayloadSchema } from '@atlas/contracts';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { Public } from '../common/public.decorator.js';
|
import { Public } from '../common/public.decorator.js';
|
||||||
import { ExtensionAccessGuard, type ExtensionAuthenticatedRequest } from './extension-auth.guard.js';
|
import {
|
||||||
|
ExtensionAccessGuard,
|
||||||
|
type ExtensionAuthenticatedRequest,
|
||||||
|
} from './extension-auth.guard.js';
|
||||||
import { ExtensionService } from './extension.service.js';
|
import { ExtensionService } from './extension.service.js';
|
||||||
|
|
||||||
type ResponseLike = { status: (code: number) => ResponseLike };
|
type ResponseLike = { status: (code: number) => ResponseLike };
|
||||||
|
|
||||||
@Controller('extension')
|
@Controller('extension')
|
||||||
export class ExtensionController {
|
export class ExtensionController {
|
||||||
public constructor(@Inject(ExtensionService) private readonly extension: ExtensionService) {}
|
public constructor(
|
||||||
@Get('session') @Public() @UseGuards(ExtensionAccessGuard)
|
@Inject(ExtensionService) private readonly extension: ExtensionService,
|
||||||
public session(@Req() request: ExtensionAuthenticatedRequest) { return { data: { authenticated: true, workspace: { id: request.extensionAuth.workspaceId, available: true }, can_collect: true } }; }
|
) {}
|
||||||
@Post('materials') @Public() @UseGuards(ExtensionAccessGuard)
|
@Get('session')
|
||||||
public async materials(@Req() request: ExtensionAuthenticatedRequest, @Headers('idempotency-key') key: string | undefined, @Body() body: unknown, @Res({ passthrough: true }) response: ResponseLike) {
|
@Public()
|
||||||
if (!key || !z.uuid().safeParse(key).success) throw new ApiError(400, 'IDEMPOTENCY_KEY_REQUIRED', '请求缺少或格式错误的 Idempotency-Key');
|
@UseGuards(ExtensionAccessGuard)
|
||||||
const result = await this.extension.ingest(request.extensionAuth.workspaceId, request.extensionAuth.userId, key, parse(collectedProductPayloadSchema, body)); response.status(result.status); return result.body;
|
public session(@Req() request: ExtensionAuthenticatedRequest) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
authenticated: true,
|
||||||
|
workspace: {
|
||||||
|
id: request.extensionAuth.workspaceId,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
can_collect: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@Post('materials')
|
||||||
|
@Public()
|
||||||
|
@UseGuards(ExtensionAccessGuard)
|
||||||
|
public async materials(
|
||||||
|
@Req() request: ExtensionAuthenticatedRequest,
|
||||||
|
@Headers('idempotency-key') key: string | undefined,
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Res({ passthrough: true }) response: ResponseLike,
|
||||||
|
) {
|
||||||
|
if (!key || !z.uuid().safeParse(key).success)
|
||||||
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'IDEMPOTENCY_KEY_REQUIRED',
|
||||||
|
'请求缺少或格式错误的 Idempotency-Key',
|
||||||
|
);
|
||||||
|
const result = await this.extension.ingest(
|
||||||
|
request.extensionAuth.workspaceId,
|
||||||
|
request.extensionAuth.userId,
|
||||||
|
key,
|
||||||
|
parse(collectedProductPayloadSchema, body),
|
||||||
|
);
|
||||||
|
response.status(result.status);
|
||||||
|
return result.body;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function parse<T extends { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: Array<{ path: PropertyKey[]; message: string }> } } }>(schema: T, value: unknown) { const result = schema.safeParse(value); if (result.success) return result.data as never; throw new ApiError(422, 'VALIDATION_FAILED', '请求参数不符合要求', result.error?.issues.map((item) => ({ field: item.path.join('.'), code: 'INVALID_FIELD', message: item.message }))); }
|
function parse<
|
||||||
|
T extends {
|
||||||
|
safeParse: (value: unknown) => {
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown;
|
||||||
|
error?: { issues: Array<{ path: PropertyKey[]; message: string }> };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
>(schema: T, value: unknown) {
|
||||||
|
const result = schema.safeParse(value);
|
||||||
|
if (result.success) return result.data as never;
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
'请求参数不符合要求',
|
||||||
|
result.error?.issues.map((item) => ({
|
||||||
|
field: item.path.join('.'),
|
||||||
|
code: 'INVALID_FIELD',
|
||||||
|
message: item.message,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,5 +3,9 @@ import { AuthModule } from '../auth/auth.module.js';
|
|||||||
import { ExtensionAccessGuard } from './extension-auth.guard.js';
|
import { ExtensionAccessGuard } from './extension-auth.guard.js';
|
||||||
import { ExtensionController } from './extension.controller.js';
|
import { ExtensionController } from './extension.controller.js';
|
||||||
import { ExtensionService } from './extension.service.js';
|
import { ExtensionService } from './extension.service.js';
|
||||||
@Module({ imports: [AuthModule], controllers: [ExtensionController], providers: [ExtensionService, ExtensionAccessGuard] })
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [ExtensionController],
|
||||||
|
providers: [ExtensionService, ExtensionAccessGuard],
|
||||||
|
})
|
||||||
export class ExtensionModule {}
|
export class ExtensionModule {}
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import type { ProductAttributeSchema } from '@atlas/contracts';
|
||||||
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
|
import { ApiError } from '../common/api-error.js';
|
||||||
|
import type { ProfileRow } from './product-editor.models.js';
|
||||||
|
|
||||||
|
type CatalogSyncInput = {
|
||||||
|
workspaceId: string;
|
||||||
|
userId: string;
|
||||||
|
profileId: string;
|
||||||
|
platformId: string;
|
||||||
|
key: string;
|
||||||
|
scope: 'attributes' | 'dictionary';
|
||||||
|
path: string;
|
||||||
|
categoryId?: string;
|
||||||
|
dictionaryId?: string;
|
||||||
|
query?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CatalogSyncResult = {
|
||||||
|
status: number;
|
||||||
|
body: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CatalogAccess = {
|
||||||
|
requireProfileForPlatform: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
) => Promise<ProfileRow>;
|
||||||
|
requireEditableStore: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
platformId: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
requireActiveCategory: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
categoryId: string,
|
||||||
|
) => Promise<unknown>;
|
||||||
|
attributeSchemaForCategory: (
|
||||||
|
transaction: DatabaseTransaction,
|
||||||
|
storeId: string,
|
||||||
|
categoryId: string,
|
||||||
|
) => Promise<ProductAttributeSchema[]>;
|
||||||
|
requestCatalogSync: (input: CatalogSyncInput) => Promise<CatalogSyncResult>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理商品编辑器的类目、属性定义和字典缓存读取。
|
||||||
|
* 数据校验仍复用工作流服务的统一访问边界,避免目录读取绕过平台和店铺隔离。
|
||||||
|
*/
|
||||||
|
export class ProductEditorCatalogOperations {
|
||||||
|
/** 注入工作空间事务入口及共享的权限、归属校验能力。 */
|
||||||
|
public constructor(
|
||||||
|
private readonly database: Database,
|
||||||
|
private readonly access: CatalogAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 读取当前或指定目标店铺的完整类目缓存。 */
|
||||||
|
async categoryOptions(
|
||||||
|
workspaceId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
search: string | undefined,
|
||||||
|
storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const profile = await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||||
|
if (storeId)
|
||||||
|
await this.access.requireEditableStore(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const categories = await transaction.query<{
|
||||||
|
id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
external_category_id: string;
|
||||||
|
external_type_id: string | null;
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
||||||
|
FROM atlas.store_product_categories
|
||||||
|
WHERE store_id = $1
|
||||||
|
ORDER BY path, name, id
|
||||||
|
LIMIT 10000`,
|
||||||
|
[selectedStoreId],
|
||||||
|
);
|
||||||
|
// 保留参数以保持既有 HTTP 契约;当前缓存树始终完整返回。
|
||||||
|
void search;
|
||||||
|
return categories.rows;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前或指定类目的已缓存动态属性定义。 */
|
||||||
|
async attributeSchema(
|
||||||
|
workspaceId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
categoryId?: string,
|
||||||
|
storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const profile = await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const selectedCategoryId =
|
||||||
|
categoryId ?? profile.store_product_category_id;
|
||||||
|
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||||
|
if (storeId)
|
||||||
|
await this.access.requireEditableStore(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
if (!selectedCategoryId)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATEGORY_REQUIRED',
|
||||||
|
'请先选择产品类目',
|
||||||
|
);
|
||||||
|
if (categoryId)
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
selectedStoreId,
|
||||||
|
categoryId,
|
||||||
|
);
|
||||||
|
return this.access.attributeSchemaForCategory(
|
||||||
|
transaction,
|
||||||
|
selectedStoreId,
|
||||||
|
selectedCategoryId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在确认字典属于当前类目后,搜索店铺本地缓存的字典值。 */
|
||||||
|
async attributeDictionary(
|
||||||
|
workspaceId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
dictionaryId: string,
|
||||||
|
search: string | undefined,
|
||||||
|
categoryId?: string,
|
||||||
|
storeId?: string,
|
||||||
|
) {
|
||||||
|
return this.database.workspaceTransaction(
|
||||||
|
workspaceId,
|
||||||
|
async (transaction) => {
|
||||||
|
const profile = await this.access.requireProfileForPlatform(
|
||||||
|
transaction,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
const selectedCategoryId =
|
||||||
|
categoryId ?? profile.store_product_category_id;
|
||||||
|
const selectedStoreId = storeId ?? profile.target_store_id;
|
||||||
|
if (storeId)
|
||||||
|
await this.access.requireEditableStore(
|
||||||
|
transaction,
|
||||||
|
storeId,
|
||||||
|
platformId,
|
||||||
|
);
|
||||||
|
if (!selectedCategoryId)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'CATEGORY_REQUIRED',
|
||||||
|
'请先选择产品类目',
|
||||||
|
);
|
||||||
|
if (categoryId)
|
||||||
|
await this.access.requireActiveCategory(
|
||||||
|
transaction,
|
||||||
|
selectedStoreId,
|
||||||
|
categoryId,
|
||||||
|
);
|
||||||
|
const allowed = await transaction.query<{ dictionary_id: string }>(
|
||||||
|
`SELECT dictionary_id
|
||||||
|
FROM atlas.store_product_category_attributes
|
||||||
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
|
AND dictionary_id = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
[selectedStoreId, selectedCategoryId, dictionaryId],
|
||||||
|
);
|
||||||
|
if (!allowed.rows[0])
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'ATTRIBUTE_DICTIONARY_INVALID',
|
||||||
|
'字典不属于当前类目的属性',
|
||||||
|
);
|
||||||
|
const needle = search?.trim() ?? '';
|
||||||
|
const values = await transaction.query<{
|
||||||
|
value_id: string;
|
||||||
|
value: string;
|
||||||
|
}>(
|
||||||
|
`SELECT external_value_id AS value_id, value
|
||||||
|
FROM atlas.store_attribute_dictionary_values
|
||||||
|
WHERE store_id = $1 AND store_product_category_id = $2
|
||||||
|
AND dictionary_id = $3 AND is_active
|
||||||
|
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
||||||
|
ORDER BY value, external_value_id
|
||||||
|
LIMIT 100`,
|
||||||
|
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
||||||
|
);
|
||||||
|
return { items: values.rows, next_cursor: null };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建类目属性定义同步任务。 */
|
||||||
|
async requestAttributeSchemaSync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
key: string,
|
||||||
|
categoryId: string,
|
||||||
|
) {
|
||||||
|
return this.access.requestCatalogSync({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
key,
|
||||||
|
scope: 'attributes',
|
||||||
|
path: 'attribute-schema-syncs',
|
||||||
|
categoryId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建带关键词限制的属性字典同步任务。 */
|
||||||
|
async requestAttributeDictionarySync(
|
||||||
|
workspaceId: string,
|
||||||
|
userId: string,
|
||||||
|
profileId: string,
|
||||||
|
platformId: string,
|
||||||
|
key: string,
|
||||||
|
categoryId: string,
|
||||||
|
dictionaryId: string,
|
||||||
|
query: string,
|
||||||
|
) {
|
||||||
|
if (query.trim().length < 2)
|
||||||
|
throw new ApiError(
|
||||||
|
422,
|
||||||
|
'DICTIONARY_SEARCH_REQUIRED',
|
||||||
|
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
||||||
|
);
|
||||||
|
return this.access.requestCatalogSync({
|
||||||
|
workspaceId,
|
||||||
|
userId,
|
||||||
|
profileId,
|
||||||
|
platformId,
|
||||||
|
key,
|
||||||
|
scope: 'dictionary',
|
||||||
|
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
||||||
|
categoryId,
|
||||||
|
dictionaryId,
|
||||||
|
query: query.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,11 +25,13 @@ type Response = { status: (value: number) => Response };
|
|||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class ProductEditorController {
|
export class ProductEditorController {
|
||||||
|
/** 注入所有商品编辑接口共用的工作流服务。 */
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(ProductEditorService)
|
@Inject(ProductEditorService)
|
||||||
private readonly editor: ProductEditorService,
|
private readonly editor: ProductEditorService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** 为采集记录创建或复用当前平台的商品编辑上下文。 */
|
||||||
@Post('collection-items/:collectionItemId/editor-contexts')
|
@Post('collection-items/:collectionItemId/editor-contexts')
|
||||||
async enter(
|
async enter(
|
||||||
@Param('collectionItemId') collectionItemId: string,
|
@Param('collectionItemId') collectionItemId: string,
|
||||||
@@ -50,6 +52,7 @@ export class ProductEditorController {
|
|||||||
return result.body;
|
return result.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回当前平台下完整的商品编辑读模型。 */
|
||||||
@Get('products/:productId/editor')
|
@Get('products/:productId/editor')
|
||||||
async read(
|
async read(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@@ -65,6 +68,7 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回目标店铺已缓存的类目树,供编辑器选择。 */
|
||||||
@Get('product-platform-profiles/:profileId/category-options')
|
@Get('product-platform-profiles/:profileId/category-options')
|
||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@@ -84,6 +88,7 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回所选类目的缓存动态属性定义。 */
|
||||||
@Get('product-platform-profiles/:profileId/attribute-schema')
|
@Get('product-platform-profiles/:profileId/attribute-schema')
|
||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@@ -103,6 +108,7 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 幂等地创建所选类目属性定义的刷新任务。 */
|
||||||
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
@Post('product-platform-profiles/:profileId/attribute-schema-syncs')
|
||||||
async attributeSchemaSync(
|
async attributeSchemaSync(
|
||||||
@Param('profileId') profileId: string,
|
@Param('profileId') profileId: string,
|
||||||
@@ -125,6 +131,7 @@ export class ProductEditorController {
|
|||||||
return result.body;
|
return result.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从本地缓存中查询属性字典值。 */
|
||||||
@Get(
|
@Get(
|
||||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId',
|
||||||
)
|
)
|
||||||
@@ -152,6 +159,7 @@ export class ProductEditorController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 本地缓存不足时幂等地创建远程字典查询任务。 */
|
||||||
@Post(
|
@Post(
|
||||||
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
'product-platform-profiles/:profileId/attribute-dictionaries/:dictionaryId/syncs',
|
||||||
)
|
)
|
||||||
@@ -182,6 +190,7 @@ export class ProductEditorController {
|
|||||||
return result.body;
|
return result.body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验并原子保存商品及其上架草稿。 */
|
||||||
@Put('products/:productId/editor-draft')
|
@Put('products/:productId/editor-draft')
|
||||||
async save(
|
async save(
|
||||||
@Param('productId') productId: string,
|
@Param('productId') productId: string,
|
||||||
@@ -219,6 +228,7 @@ export class ProductEditorController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验路由参数或请求头中的 UUID,并返回原始值。 */
|
||||||
function requiredUuid(field: string, value: string | undefined) {
|
function requiredUuid(field: string, value: string | undefined) {
|
||||||
if (value && z.uuid().safeParse(value).success) return value;
|
if (value && z.uuid().safeParse(value).success) return value;
|
||||||
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
throw new ApiError(400, 'VALIDATION_FAILED', '请求参数不符合要求', [
|
||||||
@@ -226,11 +236,13 @@ function requiredUuid(field: string, value: string | undefined) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将缺省查询参数保留为 undefined;有值时校验其 UUID 格式。 */
|
||||||
function optionalUuid(field: string, value: string | undefined) {
|
function optionalUuid(field: string, value: string | undefined) {
|
||||||
if (value === undefined || value === '') return undefined;
|
if (value === undefined || value === '') return undefined;
|
||||||
return requiredUuid(field, value);
|
return requiredUuid(field, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 解析 If-Match 中成对传递的商品和发布配置版本号。 */
|
||||||
function parseEditorRevision(value: string | undefined) {
|
function parseEditorRevision(value: string | undefined) {
|
||||||
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
const match = value?.match(/^"([1-9]\d*):([1-9]\d*)"$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/** 商品编辑领域从数据库读取的商品行。 */
|
||||||
|
export type ProductRow = {
|
||||||
|
id: string;
|
||||||
|
collection_item_id: string;
|
||||||
|
revision: number;
|
||||||
|
source_title: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
model: string | null;
|
||||||
|
barcode: string | null;
|
||||||
|
purchase_url: string | null;
|
||||||
|
supply_note: string | null;
|
||||||
|
default_physical_length_mm: string | null;
|
||||||
|
default_physical_width_mm: string | null;
|
||||||
|
default_physical_height_mm: string | null;
|
||||||
|
default_net_weight_g: string | null;
|
||||||
|
default_package_length_mm: string | null;
|
||||||
|
default_package_width_mm: string | null;
|
||||||
|
default_package_height_mm: string | null;
|
||||||
|
default_shipping_weight_g: string | null;
|
||||||
|
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||||
|
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||||
|
ai_source_material_overrides: unknown;
|
||||||
|
ai_source_material_base_revision: number | string | null;
|
||||||
|
normalized_payload: unknown;
|
||||||
|
source_snapshot_revision: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 商品在一个发布平台下的编辑配置行。 */
|
||||||
|
export type ProfileRow = {
|
||||||
|
id: string;
|
||||||
|
revision: number;
|
||||||
|
platform_id: string;
|
||||||
|
market_code: string | null;
|
||||||
|
fulfillment: string;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
tags: unknown;
|
||||||
|
store_product_category_id: string | null;
|
||||||
|
platform_type_id: string | null;
|
||||||
|
category_source: string | null;
|
||||||
|
category_name: string | null;
|
||||||
|
category_path: string | null;
|
||||||
|
target_store_id: string;
|
||||||
|
store_name: string;
|
||||||
|
currency_code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 异步目录任务返回给客户端所需的操作行。 */
|
||||||
|
export type OperationRow = {
|
||||||
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
created_at: Date | string;
|
||||||
|
completed_at: Date | string | null;
|
||||||
|
error_code: string | null;
|
||||||
|
error_message: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 统一读取发布配置及其目标店铺与类目展示信息的查询片段。 */
|
||||||
|
export const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
|
||||||
|
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
|
||||||
|
p.platform_type_id, p.category_source, category.name AS category_name,
|
||||||
|
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
||||||
|
s.currency_code
|
||||||
|
FROM atlas.product_platform_profiles p
|
||||||
|
JOIN atlas.stores s ON s.id = p.target_store_id
|
||||||
|
LEFT JOIN atlas.store_product_categories category
|
||||||
|
ON category.id = p.store_product_category_id
|
||||||
|
AND category.store_id = p.target_store_id`;
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import {
|
|
||||||
productAiSourceMaterialOverridesSchema,
|
|
||||||
productAiSourceMaterialSchema,
|
|
||||||
} from '@atlas/contracts';
|
|
||||||
import type {
|
import type {
|
||||||
ProductAiSourceField,
|
|
||||||
ProductAiSourceMaterial,
|
|
||||||
ProductEditorContext,
|
ProductEditorContext,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
SaveProductEditorDraftRequest,
|
SaveProductEditorDraftRequest,
|
||||||
@@ -15,68 +9,51 @@ import type { Database, DatabaseTransaction } from '@atlas/database';
|
|||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
import { DATABASE } from '../database/database.module.js';
|
import { DATABASE } from '../database/database.module.js';
|
||||||
|
import {
|
||||||
type ProductRow = {
|
type OperationRow,
|
||||||
id: string;
|
type ProductRow,
|
||||||
collection_item_id: string;
|
type ProfileRow,
|
||||||
revision: number;
|
profileSelect,
|
||||||
source_title: string | null;
|
} from './product-editor.models.js';
|
||||||
brand: string | null;
|
import { ProductEditorCatalogOperations } from './product-editor.catalog-operations.js';
|
||||||
model: string | null;
|
import {
|
||||||
barcode: string | null;
|
latestProductAiSourceMaterial,
|
||||||
purchase_url: string | null;
|
normalizedCategoryPath as normalizeCapturedCategoryPath,
|
||||||
supply_note: string | null;
|
productAiSourceMaterial as resolveSourceMaterial,
|
||||||
default_physical_length_mm: string | null;
|
productAiSourceOverrides as selectSourceMaterialOverrides,
|
||||||
default_physical_width_mm: string | null;
|
} from './product-editor.source-material.js';
|
||||||
default_physical_height_mm: string | null;
|
import {
|
||||||
default_net_weight_g: string | null;
|
effectivePackageSpec as resolveEffectivePackageSpec,
|
||||||
default_package_length_mm: string | null;
|
effectivePhysicalSpec as resolveEffectivePhysicalSpec,
|
||||||
default_package_width_mm: string | null;
|
productPackageSpec as mapProductPackageSpec,
|
||||||
default_package_height_mm: string | null;
|
productPhysicalSpec as mapProductPhysicalSpec,
|
||||||
default_shipping_weight_g: string | null;
|
specificationValues as filterSpecificationValues,
|
||||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
variantPackageSpec as mapVariantPackageSpec,
|
||||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
variantPhysicalSpec as mapVariantPhysicalSpec,
|
||||||
ai_source_material_overrides: unknown;
|
} from './product-editor.specifications.js';
|
||||||
ai_source_material_base_revision: number | string | null;
|
|
||||||
normalized_payload: unknown;
|
|
||||||
source_snapshot_revision: number | string;
|
|
||||||
};
|
|
||||||
type ProfileRow = {
|
|
||||||
id: string;
|
|
||||||
revision: number;
|
|
||||||
platform_id: string;
|
|
||||||
market_code: string | null;
|
|
||||||
fulfillment: string;
|
|
||||||
title: string | null;
|
|
||||||
description: string | null;
|
|
||||||
tags: unknown;
|
|
||||||
store_product_category_id: string | null;
|
|
||||||
platform_type_id: string | null;
|
|
||||||
category_source: string | null;
|
|
||||||
category_name: string | null;
|
|
||||||
category_path: string | null;
|
|
||||||
target_store_id: string;
|
|
||||||
store_name: string;
|
|
||||||
currency_code: string;
|
|
||||||
};
|
|
||||||
type OperationRow = {
|
|
||||||
id: string;
|
|
||||||
kind: string;
|
|
||||||
status: string;
|
|
||||||
created_at: Date | string;
|
|
||||||
completed_at: Date | string | null;
|
|
||||||
error_code: string | null;
|
|
||||||
error_message: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductEditorService {
|
export class ProductEditorService {
|
||||||
|
private readonly catalog: ProductEditorCatalogOperations;
|
||||||
|
|
||||||
|
/** 注入编辑工作流所需的事务数据库和幂等边界。 */
|
||||||
public constructor(
|
public constructor(
|
||||||
@Inject(IdempotencyService)
|
@Inject(IdempotencyService)
|
||||||
private readonly idempotency: IdempotencyService,
|
private readonly idempotency: IdempotencyService,
|
||||||
@Inject(DATABASE) private readonly database: Database,
|
@Inject(DATABASE) private readonly database: Database,
|
||||||
) {}
|
) {
|
||||||
|
this.catalog = new ProductEditorCatalogOperations(this.database, {
|
||||||
|
requireProfileForPlatform: (...args) =>
|
||||||
|
this.requireProfileForPlatform(...args),
|
||||||
|
requireEditableStore: (...args) => this.requireEditableStore(...args),
|
||||||
|
requireActiveCategory: (...args) => this.requireActiveCategory(...args),
|
||||||
|
attributeSchemaForCategory: (...args) =>
|
||||||
|
this.attributeSchemaForCategory(...args),
|
||||||
|
requestCatalogSync: (input) => this.requestCatalogSync(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建或复用平台发布配置,并返回编辑器导航上下文。 */
|
||||||
async enter(
|
async enter(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -169,6 +146,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 构建工作空间隔离的编辑读模型,并计算 SKU 继承规格。 */
|
||||||
async read(
|
async read(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -340,9 +318,9 @@ export class ProductEditorService {
|
|||||||
WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`,
|
WHERE profile_id = $1 AND status = 'pending' ORDER BY created_at, id`,
|
||||||
[profile.id],
|
[profile.id],
|
||||||
);
|
);
|
||||||
const defaultPhysicalSpec = productPhysicalSpec(product);
|
const defaultPhysicalSpec = mapProductPhysicalSpec(product);
|
||||||
const defaultPackageSpec = productPackageSpec(product);
|
const defaultPackageSpec = mapProductPackageSpec(product);
|
||||||
const sourceMaterialResult = productAiSourceMaterial(product);
|
const sourceMaterialResult = resolveSourceMaterial(product);
|
||||||
const sourceMaterial = sourceMaterialResult.effective;
|
const sourceMaterial = sourceMaterialResult.effective;
|
||||||
const editorVariants: ProductEditorSnapshot['variants'] =
|
const editorVariants: ProductEditorSnapshot['variants'] =
|
||||||
variants.rows.map((variant) => ({
|
variants.rows.map((variant) => ({
|
||||||
@@ -352,16 +330,16 @@ export class ProductEditorService {
|
|||||||
display_name: variant.display_name,
|
display_name: variant.display_name,
|
||||||
sort_order: variant.sort_order,
|
sort_order: variant.sort_order,
|
||||||
purchase_cost_cny: variant.purchase_cost_cny,
|
purchase_cost_cny: variant.purchase_cost_cny,
|
||||||
specification_values: specificationValues(
|
specification_values: filterSpecificationValues(
|
||||||
variant.specification_values,
|
variant.specification_values,
|
||||||
),
|
),
|
||||||
physical_spec: variantPhysicalSpec(variant),
|
physical_spec: mapVariantPhysicalSpec(variant),
|
||||||
package_spec: variantPackageSpec(variant),
|
package_spec: mapVariantPackageSpec(variant),
|
||||||
effective_physical_spec: effectivePhysicalSpec(
|
effective_physical_spec: resolveEffectivePhysicalSpec(
|
||||||
product,
|
product,
|
||||||
variant,
|
variant,
|
||||||
),
|
),
|
||||||
effective_package_spec: effectivePackageSpec(
|
effective_package_spec: resolveEffectivePackageSpec(
|
||||||
product,
|
product,
|
||||||
variant,
|
variant,
|
||||||
),
|
),
|
||||||
@@ -475,6 +453,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取发布配置当前或指定目标店铺的缓存类目。 */
|
||||||
async categoryOptions(
|
async categoryOptions(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -482,39 +461,16 @@ export class ProductEditorService {
|
|||||||
search: string | undefined,
|
search: string | undefined,
|
||||||
storeId?: string,
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.catalog.categoryOptions(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
profileId,
|
||||||
const profile = await this.requireProfileForPlatform(
|
platformId,
|
||||||
transaction,
|
search,
|
||||||
profileId,
|
storeId,
|
||||||
platformId,
|
|
||||||
);
|
|
||||||
const needle = search?.trim() ?? '';
|
|
||||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
|
||||||
if (storeId)
|
|
||||||
await this.requireEditableStore(transaction, storeId, platformId);
|
|
||||||
const categories = await transaction.query<{
|
|
||||||
id: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
external_category_id: string;
|
|
||||||
external_type_id: string | null;
|
|
||||||
name: string;
|
|
||||||
path: string;
|
|
||||||
is_active: boolean;
|
|
||||||
}>(
|
|
||||||
`SELECT id, parent_id, external_category_id, external_type_id, name, path, is_active
|
|
||||||
FROM atlas.store_product_categories
|
|
||||||
WHERE store_id = $1
|
|
||||||
ORDER BY path, name, id
|
|
||||||
LIMIT 10000`,
|
|
||||||
[selectedStoreId],
|
|
||||||
);
|
|
||||||
return categories.rows;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 获取有效启用类目的缓存动态属性定义。 */
|
||||||
async attributeSchema(
|
async attributeSchema(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -522,40 +478,16 @@ export class ProductEditorService {
|
|||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.catalog.attributeSchema(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
profileId,
|
||||||
const profile = await this.requireProfileForPlatform(
|
platformId,
|
||||||
transaction,
|
categoryId,
|
||||||
profileId,
|
storeId,
|
||||||
platformId,
|
|
||||||
);
|
|
||||||
const selectedCategoryId =
|
|
||||||
categoryId ?? profile.store_product_category_id;
|
|
||||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
|
||||||
if (storeId)
|
|
||||||
await this.requireEditableStore(transaction, storeId, platformId);
|
|
||||||
if (!selectedCategoryId)
|
|
||||||
throw new ApiError(
|
|
||||||
422,
|
|
||||||
'CATEGORY_REQUIRED',
|
|
||||||
'请先选择产品类目',
|
|
||||||
);
|
|
||||||
if (categoryId)
|
|
||||||
await this.requireActiveCategory(
|
|
||||||
transaction,
|
|
||||||
selectedStoreId,
|
|
||||||
categoryId,
|
|
||||||
);
|
|
||||||
return this.attributeSchemaForCategory(
|
|
||||||
transaction,
|
|
||||||
selectedStoreId,
|
|
||||||
selectedCategoryId,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅从店铺本地缓存查询当前类目的字典值。 */
|
||||||
async attributeDictionary(
|
async attributeDictionary(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -565,66 +497,18 @@ export class ProductEditorService {
|
|||||||
categoryId?: string,
|
categoryId?: string,
|
||||||
storeId?: string,
|
storeId?: string,
|
||||||
) {
|
) {
|
||||||
return this.database.workspaceTransaction(
|
return this.catalog.attributeDictionary(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
async (transaction) => {
|
profileId,
|
||||||
const profile = await this.requireProfileForPlatform(
|
platformId,
|
||||||
transaction,
|
dictionaryId,
|
||||||
profileId,
|
search,
|
||||||
platformId,
|
categoryId,
|
||||||
);
|
storeId,
|
||||||
const selectedCategoryId =
|
|
||||||
categoryId ?? profile.store_product_category_id;
|
|
||||||
const selectedStoreId = storeId ?? profile.target_store_id;
|
|
||||||
if (storeId)
|
|
||||||
await this.requireEditableStore(transaction, storeId, platformId);
|
|
||||||
if (!selectedCategoryId)
|
|
||||||
throw new ApiError(
|
|
||||||
422,
|
|
||||||
'CATEGORY_REQUIRED',
|
|
||||||
'请先选择产品类目',
|
|
||||||
);
|
|
||||||
if (categoryId)
|
|
||||||
await this.requireActiveCategory(
|
|
||||||
transaction,
|
|
||||||
selectedStoreId,
|
|
||||||
categoryId,
|
|
||||||
);
|
|
||||||
const allowed = await transaction.query<{
|
|
||||||
dictionary_id: string;
|
|
||||||
}>(
|
|
||||||
`SELECT dictionary_id
|
|
||||||
FROM atlas.store_product_category_attributes
|
|
||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
|
||||||
AND dictionary_id = $3
|
|
||||||
LIMIT 1`,
|
|
||||||
[selectedStoreId, selectedCategoryId, dictionaryId],
|
|
||||||
);
|
|
||||||
if (!allowed.rows[0])
|
|
||||||
throw new ApiError(
|
|
||||||
422,
|
|
||||||
'ATTRIBUTE_DICTIONARY_INVALID',
|
|
||||||
'字典不属于当前类目的属性',
|
|
||||||
);
|
|
||||||
const needle = search?.trim() ?? '';
|
|
||||||
const values = await transaction.query<{
|
|
||||||
value_id: string;
|
|
||||||
value: string;
|
|
||||||
}>(
|
|
||||||
`SELECT external_value_id AS value_id, value
|
|
||||||
FROM atlas.store_attribute_dictionary_values
|
|
||||||
WHERE store_id = $1 AND store_product_category_id = $2
|
|
||||||
AND dictionary_id = $3 AND is_active
|
|
||||||
AND ($4 = '' OR value ILIKE '%' || $4 || '%')
|
|
||||||
ORDER BY value, external_value_id
|
|
||||||
LIMIT 100`,
|
|
||||||
[selectedStoreId, selectedCategoryId, dictionaryId, needle],
|
|
||||||
);
|
|
||||||
return { items: values.rows, next_cursor: null };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 幂等地调度 Worker 刷新类目属性定义。 */
|
||||||
async requestAttributeSchemaSync(
|
async requestAttributeSchemaSync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -633,18 +517,17 @@ export class ProductEditorService {
|
|||||||
key: string,
|
key: string,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
return this.requestCatalogSync({
|
return this.catalog.requestAttributeSchemaSync(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
key,
|
key,
|
||||||
scope: 'attributes',
|
|
||||||
path: 'attribute-schema-syncs',
|
|
||||||
categoryId,
|
categoryId,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在查询词有效后幂等地调度远程字典检索。 */
|
||||||
async requestAttributeDictionarySync(
|
async requestAttributeDictionarySync(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -655,27 +538,19 @@ export class ProductEditorService {
|
|||||||
dictionaryId: string,
|
dictionaryId: string,
|
||||||
query: string,
|
query: string,
|
||||||
) {
|
) {
|
||||||
if (query.trim().length < 2) {
|
return this.catalog.requestAttributeDictionarySync(
|
||||||
throw new ApiError(
|
|
||||||
422,
|
|
||||||
'DICTIONARY_SEARCH_REQUIRED',
|
|
||||||
'请至少输入 2 个字符后再从 Ozon 搜索字典值',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return this.requestCatalogSync({
|
|
||||||
workspaceId,
|
workspaceId,
|
||||||
userId,
|
userId,
|
||||||
profileId,
|
profileId,
|
||||||
platformId,
|
platformId,
|
||||||
key,
|
key,
|
||||||
scope: 'dictionary',
|
|
||||||
path: `attribute-dictionaries/${encodeURIComponent(dictionaryId)}/syncs`,
|
|
||||||
categoryId,
|
categoryId,
|
||||||
dictionaryId,
|
dictionaryId,
|
||||||
query: query.trim(),
|
query,
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 原子校验并保存商品、发布配置、SKU、媒体和动态属性。 */
|
||||||
async save(
|
async save(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -700,10 +575,8 @@ export class ProductEditorService {
|
|||||||
},
|
},
|
||||||
async (transaction) => {
|
async (transaction) => {
|
||||||
await this.requireTargetPlatform(transaction, platformId);
|
await this.requireTargetPlatform(transaction, platformId);
|
||||||
// Lock the current source row before comparing its snapshot
|
// 先锁定来源快照,再比较版本和保存覆盖字段;并发复采只能先完成
|
||||||
// revision and persisting overrides. A simultaneous re-capture
|
// 并让本请求返回 409,或等待本次原子草稿保存提交,避免覆盖错位。
|
||||||
// then either completes first (and this request returns 409),
|
|
||||||
// or waits until this atomic draft save commits.
|
|
||||||
const product = await this.requireProduct(
|
const product = await this.requireProduct(
|
||||||
transaction,
|
transaction,
|
||||||
productId,
|
productId,
|
||||||
@@ -792,7 +665,7 @@ export class ProductEditorService {
|
|||||||
platformId,
|
platformId,
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
const sourceMaterialOverrides = productAiSourceOverrides(
|
const sourceMaterialOverrides = selectSourceMaterialOverrides(
|
||||||
input.ai_source_material,
|
input.ai_source_material,
|
||||||
input.ai_source_overridden_fields,
|
input.ai_source_overridden_fields,
|
||||||
);
|
);
|
||||||
@@ -1028,9 +901,8 @@ export class ProductEditorService {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// A removed, never-imported SKU must release its draft offer_id;
|
// 已删除且未导入的 SKU 必须释放草稿 offer_id;否则同店替换 SKU
|
||||||
// otherwise a replacement SKU in the same store would hit the
|
// 会因持久化唯一约束失败,即使用户已在本次草稿中删除原 SKU。
|
||||||
// durable uniqueness rule despite the user having deleted it.
|
|
||||||
await transaction.query(
|
await transaction.query(
|
||||||
`UPDATE atlas.product_variant_platform_listings l
|
`UPDATE atlas.product_variant_platform_listings l
|
||||||
SET offer_id = NULL, revision = revision + 1, updated_at = now()
|
SET offer_id = NULL, revision = revision + 1, updated_at = now()
|
||||||
@@ -1078,6 +950,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保每个未删除 SKU 都有当前发布配置对应的上架记录。 */
|
||||||
private async ensureListings(
|
private async ensureListings(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -1101,10 +974,8 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist final image order only after every supplied asset has passed the
|
* 仅在每个素材都通过商品/SKU 来源校验后保存最终排序。已完成的转存是
|
||||||
* product/SKU provenance check. A completed transfer is the bridge from
|
* 不可信来源 URL 转为 Atlas 素材的边界,调用方不能任意关联工作空间素材。
|
||||||
* an untrusted source URL to an Atlas-owned media asset; callers cannot
|
|
||||||
* attach arbitrary workspace assets to a listing.
|
|
||||||
*/
|
*/
|
||||||
private async persistListingMedia(
|
private async persistListingMedia(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
@@ -1257,6 +1128,7 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按类目定义保存草稿属性,并保留无法映射的历史属性。 */
|
||||||
private async persistDynamicAttributes(
|
private async persistDynamicAttributes(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
input: {
|
input: {
|
||||||
@@ -1388,10 +1260,8 @@ export class ProductEditorService {
|
|||||||
'SKU 属性必须关联当前商品中未删除的 SKU',
|
'SKU 属性必须关联当前商品中未删除的 SKU',
|
||||||
);
|
);
|
||||||
if (!input.retainedVariantIds.includes(attribute.variant_id)) {
|
if (!input.retainedVariantIds.includes(attribute.variant_id)) {
|
||||||
// A removed SKU takes its old attributes with it. Reject a
|
// 删除 SKU 时其旧属性应一并删除;拒绝伪造的新引用,但不能因
|
||||||
// newly fabricated reference, but do not make the normal
|
// 表单快照仍带着该 SKU 的旧属性而使正常的删除操作失败。
|
||||||
// “delete SKU” operation fail because the form snapshot
|
|
||||||
// still contains that SKU's previous attribute row.
|
|
||||||
if (!existingByKey.has(savedKey))
|
if (!existingByKey.has(savedKey))
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
@@ -1411,9 +1281,8 @@ export class ProductEditorService {
|
|||||||
valid.push(attribute);
|
valid.push(attribute);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attribute definitions can disappear when the category changes (or a
|
// 类目变更或平台同步下线属性时,定义可能消失;需保留既有草稿,不能因
|
||||||
// platform sync retires an attribute). Preserve the existing draft
|
// 本次保存未携带它就让用户数据静默丢失。
|
||||||
// rather than allowing it to vanish simply because this save omitted it.
|
|
||||||
for (const attribute of existing.rows) {
|
for (const attribute of existing.rows) {
|
||||||
const key = savedAttributeKey(attribute);
|
const key = savedAttributeKey(attribute);
|
||||||
if (
|
if (
|
||||||
@@ -1480,6 +1349,7 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 校验草稿属性的类型、数量、字典值与 SKU 归属。 */
|
||||||
private async assertAttributeValues(
|
private async assertAttributeValues(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
storeId: string,
|
||||||
@@ -1546,6 +1416,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回店铺有效类目的标准化属性定义。 */
|
||||||
private async attributeSchemaForCategory(
|
private async attributeSchemaForCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
storeId: string,
|
||||||
@@ -1573,6 +1444,7 @@ export class ProductEditorService {
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 写入操作记录和 Outbox 事件,供异步刷新目录缓存。 */
|
||||||
private async requestCatalogSync(input: {
|
private async requestCatalogSync(input: {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -1709,9 +1581,8 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ozon capture records breadcrumb names rather than a cache UUID. Resolve
|
* Ozon 采集只保存类目面包屑而非缓存 UUID。仅当发布配置尚未保存类目时,
|
||||||
* that evidence only while a profile has no saved category; a later
|
* 才能用精确路径匹配回填;之后的复采绝不能覆盖用户或既有持久化选择。
|
||||||
* recapture must never replace a user or previously persisted selection.
|
|
||||||
*/
|
*/
|
||||||
private async applyCapturedCategory(
|
private async applyCapturedCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
@@ -1720,7 +1591,7 @@ export class ProductEditorService {
|
|||||||
): Promise<ProfileRow> {
|
): Promise<ProfileRow> {
|
||||||
if (profile.store_product_category_id) return profile;
|
if (profile.store_product_category_id) return profile;
|
||||||
const categories = latestProductAiSourceMaterial(product).categories;
|
const categories = latestProductAiSourceMaterial(product).categories;
|
||||||
const sourcePath = normalizedCategoryPath(categories);
|
const sourcePath = normalizeCapturedCategoryPath(categories);
|
||||||
if (!sourcePath) return profile;
|
if (!sourcePath) return profile;
|
||||||
|
|
||||||
const sourcePlatform = await transaction.query<{ code: string }>(
|
const sourcePlatform = await transaction.query<{ code: string }>(
|
||||||
@@ -1761,6 +1632,7 @@ export class ProductEditorService {
|
|||||||
: profile;
|
: profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认类目属于目标店铺且仍处于启用状态。 */
|
||||||
private async requireActiveCategory(
|
private async requireActiveCategory(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
storeId: string,
|
||||||
@@ -1784,6 +1656,7 @@ export class ProductEditorService {
|
|||||||
return result.rows[0];
|
return result.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取发布配置,并拒绝跨平台上下文访问。 */
|
||||||
private async requireProfileForPlatform(
|
private async requireProfileForPlatform(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -1796,6 +1669,7 @@ export class ProductEditorService {
|
|||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认页头当前平台已启用且可作为发布目标。 */
|
||||||
private async requireTargetPlatform(
|
private async requireTargetPlatform(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
@@ -1815,6 +1689,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取创建发布配置时必需的、已验证默认店铺。 */
|
||||||
private async requireDefaultStore(
|
private async requireDefaultStore(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
platformId: string,
|
platformId: string,
|
||||||
@@ -1835,6 +1710,7 @@ export class ProductEditorService {
|
|||||||
return store.rows[0];
|
return store.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认指定店铺属于平台、可用且凭据已验证。 */
|
||||||
private async requireEditableStore(
|
private async requireEditableStore(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
storeId: string,
|
storeId: string,
|
||||||
@@ -1854,6 +1730,7 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在保存草稿前拒绝过期或跨商品的 SKU 标识。 */
|
||||||
private async ensureVariantIdsBelongToProduct(
|
private async ensureVariantIdsBelongToProduct(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -1881,6 +1758,7 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确认草稿 offer_id 在目标店铺内保持唯一。 */
|
||||||
private async assertOfferIdsAvailable(
|
private async assertOfferIdsAvailable(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -1920,6 +1798,7 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 实物或包装规格标记为已确认时,要求尺寸和重量完整填写。 */
|
||||||
private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) {
|
private assertConfirmedSpecsComplete(input: SaveProductEditorDraftRequest) {
|
||||||
const check = (
|
const check = (
|
||||||
status: 'missing' | 'ambiguous' | 'confirmed' | null,
|
status: 'missing' | 'ambiguous' | 'confirmed' | null,
|
||||||
@@ -1985,6 +1864,7 @@ export class ProductEditorService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 使用数据库当前版本返回结构化的商品并发冲突。 */
|
||||||
private async throwProductRevisionConflict(
|
private async throwProductRevisionConflict(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -2007,6 +1887,7 @@ export class ProductEditorService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 使用数据库当前版本返回结构化的发布配置并发冲突。 */
|
||||||
private async throwProfileRevisionConflict(
|
private async throwProfileRevisionConflict(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -2029,6 +1910,7 @@ export class ProductEditorService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取关联到有效采集记录的编辑商品。 */
|
||||||
private async requireProductByCollectionItem(
|
private async requireProductByCollectionItem(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
collectionItemId: string,
|
collectionItemId: string,
|
||||||
@@ -2052,6 +1934,7 @@ export class ProductEditorService {
|
|||||||
return product.rows[0];
|
return product.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取有效编辑商品;保存草稿时可锁定其来源快照。 */
|
||||||
private async requireProduct(
|
private async requireProduct(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -2076,6 +1959,7 @@ export class ProductEditorService {
|
|||||||
return product.rows[0];
|
return product.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 查询商品在目标平台的发布配置;不存在时不视为异常。 */
|
||||||
private async findProfile(
|
private async findProfile(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -2089,6 +1973,7 @@ export class ProductEditorService {
|
|||||||
return profile.rows[0] ?? null;
|
return profile.rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取单个发布配置;并发删除时返回失败。 */
|
||||||
private async requireProfile(
|
private async requireProfile(
|
||||||
transaction: DatabaseTransaction,
|
transaction: DatabaseTransaction,
|
||||||
profileId: string,
|
profileId: string,
|
||||||
@@ -2101,6 +1986,7 @@ export class ProductEditorService {
|
|||||||
return profile.rows[0];
|
return profile.rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将持久化发布配置转换为进入编辑器后的稳定上下文。 */
|
||||||
private toContext(
|
private toContext(
|
||||||
collectionItemId: string,
|
collectionItemId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
@@ -2120,256 +2006,6 @@ export class ProductEditorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const profileSelect = `SELECT p.id, p.revision, p.platform_id, p.market_code,
|
|
||||||
p.fulfillment, p.title, p.description, p.tags, p.store_product_category_id,
|
|
||||||
p.platform_type_id, p.category_source, category.name AS category_name,
|
|
||||||
category.path AS category_path, p.target_store_id, s.name AS store_name,
|
|
||||||
s.currency_code
|
|
||||||
FROM atlas.product_platform_profiles p
|
|
||||||
JOIN atlas.stores s ON s.id = p.target_store_id
|
|
||||||
LEFT JOIN atlas.store_product_categories category
|
|
||||||
ON category.id = p.store_product_category_id
|
|
||||||
AND category.store_id = p.target_store_id`;
|
|
||||||
|
|
||||||
type VariantSpecRow = {
|
|
||||||
specification_values: unknown;
|
|
||||||
physical_length_mm: string | null;
|
|
||||||
physical_width_mm: string | null;
|
|
||||||
physical_height_mm: string | null;
|
|
||||||
net_weight_g: string | null;
|
|
||||||
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
|
||||||
package_length_mm: string | null;
|
|
||||||
package_width_mm: string | null;
|
|
||||||
package_height_mm: string | null;
|
|
||||||
shipping_weight_g: string | null;
|
|
||||||
package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function productPhysicalSpec(product: ProductRow) {
|
|
||||||
return {
|
|
||||||
length_mm: product.default_physical_length_mm,
|
|
||||||
width_mm: product.default_physical_width_mm,
|
|
||||||
height_mm: product.default_physical_height_mm,
|
|
||||||
net_weight_g: product.default_net_weight_g,
|
|
||||||
status: product.physical_spec_status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function productPackageSpec(product: ProductRow) {
|
|
||||||
return {
|
|
||||||
length_mm: product.default_package_length_mm,
|
|
||||||
width_mm: product.default_package_width_mm,
|
|
||||||
height_mm: product.default_package_height_mm,
|
|
||||||
shipping_weight_g: product.default_shipping_weight_g,
|
|
||||||
status: product.package_spec_status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantPhysicalSpec(variant: VariantSpecRow) {
|
|
||||||
return {
|
|
||||||
length_mm: variant.physical_length_mm,
|
|
||||||
width_mm: variant.physical_width_mm,
|
|
||||||
height_mm: variant.physical_height_mm,
|
|
||||||
net_weight_g: variant.net_weight_g,
|
|
||||||
status: variant.physical_spec_status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function variantPackageSpec(variant: VariantSpecRow) {
|
|
||||||
return {
|
|
||||||
length_mm: variant.package_length_mm,
|
|
||||||
width_mm: variant.package_width_mm,
|
|
||||||
height_mm: variant.package_height_mm,
|
|
||||||
shipping_weight_g: variant.shipping_weight_g,
|
|
||||||
status: variant.package_spec_status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectivePhysicalSpec(product: ProductRow, variant: VariantSpecRow) {
|
|
||||||
const fallback = productPhysicalSpec(product);
|
|
||||||
const override = variantPhysicalSpec(variant);
|
|
||||||
return {
|
|
||||||
length_mm: override.length_mm ?? fallback.length_mm,
|
|
||||||
width_mm: override.width_mm ?? fallback.width_mm,
|
|
||||||
height_mm: override.height_mm ?? fallback.height_mm,
|
|
||||||
net_weight_g: override.net_weight_g ?? fallback.net_weight_g,
|
|
||||||
status: override.status ?? fallback.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function effectivePackageSpec(product: ProductRow, variant: VariantSpecRow) {
|
|
||||||
const fallback = productPackageSpec(product);
|
|
||||||
const override = variantPackageSpec(variant);
|
|
||||||
return {
|
|
||||||
length_mm: override.length_mm ?? fallback.length_mm,
|
|
||||||
width_mm: override.width_mm ?? fallback.width_mm,
|
|
||||||
height_mm: override.height_mm ?? fallback.height_mm,
|
|
||||||
shipping_weight_g:
|
|
||||||
override.shipping_weight_g ?? fallback.shipping_weight_g,
|
|
||||||
status: override.status ?? fallback.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function specificationValues(value: unknown) {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value.filter(
|
|
||||||
(
|
|
||||||
item,
|
|
||||||
): item is {
|
|
||||||
axis_key: string;
|
|
||||||
value_key: string;
|
|
||||||
value_name: string;
|
|
||||||
} =>
|
|
||||||
!!item &&
|
|
||||||
typeof item === 'object' &&
|
|
||||||
typeof (item as { axis_key?: unknown }).axis_key === 'string' &&
|
|
||||||
typeof (item as { value_key?: unknown }).value_key === 'string' &&
|
|
||||||
typeof (item as { value_name?: unknown }).value_name === 'string',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const productAiSourceFields: ProductAiSourceField[] = [
|
|
||||||
'title',
|
|
||||||
'description',
|
|
||||||
'price',
|
|
||||||
'brand',
|
|
||||||
'attributes',
|
|
||||||
'categories',
|
|
||||||
];
|
|
||||||
|
|
||||||
function productAiSourceMaterial(product: ProductRow) {
|
|
||||||
const latest = latestProductAiSourceMaterial(product);
|
|
||||||
const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse(
|
|
||||||
product.ai_source_material_overrides,
|
|
||||||
);
|
|
||||||
const overrides = parsedOverrides.success ? parsedOverrides.data : {};
|
|
||||||
const overriddenFields = productAiSourceFields.filter((field) =>
|
|
||||||
Object.prototype.hasOwnProperty.call(overrides, field),
|
|
||||||
);
|
|
||||||
const effective = productAiSourceMaterialSchema.parse({
|
|
||||||
...latest,
|
|
||||||
...overrides,
|
|
||||||
source_snapshot_revision: latest.source_snapshot_revision,
|
|
||||||
});
|
|
||||||
const baseRevision = product.ai_source_material_base_revision
|
|
||||||
? Number(product.ai_source_material_base_revision)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
effective,
|
|
||||||
state: {
|
|
||||||
latest,
|
|
||||||
overridden_fields: overriddenFields,
|
|
||||||
changed_fields: overriddenFields.filter(
|
|
||||||
(field) =>
|
|
||||||
JSON.stringify(effective[field]) !==
|
|
||||||
JSON.stringify(latest[field]),
|
|
||||||
),
|
|
||||||
base_source_snapshot_revision: baseRevision,
|
|
||||||
has_source_updates:
|
|
||||||
baseRevision !== null &&
|
|
||||||
baseRevision !== latest.source_snapshot_revision,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function productAiSourceOverrides(
|
|
||||||
material: ProductAiSourceMaterial,
|
|
||||||
overriddenFields: ProductAiSourceField[],
|
|
||||||
) {
|
|
||||||
const overrides: Record<string, unknown> = {};
|
|
||||||
for (const field of overriddenFields) overrides[field] = material[field];
|
|
||||||
return productAiSourceMaterialOverridesSchema.parse(overrides);
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestProductAiSourceMaterial(
|
|
||||||
product: ProductRow,
|
|
||||||
): ProductAiSourceMaterial {
|
|
||||||
const payload = objectValue(product.normalized_payload);
|
|
||||||
const common = objectValue(payload?.common);
|
|
||||||
const title = objectValue(common?.title)?.value;
|
|
||||||
const description = objectValue(common?.description)?.value;
|
|
||||||
const sourcePrice = objectValue(common?.sourcePrice);
|
|
||||||
const attributes = Array.isArray(common?.attributes)
|
|
||||||
? common.attributes.flatMap((item) => {
|
|
||||||
const attribute = objectValue(item);
|
|
||||||
return typeof attribute?.key === 'string' &&
|
|
||||||
typeof attribute.name === 'string' &&
|
|
||||||
typeof attribute.value === 'string'
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: attribute.key,
|
|
||||||
name: attribute.name,
|
|
||||||
value: attribute.value,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const categories = Array.isArray(common?.categories)
|
|
||||||
? common.categories.flatMap((item) => {
|
|
||||||
const category = objectValue(item);
|
|
||||||
return typeof category?.key === 'string' &&
|
|
||||||
typeof category.name === 'string'
|
|
||||||
? [{ key: category.key, name: category.name }]
|
|
||||||
: [];
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
const brand = attributes.find((attribute) =>
|
|
||||||
/brand|品牌/i.test(`${attribute.key} ${attribute.name}`),
|
|
||||||
)?.value;
|
|
||||||
const candidate: ProductAiSourceMaterial = {
|
|
||||||
title: typeof title === 'string' ? title : product.source_title,
|
|
||||||
description: typeof description === 'string' ? description : null,
|
|
||||||
price:
|
|
||||||
typeof sourcePrice?.amount === 'string' &&
|
|
||||||
typeof sourcePrice.currencyCode === 'string'
|
|
||||||
? {
|
|
||||||
amount: sourcePrice.amount,
|
|
||||||
currency_code: sourcePrice.currencyCode.toUpperCase(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
brand: brand ?? product.brand,
|
|
||||||
attributes,
|
|
||||||
categories,
|
|
||||||
source_snapshot_revision: Number(product.source_snapshot_revision),
|
|
||||||
};
|
|
||||||
const parsed = productAiSourceMaterialSchema.safeParse(candidate);
|
|
||||||
if (parsed.success) return parsed.data;
|
|
||||||
return {
|
|
||||||
title: product.source_title,
|
|
||||||
description: null,
|
|
||||||
price: null,
|
|
||||||
brand: product.brand,
|
|
||||||
attributes: [],
|
|
||||||
categories: [],
|
|
||||||
source_snapshot_revision: Math.max(
|
|
||||||
1,
|
|
||||||
Number(product.source_snapshot_revision) || 1,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizedCategoryPath(
|
|
||||||
categories: Array<{ name: string }>,
|
|
||||||
): string | null {
|
|
||||||
const names = categories
|
|
||||||
.map((category) =>
|
|
||||||
category.name
|
|
||||||
.normalize('NFKC')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.toLocaleLowerCase(),
|
|
||||||
)
|
|
||||||
.filter(Boolean);
|
|
||||||
return names.length ? names.join(' › ') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
? (value as Record<string, unknown>)
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attributeKey(platformAttributeId: string, complexId: string) {
|
function attributeKey(platformAttributeId: string, complexId: string) {
|
||||||
return `${platformAttributeId}\u0000${complexId}`;
|
return `${platformAttributeId}\u0000${complexId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
productAiSourceMaterialOverridesSchema,
|
||||||
|
productAiSourceMaterialSchema,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import type {
|
||||||
|
ProductAiSourceField,
|
||||||
|
ProductAiSourceMaterial,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
|
||||||
|
type SourceMaterialProduct = {
|
||||||
|
source_title: string | null;
|
||||||
|
brand: string | null;
|
||||||
|
normalized_payload: unknown;
|
||||||
|
source_snapshot_revision: number | string;
|
||||||
|
ai_source_material_overrides: unknown;
|
||||||
|
ai_source_material_base_revision: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const productAiSourceFields: ProductAiSourceField[] = [
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'price',
|
||||||
|
'brand',
|
||||||
|
'attributes',
|
||||||
|
'categories',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 计算包含用户覆盖字段的有效 AI 来源素材及其状态。 */
|
||||||
|
export function productAiSourceMaterial(product: SourceMaterialProduct) {
|
||||||
|
const latest = latestProductAiSourceMaterial(product);
|
||||||
|
const parsedOverrides = productAiSourceMaterialOverridesSchema.safeParse(
|
||||||
|
product.ai_source_material_overrides,
|
||||||
|
);
|
||||||
|
const overrides = parsedOverrides.success ? parsedOverrides.data : {};
|
||||||
|
const overriddenFields = productAiSourceFields.filter((field) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(overrides, field),
|
||||||
|
);
|
||||||
|
const effective = productAiSourceMaterialSchema.parse({
|
||||||
|
...latest,
|
||||||
|
...overrides,
|
||||||
|
source_snapshot_revision: latest.source_snapshot_revision,
|
||||||
|
});
|
||||||
|
const baseRevision = product.ai_source_material_base_revision
|
||||||
|
? Number(product.ai_source_material_base_revision)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
effective,
|
||||||
|
state: {
|
||||||
|
latest,
|
||||||
|
overridden_fields: overriddenFields,
|
||||||
|
changed_fields: overriddenFields.filter(
|
||||||
|
(field) =>
|
||||||
|
JSON.stringify(effective[field]) !==
|
||||||
|
JSON.stringify(latest[field]),
|
||||||
|
),
|
||||||
|
base_source_snapshot_revision: baseRevision,
|
||||||
|
has_source_updates:
|
||||||
|
baseRevision !== null &&
|
||||||
|
baseRevision !== latest.source_snapshot_revision,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅提取用户明确覆盖的字段,供草稿持久化。 */
|
||||||
|
export function productAiSourceOverrides(
|
||||||
|
material: ProductAiSourceMaterial,
|
||||||
|
overriddenFields: ProductAiSourceField[],
|
||||||
|
) {
|
||||||
|
const overrides: Record<string, unknown> = {};
|
||||||
|
for (const field of overriddenFields) overrides[field] = material[field];
|
||||||
|
return productAiSourceMaterialOverridesSchema.parse(overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取未经覆盖的最新采集快照,用于类目匹配和变更检测。 */
|
||||||
|
export function latestProductAiSourceMaterial(
|
||||||
|
product: SourceMaterialProduct,
|
||||||
|
): ProductAiSourceMaterial {
|
||||||
|
const payload = objectValue(product.normalized_payload);
|
||||||
|
const common = objectValue(payload?.common);
|
||||||
|
const title = objectValue(common?.title)?.value;
|
||||||
|
const description = objectValue(common?.description)?.value;
|
||||||
|
const sourcePrice = objectValue(common?.sourcePrice);
|
||||||
|
const attributes = Array.isArray(common?.attributes)
|
||||||
|
? common.attributes.flatMap((item) => {
|
||||||
|
const attribute = objectValue(item);
|
||||||
|
return typeof attribute?.key === 'string' &&
|
||||||
|
typeof attribute.name === 'string' &&
|
||||||
|
typeof attribute.value === 'string'
|
||||||
|
? [{ key: attribute.key, name: attribute.name, value: attribute.value }]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const categories = Array.isArray(common?.categories)
|
||||||
|
? common.categories.flatMap((item) => {
|
||||||
|
const category = objectValue(item);
|
||||||
|
return typeof category?.key === 'string' && typeof category.name === 'string'
|
||||||
|
? [{ key: category.key, name: category.name }]
|
||||||
|
: [];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const brand = attributes.find((attribute) =>
|
||||||
|
/brand|品牌/i.test(`${attribute.key} ${attribute.name}`),
|
||||||
|
)?.value;
|
||||||
|
const candidate: ProductAiSourceMaterial = {
|
||||||
|
title: typeof title === 'string' ? title : product.source_title,
|
||||||
|
description: typeof description === 'string' ? description : null,
|
||||||
|
price:
|
||||||
|
typeof sourcePrice?.amount === 'string' &&
|
||||||
|
typeof sourcePrice.currencyCode === 'string'
|
||||||
|
? { amount: sourcePrice.amount, currency_code: sourcePrice.currencyCode.toUpperCase() }
|
||||||
|
: null,
|
||||||
|
brand: brand ?? product.brand,
|
||||||
|
attributes,
|
||||||
|
categories,
|
||||||
|
source_snapshot_revision: Number(product.source_snapshot_revision),
|
||||||
|
};
|
||||||
|
const parsed = productAiSourceMaterialSchema.safeParse(candidate);
|
||||||
|
if (parsed.success) return parsed.data;
|
||||||
|
return {
|
||||||
|
title: product.source_title,
|
||||||
|
description: null,
|
||||||
|
price: null,
|
||||||
|
brand: product.brand,
|
||||||
|
attributes: [],
|
||||||
|
categories: [],
|
||||||
|
source_snapshot_revision: Math.max(1, Number(product.source_snapshot_revision) || 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将未知 JSON 收窄为对象记录,供采集快照解析。 */
|
||||||
|
function objectValue(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化来源类目面包屑,以便和缓存路径做精确匹配。 */
|
||||||
|
export function normalizedCategoryPath(
|
||||||
|
categories: Array<{ name: string }>,
|
||||||
|
): string | null {
|
||||||
|
const names = categories
|
||||||
|
.map((category) =>
|
||||||
|
category.name.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase(),
|
||||||
|
)
|
||||||
|
.filter(Boolean);
|
||||||
|
return names.length ? names.join(' › ') : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 编辑读模型共用的纯规格映射函数。
|
||||||
|
* 独立于工作流服务,便于单独审查继承规则。
|
||||||
|
*/
|
||||||
|
type ProductSpecSource = {
|
||||||
|
default_physical_length_mm: string | null;
|
||||||
|
default_physical_width_mm: string | null;
|
||||||
|
default_physical_height_mm: string | null;
|
||||||
|
default_net_weight_g: string | null;
|
||||||
|
default_package_length_mm: string | null;
|
||||||
|
default_package_width_mm: string | null;
|
||||||
|
default_package_height_mm: string | null;
|
||||||
|
default_shipping_weight_g: string | null;
|
||||||
|
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||||
|
package_spec_status: 'missing' | 'ambiguous' | 'confirmed';
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariantSpecSource = {
|
||||||
|
specification_values: unknown;
|
||||||
|
physical_length_mm: string | null;
|
||||||
|
physical_width_mm: string | null;
|
||||||
|
physical_height_mm: string | null;
|
||||||
|
net_weight_g: string | null;
|
||||||
|
physical_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
||||||
|
package_length_mm: string | null;
|
||||||
|
package_width_mm: string | null;
|
||||||
|
package_height_mm: string | null;
|
||||||
|
shipping_weight_g: string | null;
|
||||||
|
package_spec_status: 'missing' | 'ambiguous' | 'confirmed' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 将持久化的商品默认值映射为对外的实物规格。 */
|
||||||
|
export function productPhysicalSpec(product: ProductSpecSource) {
|
||||||
|
return {
|
||||||
|
length_mm: product.default_physical_length_mm,
|
||||||
|
width_mm: product.default_physical_width_mm,
|
||||||
|
height_mm: product.default_physical_height_mm,
|
||||||
|
net_weight_g: product.default_net_weight_g,
|
||||||
|
status: product.physical_spec_status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将持久化的商品默认值映射为对外的包装规格。 */
|
||||||
|
export function productPackageSpec(product: ProductSpecSource) {
|
||||||
|
return {
|
||||||
|
length_mm: product.default_package_length_mm,
|
||||||
|
width_mm: product.default_package_width_mm,
|
||||||
|
height_mm: product.default_package_height_mm,
|
||||||
|
shipping_weight_g: product.default_shipping_weight_g,
|
||||||
|
status: product.package_spec_status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 映射 SKU 可空的实物规格覆盖值,不应用商品继承。 */
|
||||||
|
export function variantPhysicalSpec(variant: VariantSpecSource) {
|
||||||
|
return {
|
||||||
|
length_mm: variant.physical_length_mm,
|
||||||
|
width_mm: variant.physical_width_mm,
|
||||||
|
height_mm: variant.physical_height_mm,
|
||||||
|
net_weight_g: variant.net_weight_g,
|
||||||
|
status: variant.physical_spec_status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 映射 SKU 可空的包装规格覆盖值,不应用商品继承。 */
|
||||||
|
export function variantPackageSpec(variant: VariantSpecSource) {
|
||||||
|
return {
|
||||||
|
length_mm: variant.package_length_mm,
|
||||||
|
width_mm: variant.package_width_mm,
|
||||||
|
height_mm: variant.package_height_mm,
|
||||||
|
shipping_weight_g: variant.shipping_weight_g,
|
||||||
|
status: variant.package_spec_status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算 SKU 继承后的有效实物规格。 */
|
||||||
|
export function effectivePhysicalSpec(
|
||||||
|
product: ProductSpecSource,
|
||||||
|
variant: VariantSpecSource,
|
||||||
|
) {
|
||||||
|
const fallback = productPhysicalSpec(product);
|
||||||
|
const override = variantPhysicalSpec(variant);
|
||||||
|
return {
|
||||||
|
length_mm: override.length_mm ?? fallback.length_mm,
|
||||||
|
width_mm: override.width_mm ?? fallback.width_mm,
|
||||||
|
height_mm: override.height_mm ?? fallback.height_mm,
|
||||||
|
net_weight_g: override.net_weight_g ?? fallback.net_weight_g,
|
||||||
|
status: override.status ?? fallback.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 计算 SKU 继承后的有效包装规格。 */
|
||||||
|
export function effectivePackageSpec(
|
||||||
|
product: ProductSpecSource,
|
||||||
|
variant: VariantSpecSource,
|
||||||
|
) {
|
||||||
|
const fallback = productPackageSpec(product);
|
||||||
|
const override = variantPackageSpec(variant);
|
||||||
|
return {
|
||||||
|
length_mm: override.length_mm ?? fallback.length_mm,
|
||||||
|
width_mm: override.width_mm ?? fallback.width_mm,
|
||||||
|
height_mm: override.height_mm ?? fallback.height_mm,
|
||||||
|
shipping_weight_g:
|
||||||
|
override.shipping_weight_g ?? fallback.shipping_weight_g,
|
||||||
|
status: override.status ?? fallback.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将不可信 JSON 过滤为稳定的来源 SKU 规格值结构。 */
|
||||||
|
export function specificationValues(value: unknown) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is {
|
||||||
|
axis_key: string;
|
||||||
|
value_key: string;
|
||||||
|
value_name: string;
|
||||||
|
} =>
|
||||||
|
!!item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as { axis_key?: unknown }).axis_key === 'string' &&
|
||||||
|
typeof (item as { value_key?: unknown }).value_key === 'string' &&
|
||||||
|
typeof (item as { value_name?: unknown }).value_name === 'string',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,8 @@ export function decryptStoreSecret(
|
|||||||
const tag = ciphertextWithTag.subarray(-authenticationTagLength);
|
const tag = ciphertextWithTag.subarray(-authenticationTagLength);
|
||||||
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
const decipher = createDecipheriv('aes-256-gcm', key, nonce);
|
||||||
decipher.setAuthTag(tag);
|
decipher.setAuthTag(tag);
|
||||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString(
|
return Buffer.concat([
|
||||||
'utf8',
|
decipher.update(ciphertext),
|
||||||
);
|
decipher.final(),
|
||||||
|
]).toString('utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,7 +235,11 @@ export class StoresController {
|
|||||||
@CurrentWorkspace() workspace: Workspace,
|
@CurrentWorkspace() workspace: Workspace,
|
||||||
) {
|
) {
|
||||||
if (!platformId) {
|
if (!platformId) {
|
||||||
throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id');
|
throw new ApiError(
|
||||||
|
400,
|
||||||
|
'PLATFORM_CONTEXT_REQUIRED',
|
||||||
|
'请求缺少 X-Atlas-Platform-Id',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
data: await this.storesService.fetchDefaultCategoryTreeDebug(
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
/*
|
||||||
|
* 商品编辑页骨架样式。
|
||||||
|
*
|
||||||
|
* 页面级布局(页头 / 工作区 / 底部操作条)在此维护;各区块组件的专属样式
|
||||||
|
* 位于 components/*.css 并由组件自行引入。以下两组是跨组件共享的基础样式:
|
||||||
|
* - .editor-section*:区块卡片外框(主要信息 / 产品属性 / 变体设置 / 其它共用)
|
||||||
|
* - .editor-form-grid / .editor-short-input:表单栅格与短输入框(多个区块共用)
|
||||||
|
*/
|
||||||
.product-editor {
|
.product-editor {
|
||||||
max-width: 1500px;
|
max-width: 1500px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding-bottom: 92px;
|
padding-bottom: 92px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 页头 */
|
||||||
.editor-header {
|
.editor-header {
|
||||||
margin: -4px 0 20px;
|
margin: -4px 0 20px;
|
||||||
}
|
}
|
||||||
@@ -22,13 +32,6 @@
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
.editor-eyebrow {
|
|
||||||
color: #7c3aed;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.editor-header-context {
|
.editor-header-context {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -36,9 +39,11 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 工作区:左侧主编辑列 + 右侧采集信息面板(见 CollectedProductInfoPanel.css) */
|
||||||
.editor-workspace {
|
.editor-workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 410px;
|
grid-template-columns: minmax(0, 1fr) 450px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
@@ -49,6 +54,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 区块卡片公共外框 */
|
||||||
.editor-section.ant-card {
|
.editor-section.ant-card {
|
||||||
border: 1px solid #e9e7ef;
|
border: 1px solid #e9e7ef;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -57,38 +64,12 @@
|
|||||||
.editor-section .ant-card-body {
|
.editor-section .ant-card-body {
|
||||||
padding: 25px 28px;
|
padding: 25px 28px;
|
||||||
}
|
}
|
||||||
.editor-section-heading {
|
.editor-section .ant-form-item-label > label {
|
||||||
display: flex;
|
color: #344054;
|
||||||
align-items: flex-start;
|
font-weight: 600;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 22px;
|
|
||||||
}
|
|
||||||
.editor-section-heading h4.ant-typography {
|
|
||||||
margin: 2px 0 3px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
.editor-section-number {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 25px;
|
|
||||||
height: 25px;
|
|
||||||
margin-right: 9px;
|
|
||||||
border-radius: 7px;
|
|
||||||
background: #ede9fe;
|
|
||||||
color: #7c3aed;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
vertical-align: 2px;
|
|
||||||
}
|
|
||||||
.attribute-item span,
|
|
||||||
.field-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表单栅格(主要信息与其它区块使用) */
|
||||||
.editor-form-grid {
|
.editor-form-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -100,423 +81,12 @@
|
|||||||
.editor-form-grid .span-two {
|
.editor-form-grid .span-two {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
.editor-section .ant-form-item-label > label {
|
/* 型号 / 品牌 / 条形码等短输入框 */
|
||||||
color: #344054;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.package-fields {
|
|
||||||
margin-top: 22px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #f0eef4;
|
|
||||||
}
|
|
||||||
.package-fields > .package-weight-field {
|
|
||||||
width: max-content;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.package-dimensions-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 130px 22px 130px 22px 130px auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
.package-dimension-separator {
|
|
||||||
color: #98a2b3;
|
|
||||||
font-size: 23px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.package-dimension-unit {
|
|
||||||
color: #667085;
|
|
||||||
line-height: 32px;
|
|
||||||
}
|
|
||||||
.editor-category-popup .ant-cascader-menu {
|
|
||||||
/* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */
|
|
||||||
height: 324px !important;
|
|
||||||
max-height: 324px;
|
|
||||||
}
|
|
||||||
.editor-category-popup .ant-cascader-menu-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.supply-link-control .ant-btn {
|
|
||||||
min-width: 40px;
|
|
||||||
}
|
|
||||||
.category-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 14px 16px;
|
|
||||||
border: 1px solid #eae7f0;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fcfbfd;
|
|
||||||
}
|
|
||||||
.category-row strong {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
.attribute-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
.attribute-item {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 11px 13px;
|
|
||||||
border: 1px solid #eeeaf3;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.attribute-item strong {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.category-select-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 4px 8px 6px;
|
|
||||||
border-top: 1px solid var(--ant-color-border-secondary, #f0f0f0);
|
|
||||||
}
|
|
||||||
.dynamic-attributes {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 22px;
|
|
||||||
border-top: 1px solid #eeeaf4;
|
|
||||||
}
|
|
||||||
.dynamic-attributes h5.ant-typography {
|
|
||||||
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 {
|
.editor-short-input.ant-input {
|
||||||
width: 170px;
|
width: 170px;
|
||||||
}
|
}
|
||||||
.dynamic-attributes-loading {
|
|
||||||
display: flex;
|
/* 「采集信息变化」弹窗内容(页面 modal 渲染) */
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 132px;
|
|
||||||
color: #7a7485;
|
|
||||||
}
|
|
||||||
.dynamic-attribute-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.dynamic-attribute-item .ant-space-compact {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
|
|
||||||
min-width: 56px;
|
|
||||||
}
|
|
||||||
.copy-subsection {
|
|
||||||
margin-top: 24px;
|
|
||||||
padding-top: 24px;
|
|
||||||
border-top: 1px solid #eeeaf4;
|
|
||||||
}
|
|
||||||
.copy-subsection .editor-section-heading {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.copy-store-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(240px, 1fr) 170px;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.sku-section .ant-input {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.sku-section textarea.ant-input {
|
|
||||||
resize: none;
|
|
||||||
}
|
|
||||||
.sku-section .ant-input-group-addon {
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
}
|
|
||||||
.sku-section .ant-input-group .ant-input {
|
|
||||||
border-radius: 8px 0 0 8px;
|
|
||||||
}
|
|
||||||
.sku-card-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.sku-card.ant-card {
|
|
||||||
border: 1px solid #e8e4ee;
|
|
||||||
border-radius: 12px;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
.sku-card .ant-card-body {
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
.sku-card-top {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
min-height: 28px;
|
|
||||||
}
|
|
||||||
.sku-card-index {
|
|
||||||
display: inline-flex;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #eee9fe;
|
|
||||||
color: #7151df;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.sku-card-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(210px, 1.55fr) 58px minmax(132px, 1fr) 104px 112px 82px auto;
|
|
||||||
gap: 14px;
|
|
||||||
align-items: start;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.sku-card-summary .ant-form-item {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.sku-card-field,
|
|
||||||
.sku-card-value {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sku-main-thumbnail {
|
|
||||||
display: flex;
|
|
||||||
width: 42px;
|
|
||||||
aspect-ratio: 3 / 4;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #ece8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f4f2f7;
|
|
||||||
color: #90889c;
|
|
||||||
font-size: 10px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.sku-main-thumbnail .ant-image,
|
|
||||||
.sku-main-thumbnail img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.sku-main-thumbnail img {
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.sku-image-field {
|
|
||||||
justify-self: center;
|
|
||||||
}
|
|
||||||
.sku-card-field > span,
|
|
||||||
.sku-card-value > span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.sku-card-value strong {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #40394f;
|
|
||||||
font-size: 14px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.sku-card-value strong {
|
|
||||||
min-height: 32px;
|
|
||||||
padding-top: 5px;
|
|
||||||
line-height: 22px;
|
|
||||||
}
|
|
||||||
.sku-expand-button {
|
|
||||||
align-self: start;
|
|
||||||
justify-self: end;
|
|
||||||
margin-top: 23px;
|
|
||||||
}
|
|
||||||
.sku-card-expanded {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr);
|
|
||||||
gap: 32px;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #f0edf3;
|
|
||||||
}
|
|
||||||
.sku-media-panel h5.ant-typography,
|
|
||||||
.sku-spec-panel h5.ant-typography {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
}
|
|
||||||
.sku-media-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.sku-media-secondary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.sku-media-item {
|
|
||||||
position: relative;
|
|
||||||
min-width: 0;
|
|
||||||
aspect-ratio: 3 / 4;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #ece8f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #f5f3f7;
|
|
||||||
}
|
|
||||||
.sku-media-item .ant-btn {
|
|
||||||
position: absolute;
|
|
||||||
right: 6px;
|
|
||||||
bottom: 6px;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.sku-media-item.selected {
|
|
||||||
border: 2px solid #7c3aed;
|
|
||||||
}
|
|
||||||
.sku-media-item .ant-image,
|
|
||||||
.sku-media-item img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.sku-media-item img {
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.sku-media-empty {
|
|
||||||
display: flex;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
min-height: 190px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px dashed #d9d4df;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #90889c;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.sku-spec-panel {
|
|
||||||
padding: 18px 18px 6px;
|
|
||||||
border: 1px solid #eeeaf3;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fcfbfd;
|
|
||||||
}
|
|
||||||
.sku-spec-panel .ant-form-item {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.sku-spec-panel .ant-form-item-label {
|
|
||||||
flex: 0 0 86px !important;
|
|
||||||
}
|
|
||||||
.sku-spec-panel .ant-form-item-label > label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.sku-spec-panel .ant-form-item-control {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper {
|
|
||||||
max-width: 220px;
|
|
||||||
}
|
|
||||||
.sku-inline-dimensions {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
|
|
||||||
gap: 6px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.sku-dimension-separator {
|
|
||||||
color: #98a2b3;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.sku-dimension-unit {
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 13px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
.add-sku-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
.add-sku-button {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
.editor-aside {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.collected-info-panel.ant-card {
|
|
||||||
border: 1px solid #e9e7ef;
|
|
||||||
border-radius: 14px;
|
|
||||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
|
||||||
}
|
|
||||||
.collected-info-panel .ant-card-body {
|
|
||||||
min-height: 820px;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
.collected-info-panel h4.ant-typography {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.collected-info-heading {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 17px;
|
|
||||||
}
|
|
||||||
.collected-info-heading .ant-typography-secondary {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.source-update-alert {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.source-update-alert .ant-alert-description {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.collected-field {
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.collected-field:first-of-type {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.collected-field > span {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.collected-field-label {
|
|
||||||
display: flex;
|
|
||||||
min-height: 24px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.collected-field-label .ant-btn {
|
|
||||||
height: 22px;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.source-diff-list {
|
.source-diff-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -531,79 +101,8 @@
|
|||||||
color: #667085;
|
color: #667085;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.collected-field > strong {
|
|
||||||
display: block;
|
/* 底部操作条 */
|
||||||
color: #352f43;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.collected-field .ant-typography {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
color: #50485d;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
.collected-summary {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid #ece9f1;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.collected-summary > div {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-right: 1px solid #f0edf3;
|
|
||||||
border-bottom: 1px solid #f0edf3;
|
|
||||||
}
|
|
||||||
.collected-summary > div:nth-child(2n) {
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
.collected-summary > div:nth-last-child(-n + 2) {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
.collected-summary b {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 3px;
|
|
||||||
color: #77718a;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.collected-summary strong {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
color: #352f43;
|
|
||||||
font-size: 13px;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.collected-attributes {
|
|
||||||
border: 1px solid #ece9f1;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.collected-attributes > div {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr);
|
|
||||||
gap: 12px;
|
|
||||||
padding: 9px 10px;
|
|
||||||
border-bottom: 1px solid #f0edf3;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
.collected-attributes > div:last-child {
|
|
||||||
border-bottom: 0;
|
|
||||||
}
|
|
||||||
.collected-attributes b {
|
|
||||||
color: #6b6376;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.collected-attributes span {
|
|
||||||
color: #352f43;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.other-section .editor-section-heading {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
.editor-footer {
|
.editor-footer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
@@ -623,23 +122,43 @@
|
|||||||
.footer-ai-actions {
|
.footer-ai-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
|
/* 窄屏:采集面板移到主编辑列下方 */
|
||||||
.editor-workspace {
|
.editor-workspace {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
.editor-aside {
|
}
|
||||||
position: static;
|
/*
|
||||||
|
* ≤700px:整张表单切换为纵向布局(标签在上、控件在下)。
|
||||||
|
* 更宽时保持"标签 | 控件"同行,随宽度缩小优先压缩控件长度
|
||||||
|
* (控件列 flex 已允许收缩,包装尺寸等输入组各自带 minmax 下限)。
|
||||||
|
* labelCol/wrapperCol 以内联样式注入 flex,故此处需要 !important 覆盖。
|
||||||
|
*/
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.product-editor .ant-form-item-row {
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 4px;
|
||||||
}
|
}
|
||||||
.collected-info-panel {
|
.product-editor .ant-form-item-row > .ant-form-item-label {
|
||||||
max-width: 100%;
|
flex: 0 0 auto !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
.collected-info-panel .ant-card-body {
|
.product-editor .ant-form-item-row > .ant-form-item-control {
|
||||||
min-height: 0;
|
flex: 1 1 auto !important;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* SKU 规格面板的固定标签宽在纵向布局下放开 */
|
||||||
|
.sku-spec-panel .ant-form-item-label {
|
||||||
|
flex: 0 0 auto !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.editor-header-main,
|
.editor-header-main,
|
||||||
.editor-section-heading,
|
|
||||||
.editor-footer {
|
.editor-footer {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -650,66 +169,14 @@
|
|||||||
.editor-form-grid .span-two {
|
.editor-form-grid .span-two {
|
||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
.package-fields > .package-weight-field,
|
|
||||||
.package-dimensions-controls {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.package-dimensions-controls {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.package-dimension-separator {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.copy-store-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.attribute-grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
.sku-card-summary {
|
|
||||||
grid-template-columns: minmax(150px, 1fr) 54px repeat(2, minmax(115px, 1fr)) auto;
|
|
||||||
}
|
|
||||||
.sku-card-value {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.sku-card-expanded {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.editor-section .ant-card-body {
|
.editor-section .ant-card-body {
|
||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
.category-row,
|
|
||||||
.editor-footer {
|
.editor-footer {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.category-row {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.attribute-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.sku-card-summary {
|
|
||||||
grid-template-columns: minmax(0, 1fr) 48px auto;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.sku-card-field {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.sku-main-thumbnail {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
.sku-media-grid {
|
|
||||||
grid-auto-rows: 72px;
|
|
||||||
}
|
|
||||||
.sku-inline-dimensions {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.sku-inline-dimensions > span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.editor-footer .ant-space {
|
.editor-footer .ant-space {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,185 @@
|
|||||||
|
/* 右侧「采集的商品信息」面板:编辑容器为页面工作区右列(.editor-aside)。 */
|
||||||
|
.editor-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.collected-info-panel.ant-card {
|
||||||
|
border: 1px solid #e9e7ef;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.025);
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-card-body {
|
||||||
|
min-height: 820px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.collected-info-panel h4.ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.collected-info-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 17px;
|
||||||
|
}
|
||||||
|
.collected-info-heading .ant-typography-secondary {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.source-update-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.source-update-alert .ant-alert-description {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 单个采集字段(标题 / 描述 / 基础数据 / 规格参数) */
|
||||||
|
.collected-field {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.collected-field:first-of-type {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.collected-field > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-field-label {
|
||||||
|
display: flex;
|
||||||
|
min-height: 24px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-field-label .ant-btn {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.collected-field > strong {
|
||||||
|
display: block;
|
||||||
|
color: #352f43;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.collected-field .ant-typography {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
color: #50485d;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 采集基础数据(两列网格摘要) */
|
||||||
|
.collected-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece9f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.collected-summary > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-right: 1px solid #f0edf3;
|
||||||
|
border-bottom: 1px solid #f0edf3;
|
||||||
|
}
|
||||||
|
.collected-summary > div:nth-child(2n) {
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
.collected-summary > div:nth-last-child(-n + 2) {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.collected-summary b {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.collected-summary strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #352f43;
|
||||||
|
font-size: 13px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 规格 / 参数列表 */
|
||||||
|
.collected-attributes {
|
||||||
|
border: 1px solid #ece9f1;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.collected-attributes > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(100px, 0.9fr) minmax(0, 1.3fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-bottom: 1px solid #f0edf3;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.collected-attributes > div:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.collected-attributes b {
|
||||||
|
color: #6b6376;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.collected-attributes span {
|
||||||
|
color: #352f43;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 采集图片:按 主图 / SKU 图 / 详情图 分组的折叠区域(缩略图点击可放大)。
|
||||||
|
标题与图片区左右内边距清零,与「采集图片」标签水平对齐。
|
||||||
|
antd v6 自带的 header/body 内边距优先级更高,这里需要提高选择器权重覆盖。 */
|
||||||
|
.collected-image-groups.ant-collapse .ant-collapse-item > .ant-collapse-header,
|
||||||
|
.collected-image-groups .ant-collapse-header {
|
||||||
|
padding: 0 !important;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* 展开箭头自带 4px 左边距,清零后箭头与「采集图片」标签左缘对齐 */
|
||||||
|
.collected-image-groups .ant-collapse-header > .ant-collapse-expand-icon {
|
||||||
|
margin-inline-start: 0 !important;
|
||||||
|
}
|
||||||
|
.collected-image-groups.ant-collapse .ant-collapse-item > .ant-collapse-panel > .ant-collapse-body,
|
||||||
|
.collected-image-groups .ant-collapse-body {
|
||||||
|
padding: 2px 0 10px;
|
||||||
|
}
|
||||||
|
.collected-image-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.collected-image-grid .ant-image {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece8f0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f5f3f7;
|
||||||
|
}
|
||||||
|
.collected-image-grid img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
/* 窄屏下面板退化为工作区下方整行,不再固定高度 */
|
||||||
|
.editor-aside {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
.collected-info-panel {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.collected-info-panel .ant-card-body {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Alert, Button, Card, Input, Space, Tag, Typography } from 'antd';
|
import { Alert, Button, Card, Collapse, Image, Input, Space, Tag, Typography } from 'antd';
|
||||||
import type {
|
import type {
|
||||||
|
CollectionItemInspection,
|
||||||
ProductAiSourceField,
|
ProductAiSourceField,
|
||||||
ProductAiSourceMaterial,
|
ProductAiSourceMaterial,
|
||||||
ProductEditorSnapshot,
|
ProductEditorSnapshot,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
|
import './CollectedProductInfoPanel.css';
|
||||||
|
|
||||||
export type CollectedProductSummary = {
|
export type CollectedProductSummary = {
|
||||||
price: string;
|
price: string;
|
||||||
@@ -17,17 +19,30 @@ export type CollectedProductSummary = {
|
|||||||
type Props = {
|
type Props = {
|
||||||
snapshot: ProductEditorSnapshot;
|
snapshot: ProductEditorSnapshot;
|
||||||
collected: ProductAiSourceMaterial | null;
|
collected: ProductAiSourceMaterial | null;
|
||||||
|
collectionRecord: CollectionItemInspection | null;
|
||||||
summary: CollectedProductSummary;
|
summary: CollectedProductSummary;
|
||||||
overriddenFields: ProductAiSourceField[];
|
overriddenFields: ProductAiSourceField[];
|
||||||
onRestore: (field: Extract<ProductAiSourceField, 'title' | 'description'>) => void;
|
onRestore: (field: Extract<ProductAiSourceField, 'title' | 'description'>) => void;
|
||||||
onUpdate: (field: Extract<ProductAiSourceField, 'title' | 'description'>, value: string) => void;
|
onUpdate: (
|
||||||
|
field: Extract<ProductAiSourceField, 'title' | 'description'>,
|
||||||
|
value: string
|
||||||
|
) => void;
|
||||||
onShowDifferences: () => void;
|
onShowDifferences: () => void;
|
||||||
onAcceptLatest: () => void;
|
onAcceptLatest: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 右侧「采集的商品信息」面板:展示并可编辑采集来源的标题与描述,
|
||||||
|
* 以及价格、尺寸、重量摘要、原始规格参数,和按 主图 / SKU 图 / 详情图
|
||||||
|
* 分组折叠展示的采集图片(只读,点击可放大预览)。
|
||||||
|
*
|
||||||
|
* 用户改过的字段记入 overriddenFields(显示"恢复来源值"入口);
|
||||||
|
* 编辑、恢复、采用最新等动作均通过回调交由页面处理。
|
||||||
|
*/
|
||||||
export function CollectedProductInfoPanel({
|
export function CollectedProductInfoPanel({
|
||||||
snapshot,
|
snapshot,
|
||||||
collected,
|
collected,
|
||||||
|
collectionRecord,
|
||||||
summary,
|
summary,
|
||||||
overriddenFields,
|
overriddenFields,
|
||||||
onRestore,
|
onRestore,
|
||||||
@@ -35,6 +50,27 @@ export function CollectedProductInfoPanel({
|
|||||||
onShowDifferences,
|
onShowDifferences,
|
||||||
onAcceptLatest,
|
onAcceptLatest,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
// 采集图片按角色分三组展示;同组内按 URL 去重(同一张图可能同时出现在
|
||||||
|
// 画廊与详情富文本里,转存记录会以不同角色各存一条)。
|
||||||
|
const imageGroups = (
|
||||||
|
[
|
||||||
|
{ key: 'main', label: '主图', role: 'source_main' },
|
||||||
|
{ key: 'sku', label: 'SKU 图', role: 'source_sku' },
|
||||||
|
{ key: 'detail', label: '详情图', role: 'source_detail' },
|
||||||
|
] as const
|
||||||
|
)
|
||||||
|
.map(({ key, label, role }) => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const items: CollectionItemInspection['asset_transfers'] = [];
|
||||||
|
for (const transfer of collectionRecord?.asset_transfers ?? []) {
|
||||||
|
if (transfer.media_type !== 'image' || transfer.role !== role) continue;
|
||||||
|
if (seen.has(transfer.source_url)) continue;
|
||||||
|
seen.add(transfer.source_url);
|
||||||
|
items.push(transfer);
|
||||||
|
}
|
||||||
|
return { key, label, items };
|
||||||
|
})
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
return (
|
return (
|
||||||
<aside className="editor-aside">
|
<aside className="editor-aside">
|
||||||
<Card className="collected-info-panel" bordered={false}>
|
<Card className="collected-info-panel" bordered={false}>
|
||||||
@@ -43,7 +79,9 @@ export function CollectedProductInfoPanel({
|
|||||||
<Typography.Title level={4}>采集的商品信息</Typography.Title>
|
<Typography.Title level={4}>采集的商品信息</Typography.Title>
|
||||||
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
|
<Typography.Text type="secondary">AI 输入素材 · 可编辑</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Tag>第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集</Tag>
|
<Tag>
|
||||||
|
第 {snapshot.source_material_state.latest.source_snapshot_revision} 次采集
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
{snapshot.source_material_state.has_source_updates && (
|
{snapshot.source_material_state.has_source_updates && (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -102,12 +140,30 @@ export function CollectedProductInfoPanel({
|
|||||||
<div className="collected-field">
|
<div className="collected-field">
|
||||||
<span>采集基础数据</span>
|
<span>采集基础数据</span>
|
||||||
<div className="collected-summary">
|
<div className="collected-summary">
|
||||||
<div><b>价格</b><strong>{summary.price}</strong></div>
|
<div>
|
||||||
<div><b>品牌</b><strong>{summary.brand}</strong></div>
|
<b>价格</b>
|
||||||
<div><b>商品尺寸</b><strong>{summary.physicalDimensions}</strong></div>
|
<strong>{summary.price}</strong>
|
||||||
<div><b>商品重量</b><strong>{summary.physicalWeight}</strong></div>
|
</div>
|
||||||
<div><b>包装尺寸</b><strong>{summary.packageDimensions}</strong></div>
|
<div>
|
||||||
<div><b>包装重量</b><strong>{summary.packageWeight}</strong></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>
|
</div>
|
||||||
<div className="collected-field">
|
<div className="collected-field">
|
||||||
@@ -116,12 +172,43 @@ export function CollectedProductInfoPanel({
|
|||||||
<div className="collected-attributes">
|
<div className="collected-attributes">
|
||||||
{collected.attributes.map((attribute) => (
|
{collected.attributes.map((attribute) => (
|
||||||
<div key={`${attribute.key}:${attribute.value}`}>
|
<div key={`${attribute.key}:${attribute.value}`}>
|
||||||
<b>{attribute.name}</b><span>{attribute.value}</span>
|
<b>{attribute.name}</b>
|
||||||
|
<span>{attribute.value}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text type="secondary">本次采集未取得规格或参数。</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
|
本次采集未取得规格或参数。
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="collected-field">
|
||||||
|
<span>采集图片</span>
|
||||||
|
{imageGroups.length ? (
|
||||||
|
<Collapse
|
||||||
|
className="collected-image-groups"
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
defaultActiveKey={['main']}
|
||||||
|
items={imageGroups.map(({ key, label, items }) => ({
|
||||||
|
key,
|
||||||
|
label: `${label}(${items.length})`,
|
||||||
|
children: (
|
||||||
|
<div className="collected-image-grid">
|
||||||
|
{items.map((item) => (
|
||||||
|
<Image
|
||||||
|
key={item.id}
|
||||||
|
src={item.source_url}
|
||||||
|
alt={label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">本次采集未取得图片。</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/* 「02 产品属性」区块:按所选类目动态渲染的平台属性表单("更多特征")。 */
|
||||||
|
.dynamic-attributes {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 22px;
|
||||||
|
border-top: 1px solid #eeeaf4;
|
||||||
|
}
|
||||||
|
.dynamic-attributes h5.ant-typography {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.dynamic-attribute-item .ant-space-compact {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.dynamic-attribute-item .ant-btn-dangerous.ant-btn-text {
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Alert, Button, Card, Empty, Form, Input, Select, Space, Spin, Typography } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import { EditOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
ProductAttributeDictionaryValue,
|
||||||
|
ProductAttributeSchema,
|
||||||
|
ProductEditorSnapshot,
|
||||||
|
SaveProductEditorDraftRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { SectionTitle } from './SectionTitle';
|
||||||
|
import './ProductAttributeSection.css';
|
||||||
|
|
||||||
|
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||||
|
type AttributeDraft = NonNullable<EditorFormValues['attributes']>[number];
|
||||||
|
type NamePath = Array<string | number>;
|
||||||
|
|
||||||
|
/** 把属性值(字符串/数组/对象)格式化为可读文本,用于旧类目属性提示。 */
|
||||||
|
function sourceValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value || '—';
|
||||||
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map(sourceValue).join('、') || '—';
|
||||||
|
return value ? JSON.stringify(value) : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: FormInstance<EditorFormValues>;
|
||||||
|
snapshot: ProductEditorSnapshot;
|
||||||
|
selectedCategoryId: string | null | undefined;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
activeAttributeSchema: ProductAttributeSchema[];
|
||||||
|
attributeSchemaLoading: boolean;
|
||||||
|
attributeSchemaError: string | null;
|
||||||
|
catalogSyncing: string | null;
|
||||||
|
dictionaryValues: Record<string, ProductAttributeDictionaryValue[]>;
|
||||||
|
onLoadDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||||
|
onSyncDictionaryValues: (dictionaryId: string, query: string) => void;
|
||||||
|
onSyncAttributeSchema: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「02 产品属性」区块:主题标签、产品描述、条形码、JSON 富内容,
|
||||||
|
* 以及按所选类目动态渲染的平台属性表单("更多特征")。
|
||||||
|
*
|
||||||
|
* schema、字典值与同步状态由页面拉取后经 props 传入;本组件只负责
|
||||||
|
* 渲染与交互,字典搜索、Ozon 同步等动作通过回调交回页面处理。
|
||||||
|
*/
|
||||||
|
export function ProductAttributeSection({
|
||||||
|
form,
|
||||||
|
snapshot,
|
||||||
|
selectedCategoryId,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
activeAttributeSchema,
|
||||||
|
attributeSchemaLoading,
|
||||||
|
attributeSchemaError,
|
||||||
|
catalogSyncing,
|
||||||
|
dictionaryValues,
|
||||||
|
onLoadDictionaryValues,
|
||||||
|
onSyncDictionaryValues,
|
||||||
|
onSyncAttributeSchema,
|
||||||
|
}: Props) {
|
||||||
|
// 富内容本地草稿:富内容能力接入前不写入表单、不随草稿保存。
|
||||||
|
const [richContent, setRichContent] = useState('');
|
||||||
|
// 各字典下拉框的当前搜索词(仅本地状态);切换类目时清空,避免残留到新类目。
|
||||||
|
const [dictionarySearches, setDictionarySearches] = useState<Record<string, string>>({});
|
||||||
|
useEffect(() => {
|
||||||
|
setDictionarySearches({});
|
||||||
|
}, [selectedCategoryId]);
|
||||||
|
return (
|
||||||
|
<Card className="editor-section" bordered={false}>
|
||||||
|
<SectionTitle
|
||||||
|
number="02"
|
||||||
|
title="产品属性"
|
||||||
|
description="用于当前平台发布与 AI 文案生成。"
|
||||||
|
/>
|
||||||
|
<Form.Item name={['profile', 'tags']} label="主题标签">
|
||||||
|
<Select
|
||||||
|
className="editor-standard-select"
|
||||||
|
mode="tags"
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
placeholder="输入后回车添加标签"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['profile', 'description']} label="产品描述" rules={[{ max: 100000 }]}>
|
||||||
|
<Input.TextArea rows={6} placeholder="商品卖点、规格与使用场景" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={['common', 'barcode']} label="条形码">
|
||||||
|
<Input className="editor-short-input" placeholder="EAN / UPC / GTIN(选填)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="JSON 富内容">
|
||||||
|
<Input.TextArea
|
||||||
|
value={richContent}
|
||||||
|
rows={7}
|
||||||
|
placeholder="JSON 富内容将在富内容能力接入后保存与发布"
|
||||||
|
onChange={(event) => setRichContent(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<div id="dynamic-attributes" className="dynamic-attributes">
|
||||||
|
<div className="dynamic-attributes-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>更多特征</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
根据所选类目填写 Ozon 平台属性
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
onToggleExpanded();
|
||||||
|
window.requestAnimationFrame(() =>
|
||||||
|
document.getElementById('dynamic-attributes')?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{expanded ? '收起更多特征' : '展开更多特征'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{expanded &&
|
||||||
|
(!selectedCategoryId ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="请选择产品类目后填写平台属性"
|
||||||
|
/>
|
||||||
|
) : attributeSchemaLoading ? (
|
||||||
|
<div className="dynamic-attributes-loading">
|
||||||
|
<Spin size="small" />
|
||||||
|
<span>正在读取类目属性…</span>
|
||||||
|
</div>
|
||||||
|
) : !activeAttributeSchema.length ? (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
<span>
|
||||||
|
{attributeSchemaError ?? '该类目的属性 schema 尚未同步'}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
loading={
|
||||||
|
catalogSyncing === `attributes:${selectedCategoryId}`
|
||||||
|
}
|
||||||
|
onClick={onSyncAttributeSchema}
|
||||||
|
>
|
||||||
|
同步此类目属性
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form.List name="attributes">
|
||||||
|
{(fields) => (
|
||||||
|
<div className="dynamic-attribute-list">
|
||||||
|
{activeAttributeSchema.flatMap((definition) => {
|
||||||
|
// aspect(变体维度)属性按每个 SKU 展开一行,其余类目级一行。
|
||||||
|
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;
|
||||||
|
// 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;
|
||||||
|
// 字典属性:选项来自页面预取的字典缓存;搜索时经
|
||||||
|
// onLoadDictionaryValues 远程过滤,无结果时可通过
|
||||||
|
// notFoundContent 的按钮发起 Ozon 同步。
|
||||||
|
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' ? (
|
||||||
|
<Form.Item
|
||||||
|
name={
|
||||||
|
definition.is_collection
|
||||||
|
? valueName
|
||||||
|
: scalarValueName
|
||||||
|
}
|
||||||
|
rules={valueRules}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode={
|
||||||
|
definition.is_collection
|
||||||
|
? 'multiple'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
options={dictionaryOptions}
|
||||||
|
showSearch
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={(query) => {
|
||||||
|
if (!definition.dictionary_id)
|
||||||
|
return;
|
||||||
|
setDictionarySearches(
|
||||||
|
(current) => ({
|
||||||
|
...current,
|
||||||
|
[definition.dictionary_id!]:
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
onLoadDictionaryValues(
|
||||||
|
definition.dictionary_id,
|
||||||
|
query
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
notFoundContent={
|
||||||
|
dictionaryQuery.trim().length >=
|
||||||
|
2 ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
loading={
|
||||||
|
catalogSyncing ===
|
||||||
|
`dictionary:${definition.dictionary_id}:${dictionaryQuery.trim()}`
|
||||||
|
}
|
||||||
|
onMouseDown={(event) =>
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
definition.dictionary_id &&
|
||||||
|
onSyncDictionaryValues(
|
||||||
|
definition.dictionary_id,
|
||||||
|
dictionaryQuery.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
从 Ozon 搜索“
|
||||||
|
{dictionaryQuery.trim()}”
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
'输入至少 2 个字符后可从 Ozon 搜索'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder={`选择${definition.name}`}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : definition.value_type === 'boolean' ? (
|
||||||
|
<Form.Item
|
||||||
|
name={scalarValueName}
|
||||||
|
rules={valueRules}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
value: 'true',
|
||||||
|
label: '是',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'false',
|
||||||
|
label: '否',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder={`选择${definition.name}`}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : definition.is_collection ? (
|
||||||
|
<Form.Item
|
||||||
|
name={valueName}
|
||||||
|
rules={valueRules}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="tags"
|
||||||
|
tokenSeparators={[',']}
|
||||||
|
placeholder="输入后回车添加多个值"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item
|
||||||
|
name={scalarValueName}
|
||||||
|
rules={valueRules}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
inputMode={
|
||||||
|
definition.value_type ===
|
||||||
|
'integer' ||
|
||||||
|
definition.value_type === 'decimal'
|
||||||
|
? 'decimal'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
placeholder={`填写${definition.name}`}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={field.key}
|
||||||
|
label={label}
|
||||||
|
required={definition.is_required}
|
||||||
|
className="dynamic-attribute-item"
|
||||||
|
>
|
||||||
|
<Space.Compact block>
|
||||||
|
{input}
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="text"
|
||||||
|
onClick={() =>
|
||||||
|
form.setFieldValue(
|
||||||
|
[
|
||||||
|
'attributes',
|
||||||
|
field.name,
|
||||||
|
'values',
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 切换类目后无法映射到新 schema 的旧属性:仅提示,不参与保存 */}
|
||||||
|
{snapshot.unmapped_attributes.length > 0 && (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
message={`有 ${snapshot.unmapped_attributes.length} 项旧类目属性待处理`}
|
||||||
|
description={snapshot.unmapped_attributes
|
||||||
|
.map(
|
||||||
|
(attribute) =>
|
||||||
|
`${attribute.original_name}:${sourceValue(attribute.values)}`
|
||||||
|
)
|
||||||
|
.join(';')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/* 「01 主要信息」区块:默认包装规格与类目下拉弹层。 */
|
||||||
|
|
||||||
|
/* 默认包装规格(区块底部的重量 + 长宽高输入组) */
|
||||||
|
.package-fields {
|
||||||
|
margin-top: 22px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #f0eef4;
|
||||||
|
}
|
||||||
|
.package-dimensions {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
/* 长×宽×高输入组:随容器宽度收缩(压缩到下限后再由窄屏断点改为纵向堆叠) */
|
||||||
|
.package-dimensions-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(64px, 130px) 22px minmax(64px, 130px) 22px minmax(
|
||||||
|
64px,
|
||||||
|
130px
|
||||||
|
) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.package-dimension-separator {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 23px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.package-dimension-unit {
|
||||||
|
color: #667085;
|
||||||
|
line-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 类目下拉弹层(Cascader popup,经 classNames.root 挂载) */
|
||||||
|
.editor-category-popup .ant-cascader-menu {
|
||||||
|
/* Ant Design 默认固定为 180px;提高 80% 后每列实际可见更多类目。 */
|
||||||
|
height: 324px !important;
|
||||||
|
max-height: 324px;
|
||||||
|
}
|
||||||
|
.editor-category-popup .ant-cascader-menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.package-dimensions-controls {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.package-dimension-separator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Card, Cascader, Form, Input, Select, Typography } from 'antd';
|
import { Card, Cascader, Form, Input, Select } from 'antd';
|
||||||
import type { FormInstance } from 'antd';
|
import type { FormInstance } from 'antd';
|
||||||
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
import type { ProductEditorSnapshot } from '@atlas/contracts';
|
||||||
|
import { SectionTitle } from './SectionTitle';
|
||||||
|
import './ProductMainInfoSection.css';
|
||||||
|
|
||||||
type NamePath = Array<string | number>;
|
type NamePath = Array<string | number>;
|
||||||
type CategoryOption = {
|
type CategoryOption = {
|
||||||
@@ -10,21 +12,6 @@ type CategoryOption = {
|
|||||||
children?: CategoryOption[];
|
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 }) {
|
function PackageFields({ name }: { name: NamePath }) {
|
||||||
return (
|
return (
|
||||||
<div className="package-fields">
|
<div className="package-fields">
|
||||||
@@ -65,6 +52,12 @@ type Props = {
|
|||||||
onCategorySearch: (query: string) => void;
|
onCategorySearch: (query: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「01 主要信息」区块:上架店铺、产品类目、标题/型号/品牌与默认包装规格。
|
||||||
|
*
|
||||||
|
* 切换店铺会清空类目与属性草稿;切换类目会清空属性草稿并记录类目来源,
|
||||||
|
* 避免跨类目残留脏数据。
|
||||||
|
*/
|
||||||
export function ProductMainInfoSection({
|
export function ProductMainInfoSection({
|
||||||
form,
|
form,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -76,7 +69,11 @@ export function ProductMainInfoSection({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className="editor-section" bordered={false}>
|
<Card className="editor-section" bordered={false}>
|
||||||
<SectionTitle />
|
<SectionTitle
|
||||||
|
number="01"
|
||||||
|
title="主要信息"
|
||||||
|
description="上架所需的核心资料。包装信息用于定价、物流与发布。"
|
||||||
|
/>
|
||||||
<div className="editor-form-grid">
|
<div className="editor-form-grid">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['profile', 'target_store_id']}
|
name={['profile', 'target_store_id']}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ type Props = {
|
|||||||
onOpenSupplyUrl: () => void;
|
onOpenSupplyUrl: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 「04 其它」区块:货源链接与备注;已上架 SKU 仅保存 Atlas 草稿,不回写平台商品。 */
|
||||||
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
|
export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupplyUrl }: Props) {
|
||||||
return (
|
return (
|
||||||
<Card className="editor-section other-section" bordered={false}>
|
<Card className="editor-section" bordered={false}>
|
||||||
<div className="editor-section-heading">
|
<div className="editor-section-heading" style={{ marginBottom: 8 }}>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>
|
<Typography.Title level={4}>
|
||||||
<span className="editor-section-number">04</span>其它
|
<span className="editor-section-number">04</span>其它
|
||||||
@@ -20,7 +21,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
|||||||
</div>
|
</div>
|
||||||
<div className="editor-form-grid">
|
<div className="editor-form-grid">
|
||||||
<Form.Item label="货源链接" className="span-two">
|
<Form.Item label="货源链接" className="span-two">
|
||||||
<Space.Compact block className="supply-link-control">
|
<Space.Compact block>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={['common', 'purchase_url']}
|
name={['common', 'purchase_url']}
|
||||||
noStyle
|
noStyle
|
||||||
@@ -30,7 +31,7 @@ export function ProductOtherSection({ canOpenSupplyUrl, platformName, onOpenSupp
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Tooltip title="打开货源链接">
|
<Tooltip title="打开货源链接">
|
||||||
<Button
|
<Button
|
||||||
className="supply-open-button"
|
style={{ minWidth: 40 }}
|
||||||
icon={<ExportOutlined />}
|
icon={<ExportOutlined />}
|
||||||
disabled={!canOpenSupplyUrl}
|
disabled={!canOpenSupplyUrl}
|
||||||
onClick={onOpenSupplyUrl}
|
onClick={onOpenSupplyUrl}
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/* 「03 变体设置」区块:SKU 卡片、展开后的图片选择与规格面板。 */
|
||||||
|
|
||||||
|
/* SKU 卡片内的输入圆角与 antd 输入框保持一致 */
|
||||||
|
.sku-section .ant-input {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.sku-section textarea.ant-input {
|
||||||
|
resize: none;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input-group-addon {
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
.sku-section .ant-input-group .ant-input {
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片列表与卡片骨架 */
|
||||||
|
.sku-card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sku-card.ant-card {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid #e8e4ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
/* 用 .sku-card.ant-card 提高权重:内层卡片同时命中外层
|
||||||
|
.editor-section .ant-card-body(页面级)规则,同级特异性会按加载顺序被覆盖 */
|
||||||
|
.sku-card.ant-card > .ant-card-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
/* 卡片主体:左侧主图缩略图(贴边、跨"名称 + 货号"两行),右侧内容列 */
|
||||||
|
.sku-card-main {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.sku-card-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
/* 顶部留出序号悬浮徽章的下半部分 */
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.sku-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
/* 序号:悬浮在卡片左上角,垂直方向一半露出卡片外 */
|
||||||
|
.sku-card-index {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: -10px;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #eee9fe;
|
||||||
|
color: #7151df;
|
||||||
|
font-weight: 700;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
box-shadow: 0 0 0 2px #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 摘要行:名称 / 主图 / 货号 / 采购价 / 销售价 / 净利润 / 展开按钮。
|
||||||
|
随宽度变化先压缩各字段的输入宽度,放不下时整行自动换行(不做断点隐藏)。 */
|
||||||
|
.sku-card-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.sku-card-summary .ant-form-item {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sku-card-summary .ant-input,
|
||||||
|
.sku-card-summary textarea.ant-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.sku-card-field,
|
||||||
|
.sku-card-value {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* SKU 名称:无标签,直接内联在序号右侧,占据顶行剩余宽度 */
|
||||||
|
.sku-card-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.sku-card-field {
|
||||||
|
flex: 1 1 120px;
|
||||||
|
}
|
||||||
|
.sku-price-field {
|
||||||
|
flex: 1 1 130px;
|
||||||
|
}
|
||||||
|
.sku-card-field > span,
|
||||||
|
.sku-card-value > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-self: flex-start;
|
||||||
|
width: 75px;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f4f2f7;
|
||||||
|
color: #90889c;
|
||||||
|
font-size: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail .ant-image,
|
||||||
|
.sku-main-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.sku-main-thumbnail img {
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.sku-card-value {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.sku-card-value strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #40394f;
|
||||||
|
font-size: 14px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sku-card-value strong {
|
||||||
|
min-height: 32px;
|
||||||
|
padding-top: 5px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
.sku-expand-button {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 23px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开区:左侧采集图片、右侧规格面板 */
|
||||||
|
.sku-card-expanded {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(390px, 0.92fr);
|
||||||
|
gap: 32px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0edf3;
|
||||||
|
}
|
||||||
|
.sku-media-panel h5.ant-typography,
|
||||||
|
.sku-spec-panel h5.ant-typography {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.sku-media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.sku-media-item {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #ece8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f5f3f7;
|
||||||
|
}
|
||||||
|
.sku-media-item .ant-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.sku-media-item.selected {
|
||||||
|
border: 2px solid #7c3aed;
|
||||||
|
}
|
||||||
|
.sku-media-item .ant-image,
|
||||||
|
.sku-media-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.sku-media-item img {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.sku-media-empty {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
min-height: 190px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px dashed #d9d4df;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #90889c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.sku-spec-panel {
|
||||||
|
padding: 18px 18px 6px;
|
||||||
|
border: 1px solid #eeeaf3;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fcfbfd;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-label {
|
||||||
|
flex: 0 0 86px !important;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-label > label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sku-spec-panel .ant-form-item-control {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
/* 独立的重量输入(非"长×宽×高"行内组)限制宽度 */
|
||||||
|
.sku-spec-panel > .ant-form-item:not(:has(.sku-inline-dimensions)) .ant-input-group-wrapper {
|
||||||
|
max-width: 220px;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(54px, 1fr) 16px minmax(54px, 1fr) 16px minmax(54px, 1fr) 28px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sku-dimension-separator {
|
||||||
|
color: #98a2b3;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sku-dimension-unit {
|
||||||
|
color: #77718a;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.add-sku-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.add-sku-button {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.sku-card-expanded {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.sku-media-grid {
|
||||||
|
grid-auto-rows: 72px;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sku-inline-dimensions > span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,552 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Card, Form, Image, Input, Popconfirm, Space, Tag, Typography } from 'antd';
|
||||||
|
import type { FormInstance } from 'antd';
|
||||||
|
import { CopyOutlined, DeleteOutlined, DownOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
CollectionItemInspection,
|
||||||
|
ProductEditorSnapshot,
|
||||||
|
SaveProductEditorDraftRequest,
|
||||||
|
} from '@atlas/contracts';
|
||||||
|
import { SectionTitle } from './SectionTitle';
|
||||||
|
import './ProductVariantSection.css';
|
||||||
|
|
||||||
|
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||||
|
type VariantValues = EditorFormValues['variants'][number];
|
||||||
|
type SnapshotVariant = ProductEditorSnapshot['variants'][number];
|
||||||
|
type NamePath = Array<string | number>;
|
||||||
|
|
||||||
|
/** SKU 上架状态 → 标签文案与颜色;未知状态原样展示。 */
|
||||||
|
function statusTag(status: string) {
|
||||||
|
const label =
|
||||||
|
(
|
||||||
|
{
|
||||||
|
not_listed: '未上架',
|
||||||
|
submitting: '处理中',
|
||||||
|
imported: '已上架',
|
||||||
|
failed: '上架失败',
|
||||||
|
} as Record<string, string>
|
||||||
|
)[status] ?? status;
|
||||||
|
const color = (
|
||||||
|
{ imported: 'success', failed: 'error', submitting: 'processing' } as Record<string, string>
|
||||||
|
)[status];
|
||||||
|
return <Tag color={color}>{label}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从采集证据中筛选该 SKU 可选的图片候选:未标注来源变体的图片视为
|
||||||
|
* 公共图(所有 SKU 可见),其余需 source_variant_key 匹配;按 id 去重。
|
||||||
|
*/
|
||||||
|
function variantImageCandidates(
|
||||||
|
recordValue: CollectionItemInspection | null,
|
||||||
|
variant: SnapshotVariant
|
||||||
|
) {
|
||||||
|
if (!recordValue) return [];
|
||||||
|
const sourceVariants = new Map(recordValue.variants.map((item) => [item.id, item]));
|
||||||
|
return recordValue.asset_transfers
|
||||||
|
.filter((transfer) => {
|
||||||
|
if (transfer.media_type !== 'image') return false;
|
||||||
|
if (!transfer.source_variant_id) return true;
|
||||||
|
return (
|
||||||
|
sourceVariants.get(transfer.source_variant_id)?.source_variant_key ===
|
||||||
|
variant.source_variant_key
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(transfer, index, transfers) =>
|
||||||
|
transfers.findIndex((item) => item.id === transfer.id) === index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 长×宽×高行内输入组(单位 mm)。 */
|
||||||
|
function InlineDimensions({ name }: { name: NamePath }) {
|
||||||
|
return (
|
||||||
|
<div className="sku-inline-dimensions">
|
||||||
|
<Form.Item name={[...name, 'length_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="长" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="sku-dimension-separator">×</span>
|
||||||
|
<Form.Item name={[...name, 'width_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="宽" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="sku-dimension-separator">×</span>
|
||||||
|
<Form.Item name={[...name, 'height_mm']} noStyle>
|
||||||
|
<Input type="number" min="0" step="any" placeholder="高" />
|
||||||
|
</Form.Item>
|
||||||
|
<span className="sku-dimension-unit">mm</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
form: FormInstance<EditorFormValues>;
|
||||||
|
snapshot: ProductEditorSnapshot;
|
||||||
|
collectionRecord: CollectionItemInspection | null;
|
||||||
|
materializingTransferId: string | null;
|
||||||
|
onMaterialize: (transferId: string) => void;
|
||||||
|
onDirty: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「03 变体设置」区块:SKU 卡片(名称/主图/货号/价格摘要)与展开后的
|
||||||
|
* 采集图片选择、包装与商品规格、规格轴编辑。
|
||||||
|
*
|
||||||
|
* 采集证据(collectionRecord)与素材转存由页面处理;本组件维护卡片
|
||||||
|
* 展开状态与图片选择交互。
|
||||||
|
*/
|
||||||
|
export function ProductVariantSection({
|
||||||
|
form,
|
||||||
|
snapshot,
|
||||||
|
collectionRecord,
|
||||||
|
materializingTransferId,
|
||||||
|
onMaterialize,
|
||||||
|
onDirty,
|
||||||
|
}: Props) {
|
||||||
|
// 实时监听变体草稿:驱动主图回显与候选图片的选中态。
|
||||||
|
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||||
|
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||||
|
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
||||||
|
return (
|
||||||
|
<Card className="editor-section sku-section" bordered={false}>
|
||||||
|
<SectionTitle
|
||||||
|
number="03"
|
||||||
|
title="变体设置"
|
||||||
|
description="卡片用于快速核对与填写;展开后可维护图片、规格轴和该 SKU 的覆盖规格。"
|
||||||
|
extra={<Tag>{snapshot.variants.length} 个 SKU</Tag>}
|
||||||
|
/>
|
||||||
|
<Form.List name="variants">
|
||||||
|
{(fields, { add, remove }) => {
|
||||||
|
// 新建变体默认值:id 为 null 表示服务端尚不存在,
|
||||||
|
// client_key 作为保存前的临时标识。
|
||||||
|
const addVariant = () =>
|
||||||
|
add({
|
||||||
|
id: null,
|
||||||
|
client_key: crypto.randomUUID(),
|
||||||
|
copy_from_variant_id: null,
|
||||||
|
display_name: null,
|
||||||
|
source_sku: null,
|
||||||
|
specification_values: [],
|
||||||
|
purchase_cost_cny: null,
|
||||||
|
physical_spec: {
|
||||||
|
length_mm: null,
|
||||||
|
width_mm: null,
|
||||||
|
height_mm: null,
|
||||||
|
net_weight_g: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
package_spec: {
|
||||||
|
length_mm: null,
|
||||||
|
width_mm: null,
|
||||||
|
height_mm: null,
|
||||||
|
shipping_weight_g: null,
|
||||||
|
status: null,
|
||||||
|
},
|
||||||
|
offer_id: null,
|
||||||
|
media_asset_ids: [],
|
||||||
|
} satisfies VariantValues);
|
||||||
|
return (
|
||||||
|
<div className="sku-card-list">
|
||||||
|
{fields.map((field, index) => {
|
||||||
|
const sourceId = form.getFieldValue([
|
||||||
|
'variants',
|
||||||
|
field.name,
|
||||||
|
'id',
|
||||||
|
]) as string | null;
|
||||||
|
const original = snapshot.variants.find(
|
||||||
|
(variant) => variant.id === sourceId
|
||||||
|
);
|
||||||
|
const variantValue = form.getFieldValue([
|
||||||
|
'variants',
|
||||||
|
field.name,
|
||||||
|
]) as VariantValues;
|
||||||
|
const expanded = expandedVariantKeys.has(field.key);
|
||||||
|
const imageCandidates = original
|
||||||
|
? variantImageCandidates(collectionRecord, original)
|
||||||
|
: [];
|
||||||
|
const selectedAssetIds =
|
||||||
|
draftVariants[field.name]?.media_asset_ids ?? [];
|
||||||
|
const selectedImages = selectedAssetIds
|
||||||
|
.map(
|
||||||
|
(assetId) =>
|
||||||
|
imageCandidates.find(
|
||||||
|
(candidate) => candidate.asset_id === assetId
|
||||||
|
)?.source_url ??
|
||||||
|
original?.media.find(
|
||||||
|
(media) => media.asset_id === assetId
|
||||||
|
)?.source_url
|
||||||
|
)
|
||||||
|
.filter((url): url is string => !!url);
|
||||||
|
// 选中/取消候选图片;setFieldValue 不触发 Form 的
|
||||||
|
// onValuesChange,因此需通过 onDirty 手动标记未保存。
|
||||||
|
const toggleImage = (assetId: string) => {
|
||||||
|
const next = selectedAssetIds.includes(assetId)
|
||||||
|
? selectedAssetIds.filter((id) => id !== assetId)
|
||||||
|
: [...selectedAssetIds, assetId];
|
||||||
|
form.setFieldValue(
|
||||||
|
['variants', field.name, 'media_asset_ids'],
|
||||||
|
next
|
||||||
|
);
|
||||||
|
onDirty();
|
||||||
|
};
|
||||||
|
const copyVariant = () =>
|
||||||
|
add({
|
||||||
|
...variantValue,
|
||||||
|
id: null,
|
||||||
|
client_key: crypto.randomUUID(),
|
||||||
|
copy_from_variant_id: original?.id ?? null,
|
||||||
|
display_name: variantValue.display_name
|
||||||
|
? `${variantValue.display_name} 副本`
|
||||||
|
: 'SKU 副本',
|
||||||
|
offer_id: null,
|
||||||
|
media_asset_ids: [],
|
||||||
|
});
|
||||||
|
const toggle = () =>
|
||||||
|
setExpandedVariantKeys((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(field.key)) next.delete(field.key);
|
||||||
|
else next.add(field.key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Card className="sku-card" key={field.key} bordered={false}>
|
||||||
|
<Form.Item name={[field.name, 'id']} hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'client_key']} hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'copy_from_variant_id']}
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name={[field.name, 'media_asset_ids']} hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<div className="sku-card-main">
|
||||||
|
<div className="sku-main-thumbnail">
|
||||||
|
{selectedImages[0] ? (
|
||||||
|
<Image
|
||||||
|
preview={false}
|
||||||
|
src={selectedImages[0]}
|
||||||
|
alt="SKU 主图"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>暂无主图</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sku-card-content">
|
||||||
|
<div className="sku-card-top">
|
||||||
|
<span className="sku-card-index">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'display_name']}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
className="sku-card-name"
|
||||||
|
autoSize={{ minRows: 1, maxRows: 2 }}
|
||||||
|
placeholder="SKU 名称"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Space size={4}>
|
||||||
|
{original &&
|
||||||
|
statusTag(original.listing.status)}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={copyVariant}
|
||||||
|
>
|
||||||
|
复制 SKU
|
||||||
|
</Button>
|
||||||
|
{fields.length > 1 && (
|
||||||
|
<Popconfirm
|
||||||
|
title="删除 SKU?"
|
||||||
|
description={
|
||||||
|
original?.listing.status ===
|
||||||
|
'imported'
|
||||||
|
? '只会移除 Atlas 草稿与后续可发布集合,不会删除平台商品。'
|
||||||
|
: '删除后该 SKU 不再出现在当前商品结构中。'
|
||||||
|
}
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => remove(field.name)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div className="sku-card-summary">
|
||||||
|
<div className="sku-card-field">
|
||||||
|
<span>货号</span>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'offer_id']}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input placeholder="首次上架前填写" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="sku-card-field sku-price-field">
|
||||||
|
<span>采购价(CNY)</span>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'purchase_cost_cny']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
pattern: /^\d+(\.\d{1,4})?$/,
|
||||||
|
message: '请输入金额',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="—" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div className="sku-card-field sku-price-field">
|
||||||
|
<span>
|
||||||
|
销售价(
|
||||||
|
{original?.listing.currency_code ?? '₽'}
|
||||||
|
)
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
original?.listing.selling_price ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
placeholder="—"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sku-card-value">
|
||||||
|
<span>净利润</span>
|
||||||
|
<strong>—</strong>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="sku-expand-button"
|
||||||
|
type="text"
|
||||||
|
icon={
|
||||||
|
<DownOutlined
|
||||||
|
rotate={expanded ? 180 : 0}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={toggle}
|
||||||
|
>
|
||||||
|
{expanded ? '收起' : '展开'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div className="sku-card-expanded">
|
||||||
|
<div className="sku-media-panel">
|
||||||
|
<Typography.Title level={5}>
|
||||||
|
SKU 图片
|
||||||
|
</Typography.Title>
|
||||||
|
{imageCandidates.length ? (
|
||||||
|
<div className="sku-media-grid">
|
||||||
|
{imageCandidates.map((candidate) => {
|
||||||
|
// 仅"转存成功"的候选可直接勾选;
|
||||||
|
// 其余按转存状态展示对应按钮。
|
||||||
|
const selectable =
|
||||||
|
candidate.status ===
|
||||||
|
'succeeded' &&
|
||||||
|
!!candidate.asset_id;
|
||||||
|
const selected =
|
||||||
|
selectable &&
|
||||||
|
selectedAssetIds.includes(
|
||||||
|
candidate.asset_id!
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`sku-media-item ${selected ? 'selected' : ''}`}
|
||||||
|
key={candidate.id}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
candidate.source_url
|
||||||
|
}
|
||||||
|
alt="采集候选图片"
|
||||||
|
/>
|
||||||
|
{selectable ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={
|
||||||
|
selected
|
||||||
|
? 'primary'
|
||||||
|
: 'default'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
toggleImage(
|
||||||
|
candidate.asset_id!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selected
|
||||||
|
? '已选择'
|
||||||
|
: '选择'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
loading={
|
||||||
|
materializingTransferId ===
|
||||||
|
candidate.id
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
candidate.status ===
|
||||||
|
'queued' ||
|
||||||
|
candidate.status ===
|
||||||
|
'running' ||
|
||||||
|
candidate.status ===
|
||||||
|
'failed'
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
void onMaterialize(
|
||||||
|
candidate.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{candidate.status ===
|
||||||
|
'pending_selection'
|
||||||
|
? '转存后选择'
|
||||||
|
: candidate.status ===
|
||||||
|
'failed'
|
||||||
|
? '转存失败'
|
||||||
|
: '转存中'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="sku-media-empty">
|
||||||
|
暂无采集图片
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="sku-spec-panel">
|
||||||
|
<Typography.Title level={5}>
|
||||||
|
SKU 规格
|
||||||
|
</Typography.Title>
|
||||||
|
<Form.Item
|
||||||
|
name={[
|
||||||
|
field.name,
|
||||||
|
'package_spec',
|
||||||
|
'shipping_weight_g',
|
||||||
|
]}
|
||||||
|
label="包装重量"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
addonAfter="g"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="包装尺寸">
|
||||||
|
<InlineDimensions
|
||||||
|
name={[field.name, 'package_spec']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={[
|
||||||
|
field.name,
|
||||||
|
'physical_spec',
|
||||||
|
'net_weight_g',
|
||||||
|
]}
|
||||||
|
label="商品重量"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
addonAfter="g"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="商品尺寸">
|
||||||
|
<InlineDimensions
|
||||||
|
name={[field.name, 'physical_spec']}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{/* 规格轴来自采集结果:颜色轴展示为"商品颜色" */}
|
||||||
|
<Form.List
|
||||||
|
name={[field.name, 'specification_values']}
|
||||||
|
>
|
||||||
|
{(specFields) =>
|
||||||
|
specFields.length ? (
|
||||||
|
<>
|
||||||
|
{specFields.map((specField) => {
|
||||||
|
const axisKey =
|
||||||
|
form.getFieldValue([
|
||||||
|
'variants',
|
||||||
|
field.name,
|
||||||
|
'specification_values',
|
||||||
|
specField.name,
|
||||||
|
'axis_key',
|
||||||
|
]) as
|
||||||
|
string | undefined;
|
||||||
|
const label =
|
||||||
|
/color|颜色/i.test(
|
||||||
|
axisKey ?? ''
|
||||||
|
)
|
||||||
|
? '商品颜色'
|
||||||
|
: axisKey ||
|
||||||
|
'商品规格';
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={specField.key}
|
||||||
|
name={[
|
||||||
|
specField.name,
|
||||||
|
'value_name',
|
||||||
|
]}
|
||||||
|
label={label}
|
||||||
|
>
|
||||||
|
<Input placeholder="未填写" />
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="商品颜色">
|
||||||
|
<Input
|
||||||
|
placeholder="未填写"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Form.List>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="add-sku-row">
|
||||||
|
<Button
|
||||||
|
className="add-sku-button"
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={addVariant}
|
||||||
|
>
|
||||||
|
添加 SKU 变体
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Form.List>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* 编辑区块标题(SectionTitle):编号徽标 + 标题 + 副标题,各区块共用。 */
|
||||||
|
.editor-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.editor-section-heading h4.ant-typography {
|
||||||
|
margin: 2px 0 3px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.editor-section-number {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 25px;
|
||||||
|
height: 25px;
|
||||||
|
margin-right: 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #7c3aed;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
vertical-align: 2px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.editor-section-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import './SectionTitle.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
extra?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 编辑区块标题:编号 + 标题 + 副标题 + 右侧附加内容,各区块共用。 */
|
||||||
|
export function SectionTitle({ number, title, description, extra }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>
|
||||||
|
{number && <span className="editor-section-number">{number}</span>}
|
||||||
|
{title}
|
||||||
|
</Typography.Title>
|
||||||
|
{description && <Typography.Text type="secondary">{description}</Typography.Text>}
|
||||||
|
</div>
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user