From 87db74fb89a7674230490484b0a79c8a3c051610 Mon Sep 17 00:00:00 2001 From: R524809 Date: Fri, 4 Sep 2026 17:32:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(collection):=20=E9=87=87=E9=9B=86=E7=AE=B1?= =?UTF-8?q?=E5=BC=80=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/app.module.ts | 2 + .../src/collection/collection.controller.ts | 36 ++++++ apps/api/src/collection/collection.module.ts | 7 ++ apps/api/src/collection/collection.service.ts | 57 +++++++++ apps/api/src/stores/platforms.service.ts | 1 + apps/api/test/collection.e2e.test.ts | 17 +++ .../src/pages/collection/CollectionPage.tsx | 32 ++++-- .../src/pages/watermarks/WatermarksPage.tsx | 108 +++++++++++++----- apps/web/src/services/collection.ts | 9 ++ apps/worker/src/main.ts | 27 +++-- apps/worker/src/outbox-dispatcher.ts | 2 +- docs/project/HANDOFF.md | 24 ++-- .../project/m2.1-collection-box-foundation.md | 56 +++++++++ packages/contracts/src/collection.ts | 31 +++++ .../008_collection_box_foundation.sql | 81 +++++++++++++ .../009_fix_collection_limit_function.sql | 12 ++ 16 files changed, 452 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/collection/collection.controller.ts create mode 100644 apps/api/src/collection/collection.module.ts create mode 100644 apps/api/src/collection/collection.service.ts create mode 100644 apps/api/test/collection.e2e.test.ts create mode 100644 apps/web/src/services/collection.ts create mode 100644 docs/project/m2.1-collection-box-foundation.md create mode 100644 packages/database/migrations/008_collection_box_foundation.sql create mode 100644 packages/database/migrations/009_fix_collection_limit_function.sql diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index f9217bc..8f4474b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -9,6 +9,7 @@ import { SecurityModule } from './security/security.module.js'; import { StoresModule } from './stores/stores.module.js'; import { WatermarksModule } from './watermarks/watermarks.module.js'; import { MediaModule } from './media/media.module.js'; +import { CollectionModule } from './collection/collection.module.js'; @Controller() class HealthController { @@ -45,6 +46,7 @@ class HealthController { StoresModule, WatermarksModule, MediaModule, + CollectionModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/collection/collection.controller.ts b/apps/api/src/collection/collection.controller.ts new file mode 100644 index 0000000..8b878a0 --- /dev/null +++ b/apps/api/src/collection/collection.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Headers, Inject, Param, Patch, Post, Query, Req, Res } from '@nestjs/common'; +import { z } from 'zod'; +import { bulkDeleteCollectionItemsRequestSchema, clearCollectionItemsRequestSchema, createCollectionItemRequestSchema, listCollectionItemsQuerySchema, retryCollectionAssetsRequestSchema, updateCollectionItemRequestSchema, type Workspace } from '@atlas/contracts'; +import { ApiError } from '../common/api-error.js'; +import type { AuthenticatedRequest } from '../auth/auth.guard.js'; +import { CurrentWorkspace } from '../workspace/current-workspace.decorator.js'; +import { CollectionService } from './collection.service.js'; + +type Response = { status: (value: number) => Response }; +@Controller('collection-items') +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) { + if (!platformId) throw new ApiError(400, 'PLATFORM_CONTEXT_REQUIRED', '请求缺少 X-Atlas-Platform-Id'); + return this.collection.list(workspace.id, requireUuid('X-Atlas-Platform-Id', platformId), parse(listCollectionItemsQuerySchema, query)); + } + @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; + } + @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; + } + @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; + } +} +function parse { 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]); } diff --git a/apps/api/src/collection/collection.module.ts b/apps/api/src/collection/collection.module.ts new file mode 100644 index 0000000..2360505 --- /dev/null +++ b/apps/api/src/collection/collection.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { IdempotencyService } from '../common/idempotency.service.js'; +import { CollectionController } from './collection.controller.js'; +import { CollectionService } from './collection.service.js'; + +@Module({ controllers: [CollectionController], providers: [IdempotencyService, CollectionService] }) +export class CollectionModule {} diff --git a/apps/api/src/collection/collection.service.ts b/apps/api/src/collection/collection.service.ts new file mode 100644 index 0000000..e049189 --- /dev/null +++ b/apps/api/src/collection/collection.service.ts @@ -0,0 +1,57 @@ +import { randomUUID } from 'node:crypto'; +import { Inject, Injectable } from '@nestjs/common'; +import type { CreateCollectionItemRequest, ListCollectionItemsQuery, UpdateCollectionItemRequest } from '@atlas/contracts'; +import type { Database, DatabaseTransaction } from '@atlas/database'; +import { ApiError } from '../common/api-error.js'; +import { IdempotencyService } from '../common/idempotency.service.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 }; +@Injectable() +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) { + 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(`${selectRow} + 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 ($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]); + 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'); + 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 } }; + }); + } + async create(workspaceId: string, userId: string, key: string, 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: `/collection-items/${item.rows[0].id}/edit` } } }; + }); + } + 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 this.versionConflict(tx, 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(`UPDATE atlas.collection_asset_transfers SET status = 'queued', error_payload = NULL, updated_at = now() WHERE collection_item_id = $1 AND id = ANY($2::uuid[]) AND status IN ('failed', 'cancelled')`, [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 }); return { status: 202, body: { data: { operation } } }; }); } + 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 }; } + 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}`]); } + 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 上下文'); } + private async requireItem(tx: DatabaseTransaction, id: string) { const row = await tx.query(`${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]); } + private async versionConflict(tx: DatabaseTransaction, id: string, expected: number) { const row = await tx.query<{ revision: number }>('SELECT revision FROM atlas.collection_items WHERE id = $1 AND deleted_at IS NULL', [id]); if (!row.rows[0]) throw new ApiError(404, 'RESOURCE_NOT_FOUND', '采集记录不存在'); if (row.rows[0].revision !== expected) throw new ApiError(409, 'VERSION_CONFLICT', '采集记录已被更新', [{ field: 'If-Match', code: 'VERSION_CONFLICT', message: `当前版本为 ${row.rows[0].revision}`, current_revision: row.rows[0].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`; +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 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', '分页游标无效'); } } diff --git a/apps/api/src/stores/platforms.service.ts b/apps/api/src/stores/platforms.service.ts index 3368148..fa939ea 100644 --- a/apps/api/src/stores/platforms.service.ts +++ b/apps/api/src/stores/platforms.service.ts @@ -24,6 +24,7 @@ export class PlatformsService { LEFT JOIN atlas.platform_currency_support pcs ON pcs.platform_id = p.id LEFT JOIN atlas.currencies c ON c.code = pcs.currency_code WHERE p.status = 'active' + AND coalesce((p.capabilities->>'can_be_header_context')::boolean, false) GROUP BY p.id ORDER BY CASE WHEN p.code = 'OZON' THEN 0 ELSE 1 END, p.name`, ); diff --git a/apps/api/test/collection.e2e.test.ts b/apps/api/test/collection.e2e.test.ts new file mode 100644 index 0000000..1d1228a --- /dev/null +++ b/apps/api/test/collection.e2e.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; +import type { Database } from '@atlas/database'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { afterAll, beforeAll, describe, it } from 'vitest'; +import { DATABASE } from '../src/database/database.module.js'; +import { createApplication } from '../src/main.js'; + +let app: NestExpressApplication; let database: Database; let baseUrl: string; let userId = ''; let workspaceId = ''; let token = ''; let platformId = ''; +async function request(path: string, init: RequestInit = {}) { const response = await fetch(`${baseUrl}${path}`, { ...init, headers: { 'Content-Type': 'application/json', ...init.headers } }); return { response, body: response.status === 204 ? undefined : await response.json() as any }; } +function headers(extra: Record = {}) { return { Authorization: `Bearer ${token}`, ...extra }; } +describe('采集箱 HTTP 集成测试', () => { + beforeAll(async () => { Object.assign(process.env, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false', AUTH_REGISTER_IP_LIMIT: '100', AUTH_REGISTER_EMAIL_LIMIT: '100' }); const application = await createApplication(); app = application.app; database = app.get(DATABASE); await app.listen(0, '127.0.0.1'); baseUrl = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`; const registration = await request('/api/auth/register', { method: 'POST', body: JSON.stringify({ email: `collection-${randomUUID()}@example.test`, password: 'correct-horse-battery-staple-123', password_confirmation: 'correct-horse-battery-staple-123' }) }); assert.equal(registration.response.status, 201); token = registration.body.data.access_token; userId = registration.body.data.user.id; workspaceId = registration.body.data.workspace.id; const platforms = await request('/api/platforms', { headers: headers() }); platformId = platforms.body.data[0].id; }); + afterAll(async () => { await database.workspaceTransaction(workspaceId, async (tx) => { await tx.query('DELETE FROM atlas.idempotency_requests WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.outbox_events WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.operations WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.product_variants WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.products WHERE workspace_id = $1', [workspaceId]); await tx.query('DELETE FROM atlas.collection_items WHERE workspace_id = $1', [workspaceId]); }); await database.query('DELETE FROM atlas.workspace_memberships WHERE user_id = $1', [userId]); await database.query('DELETE FROM atlas.workspaces WHERE id = $1', [workspaceId]); await database.query('DELETE FROM atlas.users WHERE id = $1', [userId]); await app.close(); }); + it('创建、查询、乐观锁更新与软删除采集草稿', async () => { const key = randomUUID(); const created = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.equal(created.response.status, 201); const item = created.body.data.collection_item; assert.equal(item.variant_summary.count, 1); const replay = await request('/api/collection-items', { method: 'POST', headers: headers({ 'Idempotency-Key': key }), body: JSON.stringify({ title: '手动商品', note: '初始备注' }) }); assert.deepEqual(replay.body, created.body); const listed = await request('/api/collection-items', { headers: headers({ 'X-Atlas-Platform-Id': platformId }) }); assert.equal(listed.response.status, 200); assert.equal(listed.body.data[0].id, item.id); const updated = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '已修改' }) }); assert.equal(updated.response.status, 200); const stale = await request(`/api/collection-items/${item.id}`, { method: 'PATCH', headers: headers({ 'Idempotency-Key': randomUUID(), 'If-Match': `"${item.revision}"` }), body: JSON.stringify({ note: '过期写入' }) }); assert.equal(stale.response.status, 409); const removed = await request('/api/collection-items/bulk-delete', { method: 'POST', headers: headers({ 'Idempotency-Key': randomUUID() }), body: JSON.stringify({ ids: [item.id] }) }); assert.equal(removed.response.status, 202); assert.equal(removed.body.data.deleted_count, 1); }); +}); diff --git a/apps/web/src/pages/collection/CollectionPage.tsx b/apps/web/src/pages/collection/CollectionPage.tsx index e86fdaf..f758ed3 100644 --- a/apps/web/src/pages/collection/CollectionPage.tsx +++ b/apps/web/src/pages/collection/CollectionPage.tsx @@ -1,8 +1,26 @@ -import PagePlaceholder from '@/components/PagePlaceholder'; +import { useCallback, useEffect, useState } from 'react'; +import { App as AntdApp, Button, Card, Form, Input, Modal, Progress, Select, Space, Table, Tag, Typography } from 'antd'; +import type { TableProps } from 'antd'; +import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import type { CollectionItem } from '@atlas/contracts'; +import { collectionApi } from '@/services/collection'; +import { apiErrorMessage } from '@/services/api'; +import { usePlatform } from '@/services/platform'; -/** 采集箱:查看已采集的商品,进入编辑上架(业务模块尚未实现) */ -const CollectionPage = () => ( - -); - -export default CollectionPage; +const statusLabel: Record = { received: '已接收', materializing: '素材处理中', ready_for_edit: '可编辑', needs_review: '待确认', failed: '采集失败' }; +export default function CollectionPage() { + const { message, modal } = AntdApp.useApp(); const { currentPlatform } = usePlatform(); const [items, setItems] = useState([]); const [meta, setMeta] = useState({ active_count: 0, collection_limit: 1000 }); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState([]); const [q, setQ] = useState(''); const [source, setSource] = useState(); const [createOpen, setCreateOpen] = useState(false); const [form] = Form.useForm<{ title?: string; note?: string }>(); + const load = useCallback(async () => { if (!currentPlatform) return; setLoading(true); try { const result = await collectionApi.list(currentPlatform.id, { ...(q.trim() ? { q: q.trim() } : {}), ...(source ? { source_platform_code: source as 'OZON' } : {}) }); setItems(result.data); setMeta(result.meta); } catch (error) { message.error(apiErrorMessage(error)); } finally { setLoading(false); } }, [currentPlatform, message, q, source]); + useEffect(() => { void load(); }, [load]); + const create = async (values: { title?: string; note?: string }) => { try { await collectionApi.create({ title: values.title?.trim() || undefined, note: values.note?.trim() || null }); message.success('商品草稿已加入采集箱'); setCreateOpen(false); form.resetFields(); await load(); } catch (error) { message.error(apiErrorMessage(error)); } }; + const remove = (ids: string[], all = false) => modal.confirm({ title: all ? '清空采集箱' : '删除采集商品', content: all ? `将清空当前 ${meta.active_count} 条采集草稿,不会删除或下架任何平台商品。确定继续吗?` : '仅删除 Atlas 内的采集草稿,不会删除或下架任何平台商品。确定继续吗?', okText: all ? '清空' : '删除', okButtonProps: { danger: true }, onOk: async () => { try { const result = all ? await collectionApi.clear(meta.active_count) : await collectionApi.bulkDelete(ids); message.success(`已删除 ${result.deleted_count} 条采集草稿`); setSelected([]); await load(); } catch (error) { message.error(apiErrorMessage(error)); } } }); + const columns: TableProps['columns'] = [ + { title: '产品名称', dataIndex: 'title', render: (value) => value || '未命名商品' }, { title: '来源', dataIndex: 'source', width: 110, render: (value) => {value.kind === 'manual' ? '手动创建' : value.platform_code} }, { title: 'SKU', dataIndex: 'variant_summary', width: 80, render: (value) => value.count }, { title: '采集状态', dataIndex: 'collection_status', width: 120, render: (value) => statusLabel[value] ?? value }, { title: '当前平台上架', dataIndex: 'listing_summary', width: 150, render: (value) => {value.detail} }, { title: '备注', dataIndex: 'note', ellipsis: true, render: (value) => value || '—' }, { title: '更新时间', dataIndex: 'updated_at', width: 170, render: (value) => new Date(value).toLocaleString('zh-CN', { hour12: false }) }, { title: '操作', width: 150, render: (_, item) => }, + ]; + return }> + `${meta.active_count} / ${meta.collection_limit}`} style={{ maxWidth: 420, marginBottom: 18 }} /> + setQ(e.target.value)} placeholder="搜索产品名称" allowClear style={{ width: 220 }} /> + ; +} diff --git a/apps/web/src/pages/watermarks/WatermarksPage.tsx b/apps/web/src/pages/watermarks/WatermarksPage.tsx index fa894bc..29a5c82 100644 --- a/apps/web/src/pages/watermarks/WatermarksPage.tsx +++ b/apps/web/src/pages/watermarks/WatermarksPage.tsx @@ -21,7 +21,6 @@ import { } from 'antd'; import type { TableProps, UploadFile } from 'antd'; import { - CopyOutlined, FileImageOutlined, PlusOutlined, ReloadOutlined, @@ -165,6 +164,21 @@ const WatermarksPage = () => { } }, [templates, assetPreviewUrls]); + /** + * 模板列表不承诺始终带预览 URL;编辑既有图片模板时按需换取新的短时地址。 + * 这样不会把对象存储 URL 写入模板数据,也避免打开编辑框时只显示空占位。 + */ + const loadAssetPreviewUrl = useCallback( + async (assetId: string): Promise => { + const cached = assetPreviewUrls[assetId]; + if (cached) return cached; + const access = await mediaApi.createPreviewUrl(assetId); + setAssetPreviewUrls((previous) => ({ ...previous, [assetId]: access.url })); + return access.url; + }, + [assetPreviewUrls] + ); + const filtered = useMemo( () => templates.filter((template) => { @@ -232,6 +246,42 @@ const WatermarksPage = () => { form.setFieldsValue(initial); setPreviewValues(initial); setEditorOpen(true); + + if (template.kind === 'image' && assetId && !assetPreviewUrls[assetId]) { + void loadAssetPreviewUrl(assetId) + .then((previewUrl) => { + setImageAsset((current) => + current?.id === assetId + ? { id: assetId, previewUrl } + : current + ); + }) + .catch(() => undefined); + } + }; + + /** + * 类型切换会丢弃另一种类型的专属配置(尤其是已上传图片),先明确告知用户。 + * 表单字段由这里显式写入,避免 Select 在确认框出现前就提交了类型变更。 + */ + const requestKindChange = (nextKind: WatermarkKind) => { + const currentKind = form.getFieldValue('kind') as WatermarkKind | undefined; + if (!currentKind || currentKind === nextKind) return; + const applyChange = () => { + form.setFieldValue('kind', nextKind); + setPreviewValues((previous) => ({ + ...(previous ?? (form.getFieldsValue() as WatermarkFormValues)), + kind: nextKind, + })); + if (currentKind === 'image') setImageAsset(null); + }; + modal.confirm({ + title: '切换水印类型', + content: '切换后将清空当前类型的专属配置,是否继续?', + okText: '继续切换', + cancelText: '取消', + onOk: applyChange, + }); }; const handleBeforeUpload = async (file: UploadFile): Promise => { @@ -307,7 +357,7 @@ const WatermarksPage = () => { } const configPatch: NonNullable = {}; if (values.text_content?.trim() !== current.text_content) { - configPatch.text_content = values.text_content?.trim() ?? null; + configPatch.text_content = values.text_content.trim(); } if (values.font_code !== current.font_code) { configPatch.font_code = values.font_code; @@ -332,7 +382,8 @@ const WatermarksPage = () => { diff.name = values.name.trim(); } const configPatch: NonNullable = {}; - if (imageAsset && imageAsset.id !== current.image_asset?.id) { + const currentAssetId = current.image_asset?.id ?? current.image_asset_id; + if (imageAsset && imageAsset.id !== currentAssetId) { configPatch.image_asset_id = imageAsset.id; } if (percentToScale(values.scalePercent) !== current.scale_ratio) { @@ -436,9 +487,7 @@ const WatermarksPage = () => { let url = assetPreviewUrls[assetId]; if (!url) { try { - const access = await mediaApi.createPreviewUrl(assetId); - url = access.url; - setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url })); + url = await loadAssetPreviewUrl(assetId); } catch { // 换取失败按原样打开,图片渲染失败会显示不可用提示 } @@ -469,29 +518,34 @@ const WatermarksPage = () => { okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { - if (targets.length === 1) { - await watermarksApi.remove(targets[0].id); - message.success('水印模板已删除'); - } else { - setBatchDeleting(true); - let failed = 0; - for (const template of targets) { - try { - await watermarksApi.remove(template.id); - } catch (err) { - failed += 1; - message.error(`${template.name}:${apiErrorMessage(err)}`); + try { + if (targets.length === 1) { + await watermarksApi.remove(targets[0].id); + message.success('水印模板已删除'); + } else { + setBatchDeleting(true); + let failed = 0; + for (const template of targets) { + try { + await watermarksApi.remove(template.id); + } catch (err) { + failed += 1; + message.error(`${template.name}:${apiErrorMessage(err)}`); + } + } + if (failed === 0) { + message.success(`已删除 ${targets.length} 个模板`); + } else { + message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`); } } + setSelectedIds([]); + await loadTemplates(); + } catch (err) { + message.error(apiErrorMessage(err)); + } finally { setBatchDeleting(false); - if (failed === 0) { - message.success(`已删除 ${targets.length} 个模板`); - } else { - message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`); - } } - setSelectedIds([]); - await loadTemplates(); }, }); }; @@ -747,8 +801,10 @@ const WatermarksPage = () => { - +