task-83(店铺中心): 实现店铺密钥筛选和分页

新增 shop-keys-list-state.ts:密钥页本地关键词过滤(备注名/紫鸟账号)与分页
信息派生(页码钳制/首末/前后页)。Java 密钥接口无服务端筛选,搜索在已加载
当前页行上本地过滤,纯逻辑可测。

TDD: task-83.test.ts 8 用例先 RED 后 GREEN。
This commit is contained in:
2026-09-05 16:36:19 +08:00
parent 8c6088bdc9
commit 119095a57b
2 changed files with 171 additions and 0 deletions
@@ -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>): 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,
}
}
+104
View File
@@ -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/)
})