From bc1e862b8a487589dae756e3e1c5a5644c7986f5 Mon Sep 17 00:00:00 2001
From: wsj <593216709@qq.com>
Date: Sat, 17 Oct 2026 17:43:37 +0800
Subject: [PATCH] feat: api
---
src/pages/business/orders/types.ts | 19
package-lock.json | 195 +++++++
CLAUDE.md | 27
.claude/skills/new-page/SKILL.md | 139 +++++
.claude/skills/new-page/templates/non-table-page/types.ts | 3
src/pages/login/index.tsx | 55 -
.claude/skills/new-page/templates/table-page/columns.tsx | 58 ++
.claude/skills/new-page/templates/non-table-page/index.tsx | 63 ++
.claude/skills/new-page/templates/non-table-page/server.ts | 12
src/pages/business/orders/columns.tsx | 84 +++
vite.config.ts | 36
src/api/request.ts | 302 +++++++++++
src/components/AntdAppBridge.tsx | 16
src/pages/login/Login.scss | 58 --
.claude/skills/new-page/templates/table-page/types.ts | 25
src/pages/business/orders/index.tsx | 264 +++------
src/api/auth.ts | 77 +-
src/main.tsx | 2
.env | 3
src/utils/feedback.ts | 15
README.md | 53 +
package.json | 1
src/types/auth.ts | 17
.claude/skills/new-page/templates/table-page/server.ts | 19
.claude/skills/new-page/templates/table-page/index.tsx | 77 ++
25 files changed, 1,289 insertions(+), 331 deletions(-)
diff --git a/.claude/skills/new-page/SKILL.md b/.claude/skills/new-page/SKILL.md
new file mode 100644
index 0000000..5991700
--- /dev/null
+++ b/.claude/skills/new-page/SKILL.md
@@ -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` 通过
diff --git a/.claude/skills/new-page/templates/non-table-page/index.tsx b/.claude/skills/new-page/templates/non-table-page/index.tsx
new file mode 100644
index 0000000..d8a991c
--- /dev/null
+++ b/.claude/skills/new-page/templates/non-table-page/index.tsx
@@ -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;
diff --git a/.claude/skills/new-page/templates/non-table-page/server.ts b/.claude/skills/new-page/templates/non-table-page/server.ts
new file mode 100644
index 0000000..e83737c
--- /dev/null
+++ b/.claude/skills/new-page/templates/non-table-page/server.ts
@@ -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,
+ });
diff --git a/.claude/skills/new-page/templates/non-table-page/types.ts b/.claude/skills/new-page/templates/non-table-page/types.ts
new file mode 100644
index 0000000..6d5497e
--- /dev/null
+++ b/.claude/skills/new-page/templates/non-table-page/types.ts
@@ -0,0 +1,3 @@
+export interface XxxFormValues {
+ name: string;
+}
diff --git a/.claude/skills/new-page/templates/table-page/columns.tsx b/.claude/skills/new-page/templates/table-page/columns.tsx
new file mode 100644
index 0000000..df92d82
--- /dev/null
+++ b/.claude/skills/new-page/templates/table-page/columns.tsx
@@ -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>
+ ),
+ },
+];
diff --git a/.claude/skills/new-page/templates/table-page/index.tsx b/.claude/skills/new-page/templates/table-page/index.tsx
new file mode 100644
index 0000000..5bc99e8
--- /dev/null
+++ b/.claude/skills/new-page/templates/table-page/index.tsx
@@ -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;
diff --git a/.claude/skills/new-page/templates/table-page/server.ts b/.claude/skills/new-page/templates/table-page/server.ts
new file mode 100644
index 0000000..b97d3e4
--- /dev/null
+++ b/.claude/skills/new-page/templates/table-page/server.ts
@@ -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',
+ });
diff --git a/.claude/skills/new-page/templates/table-page/types.ts b/.claude/skills/new-page/templates/table-page/types.ts
new file mode 100644
index 0000000..41101c2
--- /dev/null
+++ b/.claude/skills/new-page/templates/table-page/types.ts
@@ -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;
+}
diff --git a/.env b/.env
new file mode 100644
index 0000000..c8d1cf5
--- /dev/null
+++ b/.env
@@ -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
diff --git a/CLAUDE.md b/CLAUDE.md
index 08801be..ff38e16 100644
--- a/CLAUDE.md
+++ b/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 全量依赖),后续可通过路由懒加载 / 手动分包优化。
diff --git a/README.md b/README.md
index 8593d2a..efbbb9f 100644
--- a/README.md
+++ b/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 或安全的存储方案,并支持过期 / 刷新机制。
diff --git a/package-lock.json b/package-lock.json
index bb86481..61135be 100644
--- a/package-lock.json
+++ b/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",
diff --git a/package.json b/package.json
index 2bd2273..fd2223f 100644
--- a/package.json
+++ b/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",
diff --git a/src/api/auth.ts b/src/api/auth.ts
index 5a2738c..91eccac 100644
--- a/src/api/auth.ts
+++ b/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),
};
};
diff --git a/src/api/request.ts b/src/api/request.ts
new file mode 100644
index 0000000..19e5816
--- /dev/null
+++ b/src/api/request.ts
@@ -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;
+ }
+};
diff --git a/src/components/AntdAppBridge.tsx b/src/components/AntdAppBridge.tsx
new file mode 100644
index 0000000..a073947
--- /dev/null
+++ b/src/components/AntdAppBridge.tsx
@@ -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;
diff --git a/src/main.tsx b/src/main.tsx
index 368cf92..79e9751 100644
--- a/src/main.tsx
+++ b/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>
diff --git a/src/pages/business/orders/columns.tsx b/src/pages/business/orders/columns.tsx
new file mode 100644
index 0000000..dbbc670
--- /dev/null
+++ b/src/pages/business/orders/columns.tsx
@@ -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>
+ ),
+ },
+];
diff --git a/src/pages/business/orders/index.tsx b/src/pages/business/orders/index.tsx
index 2686822..44d2bbe 100644
--- a/src/pages/business/orders/index.tsx
+++ b/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>
);
};
diff --git a/src/pages/business/orders/types.ts b/src/pages/business/orders/types.ts
new file mode 100644
index 0000000..891364d
--- /dev/null
+++ b/src/pages/business/orders/types.ts
@@ -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' },
+};
diff --git a/src/pages/login/Login.scss b/src/pages/login/Login.scss
index c19848f..1501399 100644
--- a/src/pages/login/Login.scss
+++ b/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;
}
}
diff --git a/src/pages/login/index.tsx b/src/pages/login/index.tsx
index 0b68820..66095de 100644
--- a/src/pages/login/index.tsx
+++ b/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>
diff --git a/src/types/auth.ts b/src/types/auth.ts
index db880cb..eecd57d 100644
--- a/src/types/auth.ts
+++ b/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;
diff --git a/src/utils/feedback.ts b/src/utils/feedback.ts
new file mode 100644
index 0000000..d634a1e
--- /dev/null
+++ b/src/utils/feedback.ts
@@ -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);
+};
diff --git a/vite.config.ts b/vite.config.ts
index b76ce5c..af0bd62 100644
--- a/vite.config.ts
+++ b/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/, ''),
+ },
+ },
+ },
+ };
});
--
Gitblit v1.9.3