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 | +}); |