wsj
2026-10-17 bc1e862b8a487589dae756e3e1c5a5644c7986f5
feat: api
11 files modified
14 files added
1620 ■■■■■ changed files
.claude/skills/new-page/SKILL.md 139 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/index.tsx 63 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/server.ts 12 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/non-table-page/types.ts 3 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/columns.tsx 58 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/index.tsx 77 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/server.ts 19 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/templates/table-page/types.ts 25 ●●●●● patch | view | raw | blame | history
.env 3 ●●●●● patch | view | raw | blame | history
CLAUDE.md 27 ●●●●● patch | view | raw | blame | history
README.md 53 ●●●●● patch | view | raw | blame | history
package-lock.json 195 ●●●●● patch | view | raw | blame | history
package.json 1 ●●●●● patch | view | raw | blame | history
src/api/auth.ts 77 ●●●●● patch | view | raw | blame | history
src/api/request.ts 302 ●●●●● patch | view | raw | blame | history
src/components/AntdAppBridge.tsx 16 ●●●●● patch | view | raw | blame | history
src/main.tsx 2 ●●●●● patch | view | raw | blame | history
src/pages/business/orders/columns.tsx 84 ●●●●● patch | view | raw | blame | history
src/pages/business/orders/index.tsx 264 ●●●●● patch | view | raw | blame | history
src/pages/business/orders/types.ts 19 ●●●●● patch | view | raw | blame | history
src/pages/login/Login.scss 58 ●●●●● patch | view | raw | blame | history
src/pages/login/index.tsx 55 ●●●●● patch | view | raw | blame | history
src/types/auth.ts 17 ●●●●● patch | view | raw | blame | history
src/utils/feedback.ts 15 ●●●●● patch | view | raw | blame | history
vite.config.ts 36 ●●●●● patch | view | raw | blame | history
.claude/skills/new-page/SKILL.md
New file
@@ -0,0 +1,139 @@
---
name: new-page
description: 当用户要创建新页面、新建新页面、新建页面、新增页面、加一个页面或搭建页面脚手架时使用。先选择分支:表格/列表页使用 ProTable 模板,表单/详情/仪表盘等非表格页使用 Ant Design 模板;两个分支都会生成 server.ts 维护接口调用,并统一执行代码规范、路由注册、文档更新与类型检查。
---
# 新增页面
为 CRM 管理后台新增页面。先选择分支并套用该分支的唯一模板,再执行公共步骤。
## 1. 选择分支
| 分支 | 适用场景 | 模板(整体复制) |
| --- | --- | --- |
| **表格页** | 列表、数据表格、带搜索/筛选/分页 | `templates/table-page/` |
| **非表格页** | 表单、详情、仪表盘、设置页、纯展示页 | `templates/non-table-page/` |
无法从需求判断时向用户确认。两个分支都不重复维护路由等公共逻辑,完成后统一执行第 4 节。
同时确认:
| 项目 | 说明 | 示例 |
| --- | --- | --- |
| 业务域 | 决定目录 `src/pages/<domain>/` | `system`、`business` |
| 页面名 | 目录名与组件名 | `invoices` / `Invoices` |
| 路由 | 完整路径,同时作为菜单 key | `/business/invoices` |
| 菜单标题 / 图标 | 中文标题 + `@ant-design/icons` | 发票管理 / `FileTextOutlined` |
| 数据来源 | 接口调用放在同目录 `server.ts`;演示阶段可先用本地 mock | `server.ts` |
| 是否分组 | 是否放入现有的「系统管理 / 业务管理」分组 | `children` |
表格页额外确认:字段、列展示方式、可搜索字段、分页大小。
非表格页额外确认:表单字段与校验、详情字段、布局形式。
## 2. 分支 A:表格页
模板目录:`templates/table-page/`,整体复制到 `src/pages/<domain>/<page>/`:
```text
src/pages/<domain>/<page>/
├── index.tsx       # 页面组件(默认导出)
├── columns.tsx     # ProColumns<T>[] 列定义
├── types.ts        # 记录类型与查询参数
├── server.ts       # 接口调用(列表查询、删除等)
└── <Page>.scss     # 页面级样式(按需新增)
```
替换占位符:`Xxx` → 实体名(大驼峰),`xxx` → 目录/样式名(小写)。
该分支要求:
- 使用 `ProTable`,不要手写「筛选表单 + Table」。
- `columns` 必须放在独立 `columns.tsx`,类型为 `ProColumns<T>[]`;需要回调时导出 `createXxxColumns(options)` 工厂函数。
- 页面中用 `useMemo` 缓存 columns,用 `useCallback` 包裹事件处理函数。
- 页面组件不直接写请求:列表查询、删除等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。
- `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios;泛型顺序为 `request<请求参数, 响应数据>`。
- 分页使用 `defaultPageSize`(不要用受控的 `pageSize`);需要切换每页条数时设置 `showSizeChanger: true`。
- 不需要右侧工具栏图标时用 `options={false}`。
- 表格补充 `scroll={{ x: ... }}` 以兼容窄屏。
## 3. 分支 B:非表格页
模板文件:`templates/non-table-page/index.tsx`,复制到 `src/pages/<domain>/<page>/index.tsx`:
```text
src/pages/<domain>/<page>/
├── index.tsx       # 页面组件(默认导出)
├── types.ts        # 表单 / 详情类型
├── server.ts       # 接口调用(保存、详情查询等)
└── <Page>.scss     # 页面级样式(按需新增)
```
表单页直接使用模板;详情页把 `Form` 换成 `Descriptions`,仪表盘使用 `Card` + `Statistic` + `Row`/`Col`。
该分支要求:
- **不要创建 `columns.tsx`,不要使用 `ProTable`。**
- 表单使用 Ant Design `Form` + `Form.Item` + `rules` 校验。
- 数据展示优先使用 `Card`、`Descriptions`、`Statistic`、`Tag` 等 Ant Design 组件。
- 页面局部状态使用 `useState`,派生数据使用 `useMemo`。
- 保存、详情查询等接口调用统一放在同目录的 `server.ts`,组件只调用导出的方法。
- `server.ts` 使用 `@/api/request` 提供的 `request` 发送请求,不要直接使用 axios。
## 4. 公共步骤(两个分支都执行)
### 4.1 代码规范
- 页面级接口调用统一放在页面目录的 `server.ts`,不要写在组件内部。
- 统一使用 `src/api/request.ts` 的 `request`,不要直接使用 axios。默认会合并相同并发请求、自动提示错误,并只返回响应 `data`。
- 请求选项:`merge` 控制是否合并、`showError` 控制是否提示错误、`parse: false` 返回完整 `AxiosResponse`、`signal` 用于取消。
- 全部使用箭头函数,不新增 `function` 声明。
- 提示与确认框使用 `AntdApp.useApp()` 提供的 `message` / `modal`,不要用静态方法。
- 使用 Ant Design 5 API,不使用已废弃属性(如 Card 用 `variant="borderless"`)。
- 页面只负责内容区域,外层布局由 `BasicLayout` 提供。
- 页面级样式与页面同目录,使用 BEM 类名。
- 不添加权限相关代码:本项目已移除权限体系。
### 4.2 注册路由
在 `src/router/routeConfig.tsx` 中增加配置,并补充组件与图标的 import:
```tsx
{
  path: '/business/invoices',
  title: '发票管理',
  icon: <FileTextOutlined />,
  element: <Invoices />,
},
```
放入现有分组时写在其 `children` 中;新增一级分组时保持与现有分组相同的结构。
### 4.3 更新文档
如果新增的是演示页面,同步更新 `README.md` 的「演示页面」表格。不需要更新 `CLAUDE.md`。
### 4.4 验证
```bash
npm run typecheck
```
涉及样式或构建配置时再执行:
```bash
npm run build
```
完成后简要说明:分支类型、新增文件、路由地址,以及验证结果。
### 4.5 完成检查清单
- [ ] 已选择正确分支并整体使用对应模板
- [ ] 表格页使用 ProTable,columns 独立在 `columns.tsx`
- [ ] 非表格页未创建 `columns.tsx`,使用合适的 Ant Design 组件
- [ ] 两个分支均已创建 `server.ts`,组件内没有直接写请求
- [ ] 类型使用 `ProColumns<T>` / 明确接口,没有 `any`
- [ ] 全部使用箭头函数
- [ ] 提示与确认框通过 `AntdApp.useApp()` 获取
- [ ] 路由已注册,菜单与面包屑可正常显示
- [ ] `npm run typecheck` 通过
.claude/skills/new-page/templates/non-table-page/index.tsx
New file
@@ -0,0 +1,63 @@
import { useState } from 'react';
import { App as AntdApp, Button, Card, Form, Input, Space } from 'antd';
import { saveXxx } from './server';
import type { XxxFormValues } from './types';
import './Xxx.scss';
const Xxx = () => {
  const [submitting, setSubmitting] = useState(false);
  const [form] = Form.useForm<XxxFormValues>();
  const { message } = AntdApp.useApp();
  const handleFinish = async (values: XxxFormValues) => {
    setSubmitting(true);
    try {
      await saveXxx(values);
      message.success('保存成功');
      form.resetFields();
    } catch (error) {
      message.error(error instanceof Error ? error.message : '保存失败,请稍后重试');
    } finally {
      setSubmitting(false);
    }
  };
  return (
    <div className="xxx-page">
      <div className="page-header">
        <div>
          <h1 className="page-header__title">页面标题</h1>
          <p className="page-header__desc">页面说明。</p>
        </div>
      </div>
      <Card variant="borderless">
        <Form<XxxFormValues>
          form={form}
          layout="vertical"
          requiredMark={false}
          onFinish={handleFinish}
        >
          <Form.Item
            name="name"
            label="名称"
            rules={[{ required: true, message: '请输入名称' }]}
          >
            <Input placeholder="请输入名称" />
          </Form.Item>
          <Form.Item>
            <Space>
              <Button type="primary" htmlType="submit" loading={submitting}>
                保存
              </Button>
              <Button onClick={() => form.resetFields()}>重置</Button>
            </Space>
          </Form.Item>
        </Form>
      </Card>
    </div>
  );
};
export default Xxx;
.claude/skills/new-page/templates/non-table-page/server.ts
New file
@@ -0,0 +1,12 @@
import { request } from '@/api/request';
import type { XxxFormValues } from './types';
// TODO: 替换为真实接口地址与字段。
/** 保存表单。 */
export const saveXxx = (values: XxxFormValues) =>
  request<XxxFormValues, void>({
    url: '/api/xxx',
    method: 'POST',
    data: values,
  });
.claude/skills/new-page/templates/non-table-page/types.ts
New file
@@ -0,0 +1,3 @@
export interface XxxFormValues {
  name: string;
}
.claude/skills/new-page/templates/table-page/columns.tsx
New file
@@ -0,0 +1,58 @@
import { Button, Space, Tag } from 'antd';
import type { ProColumns } from '@ant-design/pro-components';
import type { XxxRecord, XxxStatus } from './types';
const statusMeta: Record<XxxStatus, { text: string; color: string }> = {
  active: { text: '启用', color: 'success' },
  disabled: { text: '停用', color: 'default' },
};
export const createXxxColumns = (options: {
  onEdit?: (record: XxxRecord) => void;
  onDelete: (record: XxxRecord) => void;
}): ProColumns<XxxRecord>[] => [
  {
    title: '名称',
    dataIndex: 'name',
    width: 240,
    fieldProps: { placeholder: '请输入名称' },
  },
  {
    title: '状态',
    dataIndex: 'status',
    width: 110,
    valueEnum: {
      active: { text: '启用' },
      disabled: { text: '停用' },
    },
    render: (_value, record) => (
      <Tag color={statusMeta[record.status].color} bordered={false}>
        {statusMeta[record.status].text}
      </Tag>
    ),
  },
  {
    title: '创建时间',
    dataIndex: 'createdAt',
    width: 170,
    search: false,
  },
  {
    title: '操作',
    key: 'action',
    valueType: 'option',
    width: 140,
    render: (_value, record) => (
      <Space size={0}>
        {options.onEdit && (
          <Button type="link" size="small" onClick={() => options.onEdit?.(record)}>
            编辑
          </Button>
        )}
        <Button type="link" size="small" danger onClick={() => options.onDelete(record)}>
          删除
        </Button>
      </Space>
    ),
  },
];
.claude/skills/new-page/templates/table-page/index.tsx
New file
@@ -0,0 +1,77 @@
import { useCallback, useMemo, useRef } from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import type { ActionType } from '@ant-design/pro-components';
import { App as AntdApp, Button } from 'antd';
import { createXxxColumns } from './columns';
import { deleteXxx, getXxxList } from './server';
import type { XxxQuery, XxxRecord } from './types';
import './Xxx.scss';
const Xxx = () => {
  const actionRef = useRef<ActionType>();
  const { message, modal } = AntdApp.useApp();
  const handleDelete = useCallback(
    (record: XxxRecord) => {
      modal.confirm({
        title: '删除记录',
        content: `确定删除「${record.name}」吗?删除后不可恢复。`,
        okText: '删除',
        cancelText: '取消',
        okButtonProps: { danger: true },
        onOk: async () => {
          await deleteXxx(record.key);
          message.success('已删除');
          actionRef.current?.reload();
        },
      });
    },
    [message, modal],
  );
  const columns = useMemo(
    () => createXxxColumns({ onDelete: handleDelete }),
    [handleDelete],
  );
  return (
    <div className="xxx-page">
      <div className="page-header">
        <div>
          <h1 className="page-header__title">页面标题</h1>
          <p className="page-header__desc">页面说明。</p>
        </div>
      </div>
      <ProTable<XxxRecord, XxxQuery>
        rowKey="key"
        actionRef={actionRef}
        columns={columns}
        cardProps={{ className: 'xxx-table-card' }}
        search={{ labelWidth: 'auto', defaultCollapsed: false }}
        scroll={{ x: 900 }}
        options={false}
        pagination={{
          defaultPageSize: 10,
          showSizeChanger: true,
          hideOnSinglePage: false,
          showTotal: (total) => `共 ${total} 条`,
        }}
        toolBarRender={() => [
          <Button
            key="create"
            type="primary"
            icon={<PlusOutlined />}
            onClick={() => message.info('TODO: 打开新增表单')}
          >
            新增
          </Button>,
        ]}
        request={(params) => getXxxList(params)}
      />
    </div>
  );
};
export default Xxx;
.claude/skills/new-page/templates/table-page/server.ts
New file
@@ -0,0 +1,19 @@
import { request } from '@/api/request';
import type { XxxListParams, XxxListResult } from './types';
// TODO: 替换为真实接口地址与字段。
/** 查询列表。 */
export const getXxxList = (params: XxxListParams) =>
  request<XxxListParams, XxxListResult>({
    url: '/api/xxx',
    method: 'GET',
    params,
  });
/** 删除记录。 */
export const deleteXxx = (key: string) =>
  request<void, void>({
    url: `/api/xxx/${key}`,
    method: 'DELETE',
  });
.claude/skills/new-page/templates/table-page/types.ts
New file
@@ -0,0 +1,25 @@
export type XxxStatus = 'active' | 'disabled';
export interface XxxRecord {
  key: string;
  name: string;
  status: XxxStatus;
  createdAt: string;
}
/** ProTable 搜索参数。 */
export interface XxxQuery {
  name?: string;
  status?: XxxStatus;
}
export interface XxxListParams extends XxxQuery {
  current?: number;
  pageSize?: number;
}
export interface XxxListResult {
  data: XxxRecord[];
  total: number;
  success: boolean;
}
.env
New file
@@ -0,0 +1,3 @@
VITE_API_BASE_URL=http://121.41.54.56:9090/
# 开发环境代理目标(Vite 将 /proxy-api 转发到该地址)
VITE_API_PROXY_TARGET=http://121.41.54.56:9090
CLAUDE.md
@@ -23,6 +23,7 @@
- **UI**:Ant Design 5 + `@ant-design/icons`
- **高级组件**:`@ant-design/pro-components`(表格优先使用其 `ProTable`)
- **状态**:Zustand 5(登录态持久化到 localStorage)
- **请求**:Axios(统一封装在 `src/api/request.ts`)
- **样式**:SCSS + Ant Design 主题 token + CSS 变量
- **日期**:Day.js(已配置中文 locale)
@@ -30,7 +31,8 @@
```text
src/
├── api/                 # 接口层;当前只有模拟登录 auth.ts
├── api/                 # 接口层:request.ts(axios 封装)与登录 auth.ts
├── components/          # 通用组件:AntdAppBridge(message 注入)
├── layouts/             # BasicLayout:侧边栏、头部、内容区、面包屑
├── pages/               # 页面,按业务域分目录
│   ├── login/
@@ -38,6 +40,7 @@
│   ├── system/users/
│   ├── business/{orders,customers}/
│   └── error/NotFound.tsx
│   # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、同名 .scss(按需)
├── router/
│   ├── routeConfig.tsx  # 路由 + 菜单唯一数据源(含页面组件)
│   ├── menu.tsx         # 菜单过滤与 antd Menu 结构生成
@@ -45,6 +48,7 @@
├── store/               # Zustand 登录态
├── styles/              # 全局 SCSS、通用页面样式
├── types/               # 全局类型
├── utils/               # 工具:feedback.ts(全局错误提示)
└── main.tsx             # ConfigProvider + AntdApp + BrowserRouter
```
@@ -62,10 +66,8 @@
### 2. 登录态
- `src/store/auth.ts`:Zustand store,持久化 `token` 与 `user`。
- 登录 / 退出:`src/api/auth.ts` 提供模拟登录。
- 演示账号:
  - `admin / admin123`:超级管理员。
  - `user / user123`:普通运营账号。
- 登录 / 退出:`src/api/auth.ts` 调用 `POST /auth/tenant-login`,参数为 `{ phone, password, tenantCode }`。
- 接口基础地址由 `.env` 中的 `VITE_API_BASE_URL` 配置。
- 未登录访问受保护页面会跳转 `/login`,登录成功后返回原地址。
### 3. 布局与样式
@@ -180,7 +182,7 @@
## 六、新增页面流程
1. 在 `src/pages/<业务域>/<页面>/` 下创建 `index.tsx`(有表格时增加 `columns.tsx`,有独立样式时增加同名 `.scss`)。
1. 在 `src/pages/<业务域>/<页面>/` 下创建 `index.tsx`(有表格时增加 `columns.tsx`,接口调用放在 `server.ts`,类型放 `types.ts`,有独立样式时增加同名 `.scss`)。
2. 在 `src/router/routeConfig.tsx` 中增加路由配置:
   - 使用完整路径(如 `/system/users`)。
   - 配置 `title`、`icon`、`element`。
@@ -200,8 +202,16 @@
## 七、接口与状态约定
- 所有请求统一放在 `src/api/` 下,按业务域拆分文件;不要在页面内直接写请求实现。
- 当前 `src/api/auth.ts` 是前端模拟接口。接入真实后端时替换为实际请求,并保留登录态写入 store 的流程。
- 页面级接口调用统一放在页面目录下的 `server.ts`,页面组件通过它调用接口,不要在组件内直接写请求实现。
- 跨页面复用的接口可放在 `src/api/` 下,按业务域拆分文件。
- 网络请求统一使用 `src/api/request.ts` 的 `request`(基于 axios),不要在页面或 `server.ts` 中直接使用 axios。
- 开发环境通过 Vite 代理 `/proxy-api` 转发到 `VITE_API_PROXY_TARGET`(未配置时取 `VITE_API_BASE_URL`),因此浏览器端不会产生跨域请求;生产环境需后端配置 CORS,或使用同域反向代理。
  - 泛型:`request<TReq, TRes>(config, options)`,分别表示请求参数/请求体与响应 `data` 的类型。
  - 默认只返回响应 `data`;传 `parse: false` 时返回完整 `AxiosResponse`。
  - 默认自动提示错误,`showError: false` 可关闭;失败抛出 `ApiError`,包含 `status`、`data`、`response`、`config` 与原始错误。
  - 默认合并方法、URL、参数与请求体都相同的并发请求;`merge: false` 时每次都单独发送。
  - 通过 `signal: AbortSignal` 取消当前调用方的请求。
- `src/api/auth.ts` 调用真实登录接口,登录成功后仍由 `src/store/auth.ts` 写入登录态。
- 全局登录态只放在 `src/store/auth.ts`;页面局部状态使用 `useState` / `useMemo`。
- 不要在前端持久化敏感信息;真实项目的 Token 建议使用 HttpOnly Cookie,并由后端处理过期与刷新。
@@ -216,4 +226,5 @@
- 使用 BrowserRouter,生产环境需要配置 SPA fallback:所有前端路由回退到 `index.html`。
- 构建产物在 `dist/`。
- 开发环境的跨域由 Vite 代理 `/proxy-api` 处理;生产环境需要后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。
- 当前主包较大(Ant Design 全量依赖),后续可通过路由懒加载 / 手动分包优化。
README.md
@@ -20,20 +20,26 @@
npm run typecheck  # 仅做 TypeScript 类型检查
```
## 演示账号
## 登录接口
| 账号    | 密码       | 说明         |
| ------- | ---------- | ------------ |
| `admin` | `admin123` | 超级管理员   |
| `user`  | `user123`  | 普通运营账号 |
登录使用 `POST /auth/tenant-login`:
登录页提供了一键填充演示账号的按钮。
```json
{
  "phone": "string",
  "password": "string",
  "tenantCode": "string"
}
```
- **开发环境**:请求走 Vite 代理 `/proxy-api`,代理目标由 `.env` 中的 `VITE_API_PROXY_TARGET` 配置(未配置时取 `VITE_API_BASE_URL`),浏览器端不会产生跨域请求。
- **生产环境**:请求直接使用 `VITE_API_BASE_URL`;需要在后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。
## 功能说明
### 1. 登录 / 退出
- 登录页:`src/pages/login/index.tsx`
- 登录页:`src/pages/login/index.tsx`,调用 `POST /auth/tenant-login`
- 登录状态使用 Zustand + `localStorage` 持久化:`src/store/auth.ts`
- 未登录访问受保护页面时,会跳转登录页并记录原地址,登录成功后自动返回
- 顶部用户菜单支持退出登录(带二次确认),退出后回到登录页
@@ -68,7 +74,28 @@
- 受保护路由统一包裹登录校验,未登录时跳转 `/login` 并带上 `state.from`
- 登录成功后自动返回原地址
### 4. 演示页面
### 4. 网络请求
统一使用 `src/api/request.ts` 的 `request`(基于 axios):
```ts
const result = await request<{ keyword: string }, PageResult<User>>({
  url: '/api/users',
  method: 'GET',
  params: { keyword },
});
```
- 泛型 `request<TReq, TRes>` 分别表示请求参数/请求体与响应 `data` 的类型。
- 默认只返回响应 `data`;传 `parse: false` 返回完整 `AxiosResponse`。
- 默认自动提示错误,`showError: false` 可关闭。
- 默认合并方法、URL、参数与请求体都相同的并发请求;`merge: false` 时每次都单独发送。
- 通过 `signal: AbortSignal` 取消调用方的请求。
- 失败时抛出 `ApiError`,保留 `status`、`data`、`response`、`config` 与原始错误。
页面级接口调用放在页面目录的 `server.ts`,组件通过它调用请求。
### 5. 演示页面
| 页面     | 路由                    | 说明                               |
| -------- | ----------------------- | ---------------------------------- |
@@ -82,7 +109,8 @@
```text
src/
├── api/                 # 模拟登录接口(替换为真实后端)
├── api/                 # request.ts(axios 封装)与登录接口
├── components/          # 通用组件(如 AntdAppBridge)
├── layouts/             # 后台整体布局(侧边栏、头部、内容区)
├── pages/               # 业务页面
│   ├── login/
@@ -96,6 +124,7 @@
│   └── index.tsx        # React Router 路由生成 + 登录校验
├── store/               # Zustand 登录状态
├── styles/              # 全局 SCSS
├── utils/               # feedback.ts(全局错误提示)
├── types/
└── main.tsx
```
@@ -109,10 +138,6 @@
## 安全说明
本项目中的登录接口与账号数据均为 **前端模拟**,仅用于演示。
接入真实后端时:
- 登录、菜单、用户信息应由后端接口返回;
- 登录、菜单、用户信息由后端接口返回;
- 所有敏感接口必须在服务端校验身份与权限;
- Token 建议使用 HttpOnly Cookie 或安全的存储方案,并支持过期 / 刷新机制。
package-lock.json
@@ -1282,6 +1282,14 @@
        "object-assign": "4.x"
      }
    },
    "agent-base": {
      "version": "6.0.2",
      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
      "requires": {
        "debug": "4"
      }
    },
    "antd": {
      "version": "5.29.3",
      "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz",
@@ -1338,6 +1346,22 @@
        "throttle-debounce": "^5.0.2"
      }
    },
    "asynckit": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
    },
    "axios": {
      "version": "1.20.0",
      "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
      "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
      "requires": {
        "follow-redirects": "^1.16.0",
        "form-data": "^4.0.6",
        "https-proxy-agent": "^5.0.1",
        "proxy-from-env": "^2.1.0"
      }
    },
    "baseline-browser-mapping": {
      "version": "2.11.29",
      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz",
@@ -1355,6 +1379,15 @@
        "electron-to-chromium": "^1.5.439",
        "node-releases": "^2.0.57",
        "update-browserslist-db": "^1.3.3"
      }
    },
    "call-bind-apply-helpers": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
      "requires": {
        "es-errors": "^1.3.0",
        "function-bind": "^1.1.2"
      }
    },
    "caniuse-lite": {
@@ -1376,6 +1409,14 @@
      "version": "2.5.1",
      "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
      "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
    },
    "combined-stream": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
      "requires": {
        "delayed-stream": "~1.0.0"
      }
    },
    "compute-scroll-into-view": {
      "version": "3.1.1",
@@ -1410,10 +1451,14 @@
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "dev": true,
      "requires": {
        "ms": "^2.1.3"
      }
    },
    "delayed-stream": {
      "version": "1.0.0",
      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
    },
    "dequal": {
      "version": "2.0.3",
@@ -1427,11 +1472,50 @@
      "dev": true,
      "optional": true
    },
    "dunder-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
      "requires": {
        "call-bind-apply-helpers": "^1.0.1",
        "es-errors": "^1.3.0",
        "gopd": "^1.2.0"
      }
    },
    "electron-to-chromium": {
      "version": "1.5.459",
      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz",
      "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==",
      "dev": true
    },
    "es-define-property": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
    },
    "es-errors": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
    },
    "es-object-atoms": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
      "requires": {
        "es-errors": "^1.3.0"
      }
    },
    "es-set-tostringtag": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
      "requires": {
        "es-errors": "^1.3.0",
        "get-intrinsic": "^1.2.6",
        "has-tostringtag": "^1.0.2",
        "hasown": "^2.0.2"
      }
    },
    "esbuild": {
      "version": "0.25.12",
@@ -1479,6 +1563,23 @@
      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
      "dev": true
    },
    "follow-redirects": {
      "version": "1.16.1",
      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
      "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="
    },
    "form-data": {
      "version": "4.0.6",
      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
      "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
      "requires": {
        "asynckit": "^0.4.0",
        "combined-stream": "^1.0.8",
        "es-set-tostringtag": "^2.1.0",
        "hasown": "^2.0.4",
        "mime-types": "^2.1.35"
      }
    },
    "fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1486,11 +1587,77 @@
      "dev": true,
      "optional": true
    },
    "function-bind": {
      "version": "1.1.2",
      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
    },
    "gensync": {
      "version": "1.0.0-beta.2",
      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
      "dev": true
    },
    "get-intrinsic": {
      "version": "1.3.0",
      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
      "requires": {
        "call-bind-apply-helpers": "^1.0.2",
        "es-define-property": "^1.0.1",
        "es-errors": "^1.3.0",
        "es-object-atoms": "^1.1.1",
        "function-bind": "^1.1.2",
        "get-proto": "^1.0.1",
        "gopd": "^1.2.0",
        "has-symbols": "^1.1.0",
        "hasown": "^2.0.2",
        "math-intrinsics": "^1.1.0"
      }
    },
    "get-proto": {
      "version": "1.0.1",
      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
      "requires": {
        "dunder-proto": "^1.0.1",
        "es-object-atoms": "^1.0.0"
      }
    },
    "gopd": {
      "version": "1.2.0",
      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
    },
    "has-symbols": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
    },
    "has-tostringtag": {
      "version": "1.0.2",
      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
      "requires": {
        "has-symbols": "^1.0.3"
      }
    },
    "hasown": {
      "version": "2.0.4",
      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
      "requires": {
        "function-bind": "^1.1.2"
      }
    },
    "https-proxy-agent": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
      "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
      "requires": {
        "agent-base": "6",
        "debug": "4"
      }
    },
    "immutable": {
      "version": "5.1.9",
@@ -1567,11 +1734,28 @@
        "yallist": "^3.0.2"
      }
    },
    "math-intrinsics": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
    },
    "mime-db": {
      "version": "1.52.0",
      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
    },
    "mime-types": {
      "version": "2.1.35",
      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
      "requires": {
        "mime-db": "1.52.0"
      }
    },
    "ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "dev": true
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
    },
    "nanoid": {
      "version": "3.3.20",
@@ -1642,6 +1826,11 @@
        }
      }
    },
    "proxy-from-env": {
      "version": "2.1.0",
      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
      "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
    },
    "rc-cascader": {
      "version": "3.34.0",
      "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz",
package.json
@@ -13,6 +13,7 @@
    "@ant-design/icons": "^5.5.2",
    "@ant-design/pro-components": "^2.8.10",
    "antd": "^5.22.5",
    "axios": "^1.20.0",
    "dayjs": "^1.11.13",
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
src/api/auth.ts
@@ -1,47 +1,56 @@
import type { LoginParams, LoginResult, UserInfo } from '@/types/auth';
import { ApiError, request } from '@/api/request';
import type {
  LoginParams,
  LoginResult,
  TenantLoginData,
  UserInfo,
} from '@/types/auth';
interface MockAccount {
interface TenantLoginRequest {
  phone: string;
  password: string;
  user: UserInfo;
  tenantCode: string;
}
/**
 * 演示环境下的本地账号。
 * 真实项目请替换为后端登录接口。
 */
const MOCK_ACCOUNTS: Record<string, MockAccount> = {
  admin: {
    password: 'admin123',
    user: {
      id: 1,
      username: 'admin',
      nickname: '超级管理员',
      role: 'admin',
    },
  },
  user: {
    password: 'user123',
    user: {
      id: 2,
      username: 'user',
      nickname: '普通运营',
      role: 'user',
    },
  },
const toUserInfo = (data: TenantLoginData, params: LoginParams): UserInfo => {
  const phone = data.phone ?? params.phone.trim();
  const loginType = data.loginType?.toLowerCase() ?? '';
  return {
    id: data.employeeId ?? 0,
    username: phone,
    nickname: data.employeeName ?? phone,
    role: loginType.includes('admin') ? 'admin' : 'user',
    tenant: data.tenantCode ?? params.tenant.trim(),
    phone,
  };
};
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/** 租户登录。 */
export const loginApi = async (params: LoginParams): Promise<LoginResult> => {
  await delay(500);
  const data = await request<TenantLoginRequest, TenantLoginData>(
    {
      url: '/auth/tenant-login',
      method: 'POST',
      data: {
        phone: params.phone.trim(),
        password: params.password,
        tenantCode: params.tenant.trim(),
      },
    },
    {
      // 登录有副作用,不合并;错误由登录页统一提示。
      merge: false,
      showError: false,
    },
  );
  const account = MOCK_ACCOUNTS[params.username];
  if (!account || account.password !== params.password) {
    throw new Error('用户名或密码错误');
  if (!data?.token) {
    throw new ApiError('登录响应中未找到 token', { data });
  }
  return {
    token: `mock-token-${account.user.id}-${Date.now()}`,
    user: account.user,
    token: data.token,
    user: toUserInfo(data, params),
  };
};
src/api/request.ts
New file
@@ -0,0 +1,302 @@
import axios from 'axios';
import type {
  AxiosError,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig,
} from 'axios';
import { showErrorMessage } from '@/utils/feedback';
/** 一个请求的配置。TReq 为请求参数 / 请求体的类型。 */
export type ApiRequestConfig<TReq = unknown> = Omit<
  AxiosRequestConfig<TReq>,
  'signal'
> & {
  url: string;
};
/** 每个调用方可以单独设置的请求选项。 */
export interface RequestOptions<TParse extends boolean = boolean> {
  /** 请求失败时是否自动提示错误信息,默认 true。 */
  showError?: boolean;
  /** 是否合并相同的并发请求,默认 true;为 false 时每次都单独发送。 */
  merge?: boolean;
  /** 是否解析响应:true 返回 response.data,false 返回完整 AxiosResponse。默认 true。 */
  parse?: TParse;
  /** 用于取消当前调用方发起的请求。 */
  signal?: AbortSignal;
}
/** 请求失败时抛出的统一错误,保留 Axios 返回的完整信息。 */
export class ApiError extends Error {
  readonly code?: string;
  readonly status?: number;
  readonly data?: unknown;
  readonly response?: AxiosResponse;
  readonly config?: InternalAxiosRequestConfig;
  readonly isCanceled: boolean;
  readonly originalError?: unknown;
  /** 相同请求被多个调用方共享时,保证错误信息只提示一次。 */
  notified = false;
  constructor(
    message: string,
    options: {
      code?: string;
      status?: number;
      data?: unknown;
      response?: AxiosResponse;
      config?: InternalAxiosRequestConfig;
      isCanceled?: boolean;
      originalError?: unknown;
    } = {},
  ) {
    super(message);
    this.name = 'ApiError';
    this.code = options.code;
    this.status = options.status;
    this.data = options.data;
    this.response = options.response;
    this.config = options.config;
    this.isCanceled = options.isCanceled ?? false;
    this.originalError = options.originalError;
  }
}
const http = axios.create({
  // 开发环境走 Vite 代理(同源),生产环境使用真实接口地址。
  baseURL: import.meta.env.DEV
    ? '/proxy-api'
    : import.meta.env.VITE_API_BASE_URL ?? '',
  timeout: 15000,
});
/** 递归排序对象字段,保证相同参数得到相同的请求 key。 */
const sortValue = (value: unknown): unknown => {
  if (Array.isArray(value)) return value.map(sortValue);
  if (value && typeof value === 'object') {
    return Object.keys(value as Record<string, unknown>)
      .sort()
      .reduce<Record<string, unknown>>((result, key) => {
        result[key] = sortValue((value as Record<string, unknown>)[key]);
        return result;
      }, {});
  }
  return value;
};
const stringify = (value: unknown): string => {
  if (value === undefined) return '';
  try {
    return JSON.stringify(sortValue(value));
  } catch {
    return String(value);
  }
};
const getRequestKey = <TReq>(config: ApiRequestConfig<TReq>): string =>
  [
    (config.method ?? 'GET').toUpperCase(),
    config.url,
    stringify(config.params),
    stringify(config.data),
  ].join('|');
/** 正在进行中的相同请求,值为完整的 AxiosResponse。 */
const pendingRequests = new Map<string, Promise<AxiosResponse>>();
const createCanceledError = () =>
  new ApiError('请求已取消', { isCanceled: true, code: 'ERR_CANCELED' });
const toApiError = (error: unknown): ApiError => {
  if (error instanceof ApiError) return error;
  const axiosError = error as AxiosError<{ message?: string; msg?: string }>;
  const response = axiosError?.response;
  const data = response?.data;
  const serverMessage =
    typeof data?.message === 'string'
      ? data.message
      : typeof data?.msg === 'string'
        ? data.msg
        : undefined;
  const isCanceled = axios.isCancel(error) || axiosError?.code === 'ERR_CANCELED';
  return new ApiError(
    isCanceled ? '请求已取消' : serverMessage ?? axiosError?.message ?? '网络请求失败',
    {
      code: axiosError?.code,
      status: response?.status,
      data,
      response,
      config: axiosError?.config,
      isCanceled,
      originalError: error,
    },
  );
};
/** 发送底层请求并始终保留完整响应;解析与提示交给每个调用方处理。 */
const execute = async <TReq, TRes>(
  config: ApiRequestConfig<TReq>,
  options: RequestOptions,
): Promise<AxiosResponse<TRes>> => {
  try {
    return await http.request<TRes, AxiosResponse<TRes>, TReq>({
      ...config,
      // 合并请求时不能把某个调用方的 signal 交给底层请求,否则会取消其他调用方。
      signal: options.merge === false ? options.signal : undefined,
    });
  } catch (error) {
    throw toApiError(error);
  }
};
/** 后端统一响应包装:{ code, message, data, success }。 */
interface ResultEnvelope {
  code: number | string;
  message?: string;
  data?: unknown;
  success?: boolean;
}
const isResultEnvelope = (body: unknown): body is ResultEnvelope => {
  if (!body || typeof body !== 'object') return false;
  const record = body as Record<string, unknown>;
  return (
    'code' in record &&
    ('data' in record || 'success' in record || 'message' in record)
  );
};
const isSuccessCode = (code: number | string) => code === 200;
const formatResponse = <TRes, TParse extends boolean>(
  response: AxiosResponse<TRes>,
  options: RequestOptions<TParse>,
): TParse extends true ? TRes : AxiosResponse<TRes> => {
  if (options.parse === false) {
    return response as TParse extends true ? TRes : AxiosResponse<TRes>;
  }
  const body = response.data as unknown;
  if (isResultEnvelope(body)) {
    const succeeded = isSuccessCode(body.code) || body.success === true ;
    if (!succeeded) {
      throw new ApiError(
        body.message || '请求失败',
        {
          code: String(body.code),
          status: response.status,
          data: body,
          response,
          config: response.config,
        },
      );
    }
    return body.data as TParse extends true ? TRes : AxiosResponse<TRes>;
  }
  return body as TParse extends true ? TRes : AxiosResponse<TRes>;
};
const notifyError = (error: ApiError, options: RequestOptions) => {
  if (error.isCanceled || options.showError === false || error.notified) return;
  error.notified = true;
  showErrorMessage(error.message);
};
/** 让共享请求对每个调用方单独支持取消。 */
const raceWithSignal = <T>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
  if (signal.aborted) return Promise.reject(createCanceledError());
  return new Promise<T>((resolve, reject) => {
    const onAbort = () => reject(createCanceledError());
    signal.addEventListener('abort', onAbort, { once: true });
    promise.then(
      (value) => {
        signal.removeEventListener('abort', onAbort);
        resolve(value);
      },
      (error) => {
        signal.removeEventListener('abort', onAbort);
        reject(error);
      },
    );
  });
};
/**
 * 发送网络请求。
 *
 * 默认合并相同的并发请求、自动提示错误,并且只返回服务端的 data。
 *
 * @example
 * const result = await request<{ keyword: string }, PageResult<User>>({
 *   url: '/api/users',
 *   method: 'GET',
 *   params: { keyword },
 * });
 *
 * // 不解析,拿到完整的 AxiosResponse
 * const response = await request<void, User[]>(
 *   { url: '/api/users' },
 *   { parse: false },
 * );
 *
 * // 不合并、不自动提示,并通过 signal 取消
 * const controller = new AbortController();
 * await request({ url: '/api/users' }, {
 *   merge: false,
 *   showError: false,
 *   signal: controller.signal,
 * });
 */
export const request = async <
  TReq = unknown,
  TRes = unknown,
  TParse extends boolean = true,
>(
  config: ApiRequestConfig<TReq>,
  options: RequestOptions<TParse> = {},
): Promise<TParse extends true ? TRes : AxiosResponse<TRes>> => {
  const { signal, merge = true } = options;
  if (signal?.aborted) {
    throw createCanceledError();
  }
  let task: Promise<AxiosResponse<TRes>>;
  if (merge) {
    const key = getRequestKey(config);
    const existing = pendingRequests.get(key) as Promise<AxiosResponse<TRes>> | undefined;
    if (existing) {
      task = existing;
    } else {
      task = execute<TReq, TRes>(config, options);
      pendingRequests.set(key, task);
      const cleanup = () => {
        if (pendingRequests.get(key) === task) {
          pendingRequests.delete(key);
        }
      };
      task.then(cleanup, cleanup);
    }
  } else {
    task = execute<TReq, TRes>(config, options);
  }
  try {
    const response = merge && signal ? await raceWithSignal(task, signal) : await task;
    console.log('response', response);
    return formatResponse(response, options);
  } catch (error) {
    const apiError = toApiError(error);
    notifyError(apiError, options);
    throw apiError;
  }
};
src/components/AntdAppBridge.tsx
New file
@@ -0,0 +1,16 @@
import { useEffect } from 'react';
import { App } from 'antd';
import { setMessageInstance } from '@/utils/feedback';
/** 把 AntdApp 上下文中的 message 注入到非组件模块(如 API 请求)。 */
const AntdAppBridge = () => {
  const { message } = App.useApp();
  useEffect(() => {
    setMessageInstance(message);
  }, [message]);
  return null;
};
export default AntdAppBridge;
src/main.tsx
@@ -5,6 +5,7 @@
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { BrowserRouter } from 'react-router-dom';
import AntdAppBridge from '@/components/AntdAppBridge';
import AppRouter from '@/router';
import '@/styles/global.scss';
@@ -38,6 +39,7 @@
      }}
    >
      <AntdApp>
        <AntdAppBridge />
        <BrowserRouter>
          <AppRouter />
        </BrowserRouter>
src/pages/business/orders/columns.tsx
New file
@@ -0,0 +1,84 @@
import { Button, Tag } from 'antd';
import type { ProColumns } from '@ant-design/pro-components';
import { ORDER_STATUS_META, type OrderRecord } from './types';
export const createOrderColumns = (options: {
  onProcess: (record: OrderRecord) => void;
}): ProColumns<OrderRecord>[] => [
  {
    title: '订单号',
    dataIndex: 'id',
    width: 160,
    render: (_value, record) => <code className="order-id">{record.id}</code>,
    fieldProps: { placeholder: '请输入订单号' },
  },
  {
    title: '客户',
    dataIndex: 'customer',
    width: 240,
    render: (_value, record) => (
      <div className="order-customer">
        <strong>{record.customer}</strong>
        <span>{record.contact}</span>
      </div>
    ),
    fieldProps: { placeholder: '请输入客户名称' },
  },
  {
    title: '订单金额',
    dataIndex: 'amount',
    width: 120,
    align: 'right',
    search: false,
    render: (_value, record) => (
      <strong className="order-amount">¥{record.amount.toLocaleString()}</strong>
    ),
  },
  {
    title: '来源渠道',
    dataIndex: 'channel',
    width: 110,
    search: false,
    render: (_value, record) => (
      <Tag bordered={false} color="geekblue">
        {record.channel}
      </Tag>
    ),
  },
  {
    title: '状态',
    dataIndex: 'status',
    width: 110,
    valueEnum: {
      pending: { text: '待付款' },
      processing: { text: '处理中' },
      completed: { text: '已完成' },
      cancelled: { text: '已取消' },
    },
    render: (_value, record) => (
      <Tag color={ORDER_STATUS_META[record.status].color} bordered={false}>
        {ORDER_STATUS_META[record.status].label}
      </Tag>
    ),
  },
  {
    title: '创建时间',
    dataIndex: 'createdAt',
    width: 170,
    search: false,
  },
  {
    title: '操作',
    key: 'action',
    valueType: 'option',
    width: 100,
    render: (_value, record) =>
      record.status === 'completed' ? (
        <span className="text-muted">已完成</span>
      ) : (
        <Button type="link" size="small" onClick={() => options.onProcess(record)}>
          处理
        </Button>
      ),
  },
];
src/pages/business/orders/index.tsx
@@ -1,36 +1,17 @@
import { useMemo, useState } from 'react';
import { DownloadOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import {
  App as AntdApp,
  Button,
  Card,
  Input,
  Select,
  Table,
  Tag,
} from 'antd';
import type { TableProps } from 'antd';
import { useCallback, useMemo, useState } from 'react';
import { DownloadOutlined } from '@ant-design/icons';
import { ProTable } from '@ant-design/pro-components';
import { App as AntdApp, Button } from 'antd';
import { createOrderColumns } from './columns';
import type { OrderRecord, OrderStatus } from './types';
import './Orders.scss';
type OrderStatus = 'pending' | 'processing' | 'completed' | 'cancelled';
interface OrderRecord {
  key: string;
  id: string;
  customer: string;
  contact: string;
  amount: number;
  channel: string;
  status: OrderStatus;
  createdAt: string;
interface OrderQuery {
  id?: string;
  customer?: string;
  status?: OrderStatus;
  orders: OrderRecord[];
}
const statusMeta: Record<OrderStatus, { label: string; color: string }> = {
  pending: { label: '待付款', color: 'orange' },
  processing: { label: '处理中', color: 'processing' },
  completed: { label: '已完成', color: 'success' },
  cancelled: { label: '已取消', color: 'default' },
};
const initialOrders: OrderRecord[] = [
  {
@@ -137,103 +118,42 @@
const Orders = () => {
  const [orders, setOrders] = useState<OrderRecord[]>(initialOrders);
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState<OrderStatus | 'all'>('all');
  const [summary, setSummary] = useState(() => ({
    count: initialOrders.length,
    amount: initialOrders.reduce((sum, order) => sum + order.amount, 0),
  }));
  const { message, modal } = AntdApp.useApp();
  const filteredOrders = useMemo(() => {
    const normalizedKeyword = keyword.trim().toLowerCase();
    return orders.filter((order) => {
      const matchesKeyword =
        !normalizedKeyword ||
        order.id.toLowerCase().includes(normalizedKeyword) ||
        order.customer.toLowerCase().includes(normalizedKeyword);
      const matchesStatus = status === 'all' || order.status === status;
      return matchesKeyword && matchesStatus;
    });
  }, [orders, keyword, status]);
  const tableParams = useMemo(() => ({ orders }), [orders]);
  const handleProcess = (record: OrderRecord) => {
    modal.confirm({
      title: '处理订单',
      content: `确定将订单「${record.id}」标记为已完成吗?`,
      okText: '标记完成',
      cancelText: '取消',
      onOk: () => {
        setOrders((prev) =>
          prev.map((order) =>
            order.key === record.key ? { ...order, status: 'completed' } : order,
          ),
        );
        message.success('订单状态已更新');
      },
    });
  };
  const handleProcess = useCallback(
    (record: OrderRecord) => {
      modal.confirm({
        title: '处理订单',
        content: `确定将订单「${record.id}」标记为已完成吗?`,
        okText: '标记完成',
        cancelText: '取消',
        onOk: () => {
          setOrders((prev) =>
            prev.map((order) =>
              order.key === record.key ? { ...order, status: 'completed' } : order,
            ),
          );
          message.success('订单状态已更新');
        },
      });
    },
    [message, modal],
  );
  const columns = useMemo(
    () => createOrderColumns({ onProcess: handleProcess }),
    [handleProcess],
  );
  const handleExport = () => {
    message.success(`已导出 ${filteredOrders.length} 条订单记录(演示)`);
    message.success(`已导出 ${summary.count} 条订单记录(演示)`);
  };
  const columns: TableProps<OrderRecord>['columns'] = [
    {
      title: '订单号',
      dataIndex: 'id',
      width: 160,
      render: (value: string) => <code className="order-id">{value}</code>,
    },
    {
      title: '客户',
      dataIndex: 'customer',
      width: 240,
      render: (_value, record) => (
        <div className="order-customer">
          <strong>{record.customer}</strong>
          <span>{record.contact}</span>
        </div>
      ),
    },
    {
      title: '订单金额',
      dataIndex: 'amount',
      width: 120,
      align: 'right',
      render: (value: number) => <strong className="order-amount">¥{value.toLocaleString()}</strong>,
    },
    {
      title: '来源渠道',
      dataIndex: 'channel',
      width: 110,
      render: (value: string) => (
        <Tag bordered={false} color="geekblue">
          {value}
        </Tag>
      ),
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 100,
      render: (value: OrderStatus) => (
        <Tag color={statusMeta[value].color} bordered={false}>
          {statusMeta[value].label}
        </Tag>
      ),
    },
    { title: '创建时间', dataIndex: 'createdAt', width: 160 },
    {
      title: '操作',
      key: 'action',
      width: 100,
      render: (_value, record) =>
        record.status === 'completed' ? (
          <span className="text-muted">已完成</span>
        ) : (
          <Button type="link" size="small" onClick={() => handleProcess(record)}>
            处理
          </Button>
        ),
    },
  ];
  return (
    <div className="orders-page">
@@ -244,61 +164,59 @@
            跟踪销售订单的处理进度,支持筛选与导出。
          </p>
        </div>
        <div className="page-header__actions">
          <Button
            icon={<ReloadOutlined />}
            onClick={() => message.success('订单数据已刷新')}
          >
            刷新
          </Button>
          <Button type="primary" icon={<DownloadOutlined />} onClick={handleExport}>
            导出订单
          </Button>
        </div>
      </div>
      <Card className="filter-card" variant="borderless">
        <div className="page-toolbar">
          <Input
            allowClear
            prefix={<SearchOutlined />}
            placeholder="搜索订单号 / 客户名称"
            value={keyword}
            onChange={(event) => setKeyword(event.target.value)}
            style={{ width: 280 }}
          />
          <Select<OrderStatus | 'all'>
            value={status}
            onChange={setStatus}
            style={{ width: 150 }}
            options={[
              { value: 'all', label: '全部状态' },
              { value: 'pending', label: '待付款' },
              { value: 'processing', label: '处理中' },
              { value: 'completed', label: '已完成' },
              { value: 'cancelled', label: '已取消' },
            ]}
          />
          <span className="text-muted">
            共 {filteredOrders.length} 条订单 · 合计 ¥
            {filteredOrders.reduce((sum, order) => sum + order.amount, 0).toLocaleString()}
          </span>
        </div>
      </Card>
      <ProTable<OrderRecord, OrderQuery>
        rowKey="key"
        columns={columns}
        params={tableParams}
        cardProps={{ className: 'orders-table-card' }}
        search={{ labelWidth: 'auto', defaultCollapsed: false }}
        scroll={{ x: 1000 }}
        options={false}
        pagination={{
          defaultPageSize: 8,
          showSizeChanger: true,
          hideOnSinglePage: false,
          showTotal: (total) => `共 ${total} 条`,
        }}
        toolBarRender={() => [
          <span key="summary" className="text-muted">
            共 {summary.count} 条订单 · 合计 ¥{summary.amount.toLocaleString()}
          </span>,
          <Button key="export" type="primary" icon={<DownloadOutlined />} onClick={handleExport}>
            导出订单
          </Button>,
        ]}
        request={async (params) => {
          const { current = 1, pageSize = 8, id, customer, status, orders: dataSource } =
            params;
          const normalizedId = id?.trim().toLowerCase();
          const normalizedCustomer = customer?.trim().toLowerCase();
      <Card className="table-card" variant="borderless">
        <Table<OrderRecord>
          rowKey="key"
          columns={columns}
          dataSource={filteredOrders}
          scroll={{ x: 1000 }}
          pagination={{
            pageSize: 8,
            showSizeChanger: false,
            showTotal: (total) => `共 ${total} 条`,
          }}
        />
      </Card>
          const filteredOrders = dataSource.filter((order) => {
            const matchesId =
              !normalizedId || order.id.toLowerCase().includes(normalizedId);
            const matchesCustomer =
              !normalizedCustomer ||
              order.customer.toLowerCase().includes(normalizedCustomer);
            const matchesStatus = !status || order.status === status;
            return matchesId && matchesCustomer && matchesStatus;
          });
          setSummary({
            count: filteredOrders.length,
            amount: filteredOrders.reduce((sum, order) => sum + order.amount, 0),
          });
          const start = (current - 1) * pageSize;
          return {
            data: filteredOrders.slice(start, start + pageSize),
            total: filteredOrders.length,
            success: true,
          };
        }}
      />
    </div>
  );
};
src/pages/business/orders/types.ts
New file
@@ -0,0 +1,19 @@
export type OrderStatus = 'pending' | 'processing' | 'completed' | 'cancelled';
export interface OrderRecord {
  key: string;
  id: string;
  customer: string;
  contact: string;
  amount: number;
  channel: string;
  status: OrderStatus;
  createdAt: string;
}
export const ORDER_STATUS_META: Record<OrderStatus, { label: string; color: string }> = {
  pending: { label: '待付款', color: 'orange' },
  processing: { label: '处理中', color: 'processing' },
  completed: { label: '已完成', color: 'success' },
  cancelled: { label: '已取消', color: 'default' },
};
src/pages/login/Login.scss
@@ -195,60 +195,6 @@
  }
}
.login-demo {
  margin-top: 28px;
  padding-top: 22px;
  border-top: 1px dashed var(--app-border);
  &__title {
    display: flex;
    align-items: center;
    gap: 7px;
    margin-bottom: 12px;
    font-size: 12px;
    color: var(--app-text-secondary);
  }
  &__accounts {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    button {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      gap: 4px;
      padding: 10px 12px;
      border: 1px solid var(--app-border);
      border-radius: 9px;
      background: #fafbff;
      cursor: pointer;
      text-align: left;
      transition:
        border-color 0.2s ease,
        background 0.2s ease;
      &:hover {
        border-color: #c7d2fe;
        background: #f5f3ff;
      }
      span {
        font-size: 12px;
        font-weight: 600;
        color: var(--app-text);
      }
      code {
        font-size: 11px;
        color: var(--app-text-secondary);
        font-family: 'SFMono-Regular', Consolas, monospace;
      }
    }
  }
}
@media (max-width: 900px) {
  .login-shell {
    grid-template-columns: 1fr;
@@ -276,9 +222,5 @@
  .login-panel {
    padding: 32px 24px;
  }
  .login-demo__accounts {
    grid-template-columns: 1fr;
  }
}
src/pages/login/index.tsx
@@ -1,9 +1,9 @@
import { useState } from 'react';
import {
  ApartmentOutlined,
  CheckCircleFilled,
  LockOutlined,
  SafetyCertificateOutlined,
  UserOutlined,
  MobileOutlined,
} from '@ant-design/icons';
import { App as AntdApp, Button, Form, Input, Tag } from 'antd';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
@@ -39,10 +39,6 @@
    } finally {
      setLoading(false);
    }
  };
  const fillAccount = (username: string, password: string) => {
    form.setFieldsValue({ username, password });
  };
  return (
@@ -92,18 +88,35 @@
              layout="vertical"
              size="large"
              requiredMark={false}
              initialValues={{ username: 'admin', password: 'admin123' }}
              onFinish={handleFinish}
            >
              <Form.Item
                name="username"
                label="用户名"
                rules={[{ required: true, message: '请输入用户名' }]}
                name="tenant"
                label="租户"
                rules={[
                  { required: true, whitespace: true, message: '请输入租户标识' },
                ]}
              >
                <Input
                  prefix={<UserOutlined />}
                  placeholder="请输入用户名"
                  autoComplete="username"
                  prefix={<ApartmentOutlined />}
                  placeholder="请输入租户标识"
                  autoComplete="organization"
                />
              </Form.Item>
              <Form.Item
                name="phone"
                label="手机号"
                rules={[
                  { required: true, message: '请输入手机号' },
                  { pattern: /^1\d{10}$/, message: '请输入正确的手机号' },
                ]}
              >
                <Input
                  prefix={<MobileOutlined />}
                  placeholder="请输入手机号"
                  autoComplete="tel"
                  maxLength={11}
                />
              </Form.Item>
@@ -128,22 +141,6 @@
                登 录
              </Button>
            </Form>
            <div className="login-demo">
              <div className="login-demo__title">
                <SafetyCertificateOutlined /> 演示账号
              </div>
              <div className="login-demo__accounts">
                <button type="button" onClick={() => fillAccount('admin', 'admin123')}>
                  <span>管理员</span>
                  <code>admin / admin123</code>
                </button>
                <button type="button" onClick={() => fillAccount('user', 'user123')}>
                  <span>普通用户</span>
                  <code>user / user123</code>
                </button>
              </div>
            </div>
          </div>
        </section>
      </div>
src/types/auth.ts
@@ -5,13 +5,28 @@
  username: string;
  nickname: string;
  role: UserRole;
  tenant: string;
  phone: string;
}
export interface LoginParams {
  username: string;
  tenant: string;
  phone: string;
  password: string;
}
/** `/auth/tenant-login` 返回的业务数据。 */
export interface TenantLoginData {
  token: string;
  employeeId?: number;
  employeeName?: string;
  phone?: string;
  tenantId?: string;
  tenantCode?: string;
  tenantName?: string;
  loginType?: string;
}
export interface LoginResult {
  token: string;
  user: UserInfo;
src/utils/feedback.ts
New file
@@ -0,0 +1,15 @@
import type { MessageInstance } from 'antd/es/message/interface';
/**
 * 非组件模块(如 API 请求)无法直接使用 `App.useApp()`,
 * 由 `AntdAppBridge` 把 App 上下文中的 message 实例注入到这里。
 */
let messageInstance: MessageInstance | null = null;
export const setMessageInstance = (instance: MessageInstance) => {
  messageInstance = instance;
};
export const showErrorMessage = (content: string) => {
  messageInstance?.error(content);
};
vite.config.ts
@@ -1,16 +1,30 @@
import { fileURLToPath, URL } from 'node:url';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
/** 开发环境通过 Vite 代理访问后端,避免浏览器跨域。 */
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '');
  const proxyTarget =
    env.VITE_API_PROXY_TARGET || env.VITE_API_BASE_URL || 'http://localhost:9090';
  return {
    plugins: [react()],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url)),
      },
    },
  },
  server: {
    port: 5173,
    host: true,
  },
    server: {
      port: 5173,
      host: true,
      proxy: {
        '/proxy-api': {
          target: proxyTarget,
          changeOrigin: true,
          rewrite: (path) => path.replace(/^\/proxy-api/, ''),
        },
      },
    },
  };
});