From 119095a57bbbe4f3165d21c554cb72c62a3327e1 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:36:19 +0800 Subject: [PATCH] =?UTF-8?q?task-83(=E5=BA=97=E9=93=BA=E4=B8=AD=E5=BF=83):?= =?UTF-8?q?=20=E5=AE=9E=E7=8E=B0=E5=BA=97=E9=93=BA=E5=AF=86=E9=92=A5?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=92=8C=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 shop-keys-list-state.ts:密钥页本地关键词过滤(备注名/紫鸟账号)与分页 信息派生(页码钳制/首末/前后页)。Java 密钥接口无服务端筛选,搜索在已加载 当前页行上本地过滤,纯逻辑可测。 TDD: task-83.test.ts 8 用例先 RED 后 GREEN。 --- .../src/pages/shop/shop-keys-list-state.ts | 67 +++++++++++ admin-frontend-vue/tests/task-83.test.ts | 104 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 admin-frontend-vue/src/pages/shop/shop-keys-list-state.ts create mode 100644 admin-frontend-vue/tests/task-83.test.ts diff --git a/admin-frontend-vue/src/pages/shop/shop-keys-list-state.ts b/admin-frontend-vue/src/pages/shop/shop-keys-list-state.ts new file mode 100644 index 00000000..51b7a157 --- /dev/null +++ b/admin-frontend-vue/src/pages/shop/shop-keys-list-state.ts @@ -0,0 +1,67 @@ +/** 店铺密钥列表筛选与分页(任务 83):密钥页本地关键词过滤与分页信息派生,纯逻辑。 + * 说明:Java 店铺密钥接口只支持分页,无服务端筛选,页面 FilterBar 的备注/紫鸟账号 + * 搜索在已加载的当前页行上做本地过滤,避免误导用户以为会跨页检索。 */ +import { SHOP_PAGE_DEFAULT_SIZE, SHOP_PAGE_MAX_SIZE, SHOP_PAGE_MIN_PAGE, type ShopKeyItem } from './shop-dto.ts' + +/** 店铺密钥列表本地筛选状态。 */ +export interface ShopKeyListFilter { + keyword?: string +} + +function finiteInt(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : null +} + +/** 归一本地关键词:去首尾空白,空白视为无筛选。 */ +export function normalizeShopKeyFilter(raw: Partial): ShopKeyListFilter { + const keyword = typeof raw.keyword === 'string' ? raw.keyword.trim() || undefined : undefined + return keyword ? { keyword } : {} +} + +export function shopKeyFilterActive(filter: ShopKeyListFilter): boolean { + return Boolean(filter.keyword && filter.keyword.trim()) +} + +/** 在已加载行上做备注名/紫鸟账号名子串过滤(大小写不敏感);无关键词原样返回副本。 */ +export function filterShopKeyRows(items: readonly ShopKeyItem[], filter: ShopKeyListFilter): ShopKeyItem[] { + const keyword = (filter.keyword || '').trim().toLowerCase() + if (!keyword) return [...items] + return items.filter( + (item) => + item.remarkName.toLowerCase().includes(keyword) || + item.ziniaoAccountName.toLowerCase().includes(keyword), + ) +} + +export interface ShopKeyPageInfo { + page: number + pageSize: number + total: number + totalPages: number + from: number + to: number + hasPrev: boolean + hasNext: boolean +} + +/** 由总数与目标分页派生页码信息:页码钳制到 [1, totalPages],空数据视为第 1 页。 */ +export function shopKeyPageInfo(page: unknown, pageSize: unknown, total: unknown): ShopKeyPageInfo { + const safePageSize = Math.max(finiteInt(pageSize) ?? SHOP_PAGE_DEFAULT_SIZE, SHOP_PAGE_MIN_PAGE) + const clampedPageSize = Math.min(safePageSize, SHOP_PAGE_MAX_SIZE) + const safeTotal = Math.max(finiteInt(total) ?? 0, 0) + const totalPages = safeTotal === 0 ? 1 : Math.max(1, Math.ceil(safeTotal / clampedPageSize)) + const pageNumber = Math.max(finiteInt(page) ?? SHOP_PAGE_MIN_PAGE, SHOP_PAGE_MIN_PAGE) + const safePage = Math.min(pageNumber, totalPages) + const from = safeTotal === 0 ? 0 : (safePage - 1) * clampedPageSize + 1 + const to = Math.min(safeTotal, safePage * clampedPageSize) + return { + page: safePage, + pageSize: clampedPageSize, + total: safeTotal, + totalPages, + from, + to, + hasPrev: safePage > 1, + hasNext: safePage < totalPages && safeTotal > 0, + } +} diff --git a/admin-frontend-vue/tests/task-83.test.ts b/admin-frontend-vue/tests/task-83.test.ts new file mode 100644 index 00000000..2c340f94 --- /dev/null +++ b/admin-frontend-vue/tests/task-83.test.ts @@ -0,0 +1,104 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readSource } from './helpers.ts' +import { + filterShopKeyRows, + normalizeShopKeyFilter, + shopKeyFilterActive, + shopKeyPageInfo, + type ShopKeyPageInfo, +} from '../src/pages/shop/shop-keys-list-state.ts' +import type { ShopKeyItem } from '../src/pages/shop/shop-dto.ts' + +function key(id: number, remarkName: string, ziniaoAccountName = ''): ShopKeyItem { + return { id, remarkName, ziniaoAccountName, ziniaoToken: '', ipWhitelistStatus: 'UNKNOWN' } +} + +test('test_task_083_shop_key_filter_page_normal_primary_path', () => { + // 正常主路径:关键词过滤加载行 + 分页信息派生。 + const rows = [key(1, '主店'), key(2, '副店'), key(3, 'blue_agent 备用')] + const filtered = filterShopKeyRows(rows, normalizeShopKeyFilter({ keyword: ' 副店 ' })) + assert.equal(filtered.length, 1) + assert.equal(filtered[0].id, 2) + const info: ShopKeyPageInfo = shopKeyPageInfo(2, 15, 30) + assert.equal(info.page, 2) + assert.equal(info.pageSize, 15) + assert.equal(info.totalPages, 2) + assert.equal(info.from, 16) + assert.equal(info.to, 30) + assert.equal(info.hasPrev, true) + assert.equal(info.hasNext, false) +}) + +test('test_task_083_shop_key_filter_page_normal_variant_input', () => { + // 正常变体:关键词可命中紫鸟账号名,大小写不敏感。 + const rows = [key(1, 'A店', 'BlueAgent'), key(2, 'B店', 'red')] + const filtered = filterShopKeyRows(rows, { keyword: 'blue' }) + assert.equal(filtered.length, 1) + assert.equal(filtered[0].id, 1) + assert.equal(shopKeyFilterActive({ keyword: 'blue' }), true) +}) + +test('test_task_083_shop_key_filter_page_normal_repeated_is_idempotent', () => { + // 正常重复:过滤不修改原列表、输入状态稳定。 + const rows = [key(1, '主店'), key(2, '副店')] + const filter = { keyword: ' 主 店 ' } + const first = filterShopKeyRows(rows, normalizeShopKeyFilter(filter)) + const second = filterShopKeyRows(rows, normalizeShopKeyFilter(filter)) + assert.deepEqual(first, second) + assert.equal(filter.keyword, ' 主 店 ', '不改调用方状态') + assert.equal(rows.length, 2, '不改原列表') + const info = shopKeyPageInfo(2, 15, 45) + assert.deepEqual(info, shopKeyPageInfo(2, 15, 45)) +}) + +test('test_task_083_shop_key_filter_page_boundary_empty_input', () => { + // 边界空值:空关键词不过滤(返回全部加载行);空状态分页信息不崩溃。 + const rows = [key(1, 'A'), key(2, 'B')] + const all = filterShopKeyRows(rows, normalizeShopKeyFilter({})) + assert.equal(all.length, 2) + const info = shopKeyPageInfo(1, 15, 0) + assert.equal(info.totalPages, 1) + assert.equal(info.page, 1) + assert.equal(info.from, 0) + assert.equal(info.to, 0) + assert.equal(info.hasPrev, false) + assert.equal(info.hasNext, false) +}) + +test('test_task_083_shop_key_filter_page_boundary_single_item', () => { + // 边界单元素:单行列表与单页总数。 + const rows = [key(1, '唯一店')] + const filtered = filterShopKeyRows(rows, { keyword: '唯一' }) + assert.equal(filtered.length, 1) + const info = shopKeyPageInfo(1, 15, 1) + assert.equal(info.totalPages, 1) + assert.equal(info.to, 1) +}) + +test('test_task_083_shop_key_filter_page_boundary_limit_or_missing_field', () => { + // 边界上限/缺字段:页码/页大小钳制;页大小越界归上限;关键字空白视为无筛选。 + const info = shopKeyPageInfo(0, 999, 50) + assert.equal(info.page, 1) + assert.equal(info.pageSize, 100) + assert.equal(info.totalPages, 1) + const late = shopKeyPageInfo(99, 15, 30) + assert.equal(late.page, 2, '请求页码超过末页应钳到末页') + assert.equal(filterShopKeyRows([key(1, 'A')], normalizeShopKeyFilter({ keyword: ' ' })).length, 1) +}) + +test('test_task_083_shop_key_filter_page_invalid_input_rejected', () => { + // 异常输入:非数字/NaN 页码回默认;非字符串关键词按无筛选处理。 + const info = shopKeyPageInfo(Number.NaN, Number.NaN, 10) + assert.equal(info.page, 1) + assert.equal(info.pageSize, 15) + assert.equal(shopKeyFilterActive(normalizeShopKeyFilter({ keyword: 3 as never })), false) +}) + +test('test_task_083_shop_key_filter_page_dependency_failure_returns_actionable_message', () => { + // 依赖失败/可操作:列表状态为纯逻辑、无框架/http;复用 DTO 常量与类型。 + const mod = readSource('src/pages/shop/shop-keys-list-state.ts') + assert.equal(/axios|http\.|vue/.test(mod), false, '列表状态保持纯逻辑') + assert.match(mod, /SHOP_PAGE_DEFAULT_SIZE/) + assert.match(mod, /ShopKeyItem/) +})