Commit a646099416d642f0ae682cde0efe98765def51ec
1 parent
09968a80
feat(usr): 用户列表页面常量与前端 CSV 导出工具 REQ-USR-003
Showing
3 changed files
with
249 additions
and
0 deletions
frontend/src/pages/usr/UserList/constants.ts
0 → 100644
| 1 | +// REQ-USR-003: 用户列表页合同级常量(枚举 / 默认 query / pageSize / 错误码 / 文案) | |
| 2 | +import type { UserListQuery } from '../../../api/types'; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * 查询字段枚举(对齐 REQ 输入表 1「显示来源」/ docs/05)。逐字一致,原样作为 | |
| 6 | + * queryField 提交值,匹配语义由后端裁决(BR4)。默认首项「用户名」(BR2)。 | |
| 7 | + */ | |
| 8 | +export const QUERY_FIELD_OPTIONS = [ | |
| 9 | + '用户名', | |
| 10 | + '员工名', | |
| 11 | + '用户号', | |
| 12 | + '部门', | |
| 13 | + '用户类型', | |
| 14 | + '作废', | |
| 15 | + '登录日期', | |
| 16 | + '制单人', | |
| 17 | +] as const; | |
| 18 | + | |
| 19 | +/** 匹配方式枚举(BR4),默认「包含」(BR2) */ | |
| 20 | +export const MATCH_TYPE_OPTIONS = ['包含', '不包含', '等于'] as const; | |
| 21 | + | |
| 22 | +/** 用户范围下拉(占位 demo,spec D2):仅「全部用户」一项,不向后端传额外参数 */ | |
| 23 | +export const SCOPE_OPTIONS = ['全部用户'] as const; | |
| 24 | + | |
| 25 | +/** 每页条数选项(上限 100 对齐 docs/05 / REQ 边界,spec D4;不采用原型 demo 10000) */ | |
| 26 | +export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const; | |
| 27 | + | |
| 28 | +/** 默认查询(BR2/BR3,pageSize 默认 10 对齐 docs/05,spec D4) */ | |
| 29 | +export const DEFAULT_QUERY: UserListQuery = { | |
| 30 | + queryField: '用户名', | |
| 31 | + matchType: '包含', | |
| 32 | + queryValue: '', | |
| 33 | + pageNum: 1, | |
| 34 | + pageSize: 10, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +// === 错误码常量(对齐 docs/05 § REQ-USR-003 / spec § 4) === | |
| 38 | +/** 分页参数非法(pageNum<1 或 pageSize 超上限 100) */ | |
| 39 | +export const ERR_PAGE_INVALID = 42201; | |
| 40 | +/** 查询参数校验失败 */ | |
| 41 | +export const ERR_QUERY_INVALID = 40001; | |
| 42 | + | |
| 43 | +// === 静态文案(逐字一致,复刻原型 / spec) === | |
| 44 | +export const TEXT_REFRESH = '刷新'; | |
| 45 | +export const TEXT_ADD = '新增'; | |
| 46 | +export const TEXT_EXPORT = '导出Excel'; | |
| 47 | +export const TEXT_SEARCH = '搜索'; | |
| 48 | +export const TEXT_CLEAR = '清空'; | |
| 49 | +export const TEXT_EMPTY = '暂无匹配的用户'; | |
| 50 | +export const TEXT_ERROR = '加载失败,点击重试'; | |
| 51 | +export const TEXT_EXPORT_SUCCESS = '导出成功'; | |
| 52 | +export const TEXT_EXPORT_FAIL = '导出失败'; | |
| 53 | +export const TEXT_MSG_PAGE_INVALID = '分页参数有误,已重置为第 1 页'; | |
| 54 | +export const TEXT_MSG_QUERY_INVALID = '查询条件有误,请检查后重试'; | |
| 55 | +export const TEXT_MSG_NETWORK = '加载失败,请稍后重试'; | |
| 56 | + | |
| 57 | +/** 分页统计文案(showTotal,total 来自 PageResult.total,BR1/§ 3) */ | |
| 58 | +export const totalText = (total: number): string => `共 ${total} 条记录`; | |
| 59 | + | |
| 60 | +/** 导出文件名 */ | |
| 61 | +export const EXPORT_FILENAME = '用户列表.csv'; | ... | ... |
frontend/src/pages/usr/UserList/exportUtils.ts
0 → 100644
| 1 | +// REQ-USR-003: 前端零依赖 CSV 导出(UTF-8 BOM + Blob + <a download>,D-PLAN-1) | |
| 2 | +import type { UserVO } from '../../../api/types'; | |
| 3 | + | |
| 4 | +/** 导出列定义:中文表头 + 从 UserVO 取值(与列定义语义一致,作废 0/1→否/是;不含序号列) */ | |
| 5 | +const EXPORT_COLUMNS: { header: string; pick: (row: UserVO) => string }[] = [ | |
| 6 | + { header: '用户名', pick: (r) => r.sUserName ?? '' }, | |
| 7 | + { header: '员工名', pick: (r) => r.employeeName ?? '' }, | |
| 8 | + { header: '用户号', pick: (r) => r.sUserNo ?? '' }, | |
| 9 | + { header: '部门', pick: (r) => r.departmentName ?? '' }, | |
| 10 | + { header: '用户类型', pick: (r) => r.sUserType ?? '' }, | |
| 11 | + { header: '语言', pick: (r) => r.sLanguage ?? '' }, | |
| 12 | + { header: '作废', pick: (r) => (r.iIsVoid === 1 ? '是' : '否') }, | |
| 13 | + { header: '登录日期', pick: (r) => r.tLastLoginDate ?? '' }, | |
| 14 | + { header: '制单人', pick: (r) => r.sCreator ?? '' }, | |
| 15 | + { header: '制单日期', pick: (r) => r.tCreateDate ?? '' }, | |
| 16 | +]; | |
| 17 | + | |
| 18 | +/** CSV 单元转义:含逗号 / 引号 / 换行时用双引号包裹并转义内部引号 */ | |
| 19 | +function escapeCell(value: string): string { | |
| 20 | + if (/[",\n\r]/.test(value)) { | |
| 21 | + return `"${value.replace(/"/g, '""')}"`; | |
| 22 | + } | |
| 23 | + return value; | |
| 24 | +} | |
| 25 | + | |
| 26 | +/** 按列定义顺序与中文表头生成 CSV 文本(含表头行;空值→空串;作废 0/1→否/是) */ | |
| 27 | +export function buildUserCsv(rows: UserVO[]): string { | |
| 28 | + const header = EXPORT_COLUMNS.map((c) => escapeCell(c.header)).join(','); | |
| 29 | + const body = rows.map((row) => | |
| 30 | + EXPORT_COLUMNS.map((c) => escapeCell(c.pick(row))).join(','), | |
| 31 | + ); | |
| 32 | + return [header, ...body].join('\n'); | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** 前置 UTF-8 BOM → Blob → createObjectURL → 触发 <a download> 下载 */ | |
| 36 | +export function downloadCsv(filename: string, csv: string): void { | |
| 37 | + const BOM = ''; | |
| 38 | + const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' }); | |
| 39 | + const url = URL.createObjectURL(blob); | |
| 40 | + const a = document.createElement('a'); | |
| 41 | + a.href = url; | |
| 42 | + a.download = filename; | |
| 43 | + document.body.appendChild(a); | |
| 44 | + a.click(); | |
| 45 | + document.body.removeChild(a); | |
| 46 | + URL.revokeObjectURL(url); | |
| 47 | +} | ... | ... |
frontend/tests/unit/exportUtils.test.ts
0 → 100644
| 1 | +// REQ-USR-003: 页面常量 + 前端 CSV 导出工具单测(D-PLAN-1) | |
| 2 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | |
| 3 | +import { buildUserCsv, downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; | |
| 4 | +import { | |
| 5 | + DEFAULT_QUERY, | |
| 6 | + PAGE_SIZE_OPTIONS, | |
| 7 | + QUERY_FIELD_OPTIONS, | |
| 8 | + MATCH_TYPE_OPTIONS, | |
| 9 | +} from '../../src/pages/usr/UserList/constants'; | |
| 10 | +import type { UserVO } from '../../src/api/types'; | |
| 11 | + | |
| 12 | +const rowVoid0: UserVO = { | |
| 13 | + id: 1, | |
| 14 | + sUserName: 'admin', | |
| 15 | + employeeName: null, | |
| 16 | + sUserNo: 'U001', | |
| 17 | + departmentName: '技术部', | |
| 18 | + sUserType: '超级管理员', | |
| 19 | + sLanguage: '中文', | |
| 20 | + iIsVoid: 0, | |
| 21 | + tLastLoginDate: null, | |
| 22 | + sCreator: '系统', | |
| 23 | + tCreateDate: '2024-01-01T00:00:00', | |
| 24 | +}; | |
| 25 | + | |
| 26 | +const rowVoid1: UserVO = { | |
| 27 | + id: 2, | |
| 28 | + sUserName: '李丹', | |
| 29 | + employeeName: '李丹', | |
| 30 | + sUserNo: 'U002', | |
| 31 | + departmentName: '客服部', | |
| 32 | + sUserType: '普通用户', | |
| 33 | + sLanguage: '中文', | |
| 34 | + iIsVoid: 1, | |
| 35 | + tLastLoginDate: '2024-02-01T10:00:00', | |
| 36 | + sCreator: 'admin', | |
| 37 | + tCreateDate: '2024-01-02T00:00:00', | |
| 38 | +}; | |
| 39 | + | |
| 40 | +describe('exportUtils.buildUserCsv', () => { | |
| 41 | + it('buildUserCsv has header row and maps 作废 0/1', () => { | |
| 42 | + const csv = buildUserCsv([rowVoid0, rowVoid1]); | |
| 43 | + const lines = csv.split('\n'); | |
| 44 | + const header = lines[0]; | |
| 45 | + // 中文表头逐字(不含序号列由前端生成,导出含中文列名) | |
| 46 | + expect(header).toContain('用户名'); | |
| 47 | + expect(header).toContain('员工名'); | |
| 48 | + expect(header).toContain('作废'); | |
| 49 | + expect(header).toContain('用户号'); | |
| 50 | + expect(header).toContain('部门'); | |
| 51 | + expect(header).toContain('用户类型'); | |
| 52 | + expect(header).toContain('语言'); | |
| 53 | + expect(header).toContain('登录日期'); | |
| 54 | + expect(header).toContain('制单人'); | |
| 55 | + expect(header).toContain('制单日期'); | |
| 56 | + // 作废列 0→否、1→是 | |
| 57 | + expect(lines[1]).toContain('否'); | |
| 58 | + expect(lines[2]).toContain('是'); | |
| 59 | + // 空值字段(employeeName=null)渲染为空串,不出现 "null" | |
| 60 | + expect(csv).not.toContain('null'); | |
| 61 | + }); | |
| 62 | +}); | |
| 63 | + | |
| 64 | +describe('exportUtils.downloadCsv', () => { | |
| 65 | + let createSpy: ReturnType<typeof vi.fn>; | |
| 66 | + let clickSpy: ReturnType<typeof vi.fn>; | |
| 67 | + let blobArg: Blob | null; | |
| 68 | + | |
| 69 | + beforeEach(() => { | |
| 70 | + blobArg = null; | |
| 71 | + createSpy = vi.fn((b: Blob) => { | |
| 72 | + blobArg = b; | |
| 73 | + return 'blob:mock'; | |
| 74 | + }); | |
| 75 | + clickSpy = vi.fn(); | |
| 76 | + // jsdom 未实现 URL.createObjectURL / revokeObjectURL | |
| 77 | + (URL as unknown as { createObjectURL: unknown }).createObjectURL = createSpy; | |
| 78 | + (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn(); | |
| 79 | + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(clickSpy); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + afterEach(() => { | |
| 83 | + vi.restoreAllMocks(); | |
| 84 | + }); | |
| 85 | + | |
| 86 | + function readBlobBytes(blob: Blob): Promise<Uint8Array> { | |
| 87 | + return new Promise((resolve, reject) => { | |
| 88 | + const reader = new FileReader(); | |
| 89 | + reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer)); | |
| 90 | + reader.onerror = () => reject(reader.error); | |
| 91 | + reader.readAsArrayBuffer(blob); | |
| 92 | + }); | |
| 93 | + } | |
| 94 | + | |
| 95 | + it('downloadCsv triggers blob download with UTF-8 BOM', async () => { | |
| 96 | + downloadCsv('users.csv', 'x'); | |
| 97 | + expect(createSpy).toHaveBeenCalledTimes(1); | |
| 98 | + expect(clickSpy).toHaveBeenCalledTimes(1); | |
| 99 | + expect(blobArg).toBeInstanceOf(Blob); | |
| 100 | + const bytes = await readBlobBytes(blobArg!); | |
| 101 | + // UTF-8 BOM 字节序列 EF BB BF | |
| 102 | + expect(bytes[0]).toBe(0xef); | |
| 103 | + expect(bytes[1]).toBe(0xbb); | |
| 104 | + expect(bytes[2]).toBe(0xbf); | |
| 105 | + // 内容 'x' 紧随 BOM 之后 | |
| 106 | + expect(bytes[3]).toBe('x'.charCodeAt(0)); | |
| 107 | + }); | |
| 108 | +}); | |
| 109 | + | |
| 110 | +describe('UserList constants', () => { | |
| 111 | + it('DEFAULT_QUERY defaults match BR2/BR3/D4', () => { | |
| 112 | + expect(DEFAULT_QUERY.queryField).toBe('用户名'); | |
| 113 | + expect(DEFAULT_QUERY.matchType).toBe('包含'); | |
| 114 | + expect(DEFAULT_QUERY.queryValue).toBe(''); | |
| 115 | + expect(DEFAULT_QUERY.pageNum).toBe(1); | |
| 116 | + expect(DEFAULT_QUERY.pageSize).toBe(10); | |
| 117 | + }); | |
| 118 | + | |
| 119 | + it('PAGE_SIZE_OPTIONS upper bound is 100', () => { | |
| 120 | + expect(PAGE_SIZE_OPTIONS).toEqual([10, 20, 50, 100]); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it('QUERY_FIELD_OPTIONS has 8 fields starting 用户名', () => { | |
| 124 | + expect(QUERY_FIELD_OPTIONS).toHaveLength(8); | |
| 125 | + expect(QUERY_FIELD_OPTIONS[0]).toBe('用户名'); | |
| 126 | + expect(QUERY_FIELD_OPTIONS).toEqual([ | |
| 127 | + '用户名', | |
| 128 | + '员工名', | |
| 129 | + '用户号', | |
| 130 | + '部门', | |
| 131 | + '用户类型', | |
| 132 | + '作废', | |
| 133 | + '登录日期', | |
| 134 | + '制单人', | |
| 135 | + ]); | |
| 136 | + }); | |
| 137 | + | |
| 138 | + it('MATCH_TYPE_OPTIONS is 包含/不包含/等于', () => { | |
| 139 | + expect(MATCH_TYPE_OPTIONS).toEqual(['包含', '不包含', '等于']); | |
| 140 | + }); | |
| 141 | +}); | ... | ... |