From 3828c90b4c9cb6ee42cfa55252f640fefe463d7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 16:17:15 +0800 Subject: [PATCH] =?UTF-8?q?task-79(ASIN=20=E6=95=B0=E6=8D=AE=E4=B8=AD?= =?UTF-8?q?=E5=BF=83):=20=E5=AE=9E=E7=8E=B0=E5=95=86=E5=93=81=E7=B1=BB?= =?UTF-8?q?=E7=9B=AE=E6=96=B0=E5=A2=9E/=E7=BC=96=E8=BE=91/=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/admin/product-category + PUT /product-category/{id} 保存适配 (名称必填/排序可选非负整数校验), 导出 URL(keyword), 8 个契约测试。 --- .../pages/asin/product-category-editor-api.ts | 15 ++++ .../asin/product-category-editor-model.ts | 54 +++++++++++++ .../src/pages/asin/product-category-export.ts | 10 +++ admin-frontend-vue/tests/task-79.test.ts | 80 +++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 admin-frontend-vue/src/pages/asin/product-category-editor-api.ts create mode 100644 admin-frontend-vue/src/pages/asin/product-category-editor-model.ts create mode 100644 admin-frontend-vue/src/pages/asin/product-category-export.ts create mode 100644 admin-frontend-vue/tests/task-79.test.ts diff --git a/admin-frontend-vue/src/pages/asin/product-category-editor-api.ts b/admin-frontend-vue/src/pages/asin/product-category-editor-api.ts new file mode 100644 index 00000000..6af57689 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/product-category-editor-api.ts @@ -0,0 +1,15 @@ +/** 商品类目新增/编辑适配(任务 79):POST /api/admin/product-category + PUT /api/admin/product-category/{id}。 */ +import { http } from '@/api/http' +import type { ProductCategorySaveRequest } from './product-category-editor-model' + +export const PRODUCT_CATEGORY_ITEM_ENDPOINT = '/api/admin/product-category' + +/** 新增类目。 */ +export async function createProductCategory(body: ProductCategorySaveRequest): Promise { + await http.post(PRODUCT_CATEGORY_ITEM_ENDPOINT, body) +} + +/** 更新类目。 */ +export async function updateProductCategory(id: number, body: ProductCategorySaveRequest): Promise { + await http.put(`${PRODUCT_CATEGORY_ITEM_ENDPOINT}/${id}`, body) +} diff --git a/admin-frontend-vue/src/pages/asin/product-category-editor-model.ts b/admin-frontend-vue/src/pages/asin/product-category-editor-model.ts new file mode 100644 index 00000000..14ef2576 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/product-category-editor-model.ts @@ -0,0 +1,54 @@ +/** 商品类目新增/编辑表单模型(任务 79):保存草稿校验与序列化(名称必填、排序可选非负整数),纯逻辑。 */ + +export interface ProductCategoryDraft { + parentId: number | null + name: string + sortOrder: string + description: string +} + +export function createProductCategoryDraft(): ProductCategoryDraft { + return { parentId: null, name: '', sortOrder: '', description: '' } +} + +export interface ProductCategorySaveRequest { + parentId?: number + name: string + sortOrder?: number + description?: string +} + +function parseSortOrder(text: string): number | null { + const trimmed = (text || '').trim() + if (!trimmed) return null + if (!/^\d+$/.test(trimmed)) return null + const num = Number(trimmed) + return Number.isInteger(num) ? num : null +} + +/** 校验保存草稿;合法返回 null,否则返回可读错误。 */ +export function categoryDraftError(draft: ProductCategoryDraft): string | null { + if (!draft || typeof draft !== 'object') return '类目表单数据无效' + const name = typeof draft.name === 'string' ? draft.name.trim() : '' + if (!name) return '类目名称不能为空' + const rawSort = typeof draft.sortOrder === 'string' ? draft.sortOrder : '' + if (rawSort.trim() !== '' && parseSortOrder(rawSort) === null) { + return '排序需为非负整数' + } + return null +} + +/** 草稿 → 保存请求;仅提交非空/合法字段,名称 trim。 */ +export function toCategorySaveRequest(draft: ProductCategoryDraft): ProductCategorySaveRequest { + const error = categoryDraftError(draft) + if (error) throw new Error(error) + const request: ProductCategorySaveRequest = { name: draft.name.trim() } + if (typeof draft.parentId === 'number' && Number.isFinite(draft.parentId) && draft.parentId >= 1) { + request.parentId = Math.floor(draft.parentId) + } + const sortOrder = parseSortOrder(draft.sortOrder) + if (sortOrder !== null) request.sortOrder = sortOrder + const description = typeof draft.description === 'string' ? draft.description.trim() : '' + if (description) request.description = description + return request +} diff --git a/admin-frontend-vue/src/pages/asin/product-category-export.ts b/admin-frontend-vue/src/pages/asin/product-category-export.ts new file mode 100644 index 00000000..b1fe6773 --- /dev/null +++ b/admin-frontend-vue/src/pages/asin/product-category-export.ts @@ -0,0 +1,10 @@ +/** 商品类目导出 URL 构建(任务 79):keyword 非空时带查询参数,纯逻辑。 */ + +export const PRODUCT_CATEGORY_EXPORT_ENDPOINT = '/api/admin/product-categories/export' + +/** 导出下载地址:浏览器直接访问该 URL 触发流式下载。 */ +export function buildProductCategoryExportUrl(keyword: string): string { + const trimmed = (keyword || '').trim() + if (!trimmed) return PRODUCT_CATEGORY_EXPORT_ENDPOINT + return `${PRODUCT_CATEGORY_EXPORT_ENDPOINT}?keyword=${encodeURIComponent(trimmed)}` +} diff --git a/admin-frontend-vue/tests/task-79.test.ts b/admin-frontend-vue/tests/task-79.test.ts new file mode 100644 index 00000000..e3826c2e --- /dev/null +++ b/admin-frontend-vue/tests/task-79.test.ts @@ -0,0 +1,80 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + createProductCategoryDraft, + categoryDraftError, + toCategorySaveRequest, +} from '../src/pages/asin/product-category-editor-model.ts' +import { buildProductCategoryExportUrl } from '../src/pages/asin/product-category-export.ts' + +test('test_task_079_product_category_editor_normal_primary_path', () => { + // 正常主路径:名称/父级/排序/备注 → 保存请求(camel, 名称 trim)。 + const draft = createProductCategoryDraft() + draft.parentId = 3 + draft.name = ' 厨房刀具 ' + draft.sortOrder = '2' + draft.description = ' 锋利 ' + assert.equal(categoryDraftError(draft), null) + assert.deepEqual(toCategorySaveRequest(draft), { + parentId: 3, + name: '厨房刀具', + sortOrder: 2, + description: '锋利', + }) +}) + +test('test_task_079_product_category_editor_normal_variant_input', () => { + // 正常变体:顶级(无父级/无排序/无备注)仅提交名称。 + const draft = { parentId: null, name: '收纳', sortOrder: '', description: '' } + assert.equal(categoryDraftError(draft), null) + assert.deepEqual(toCategorySaveRequest(draft), { name: '收纳' }) +}) + +test('test_task_079_product_category_editor_normal_repeated_operation_is_idempotent', () => { + // 正常重复:校验与序列化不改输入、结果稳定。 + const draft = { parentId: 1, name: ' A ', sortOrder: '1', description: '' } + assert.deepEqual(toCategorySaveRequest(draft), toCategorySaveRequest(draft)) + assert.deepEqual(draft, { parentId: 1, name: ' A ', sortOrder: '1', description: '' }) +}) + +test('test_task_079_product_category_editor_boundary_empty_input', () => { + // 边界空值:空名称被拒。 + const draft = { parentId: null, name: ' ', sortOrder: '', description: '' } + assert.match(categoryDraftError(draft) || '', /名称/) +}) + +test('test_task_079_product_category_editor_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:排序为空省略;排序 0 保留;描述为空省略。 + const noSort = toCategorySaveRequest({ parentId: null, name: 'X', sortOrder: '', description: '' }) + assert.equal('sortOrder' in noSort, false) + const zero = toCategorySaveRequest({ parentId: null, name: 'X', sortOrder: '0', description: '' }) + assert.equal(zero.sortOrder, 0) + assert.equal('description' in zero, false) +}) + +test('test_task_079_product_category_editor_invalid_input_rejected', () => { + // 异常输入:非法/负数排序被拒并给出可读错误。 + assert.match(categoryDraftError({ parentId: null, name: 'X', sortOrder: 'abc', description: '' }) || '', /排序/) + assert.match(categoryDraftError({ parentId: null, name: 'X', sortOrder: '-1', description: '' }) || '', /排序/) +}) + +test('test_task_079_product_category_editor_dependency_failure_returns_actionable_message', () => { + // 依赖失败/保存走 adapter:POST /product-category + PUT /product-category/{id}。 + const api = readSource('src/pages/asin/product-category-editor-api.ts') + assert.match(api, /product-category/) + assert.match(api, /http\.post/) + assert.match(api, /http\.put/) + assert.match(api, /createProductCategory/) + assert.match(api, /updateProductCategory/) + const mod = readSource('src/pages/asin/product-category-editor-model.ts') + assert.equal(/axios|http\./.test(mod), false, '类目编辑模型保持纯逻辑') +}) + +test('test_task_079_product_category_export_normal_and_boundary', () => { + // 正常/边界:导出 URL 带 keyword(编码)或不带参数。 + const url = buildProductCategoryExportUrl(' 厨房 刀 ') + assert.match(url, /\/product-categories\/export\?keyword=/) + assert.match(url, /%20/) + assert.equal(buildProductCategoryExportUrl(' '), '/api/admin/product-categories/export') +})