11 files modified
14 files added
| New file |
| | |
| | | --- |
| | | 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` 通过 |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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, |
| | | }); |
| New file |
| | |
| | | export interface XxxFormValues { |
| | | name: string; |
| | | } |
| New file |
| | |
| | | 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> |
| | | ), |
| | | }, |
| | | ]; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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', |
| | | }); |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | VITE_API_BASE_URL=http://121.41.54.56:9090/ |
| | | # 开发环境代理目标(Vite 将 /proxy-api 转发到该地址) |
| | | VITE_API_PROXY_TARGET=http://121.41.54.56:9090 |
| | |
| | | - **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) |
| | | |
| | |
| | | |
| | | ```text |
| | | src/ |
| | | ├── api/ # 接口层;当前只有模拟登录 auth.ts |
| | | ├── api/ # 接口层:request.ts(axios 封装)与登录 auth.ts |
| | | ├── components/ # 通用组件:AntdAppBridge(message 注入) |
| | | ├── layouts/ # BasicLayout:侧边栏、头部、内容区、面包屑 |
| | | ├── pages/ # 页面,按业务域分目录 |
| | | │ ├── login/ |
| | |
| | | │ ├── system/users/ |
| | | │ ├── business/{orders,customers}/ |
| | | │ └── error/NotFound.tsx |
| | | │ # 页面目录:index.tsx、columns.tsx、types.ts、server.ts、同名 .scss(按需) |
| | | ├── router/ |
| | | │ ├── routeConfig.tsx # 路由 + 菜单唯一数据源(含页面组件) |
| | | │ ├── menu.tsx # 菜单过滤与 antd Menu 结构生成 |
| | |
| | | ├── store/ # Zustand 登录态 |
| | | ├── styles/ # 全局 SCSS、通用页面样式 |
| | | ├── types/ # 全局类型 |
| | | ├── utils/ # 工具:feedback.ts(全局错误提示) |
| | | └── main.tsx # ConfigProvider + AntdApp + BrowserRouter |
| | | ``` |
| | | |
| | |
| | | ### 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. 布局与样式 |
| | |
| | | |
| | | ## 六、新增页面流程 |
| | | |
| | | 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`。 |
| | |
| | | |
| | | ## 七、接口与状态约定 |
| | | |
| | | - 所有请求统一放在 `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,并由后端处理过期与刷新。 |
| | | |
| | |
| | | |
| | | - 使用 BrowserRouter,生产环境需要配置 SPA fallback:所有前端路由回退到 `index.html`。 |
| | | - 构建产物在 `dist/`。 |
| | | - 开发环境的跨域由 Vite 代理 `/proxy-api` 处理;生产环境需要后端开启 CORS,或将前端与接口部署在同一域名下由反向代理转发。 |
| | | - 当前主包较大(Ant Design 全量依赖),后续可通过路由懒加载 / 手动分包优化。 |
| | |
| | | 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` |
| | | - 未登录访问受保护页面时,会跳转登录页并记录原地址,登录成功后自动返回 |
| | | - 顶部用户菜单支持退出登录(带二次确认),退出后回到登录页 |
| | |
| | | - 受保护路由统一包裹登录校验,未登录时跳转 `/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. 演示页面 |
| | | |
| | | | 页面 | 路由 | 说明 | |
| | | | -------- | ----------------------- | ---------------------------------- | |
| | |
| | | |
| | | ```text |
| | | src/ |
| | | ├── api/ # 模拟登录接口(替换为真实后端) |
| | | ├── api/ # request.ts(axios 封装)与登录接口 |
| | | ├── components/ # 通用组件(如 AntdAppBridge) |
| | | ├── layouts/ # 后台整体布局(侧边栏、头部、内容区) |
| | | ├── pages/ # 业务页面 |
| | | │ ├── login/ |
| | |
| | | │ └── index.tsx # React Router 路由生成 + 登录校验 |
| | | ├── store/ # Zustand 登录状态 |
| | | ├── styles/ # 全局 SCSS |
| | | ├── utils/ # feedback.ts(全局错误提示) |
| | | ├── types/ |
| | | └── main.tsx |
| | | ``` |
| | |
| | | |
| | | ## 安全说明 |
| | | |
| | | 本项目中的登录接口与账号数据均为 **前端模拟**,仅用于演示。 |
| | | |
| | | 接入真实后端时: |
| | | |
| | | - 登录、菜单、用户信息应由后端接口返回; |
| | | - 登录、菜单、用户信息由后端接口返回; |
| | | - 所有敏感接口必须在服务端校验身份与权限; |
| | | - Token 建议使用 HttpOnly Cookie 或安全的存储方案,并支持过期 / 刷新机制。 |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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": { |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | "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", |
| | |
| | | } |
| | | } |
| | | }, |
| | | "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", |
| | |
| | | "@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", |
| | |
| | | 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), |
| | | }; |
| | | }; |
| New file |
| | |
| | | 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; |
| | | } |
| | | }; |
| New file |
| | |
| | | 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; |
| | |
| | | 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'; |
| | | |
| | |
| | | }} |
| | | > |
| | | <AntdApp> |
| | | <AntdAppBridge /> |
| | | <BrowserRouter> |
| | | <AppRouter /> |
| | | </BrowserRouter> |
| New file |
| | |
| | | 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> |
| | | ), |
| | | }, |
| | | ]; |
| | |
| | | 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[] = [ |
| | | { |
| | |
| | | |
| | | 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"> |
| | |
| | | 跟踪销售订单的处理进度,支持筛选与导出。 |
| | | </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> |
| | | ); |
| | | }; |
| New file |
| | |
| | | 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' }, |
| | | }; |
| | |
| | | } |
| | | } |
| | | |
| | | .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; |
| | |
| | | |
| | | .login-panel { |
| | | padding: 32px 24px; |
| | | } |
| | | |
| | | .login-demo__accounts { |
| | | grid-template-columns: 1fr; |
| | | } |
| | | } |
| | |
| | | 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'; |
| | |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | | }; |
| | | |
| | | const fillAccount = (username: string, password: string) => { |
| | | form.setFieldsValue({ username, password }); |
| | | }; |
| | | |
| | | return ( |
| | |
| | | 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> |
| | | |
| | |
| | | 登 录 |
| | | </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> |
| | |
| | | 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; |
| New file |
| | |
| | | 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); |
| | | }; |
| | |
| | | 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/, ''), |
| | | }, |
| | | }, |
| | | }, |
| | | }; |
| | | }); |