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(['包含', '不包含', '等于']);
+ });
+});