Commit a646099416d642f0ae682cde0efe98765def51ec

Authored by zichun
1 parent 09968a80

feat(usr): 用户列表页面常量与前端 CSV 导出工具 REQ-USR-003

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