From a646099416d642f0ae682cde0efe98765def51ec Mon Sep 17 00:00:00 2001 From: zichun Date: Mon, 1 Jun 2026 17:36:57 +0800 Subject: [PATCH] feat(usr): 用户列表页面常量与前端 CSV 导出工具 REQ-USR-003 --- frontend/src/pages/usr/UserList/constants.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ frontend/src/pages/usr/UserList/exportUtils.ts | 47 +++++++++++++++++++++++++++++++++++++++++++++++ frontend/tests/unit/exportUtils.test.ts | 141 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 0 deletions(-) create mode 100644 frontend/src/pages/usr/UserList/constants.ts create mode 100644 frontend/src/pages/usr/UserList/exportUtils.ts create mode 100644 frontend/tests/unit/exportUtils.test.ts diff --git a/frontend/src/pages/usr/UserList/constants.ts b/frontend/src/pages/usr/UserList/constants.ts new file mode 100644 index 0000000..bd4148c --- /dev/null +++ b/frontend/src/pages/usr/UserList/constants.ts @@ -0,0 +1,61 @@ +// REQ-USR-003: 用户列表页合同级常量(枚举 / 默认 query / pageSize / 错误码 / 文案) +import type { UserListQuery } from '../../../api/types'; + +/** + * 查询字段枚举(对齐 REQ 输入表 1「显示来源」/ docs/05)。逐字一致,原样作为 + * queryField 提交值,匹配语义由后端裁决(BR4)。默认首项「用户名」(BR2)。 + */ +export const QUERY_FIELD_OPTIONS = [ + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '作废', + '登录日期', + '制单人', +] as const; + +/** 匹配方式枚举(BR4),默认「包含」(BR2) */ +export const MATCH_TYPE_OPTIONS = ['包含', '不包含', '等于'] as const; + +/** 用户范围下拉(占位 demo,spec D2):仅「全部用户」一项,不向后端传额外参数 */ +export const SCOPE_OPTIONS = ['全部用户'] as const; + +/** 每页条数选项(上限 100 对齐 docs/05 / REQ 边界,spec D4;不采用原型 demo 10000) */ +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; + +/** 默认查询(BR2/BR3,pageSize 默认 10 对齐 docs/05,spec D4) */ +export const DEFAULT_QUERY: UserListQuery = { + queryField: '用户名', + matchType: '包含', + queryValue: '', + pageNum: 1, + pageSize: 10, +}; + +// === 错误码常量(对齐 docs/05 § REQ-USR-003 / spec § 4) === +/** 分页参数非法(pageNum<1 或 pageSize 超上限 100) */ +export const ERR_PAGE_INVALID = 42201; +/** 查询参数校验失败 */ +export const ERR_QUERY_INVALID = 40001; + +// === 静态文案(逐字一致,复刻原型 / spec) === +export const TEXT_REFRESH = '刷新'; +export const TEXT_ADD = '新增'; +export const TEXT_EXPORT = '导出Excel'; +export const TEXT_SEARCH = '搜索'; +export const TEXT_CLEAR = '清空'; +export const TEXT_EMPTY = '暂无匹配的用户'; +export const TEXT_ERROR = '加载失败,点击重试'; +export const TEXT_EXPORT_SUCCESS = '导出成功'; +export const TEXT_EXPORT_FAIL = '导出失败'; +export const TEXT_MSG_PAGE_INVALID = '分页参数有误,已重置为第 1 页'; +export const TEXT_MSG_QUERY_INVALID = '查询条件有误,请检查后重试'; +export const TEXT_MSG_NETWORK = '加载失败,请稍后重试'; + +/** 分页统计文案(showTotal,total 来自 PageResult.total,BR1/§ 3) */ +export const totalText = (total: number): string => `共 ${total} 条记录`; + +/** 导出文件名 */ +export const EXPORT_FILENAME = '用户列表.csv'; diff --git a/frontend/src/pages/usr/UserList/exportUtils.ts b/frontend/src/pages/usr/UserList/exportUtils.ts new file mode 100644 index 0000000..bea44e3 --- /dev/null +++ b/frontend/src/pages/usr/UserList/exportUtils.ts @@ -0,0 +1,47 @@ +// REQ-USR-003: 前端零依赖 CSV 导出(UTF-8 BOM + Blob + ,D-PLAN-1) +import type { UserVO } from '../../../api/types'; + +/** 导出列定义:中文表头 + 从 UserVO 取值(与列定义语义一致,作废 0/1→否/是;不含序号列) */ +const EXPORT_COLUMNS: { header: string; pick: (row: UserVO) => string }[] = [ + { header: '用户名', pick: (r) => r.sUserName ?? '' }, + { header: '员工名', pick: (r) => r.employeeName ?? '' }, + { header: '用户号', pick: (r) => r.sUserNo ?? '' }, + { header: '部门', pick: (r) => r.departmentName ?? '' }, + { header: '用户类型', pick: (r) => r.sUserType ?? '' }, + { header: '语言', pick: (r) => r.sLanguage ?? '' }, + { header: '作废', pick: (r) => (r.iIsVoid === 1 ? '是' : '否') }, + { header: '登录日期', pick: (r) => r.tLastLoginDate ?? '' }, + { header: '制单人', pick: (r) => r.sCreator ?? '' }, + { header: '制单日期', pick: (r) => r.tCreateDate ?? '' }, +]; + +/** CSV 单元转义:含逗号 / 引号 / 换行时用双引号包裹并转义内部引号 */ +function escapeCell(value: string): string { + if (/[",\n\r]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +/** 按列定义顺序与中文表头生成 CSV 文本(含表头行;空值→空串;作废 0/1→否/是) */ +export function buildUserCsv(rows: UserVO[]): string { + const header = EXPORT_COLUMNS.map((c) => escapeCell(c.header)).join(','); + const body = rows.map((row) => + EXPORT_COLUMNS.map((c) => escapeCell(c.pick(row))).join(','), + ); + return [header, ...body].join('\n'); +} + +/** 前置 UTF-8 BOM → Blob → createObjectURL → 触发 下载 */ +export function downloadCsv(filename: string, csv: string): void { + const BOM = ''; + const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/frontend/tests/unit/exportUtils.test.ts b/frontend/tests/unit/exportUtils.test.ts new file mode 100644 index 0000000..9524f8d --- /dev/null +++ b/frontend/tests/unit/exportUtils.test.ts @@ -0,0 +1,141 @@ +// REQ-USR-003: 页面常量 + 前端 CSV 导出工具单测(D-PLAN-1) +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildUserCsv, downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; +import { + DEFAULT_QUERY, + PAGE_SIZE_OPTIONS, + QUERY_FIELD_OPTIONS, + MATCH_TYPE_OPTIONS, +} from '../../src/pages/usr/UserList/constants'; +import type { UserVO } from '../../src/api/types'; + +const rowVoid0: UserVO = { + id: 1, + sUserName: 'admin', + employeeName: null, + sUserNo: 'U001', + departmentName: '技术部', + sUserType: '超级管理员', + sLanguage: '中文', + iIsVoid: 0, + tLastLoginDate: null, + sCreator: '系统', + tCreateDate: '2024-01-01T00:00:00', +}; + +const rowVoid1: UserVO = { + id: 2, + sUserName: '李丹', + employeeName: '李丹', + sUserNo: 'U002', + departmentName: '客服部', + sUserType: '普通用户', + sLanguage: '中文', + iIsVoid: 1, + tLastLoginDate: '2024-02-01T10:00:00', + sCreator: 'admin', + tCreateDate: '2024-01-02T00:00:00', +}; + +describe('exportUtils.buildUserCsv', () => { + it('buildUserCsv has header row and maps 作废 0/1', () => { + const csv = buildUserCsv([rowVoid0, rowVoid1]); + const lines = csv.split('\n'); + const header = lines[0]; + // 中文表头逐字(不含序号列由前端生成,导出含中文列名) + expect(header).toContain('用户名'); + expect(header).toContain('员工名'); + expect(header).toContain('作废'); + expect(header).toContain('用户号'); + expect(header).toContain('部门'); + expect(header).toContain('用户类型'); + expect(header).toContain('语言'); + expect(header).toContain('登录日期'); + expect(header).toContain('制单人'); + expect(header).toContain('制单日期'); + // 作废列 0→否、1→是 + expect(lines[1]).toContain('否'); + expect(lines[2]).toContain('是'); + // 空值字段(employeeName=null)渲染为空串,不出现 "null" + expect(csv).not.toContain('null'); + }); +}); + +describe('exportUtils.downloadCsv', () => { + let createSpy: ReturnType; + let clickSpy: ReturnType; + let blobArg: Blob | null; + + beforeEach(() => { + blobArg = null; + createSpy = vi.fn((b: Blob) => { + blobArg = b; + return 'blob:mock'; + }); + clickSpy = vi.fn(); + // jsdom 未实现 URL.createObjectURL / revokeObjectURL + (URL as unknown as { createObjectURL: unknown }).createObjectURL = createSpy; + (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function readBlobBytes(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(blob); + }); + } + + it('downloadCsv triggers blob download with UTF-8 BOM', async () => { + downloadCsv('users.csv', 'x'); + expect(createSpy).toHaveBeenCalledTimes(1); + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(blobArg).toBeInstanceOf(Blob); + const bytes = await readBlobBytes(blobArg!); + // UTF-8 BOM 字节序列 EF BB BF + expect(bytes[0]).toBe(0xef); + expect(bytes[1]).toBe(0xbb); + expect(bytes[2]).toBe(0xbf); + // 内容 'x' 紧随 BOM 之后 + expect(bytes[3]).toBe('x'.charCodeAt(0)); + }); +}); + +describe('UserList constants', () => { + it('DEFAULT_QUERY defaults match BR2/BR3/D4', () => { + expect(DEFAULT_QUERY.queryField).toBe('用户名'); + expect(DEFAULT_QUERY.matchType).toBe('包含'); + expect(DEFAULT_QUERY.queryValue).toBe(''); + expect(DEFAULT_QUERY.pageNum).toBe(1); + expect(DEFAULT_QUERY.pageSize).toBe(10); + }); + + it('PAGE_SIZE_OPTIONS upper bound is 100', () => { + expect(PAGE_SIZE_OPTIONS).toEqual([10, 20, 50, 100]); + }); + + it('QUERY_FIELD_OPTIONS has 8 fields starting 用户名', () => { + expect(QUERY_FIELD_OPTIONS).toHaveLength(8); + expect(QUERY_FIELD_OPTIONS[0]).toBe('用户名'); + expect(QUERY_FIELD_OPTIONS).toEqual([ + '用户名', + '员工名', + '用户号', + '部门', + '用户类型', + '作废', + '登录日期', + '制单人', + ]); + }); + + it('MATCH_TYPE_OPTIONS is 包含/不包含/等于', () => { + expect(MATCH_TYPE_OPTIONS).toEqual(['包含', '不包含', '等于']); + }); +}); -- libgit2 0.22.2