签入版本
This commit is contained in:
@@ -1,125 +1,111 @@
|
||||
# 9.13 API 模块编写规范
|
||||
# 9.14 API 模块编写规范
|
||||
|
||||
:::tip 概述
|
||||
本节定义前端 API 模块的编写规范,包括命名规范、请求方式和文件组织。
|
||||
api 模块是前端与后端契约的镜像:按业务域分组、一模块一文件、函数对应后端动作。命名与写法以 `ui/src/api/tool/example.ts` 为参考实现。
|
||||
:::
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件名 | 小驼峰,与后端模块对应 | `example.ts` |
|
||||
| 查询函数 | `get{Module}Page` / `get{Module}List` / `get{Module}Detail` | `getExamplePage` |
|
||||
| 新增函数 | `{module}Add` | `exampleAdd` |
|
||||
| 修改函数 | `{module}Update` | `exampleUpdate` |
|
||||
| 删除函数 | `{module}Delete` / `{module}BatchDelete` | `exampleDelete` |
|
||||
| 导入函数 | `{module}Import` | `exampleImport` |
|
||||
| 导出函数 | `{module}Export` | `exampleExport` |
|
||||
|
||||
## 请求方式
|
||||
|
||||
| 操作 | HTTP 方法 | 参数传递 | 说明 |
|
||||
|------|----------|----------|------|
|
||||
| 查询 | GET | `params`(URL 参数) | 分页/列表/详情 |
|
||||
| 新增 | POST | `data`(请求体) | JSON 格式 |
|
||||
| 修改 | PUT | `data`(请求体) | JSON 格式,含 id |
|
||||
| 删除 | DELETE | URL 参数或 `data` | 单条用 URL,批量用 data |
|
||||
|
||||
## 文件组织
|
||||
|
||||
```text
|
||||
ui/src/api/
|
||||
├── common/index.ts # 公共接口(字典、验证码、菜单等)
|
||||
├── index.ts # 全局接口(登录、登出、刷新Token)
|
||||
├── system/ # 系统管理模块
|
||||
│ ├── user.ts
|
||||
│ ├── role.ts
|
||||
│ ├── menu.ts
|
||||
│ ├── dept.ts
|
||||
│ ├── position.ts
|
||||
│ └── level.ts
|
||||
├── content/ # 内容管理模块
|
||||
│ ├── article.ts
|
||||
│ ├── category.ts
|
||||
│ └── link.ts
|
||||
└── tool/ # 工具模块
|
||||
└── example.ts
|
||||
ui/src/api/{域}/{模块}.ts
|
||||
```
|
||||
|
||||
## 标准模板
|
||||
域与 views 一一对应(system/content/data/monitor/tool/file/setting...)。公共聚合接口放 common/。
|
||||
|
||||
## 命名约定(以 example 为参照)
|
||||
|
||||
| 动作 | 命名模式 | example 实际函数 |
|
||||
|---|---|---|
|
||||
| 分页 | get{模块}Page | getExamplePage |
|
||||
| 列表 | get{模块}List | getExampleList |
|
||||
| 详情 | get{模块}Detail | getExampleDetail |
|
||||
| 新增 | {模块}Add | exampleAdd |
|
||||
| 修改 | {模块}Update | exampleUpdate |
|
||||
| 删除 | {模块}Delete | exampleDelete |
|
||||
| 批量删除 | {模块}BatchDelete | exampleBatchDelete |
|
||||
| 导入/导出 | {模块}Import / {模块}Export | exampleImport / exampleExport |
|
||||
|
||||
查询类以 `get` 开头,命令类直接以模块名开头。
|
||||
|
||||
## 标准写法(摘自 ui/src/api/tool/example.ts)
|
||||
|
||||
```typescript
|
||||
// ui/src/api/tool/example.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
// 分页查询
|
||||
export function getExamplePage(params?) {
|
||||
return http.request({ url: '/example/page', method: 'GET', params });
|
||||
/**
|
||||
* 获取案例演示分页列表
|
||||
*
|
||||
* @param params 查询参数,包含 pageNo、pageSize 及业务筛选条件
|
||||
* @returns 分页结果,含 records、total、size、current、pages
|
||||
*/
|
||||
export function getExamplePage(params?: Record<string, any>) {
|
||||
return http.request({
|
||||
url: '/example/page',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 全量列表
|
||||
export function getExampleList(params?) {
|
||||
return http.request({ url: '/example/list', method: 'GET', params });
|
||||
}
|
||||
|
||||
// 详情
|
||||
export function getExampleDetail(id) {
|
||||
return http.request({ url: '/example/detail/' + id, method: 'GET' });
|
||||
}
|
||||
|
||||
// 新增
|
||||
/**
|
||||
* 添加案例演示
|
||||
*
|
||||
* @param data 案例演示数据
|
||||
* @returns 新记录的 ID
|
||||
*/
|
||||
export function exampleAdd(data: any) {
|
||||
return http.request({ url: '/example/add', method: 'POST', data });
|
||||
return http.request({
|
||||
url: '/example/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 修改
|
||||
export function exampleUpdate(data: any) {
|
||||
return http.request({ url: '/example/update', method: 'PUT', data });
|
||||
}
|
||||
|
||||
// 删除
|
||||
/**
|
||||
* 删除案例演示
|
||||
*
|
||||
* @param id 案例演示ID
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function exampleDelete(id) {
|
||||
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
|
||||
return http.request({
|
||||
url: '/example/delete/' + id,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
/**
|
||||
* 批量删除案例演示
|
||||
*
|
||||
* @param data 含 ids 数组的对象,形如 { ids: [1, 2, 3] }
|
||||
* @returns 操作结果
|
||||
*/
|
||||
export function exampleBatchDelete(data: any) {
|
||||
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
|
||||
return http.request({
|
||||
url: '/example/batchDelete',
|
||||
method: 'DELETE',
|
||||
data,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 常见扩展操作
|
||||
## 规范要点
|
||||
|
||||
| 操作 | 函数名示例 | HTTP 方法 |
|
||||
|------|-----------|----------|
|
||||
| 重置密码 | `userResetPwd(data)` | POST |
|
||||
| 导入 | `{module}Import(data: FormData)` | POST(multipart/form-data) |
|
||||
| 导出 | `{module}Export(params)` | GET |
|
||||
| 刷新缓存 | `dictRefreshCache()` | GET |
|
||||
| 获取菜单 | `getMenus()` | GET |
|
||||
| 获取字典项 | `getDictItemList(code)` | GET |
|
||||
| 要点 | 说明 |
|
||||
|---|---|
|
||||
| import 来源 | `import { http } from '@/utils/http/axios'` |
|
||||
| URL 不带 /api | 前缀由 VITE_GLOB_API_URL_PREFIX 统一拼接 |
|
||||
| 只导出函数 | 每函数带 JSDoc(参数/返回说明);不放状态、不 catch |
|
||||
| DELETE 带 body | 批量删除用 `method: 'DELETE', data`(后端 UnmarshalBody 支持) |
|
||||
| 文件类接口 | 导入传 FormData;导出返回文件 URL 字符串 |
|
||||
| 与 RouteSpec 同步 | 后端加接口 → api 模块加函数,同一 PR |
|
||||
|
||||
## 组件中使用
|
||||
## 禁则
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { getExamplePage, exampleDelete } from '@/api/tool/example';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
- 不在 api 模块拼完整 URL 域名(部署可变)
|
||||
- 不在 api 模块做数据变换(变换归页面或工具函数)
|
||||
- 不绕过 http 封装直接用 axios 实例
|
||||
|
||||
const tableData = ref([]);
|
||||
## 相关
|
||||
|
||||
// 查询
|
||||
const fetchData = async () => {
|
||||
const res = await getExamplePage({ pageNo: 1, pageSize: 20 });
|
||||
tableData.value = res.records;
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
</script>
|
||||
```
|
||||
- [9.4 HTTP 请求封装](/zh/frontend/http.html)
|
||||
- [6.22 示例模块(example)](/zh/modules/example.html)
|
||||
|
||||
@@ -1,61 +1,66 @@
|
||||
# 9.2 目录结构详解
|
||||
|
||||
:::tip 概述
|
||||
本节详细介绍前端项目的目录结构,帮助开发者快速定位代码和理解项目组织方式。
|
||||
本页给出 `ui/src` 各目录的职责与放置规则。核心原则:**按业务域分组、按层次隔离**——页面不直连 axios,组件不耦合业务。
|
||||
:::
|
||||
|
||||
## 一级目录职责
|
||||
|
||||
| 目录 | 职责 | 放置规则 |
|
||||
|---|---|---|
|
||||
| api/ | 接口函数 | 按业务域分子目录(system/content/data/monitor/tool...),一模块一文件 |
|
||||
| views/ | 页面 | 按业务域分目录,与 api/ 域名对应 |
|
||||
| components/ | 公共组件 | 任何页面可复用的才进这里 |
|
||||
| store/ | Pinia | 全局状态(用户、路由、标签页、设置);页面局部状态不进 store |
|
||||
| router/ | 路由 | constant 静态 + generator-routers 动态 + guards 守卫 |
|
||||
| directives/ | 指令 | perm / perms / scrollBar / clickOutside |
|
||||
| hooks/ | 组合式函数 | core/event/setting/web 分组 |
|
||||
| enums/ | 枚举 | http/role/permissions/cache/page/breakpoint/avatar |
|
||||
| utils/ | 工具 | http 封装、auth、validate、日期、下载、颜色、DOM... |
|
||||
| layout/ | 布局 | 侧边栏、顶栏、标签页、面包屑组件 |
|
||||
| styles/ | 样式 | 全局样式、主题变量、过渡动画 |
|
||||
| settings/ | 设置 | 应用级默认配置 |
|
||||
| plugins/ | 插件 | element、指令、echarts 等全局注册 |
|
||||
| assets/ | 静态资源 | 图片、svg |
|
||||
|
||||
## views 域划分
|
||||
|
||||
```text
|
||||
ui/src/
|
||||
├── api/ # API 接口层(按业务域分组)
|
||||
│ ├── common/ # 公共接口(登录/菜单/用户信息/城市)
|
||||
│ ├── system/ # 系统管理(用户/角色/菜单/部门/岗位/职级/日志/租户)
|
||||
│ ├── content/ # 内容管理(文章/分类/链接)
|
||||
│ ├── data/ # 数据管理(城市/配置/字典/公告/参数)
|
||||
│ ├── dashboard/ # 仪表盘(控制台/消息)
|
||||
│ ├── file/ # 文件管理(文件模板)
|
||||
│ ├── monitor/ # 监控管理(定时任务)
|
||||
│ ├── region/ # 行政区划
|
||||
│ ├── setting/ # 设置(个人资料/网站配置)
|
||||
│ └── tool/ # 工具(示例/代码生成器)
|
||||
├── components/ # 公共组件库(20+ 个)
|
||||
│ ├── Table/ # 数据表格
|
||||
│ ├── Form/ # 动态表单
|
||||
│ ├── Modal/ # 弹窗
|
||||
│ ├── Upload/ # 文件上传
|
||||
│ ├── Editor/ # 富文本编辑器
|
||||
│ ├── Excel/ # Excel 导入导出
|
||||
│ ├── Cropper/ # 头像裁剪
|
||||
│ ├── Region/ # 行政区划
|
||||
│ ├── Password/ # 密码强度
|
||||
│ ├── Qrcode/ # 二维码
|
||||
│ └── ...
|
||||
├── directives/ # 自定义指令
|
||||
│ └── permission.ts # v-perm / v-perms 权限指令
|
||||
├── enums/ # 枚举常量
|
||||
│ ├── httpEnum.ts # HTTP 枚举
|
||||
│ └── pageEnum.ts # 页面枚举
|
||||
├── hooks/ # 组合式函数
|
||||
├── layout/ # 布局组件
|
||||
├── router/ # 路由配置
|
||||
│ ├── index.ts # 路由实例
|
||||
│ ├── base.ts # 静态路由
|
||||
│ ├── generator-routers.ts # 动态路由生成
|
||||
│ └── router-guards.ts # 路由守卫
|
||||
├── store/ # Pinia 状态管理
|
||||
│ └── modules/
|
||||
│ ├── user.ts # 用户 Store
|
||||
│ ├── asyncRoute.ts # 动态路由 Store
|
||||
│ └── ...
|
||||
├── styles/ # 全局样式
|
||||
├── utils/ # 工具函数
|
||||
│ └── http/ # HTTP 请求封装
|
||||
└── views/ # 页面视图
|
||||
├── login/ # 登录页
|
||||
├── dashboard/ # 仪表盘
|
||||
├── system/ # 系统管理页面
|
||||
├── content/ # 内容管理页面
|
||||
├── data/ # 数据管理页面
|
||||
├── monitor/ # 监控管理页面
|
||||
├── tool/ # 工具页面
|
||||
└── exception/ # 异常页面
|
||||
```
|
||||
views/
|
||||
├── dashboard/ 控制台
|
||||
├── system/ 系统管理(user/role/menu/dept/level/position/tenant/logger)
|
||||
├── content/ 内容管理(article/category/link)
|
||||
├── data/ 数据管理(dict/config/param/notice/city)
|
||||
├── monitor/ 监控(job/online/server/cache)
|
||||
├── file/ 文件(fileTemplate)
|
||||
├── tool/ 工具(example/generator)
|
||||
├── setting/ 设置(configWeb/profile)
|
||||
├── login/ 登录(四种表单)
|
||||
├── exception/ 异常页
|
||||
└── iframe/ redirect/ about/
|
||||
```
|
||||
|
||||
## api 域划分
|
||||
|
||||
```text
|
||||
api/
|
||||
├── system/ user/role/menu/dept/level/position/tenant/dictionary/operLog/loginLog
|
||||
├── content/ article/category/link
|
||||
├── data/ dict/config/param/notice/city
|
||||
├── monitor/ job/onlineUser/serverMonitor/cacheMonitor/dataSource
|
||||
├── tool/ example/generator
|
||||
├── file/ fileTemplate
|
||||
├── setting/ profile/web
|
||||
├── dashboard/ console/message
|
||||
├── common/ index/menu/user/city
|
||||
└── region/ region
|
||||
```
|
||||
|
||||
## 页面内私有组件
|
||||
|
||||
页面专属的子组件放 `views/{域}/{模块}/components/`,不进全局 components——防止公共组件被单业务污染。
|
||||
|
||||
## 相关
|
||||
|
||||
- [4.5 前端分层架构](/zh/architecture/frontend-layer.html)
|
||||
- [9.14 API 模块编写规范](/zh/frontend/api-module.html)
|
||||
|
||||
@@ -1,91 +1,45 @@
|
||||
# 9.9 数据展示组件
|
||||
# 9.11 数据展示组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的数据展示组件,包括表格(BasicTable)、页面容器(PageWrapper)和图标选择器(IconPicker)。
|
||||
展示类公共组件:CountTo(数字动画)、Qrcode(二维码)、Render(渲染器)、icon(图标)、Application/Websocket/Authority/CheckToken 等功能性组件。
|
||||
:::
|
||||
|
||||
## Table 表格组件
|
||||
## 组件清单
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicTable @register="registerTable" />
|
||||
</template>
|
||||
| 组件 | 职责 |
|
||||
|---|---|
|
||||
| CountTo | 数字滚动动画(仪表盘统计卡片) |
|
||||
| Qrcode | 二维码生成(扫码登录、分享场景) |
|
||||
| Render | 函数式/配置化渲染(动态列、动态操作区) |
|
||||
| icon | 图标组件(菜单图标、操作图标统一入口) |
|
||||
| Websocket | WebSocket 封装(实时消息推送场景) |
|
||||
| Authority | 权限包装组件(与 v-perm 等价的组件式表达) |
|
||||
| CheckToken | 令牌校验组件(内嵌校验场景) |
|
||||
| Lockscreen | 锁屏组件 |
|
||||
| Application | 应用级注入 |
|
||||
|
||||
<script setup>
|
||||
import { BasicTable, useTable } from '@/components/Table';
|
||||
## CountTo(仪表盘常用)
|
||||
|
||||
const [registerTable] = useTable({
|
||||
api: getExamplePage, // 数据接口
|
||||
columns: [ // 列定义
|
||||
{ title: 'ID', field: 'id', width: 80 },
|
||||
{ title: '名称', field: 'name' },
|
||||
{ title: '状态', field: 'statusText', width: 100 },
|
||||
{ title: '创建时间', field: 'createTime', width: 180 },
|
||||
],
|
||||
actionColumn: { // 操作列
|
||||
width: 200,
|
||||
actions: [
|
||||
{ label: '编辑', onClick: handleEdit },
|
||||
{ label: '删除', onClick: handleDelete, type: 'danger' },
|
||||
],
|
||||
},
|
||||
});
|
||||
</script>
|
||||
```html
|
||||
<!-- ui/src/views/dashboard/console/index.vue 实际用法 -->
|
||||
<CountTo :startVal="1" :endVal="info.visits.dayVisits" class="text-3xl" />
|
||||
<CountTo :startVal="1" suffix="%" :endVal="info.visits.rise" />
|
||||
```
|
||||
|
||||
## Page 页面容器
|
||||
统计卡片标配(今日访问、涨幅等),配合 echarts 图表呈现运行数据。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<!-- 页面内容 -->
|
||||
</PageWrapper>
|
||||
</template>
|
||||
## icon 的统一性
|
||||
|
||||
<script setup>
|
||||
import { PageWrapper } from '@/components/Page';
|
||||
</script>
|
||||
```
|
||||
菜单图标(gin_menu.icon)与页面内图标都经 icon 组件解析——菜单管理里选的图标名与页面引用同一套图标库(@element-plus/icons + vicons)。
|
||||
|
||||
## Icon 图标选择器
|
||||
## Websocket
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconPicker v-model="formData.icon" />
|
||||
</template>
|
||||
实时通知场景的封装(连接、重连、消息分发);未接消息推送服务时组件处于待命状态,不产生副作用。
|
||||
|
||||
<script setup>
|
||||
import IconPicker from '@/components/icon/picker.vue';
|
||||
</script>
|
||||
```
|
||||
## 选型原则
|
||||
|
||||
:::warning 组件未实现
|
||||
Description 描述列表组件尚未在项目中实现,以下为规划示例。
|
||||
:::
|
||||
展示需求先查本页与 Select/Table——公共组件没有的再考虑引入第三方,新增全局组件前先评估复用面(防 components 膨胀)。
|
||||
|
||||
## Description 描述列表
|
||||
## 相关
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Description :data="detailData" :schema="schema" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Description } from '@/components/Description';
|
||||
|
||||
const schema = [
|
||||
{ field: 'username', label: '用户名' },
|
||||
{ field: 'realname', label: '姓名' },
|
||||
{ field: 'statusText', label: '状态' },
|
||||
{ field: 'createTime', label: '创建时间' },
|
||||
];
|
||||
|
||||
const detailData = ref({});
|
||||
</script>
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数据录入组件](/zh/frontend/form-components.html)
|
||||
- [Excel 导入导出组件](/zh/frontend/excel-components.html)
|
||||
- [前端组件速查](/zh/appendix/components.html)
|
||||
- [附录 H 前端组件速查](/zh/appendix/components.html)
|
||||
|
||||
@@ -1,390 +1,49 @@
|
||||
# 9.3 环境与构建配置
|
||||
|
||||
:::tip 概述
|
||||
本节说明前端项目的环境变量配置和 Vite 构建配置,包括开发环境和生产环境的差异配置。前端通过 `.env` 文件管理环境变量,Vite 自动根据模式加载对应的 `.env` 文件。
|
||||
环境变量经 `ui/.env.development` / `.env.production` 管理,键以 `VITE_` 开头。开发期核心是代理配置,构建期核心是压缩与产物控制。
|
||||
:::
|
||||
|
||||
## 环境变量文件
|
||||
## 开发环境(.env.development)
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── .env # 所有环境通用配置
|
||||
├── .env.development # 开发环境配置
|
||||
├── .env.production # 生产环境配置
|
||||
└── .env.local # 本地覆盖(不提交 git)
|
||||
VITE_PORT = 8001 # 开发端口
|
||||
VITE_PUBLIC_PATH = / # 站点根路径
|
||||
VITE_USE_MOCK = false # mock 开关
|
||||
VITE_DROP_CONSOLE = true # 移除 console
|
||||
VITE_PROXY=[["/api", "http://127.0.0.1:8000/api"]] # 代理(支持多条,勿换行)
|
||||
VITE_GLOB_API_URL_PREFIX = /api # 接口前缀
|
||||
VITE_GLOB_API_URL = # 接口基址(代理场景留空)
|
||||
VITE_GLOB_UPLOAD_URL = # 上传基址
|
||||
VITE_GLOB_IMG_URL = # 图片前缀
|
||||
```
|
||||
|
||||
**加载优先级:** `.env.local` > `.env.{mode}` > `.env`
|
||||
代理把页面的 `/api/**` 转发到后端 8000——开发期同源请求,无需处理 CORS。
|
||||
|
||||
## 开发环境配置
|
||||
## 生产环境(.env.production)
|
||||
|
||||
### `.env.development`
|
||||
无 VITE_PORT/VITE_PROXY(构建产物不带开发服务器),接口基址经 `VITE_GLOB_API_URL` 或反代同源处理;`VITE_BUILD_COMPRESS` 控制 gzip/brotli。
|
||||
|
||||
```bash
|
||||
# 开发服务器端口
|
||||
VITE_PORT = 8001
|
||||
## 关键键说明
|
||||
|
||||
# 公共基础路径(通常为 /)
|
||||
VITE_PUBLIC_PATH = /
|
||||
| 键 | 用途 |
|
||||
|---|---|
|
||||
| VITE_GLOB_API_URL_PREFIX | 请求 URL 统一前缀(api 模块里写不带 /api 的相对路径) |
|
||||
| VITE_GLOB_API_URL | 部署时 API 基址(跨域部署填网关地址) |
|
||||
| VITE_GLOB_IMG_URL / UPLOAD_URL | 图片/上传访问基址(与后端 file.domainUrl 对齐) |
|
||||
| VITE_BUILD_COMPRESS | 构建压缩:gzip / brotli / none(可逗号分隔多种) |
|
||||
|
||||
# API 接口基础路径(留空,由代理处理)
|
||||
VITE_GLOB_API_URL =
|
||||
## vite.config.ts
|
||||
|
||||
# API 前缀(所有接口路径的前缀)
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
- 插件:vue + 自动导入(unplugin-auto-import)+ svg/路径插件
|
||||
- server.proxy 读 VITE_PROXY 动态生成
|
||||
- 构建分包与 sourcemap 策略在 build 节
|
||||
|
||||
# 代理配置(JSON 数组格式:[["前缀","目标地址"]])
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp8.elevue/api"]]
|
||||
## 修改后生效
|
||||
|
||||
# 上传接口地址
|
||||
VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
`.env.*` 与 `vite.config.ts` 修改需**重启 dev server**;代码热更新即时生效。
|
||||
|
||||
# 图片访问地址(用于预览上传的图片)
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.elevue
|
||||
## 相关
|
||||
|
||||
# 是否在生产环境删除 console(开发环境保留)
|
||||
VITE_DROP_CONSOLE = false
|
||||
|
||||
# 是否启用 Mock 数据(false = 使用真实后端 API)
|
||||
VITE_USE_MOCK = false
|
||||
```
|
||||
|
||||
## 生产环境配置
|
||||
|
||||
### `.env.production`
|
||||
|
||||
```bash
|
||||
# 公共基础路径(通常为 /,如部署到子目录则设为 /subdir/)
|
||||
VITE_PUBLIC_PATH = /
|
||||
|
||||
# API 接口基础路径(留空,由 Nginx 代理处理)
|
||||
VITE_GLOB_API_URL =
|
||||
|
||||
# API 前缀
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
|
||||
# 上传接口地址
|
||||
VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
|
||||
# 图片访问地址(生产环境使用正式域名)
|
||||
VITE_GLOB_IMG_URL = https://cdn.example.com
|
||||
|
||||
# 是否在生产环境删除 console
|
||||
VITE_DROP_CONSOLE = true
|
||||
|
||||
# 是否启用 gzip 压缩
|
||||
VITE_BUILD_COMPRESS = gzip
|
||||
|
||||
# 压缩后是否删除原文件
|
||||
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
|
||||
```
|
||||
|
||||
## 环境变量详解
|
||||
|
||||
### VITE_PORT — 开发服务器端口
|
||||
|
||||
```bash
|
||||
VITE_PORT = 8001
|
||||
```
|
||||
|
||||
| 环境 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 开发 | `8001` | 前端开发服务器端口 |
|
||||
| 生产 | 不生效 | 生产环境由 Nginx 提供服务 |
|
||||
|
||||
### VITE_PUBLIC_PATH — 公共基础路径
|
||||
|
||||
```bash
|
||||
VITE_PUBLIC_PATH = /
|
||||
```
|
||||
|
||||
| 场景 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 根目录部署 | `/` | 默认值 |
|
||||
| 子目录部署 | `/admin/` | 如 `https://example.com/admin/` |
|
||||
|
||||
### VITE_GLOB_API_URL — API 基础路径
|
||||
|
||||
```bash
|
||||
VITE_GLOB_API_URL =
|
||||
```
|
||||
|
||||
| 场景 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 开发(代理) | 留空 | 由 Vite proxy 转发 |
|
||||
| 生产(同域) | 留空 | 由 Nginx 代理 |
|
||||
| 生产(跨域) | `https://api.example.com` | 直接请求后端域名 |
|
||||
|
||||
### VITE_PROXY — 开发代理配置
|
||||
|
||||
```bash
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp8.elevue/api"]]
|
||||
```
|
||||
|
||||
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
|
||||
|
||||
**作用:** 将前端 `/api/*` 请求代理到后端地址,解决开发环境跨域问题。
|
||||
|
||||
**多代理示例:**
|
||||
|
||||
```bash
|
||||
VITE_PROXY = [["/api","http://localhost:8080/api"],["/upload","http://localhost:8080/upload"]]
|
||||
```
|
||||
|
||||
### VITE_GLOB_API_URL_PREFIX — API 前缀
|
||||
|
||||
```bash
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
```
|
||||
|
||||
所有 API 请求路径的前缀,与后端路由前缀一致。
|
||||
|
||||
### VITE_GLOB_UPLOAD_URL — 上传接口地址
|
||||
|
||||
```bash
|
||||
VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
```
|
||||
|
||||
文件上传组件使用的接口地址。
|
||||
|
||||
### VITE_GLOB_IMG_URL — 图片访问地址
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.elevue
|
||||
|
||||
# 生产环境
|
||||
VITE_GLOB_IMG_URL = https://cdn.example.com
|
||||
```
|
||||
|
||||
用于预览上传的图片,拼接图片的完整 URL。
|
||||
|
||||
### VITE_DROP_CONSOLE — 删除 console
|
||||
|
||||
```bash
|
||||
# 开发环境:保留 console(方便调试)
|
||||
VITE_DROP_CONSOLE = false
|
||||
|
||||
# 生产环境:删除 console(安全+减小体积)
|
||||
VITE_DROP_CONSOLE = true
|
||||
```
|
||||
|
||||
### VITE_BUILD_COMPRESS — Gzip 压缩
|
||||
|
||||
```bash
|
||||
VITE_BUILD_COMPRESS = gzip
|
||||
```
|
||||
|
||||
启用 gzip 压缩后,构建产物会生成 `.gz` 文件,Nginx 配置 `gzip_static on` 可直接使用。
|
||||
|
||||
## 在代码中访问环境变量
|
||||
|
||||
```typescript
|
||||
// 读取环境变量(VITE_ 前缀的变量才会暴露给客户端)
|
||||
const apiUrl = import.meta.env.VITE_GLOB_API_URL;
|
||||
const apiPrefix = import.meta.env.VITE_GLOB_API_URL_PREFIX;
|
||||
const uploadUrl = import.meta.env.VITE_GLOB_UPLOAD_URL;
|
||||
const imgUrl = import.meta.env.VITE_GLOB_IMG_URL;
|
||||
|
||||
// 完整 API 地址
|
||||
const fullApiUrl = apiUrl + apiPrefix; // 如 "/api" 或 "https://api.example.com/api"
|
||||
```
|
||||
|
||||
:::warning VITE_ 前缀
|
||||
只有以 `VITE_` 开头的环境变量才会暴露给客户端代码,其他变量(如 `PORT`)不会被访问到。
|
||||
:::
|
||||
|
||||
## Vite 配置
|
||||
|
||||
### `vite.config.ts`
|
||||
|
||||
```typescript
|
||||
import { defineConfig, loadEnv } from 'vite';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
// 加载环境变量
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
|
||||
return {
|
||||
// 开发服务器
|
||||
server: {
|
||||
port: Number(env.VITE_PORT) || 8001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://admin.thinkphp8.elevue/api',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// 构建配置
|
||||
build: {
|
||||
// 生产环境删除 console
|
||||
drop: env.VITE_DROP_CONSOLE === 'true' ? ['console', 'debugger'] : [],
|
||||
// 启用 gzip 压缩
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
element: ['element-plus'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### 代理配置详解
|
||||
|
||||
```typescript
|
||||
// 开发环境代理配置
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://admin.thinkphp8.elevue/api', // 目标地址
|
||||
changeOrigin: true, // 修改请求头 Origin
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // 路径重写(可选)
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**代理效果:**
|
||||
|
||||
```text
|
||||
前端请求: GET /api/user/page
|
||||
│
|
||||
▼ Vite proxy
|
||||
│
|
||||
▼ 转发到: http://admin.thinkphp8.elevue/api/user/page
|
||||
```
|
||||
|
||||
## 构建命令
|
||||
|
||||
```bash
|
||||
# 开发服务器(热更新)
|
||||
pnpm dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 预览构建结果(本地启动静态服务器)
|
||||
pnpm preview
|
||||
|
||||
# ESLint 检查
|
||||
pnpm lint:eslint
|
||||
|
||||
# ESLint 自动修复
|
||||
pnpm lint:eslint --fix
|
||||
|
||||
# TypeScript 类型检查
|
||||
pnpm type:check
|
||||
```
|
||||
|
||||
### 构建产物
|
||||
|
||||
```text
|
||||
ui/dist/
|
||||
├── index.html # 入口文件
|
||||
├── assets/
|
||||
│ ├── index-xxx.js # 主 JS(带 hash)
|
||||
│ ├── index-xxx.css # 主 CSS(带 hash)
|
||||
│ ├── vue-xxx.js # Vue 相关(分包)
|
||||
│ └── element-xxx.js # ElementPlus(分包)
|
||||
├── favicon.ico
|
||||
└── static/ # 静态资源
|
||||
```
|
||||
|
||||
## 部署配置
|
||||
|
||||
### Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.example.com;
|
||||
|
||||
# 前端静态文件
|
||||
root /data/www/admin/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA 路由(所有非文件请求都返回 index.html)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# API 代理到后端
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Gzip 压缩
|
||||
gzip on;
|
||||
gzip_static on; # 优先使用 .gz 文件
|
||||
gzip_types text/css application/javascript application/json;
|
||||
}
|
||||
```
|
||||
|
||||
## 多环境配置
|
||||
|
||||
### 测试环境
|
||||
|
||||
```bash
|
||||
# .env.staging
|
||||
VITE_GLOB_API_URL =
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.elevue
|
||||
VITE_DROP_CONSOLE = true
|
||||
```
|
||||
|
||||
构建命令:
|
||||
|
||||
```bash
|
||||
pnpm build --mode staging
|
||||
```
|
||||
|
||||
### 环境变量对照表
|
||||
|
||||
| 变量 | 开发 | 测试 | 生产 |
|
||||
|------|------|------|------|
|
||||
| `VITE_PORT` | 8001 | - | - |
|
||||
| `VITE_GLOB_API_URL` | 留空 | 留空 | 留空 |
|
||||
| `VITE_GLOB_API_URL_PREFIX` | /api | /api | /api |
|
||||
| `VITE_GLOB_IMG_URL` | http://file.xxx | https://test-cdn.xxx | https://cdn.xxx |
|
||||
| `VITE_DROP_CONSOLE` | false | true | true |
|
||||
| `VITE_USE_MOCK` | false | false | false |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1:开发环境 API 请求 404
|
||||
|
||||
**原因:** 代理配置错误或后端未启动。
|
||||
|
||||
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确。
|
||||
|
||||
### 问题 2:生产环境 API 请求跨域
|
||||
|
||||
**原因:** `VITE_GLOB_API_URL` 配置了跨域地址但后端未配置 CORS。
|
||||
|
||||
**解决:** 推荐留空,由 Nginx 代理处理(同域无跨域问题)。
|
||||
|
||||
### 问题 3:图片不显示
|
||||
|
||||
**原因:** `VITE_GLOB_IMG_URL` 配置错误。
|
||||
|
||||
**解决:** 检查图片访问地址是否正确,确保图片 URL 可直接访问。
|
||||
|
||||
### 问题 4:构建后体积过大
|
||||
|
||||
**原因:** 未启用 gzip 压缩或未分包。
|
||||
|
||||
**解决:** 启用 `VITE_BUILD_COMPRESS = gzip`,配置 `manualChunks` 分包。
|
||||
- [2.5 前端服务启动](/zh/quickstart/frontend-start.html)
|
||||
- [11.5 前端部署](/zh/deploy/frontend.html)
|
||||
|
||||
@@ -1,77 +1,45 @@
|
||||
# 9.11 Excel 导入导出组件
|
||||
# 9.12 Excel 导入导出组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法。
|
||||
Excel 能力分前后端两半:后端 excelize 读写([7.4 数据导入导出](/zh/tools/excel.html)),前端 Excel/importFile 组件承担交互与浏览器侧解析,downloadFile 工具负责落盘。
|
||||
:::
|
||||
|
||||
## 导入组件
|
||||
## 前端组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button @click="handleImport">导入</el-button>
|
||||
<ImportExcel @success="onImportSuccess" />
|
||||
</template>
|
||||
| 组件/工具 | 职责 |
|
||||
|---|---|
|
||||
| Excel | 基于 xlsx 库的前端解析/生成(纯前端表格处理场景) |
|
||||
| importFile | 导入交互:选文件 → 上传到模块导入接口 → 展示结果汇总 |
|
||||
| utils/downloadFile | 拿到导出 URL 后触发浏览器下载 |
|
||||
|
||||
<script setup>
|
||||
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
|
||||
## 导入流(对接后端 Import 契约)
|
||||
|
||||
const onImportSuccess = (data) => {
|
||||
// data 为解析后的数组,每项是对象
|
||||
console.log(data);
|
||||
};
|
||||
</script>
|
||||
```text
|
||||
点击导入 → importFile 选择 xls/xlsx
|
||||
→ FormData 提交 POST /{模块}/import(UnmarshalBody 契约)
|
||||
→ 后端逐行校验写入
|
||||
→ 前端展示 ResMsg 提示语 + 失败原因列表( Reasons 含行号)
|
||||
```
|
||||
|
||||
## 导出功能
|
||||
## 导出流(对接后端 Export 契约)
|
||||
|
||||
```typescript
|
||||
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
|
||||
|
||||
const handleExport = () => {
|
||||
jsonToSheetXlsx({
|
||||
data: tableData.value,
|
||||
header: {
|
||||
name: '名称',
|
||||
typeText: '类型',
|
||||
statusText: '状态',
|
||||
createTime: '创建时间',
|
||||
},
|
||||
filename: '导出数据.xlsx',
|
||||
});
|
||||
};
|
||||
```text
|
||||
点击导出 → 带当前查询条件 GET /{模块}/export
|
||||
→ 后端过滤写出 xlsx,返回文件 URL
|
||||
→ downloadFile 触发下载
|
||||
```
|
||||
|
||||
## 后端导出对接
|
||||
导出条件与列表分页同参数——"所见即所导"。
|
||||
|
||||
```typescript
|
||||
// 调用后端导出接口,返回文件 URL
|
||||
const handleExport = async () => {
|
||||
const url = await exampleExport(searchParams.value);
|
||||
// 下载文件
|
||||
window.open(url);
|
||||
};
|
||||
```
|
||||
## 导入模板
|
||||
|
||||
## 导入模板下载
|
||||
模板经文件模板管理([6.21](/zh/modules/file-template.html))下载(ByCode 接口),列头与后端解析顺序一致——改模板要同步改解析代码。
|
||||
|
||||
```typescript
|
||||
// 下载导入模板
|
||||
const handleDownloadTemplate = async () => {
|
||||
const url = await exampleExportTemplate();
|
||||
window.open(url);
|
||||
};
|
||||
```
|
||||
## 约定
|
||||
|
||||
## 导入导出注意事项
|
||||
- 大文件导入无事务逐行写,失败行不影响成功行(结果汇总呈现)
|
||||
- 前端 Excel 组件不替代后端导入(后端校验才是边界),只做轻量场景补充
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 大数据量导出 | 后端自动分批导出,避免内存溢出 |
|
||||
| 导入模板 | 第一行为表头,第二行起为数据 |
|
||||
| 字段映射 | 后端 `headerMap` 配置表头与字段的对应关系 |
|
||||
| 枚举翻译 | 导入时自动将文本转为枚举值,导出时自动将枚举值转为文本 |
|
||||
## 相关
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数据导入导出](/zh/tools/excel.html)
|
||||
- [上传配置详解](/zh/tools/upload-config.html)
|
||||
- [7.4 数据导入导出](/zh/tools/excel.html)
|
||||
|
||||
@@ -1,114 +1,61 @@
|
||||
# 9.10 数据录入组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的数据录入组件,包括表单(BasicForm)、弹窗表单和上传组件的使用方法。
|
||||
表单录入类公共组件:Select/BasicSelect(异步数据源下拉)、TableSelect(表格选择器)、Upload(上传)、Editor(富文本)、Cropper(裁剪)、ChinaArea/Region(行政区划)、Password、numberInput、priceInput、importFile(导入)。
|
||||
:::
|
||||
|
||||
## Form 表单组件
|
||||
## 组件清单
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicForm @register="registerForm" />
|
||||
</template>
|
||||
| 组件 | 职责 |
|
||||
|---|---|
|
||||
| Select(components/Select) | 下拉封装:支持 `options` 直配与 `request` 异步取数(可 cache/cacheKey 缓存) |
|
||||
| TableSelect | 表格选择器(选人/选部门等弹出表格单/多选) |
|
||||
| Upload | 文件/图片上传(对接两段式契约) |
|
||||
| Editor | 富文本(TinyMCE / vue-quill 双引擎) |
|
||||
| Cropper | 图片裁剪(配合 Upload 完成头像类流程) |
|
||||
| ChinaArea / Region | 省市区级联(对接 city 模块级联数据) |
|
||||
| Password | 密码输入 |
|
||||
| numberInput / priceInput | 数字/金额输入(精度与范围控制) |
|
||||
| importFile | 导入文件交互(example 页以异步组件方式引用) |
|
||||
|
||||
<script setup>
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
## Select 的两种数据来源(components/Select/src/props.ts)
|
||||
|
||||
const [registerForm] = useForm({
|
||||
schemas: [
|
||||
{
|
||||
field: 'name',
|
||||
label: '名称',
|
||||
component: 'Input',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
```typescript
|
||||
// ① options 直配 —— example 查询表单的实际用法(querySchemas.ts 节选)
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '案例状态',
|
||||
componentProps: {
|
||||
placeholder: '请输入案例状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '在用', value: 1 },
|
||||
{ label: '停用', value: 2 },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ② request 异步取数 —— 组件内置 request 函数 prop(Promise<Option[]>),支持 cache/cacheKey
|
||||
```
|
||||
|
||||
## Modal 弹窗组件
|
||||
字典类选项经 request 函数调 `/api/dict/item/getDictItemList/{code}` 获取(与后端 SerializeMaps 同编码),不硬编码业务枚举;example 的查询配置为演示简化直接内联了 options。
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicModal @register="registerModal" @ok="handleSubmit" />
|
||||
</template>
|
||||
## 上传与两段式契约
|
||||
|
||||
<script setup>
|
||||
import { BasicModal, useModal } from '@/components/Modal';
|
||||
选择文件 → 调上传接口拿 temp 结果(fileUrl/filePath)→ 表单字段持有 → 保存记录时后端转正。前端不关心物理路径(见 [7.5 前端文件组件](/zh/tools/file-components.html))。
|
||||
|
||||
const [registerModal, { openModal, closeModal }] = useModal();
|
||||
## 行政区划
|
||||
|
||||
// 打开弹窗
|
||||
const handleAdd = () => {
|
||||
openModal(true, { isUpdate: false });
|
||||
};
|
||||
ChinaArea/Region 消费 city 模块级联接口,三级选择后取编码组合——地址类字段配套(gin_user 的 province_code/city_code/district_code 即此形态)。
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const values = await getFormValues();
|
||||
await exampleAdd(values);
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
```
|
||||
## 使用规约
|
||||
|
||||
## Upload 上传组件
|
||||
- 业务页面不裸写 el-upload/手写上传逻辑,一律公共组件
|
||||
- 组件 props 与后端字段语义对齐(modelValue 映射 dto json 名)
|
||||
- 页面私有的复合控件放 views 下 components/,不进全局
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicUpload v-model="formData.avatar" :action="uploadUrl" />
|
||||
</template>
|
||||
## 相关
|
||||
|
||||
<script setup>
|
||||
import { BasicUpload } from '@/components/Upload';
|
||||
const uploadUrl = '/api/upload/uploadFile';
|
||||
</script>
|
||||
```
|
||||
|
||||
## Editor 富文本编辑器
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Editor v-model="formData.content" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Editor from '@/components/Editor/index.vue';
|
||||
</script>
|
||||
```
|
||||
|
||||
## Password 密码强度组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Password v-model="formData.password" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Password } from '@/components/Password';
|
||||
</script>
|
||||
```
|
||||
|
||||
## ChinaArea 行政区划组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ChinaArea v-model="formData.city" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ChinaArea from '@/components/ChinaArea/index.vue';
|
||||
</script>
|
||||
```
|
||||
- [5.11 字典模块](/zh/core/dict.html)
|
||||
- [9.9 表格与页面组件](/zh/frontend/table-components.html)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# 9.18 Hooks 与枚举
|
||||
|
||||
:::tip 概述
|
||||
`hooks/` 按 core/event/setting/web 分组沉淀组合式逻辑;`enums/` 定义前端常量枚举。两者都是"写一次、处处引用"的基础设施。
|
||||
:::
|
||||
|
||||
## Hooks 分组
|
||||
|
||||
| 分组 | 代表 | 职责 |
|
||||
|---|---|---|
|
||||
| web | usePermission | 权限判定(v-if 场景,[9.7](/zh/frontend/permission.html)) |
|
||||
| core | use-async | 异步状态管理(loading/error 包装) |
|
||||
| event | 事件类 hooks | 监听与派发 |
|
||||
| setting | designSetting 相关 | 主题/布局参数读写 |
|
||||
| 根目录 | useTime / useOnline / useBattery / useDomWidth | 时间、网络状态、电量、宽度监听 |
|
||||
|
||||
## 枚举清单(enums/)
|
||||
|
||||
| 文件 | 枚举内容 |
|
||||
|---|---|
|
||||
| httpEnum.ts | HTTP 相关常量(结果类型、超时) |
|
||||
| permissionsEnum.ts | 权限相关常量 |
|
||||
| roleEnum.ts | 角色常量 |
|
||||
| pageEnum.ts | 页面路径常量(BASE_LOGIN 等,守卫引用) |
|
||||
| cacheEnum.ts | 缓存键常量 |
|
||||
| breakpointEnum.ts | 响应式断点 |
|
||||
| avatarEnum.ts | 头像相关 |
|
||||
|
||||
## 使用示例
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { PageEnum } from '@/enums/pageEnum';
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
const loginPath = PageEnum.BASE_LOGIN;
|
||||
```
|
||||
|
||||
## 编写新 hook 的规约
|
||||
|
||||
- 文件名 `use-*.ts`(kebab),导出同名函数
|
||||
- 纯组合式:内部用 ref/computed/watch,不依赖组件实例才能运行的 API(或显式在 setup 调用)
|
||||
- 通用才抽:两个以上页面用到再沉淀,单页面逻辑留页面内
|
||||
|
||||
## 枚举编写规约
|
||||
|
||||
- `as const` + 类型导出,避免 any
|
||||
- 键名大写下划线,值按语义(字符串枚举为主)
|
||||
- 与后端常量对齐的值(如权限通配码)注明来源,防两端漂移
|
||||
|
||||
## 相关
|
||||
|
||||
- [9.17 工具函数](/zh/frontend/utils.html)
|
||||
- [附录 J 前端枚举速查](/zh/appendix/enums.html)
|
||||
@@ -1,62 +1,41 @@
|
||||
# 9.4 HTTP 请求封装
|
||||
|
||||
:::tip 概述
|
||||
前端使用 Axios 封装 HTTP 请求,自动处理 Token 携带、响应码判断、错误提示。
|
||||
`utils/http/axios` 是自研的 axios 封装层:请求拦截(前缀、Token)、响应拦截(信封解包、错误提示、401 处理)、取消重复请求。业务代码只面对"成功拿 data"的干净接口。
|
||||
:::
|
||||
|
||||
## 封装结构
|
||||
## 文件结构
|
||||
|
||||
```text
|
||||
src/utils/http/axios/
|
||||
├── index.ts # Axios 实例配置和拦截器
|
||||
├── Axios.ts # VAxios 类封装
|
||||
├── axiosTransform.ts # 数据转换接口
|
||||
├── checkStatus.ts # HTTP 状态码处理
|
||||
├── helper.ts # 辅助函数
|
||||
└── types.ts # 类型定义
|
||||
utils/http/axios/
|
||||
├── Axios.ts axios 实例创建与拦截器装配
|
||||
├── axiosTransform.ts 转换器基类(各拦截点的抽象)
|
||||
├── index.ts 本项目转换器实现(核心业务逻辑)
|
||||
├── checkStatus.ts HTTP 状态码 → 提示文案映射
|
||||
├── axiosCancel.ts 重复请求取消
|
||||
├── helper.ts 辅助函数
|
||||
└── types.ts 类型定义
|
||||
```
|
||||
|
||||
## 核心拦截器
|
||||
## 拦截流程
|
||||
|
||||
### 请求拦截器(自动携带 Token)
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
|
||||
// 请求前自动添加 Token
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
```text
|
||||
请求发出
|
||||
→ 前置:拼接 VITE_GLOB_API_URL_PREFIX + URL,附加 Authorization: Bearer
|
||||
→ 发送(重复请求按策略取消)
|
||||
↓
|
||||
响应返回
|
||||
→ 后置:判断 HTTP 状态 → 解包信封
|
||||
ok=true → 返回 data(业务函数直接拿数据)
|
||||
code=401 → 清令牌跳登录(或触发刷新)
|
||||
其余失败 → 统一 toast msg,抛错给调用方 catch
|
||||
↓
|
||||
业务代码
|
||||
```
|
||||
|
||||
### 响应拦截器(统一错误处理)
|
||||
## 使用形态(摘自 ui/src/api/tool/example.ts)
|
||||
|
||||
```typescript
|
||||
// 响应处理逻辑
|
||||
const { code, data, msg } = res.data;
|
||||
|
||||
if (code === 0) {
|
||||
return data; // 成功,返回 data
|
||||
}
|
||||
|
||||
if (code === 401) {
|
||||
// Token 过期,跳转登录
|
||||
router.push('/login');
|
||||
}
|
||||
|
||||
// 其他错误,显示提示
|
||||
ElMessage.error(msg || '操作失败');
|
||||
```
|
||||
|
||||
:::tip 响应处理规则
|
||||
- `code === 0`:成功,返回 `data`
|
||||
- `code === 401`:Token 过期,自动跳转登录页
|
||||
- `code === 403`:权限不足,显示错误提示
|
||||
- 其它 `code`:业务失败,显示 `msg`
|
||||
:::
|
||||
|
||||
## API 模块编写
|
||||
|
||||
```typescript
|
||||
// src/api/tool/example.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
export function getExamplePage(params?: Record<string, any>) {
|
||||
@@ -66,25 +45,23 @@ export function getExamplePage(params?: Record<string, any>) {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function exampleAdd(data: any) {
|
||||
return http.request({
|
||||
url: '/example/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
注意 URL **不带 /api 前缀**(由配置统一拼接);Token 与信封判断都不在业务层出现。
|
||||
|
||||
```typescript
|
||||
import { getExamplePage, exampleAdd } from '@/api/tool/example';
|
||||
## 错误处理约定
|
||||
|
||||
// 获取列表
|
||||
const res = await getExamplePage({ pageNo: 1, pageSize: 10 });
|
||||
tableData.value = res.records;
|
||||
| 层 | 行为 |
|
||||
|---|---|
|
||||
| 拦截器 | 统一 toast + 401 跳转;返回 reject |
|
||||
| api 模块 | 不 catch(透传) |
|
||||
| 页面 | 需要特殊处理才 catch(如表单保留输入),否则由拦截器兜底提示 |
|
||||
|
||||
// 新增
|
||||
await exampleAdd({ name: '测试', status: 1 });
|
||||
```
|
||||
## 类型支持
|
||||
|
||||
`http.request<T>()` 支持泛型标注返回 data 类型,逐步为 api 模块补类型是推荐实践。
|
||||
|
||||
## 相关
|
||||
|
||||
- [8.1 统一响应格式](/zh/api-spec/response.html)(信封契约)
|
||||
- [9.14 API 模块编写规范](/zh/frontend/api-module.html)
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# 9.17 国际化(i18n)
|
||||
|
||||
:::danger 功能未实现
|
||||
本章描述的国际化(i18n)方案为规划中的功能,核心依赖(`locales/` 目录、`LangSwitch` 组件、`locale` Store)尚未在项目中实现。请勿按本文档操作,等待后续版本发布。
|
||||
:::
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目的国际化方案,基于 vue-i18n 实现多语言切换。项目默认提供中文和英文两种语言,可按需扩展其他语言。
|
||||
:::
|
||||
|
||||
## 技术方案
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 插件 | vue-i18n v9 |
|
||||
| 默认语言 | 中文(zh-CN) |
|
||||
| 支持语言 | zh-CN、en-US |
|
||||
| 存储位置 | localStorage(持久化用户选择) |
|
||||
|
||||
## 目录结构
|
||||
|
||||
:::warning 下列目录尚未创建
|
||||
`ui/src/locales/` 目录当前不存在,以下为规划结构。
|
||||
:::
|
||||
|
||||
```text
|
||||
ui/src/
|
||||
├── locales/
|
||||
│ ├── index.ts # i18n 初始化配置
|
||||
│ ├── lang/
|
||||
│ │ ├── zh-CN.ts # 中文语言包
|
||||
│ │ └── en-US.ts # 英文语言包
|
||||
│ └── helper.ts # 语言切换辅助函数
|
||||
```
|
||||
|
||||
## 语言包结构
|
||||
|
||||
:::warning 下列文件尚不存在
|
||||
`ui/src/locales/lang/zh-CN.ts` 当前不存在,以下为规划示例。
|
||||
:::
|
||||
|
||||
```typescript
|
||||
// ui/src/locales/lang/zh-CN.ts
|
||||
export default {
|
||||
common: {
|
||||
search: '搜索',
|
||||
reset: '重置',
|
||||
add: '新增',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
export: '导出',
|
||||
import: '导入',
|
||||
confirm: '确认',
|
||||
cancel: '取消',
|
||||
success: '操作成功',
|
||||
failed: '操作失败',
|
||||
},
|
||||
table: {
|
||||
id: 'ID',
|
||||
name: '名称',
|
||||
status: '状态',
|
||||
createTime: '创建时间',
|
||||
action: '操作',
|
||||
},
|
||||
login: {
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
captcha: '验证码',
|
||||
submit: '登录',
|
||||
rememberMe: '记住我',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 模板中使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div>
|
||||
<!-- 基础翻译 -->
|
||||
<el-button>{{ $t('common.search') }}</el-button>
|
||||
<el-button>{{ $t('common.reset') }}</el-button>
|
||||
|
||||
<!-- 带参数翻译 -->
|
||||
<p>{{ $t('message.welcome', { name: 'Admin' }) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Script 中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
// 翻译
|
||||
const searchText = t('common.search');
|
||||
|
||||
// 切换语言
|
||||
const switchLang = (lang: string) => {
|
||||
locale.value = lang;
|
||||
localStorage.setItem('lang', lang);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
### 列定义中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: t('table.id'), field: 'id', width: 80 },
|
||||
{ title: t('table.name'), field: 'name' },
|
||||
{ title: t('table.status'), field: 'statusText', width: 100 },
|
||||
{ title: t('table.createTime'), field: 'createTime', width: 180 },
|
||||
]);
|
||||
</script>
|
||||
```
|
||||
|
||||
## 语言切换组件
|
||||
|
||||
:::warning 下列组件和 Store 尚不存在
|
||||
`ui/src/components/LangSwitch/` 和 `ui/src/store/modules/locale.ts` 当前不存在,以下为规划示例。
|
||||
:::
|
||||
|
||||
```vue
|
||||
<!-- ui/src/components/LangSwitch/index.vue -->
|
||||
<template>
|
||||
<el-dropdown @command="switchLang">
|
||||
<el-button :icon="Icon" circle />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en-US">English</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useLocaleStore } from '@/store/modules/locale';
|
||||
|
||||
const { locale } = useI18n();
|
||||
const localeStore = useLocaleStore();
|
||||
|
||||
const switchLang = (lang: string) => {
|
||||
locale.value = lang;
|
||||
localeStore.setLocale(lang);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 后端消息国际化
|
||||
|
||||
后端错误消息也可支持国际化,通过请求头 `Accept-Language` 判断语言:
|
||||
|
||||
:::warning 下列中间件尚不存在
|
||||
`app/middleware/LangMiddleware.php` 当前不存在,以下为规划示例。
|
||||
:::
|
||||
|
||||
```php
|
||||
// app/middleware/LangMiddleware.php
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
{
|
||||
$lang = $request->header('Accept-Language', 'zh-CN');
|
||||
app()->setLang($lang);
|
||||
return $next($request);
|
||||
}
|
||||
```
|
||||
|
||||
## 添加新语言
|
||||
|
||||
:::warning 以下步骤依赖未实现的功能
|
||||
`ui/src/locales/` 目录尚不存在,请勿执行以下操作。
|
||||
:::
|
||||
|
||||
1. 创建语言包文件 `ui/src/locales/lang/ja-JP.ts`
|
||||
2. 复制中文语言包结构并翻译
|
||||
3. 在 `ui/src/locales/index.ts` 中注册新语言
|
||||
4. 在语言切换组件中添加选项
|
||||
|
||||
```typescript
|
||||
// ui/src/locales/index.ts
|
||||
import zhCN from './lang/zh-CN';
|
||||
import enUS from './lang/en-US';
|
||||
import jaJP from './lang/ja-JP'; // 新增
|
||||
|
||||
const messages = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
'ja-JP': jaJP, // 新增
|
||||
};
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [前端概述](/zh/frontend/overview.html)
|
||||
- [状态管理](/zh/frontend/store.html)
|
||||
- [环境配置](/zh/frontend/env-config.html)
|
||||
@@ -1,102 +1,45 @@
|
||||
# 9.15 布局系统
|
||||
# 9.16 布局系统
|
||||
|
||||
:::tip 概述
|
||||
前端布局采用经典的企业后台布局:左侧菜单 + 顶部导航 + 内容区域,支持多标签页导航。
|
||||
布局在 `ui/src/layout/`:主框架 index.vue + parentLayout.vue(多级嵌套),components/ 内含侧边栏、顶栏、标签页、面包屑等区域组件。菜单驱动侧边栏渲染。
|
||||
:::
|
||||
|
||||
## 布局结构
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 │
|
||||
│ [Logo] [面包屑] [搜索] [通知] [头像] │
|
||||
├──────────┬──────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧边栏 │ 内容区域 │
|
||||
│ 菜单 │ │
|
||||
│ │ ┌──────────────────────────────────────────┐ │
|
||||
│ ──────── │ │ 标签页导航 │ │
|
||||
│ 系统管理 │ ├──────────────────────────────────────────┤ │
|
||||
│ ├ 用户 │ │ │ │
|
||||
│ ├ 角色 │ │ 页面内容 │ │
|
||||
│ └ 菜单 │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ 内容管理 │ │ │ │
|
||||
│ ├ 文章 │ │ │ │
|
||||
│ └ 分类 │ │ │ │
|
||||
│ │ └──────────────────────────────────────────┘ │
|
||||
└──────────┴──────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────┐
|
||||
│ 顶栏:面包屑 + 标签页 + 用户头像 + 锁屏 │
|
||||
├────────────┬─────────────────────────┤
|
||||
│ 侧边栏 │ 内容区(router-view) │
|
||||
│ (菜单树) │ │
|
||||
└────────────┴─────────────────────────┘
|
||||
```
|
||||
|
||||
## 布局组件
|
||||
## 组件划分
|
||||
|
||||
```text
|
||||
ui/src/layout/
|
||||
├── index.vue # 布局入口
|
||||
├── components/
|
||||
│ ├── header/ # 顶部导航(Logo、面包屑、搜索、通知、用户头像)
|
||||
│ ├── sider/ # 侧边栏菜单(动态菜单、折叠、主题)
|
||||
│ ├── content/ # 内容区域(keep-alive 缓存)
|
||||
│ ├── tabs/ # 标签页导航(多标签切换、关闭)
|
||||
│ └── footer/ # 页脚
|
||||
└── hooks/ # 布局相关 hooks
|
||||
```
|
||||
| 区域 | 内容 |
|
||||
|---|---|
|
||||
| index.vue | 主布局(区域编排) |
|
||||
| parentLayout.vue | 动态路由的父级壳(嵌套路由承接) |
|
||||
| 侧边栏 | 按 asyncRoute 菜单树渲染,图标经 router-icons 映射 |
|
||||
| 顶栏 | 折叠开关、面包屑、标签页、用户下拉 |
|
||||
| 标签页 | tabsView store 驱动的多页签 |
|
||||
|
||||
## 侧边栏菜单
|
||||
## 菜单渲染
|
||||
|
||||
菜单数据从后端 `/index/getMenus` 接口获取,由 `asyncRouteStore` 管理:
|
||||
侧边栏数据来自动态路由表(GetMenus 生成):隐藏菜单(hide=1)、停用菜单(status=1)在生成阶段已过滤——**菜单表即侧边栏**,前端不维护第二份菜单配置。
|
||||
|
||||
```typescript
|
||||
// 菜单渲染逻辑
|
||||
const menuList = asyncRouteStore.getMenus;
|
||||
## 响应式
|
||||
|
||||
// 菜单数据结构
|
||||
interface MenuItem {
|
||||
id: number;
|
||||
parentId: number;
|
||||
name: string;
|
||||
path: string;
|
||||
component: string;
|
||||
icon: string;
|
||||
sort: number;
|
||||
hide: boolean;
|
||||
children?: MenuItem[];
|
||||
}
|
||||
```
|
||||
布局断点经 breakpointEnum 与 element-resize-detector 处理:窄屏自动折叠侧边栏,移动端适配基础布局。
|
||||
|
||||
## 标签页导航
|
||||
## 定制入口
|
||||
|
||||
标签页自动记录访问过的页面,支持:
|
||||
- 点击切换
|
||||
- 关闭当前/其他/全部
|
||||
- 右键菜单操作
|
||||
- keep-alive 缓存
|
||||
- 区域增减改 index.vue 布局编排
|
||||
- 各区域内部样式在对应组件 scoped 内
|
||||
- 全局尺寸(侧边栏宽度、顶栏高度)在 designSetting store
|
||||
|
||||
```typescript
|
||||
// 标签页 Store
|
||||
const tabsViewStore = useTabsViewStore();
|
||||
## 相关
|
||||
|
||||
// 添加标签
|
||||
tabsViewStore.addTab(route);
|
||||
|
||||
// 关闭标签
|
||||
tabsViewStore.removeTab(route);
|
||||
```
|
||||
|
||||
## 响应式布局
|
||||
|
||||
布局支持桌面端和平板端:
|
||||
- 桌面端:侧边栏默认展开
|
||||
- 平板端:侧边栏默认折叠
|
||||
- 侧边栏支持手动折叠/展开
|
||||
|
||||
```typescript
|
||||
// 侧边栏折叠状态
|
||||
const collapsed = ref(false);
|
||||
|
||||
// 响应式检测
|
||||
const { width } = useWindowSize();
|
||||
watchEffect(() => {
|
||||
collapsed.value = width.value < 1024;
|
||||
});
|
||||
```
|
||||
- [9.5 路由与动态菜单](/zh/frontend/router.html)
|
||||
- [6.3 菜单管理](/zh/modules/menu.html)
|
||||
|
||||
@@ -1,75 +1,58 @@
|
||||
# 9.8 登录流程
|
||||
|
||||
:::tip 概述
|
||||
本节说明前端登录流程的实现,包括登录表单提交、Token 存储、路由守卫和登出处理。
|
||||
登录页提供四种表单:账号密码、手机验证码、扫码登录、注册。以账密登录为主线:验证码获取 → 登录换取双令牌 → 落 storage → 跳首页(守卫接管拉信息、生成路由)。
|
||||
:::
|
||||
|
||||
## 前端登录流程
|
||||
## 页面组成
|
||||
|
||||
```text
|
||||
1. 用户输入用户名、密码、验证码
|
||||
2. 调用 POST /api/login 接口
|
||||
3. 成功 → 存储 Token 到 localStorage
|
||||
4. 路由守卫检测到 Token → 获取用户信息
|
||||
5. 生成动态路由 → 跳转首页
|
||||
views/login/
|
||||
├── index.vue 登录容器(表单切换)
|
||||
├── LoginForm.vue 账号密码 + 图形验证码
|
||||
├── PhoneForm.vue 手机验证码
|
||||
├── QrcodeForm.vue 扫码登录
|
||||
└── RegisterForm.vue 注册
|
||||
```
|
||||
|
||||
## 登录页面代码
|
||||
## 账密登录时序
|
||||
|
||||
```vue
|
||||
<!-- src/views/login/index.vue -->
|
||||
<template>
|
||||
<el-form :model="loginForm" @submit.prevent="handleLogin">
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.username" placeholder="用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.password" type="password" placeholder="密码" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-input v-model="loginForm.captchaCode" placeholder="验证码" />
|
||||
<img :src="captchaImage" @click="refreshCaptcha" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="handleLogin">登录</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { getCaptcha } from '@/api/common/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const loginForm = ref({ username: '', password: '', captchaCode: '', captchaKey: '' });
|
||||
const captchaImage = ref('');
|
||||
|
||||
// 获取验证码
|
||||
const refreshCaptcha = async () => {
|
||||
const res = await getCaptcha();
|
||||
loginForm.value.captchaKey = res.key;
|
||||
captchaImage.value = res.image;
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
await userStore.login(loginForm.value);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
onMounted(() => refreshCaptcha());
|
||||
</script>
|
||||
```text
|
||||
① 进入登录页 → /api/captcha 取 key + captcha 图片
|
||||
② 用户填账号/密码/验证码
|
||||
③ POST /api/login { username, password, key, code }
|
||||
④ 成功:data.access_token / data.refresh_token 写 storage + user store
|
||||
⑤ 跳转首页 → 路由守卫发现无动态路由 → GetInfo + generateRoutes
|
||||
⑥ 进入布局页
|
||||
```
|
||||
|
||||
失败:拦截器弹 msg(验证码错误/密码错误/租户禁用等后端业务消息)。字段名以 `internal/model/dto/auth.go`(LoginReq/LoginRes)为准。
|
||||
|
||||
## Token 存储
|
||||
|
||||
```typescript
|
||||
// 登录成功后存储
|
||||
storage.set(ACCESS_TOKEN, data.access_token, 7 * 24 * 60 * 60 * 1000); // 7天过期
|
||||
- storage 键:`ACCESS_TOKEN`(mutation-types 常量)
|
||||
- 请求拦截器自动附加 `Authorization: Bearer`
|
||||
- 支持 URL `?token=` 直接落 storage(单点登录跳转集成)
|
||||
|
||||
// 请求拦截器自动携带
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
## 验证码交互
|
||||
|
||||
// 响应拦截器检测 401
|
||||
if (response.data.code === 401) {
|
||||
router.push('/login');
|
||||
}
|
||||
```
|
||||
- 图片点击刷新(uuid 换新,一码一用)
|
||||
- 开发环境可配万能码(captcha.bypassCode,生产禁止非空)
|
||||
|
||||
## 登出
|
||||
|
||||
登出调 `/api/logout`(后端拉黑双令牌),前端清 storage 与 store 回登录页。
|
||||
|
||||
## 四种表单的后端支撑
|
||||
|
||||
| 表单 | 说明 |
|
||||
|---|---|
|
||||
| 账密 | 标准登录(本页主线) |
|
||||
| 手机 | 短信验证码通道(依赖短信能力,任务包内有 SendSms 示例) |
|
||||
| 扫码 | 扫码确认通道 |
|
||||
| 注册 | 开放注册场景(注册即建用户) |
|
||||
|
||||
## 相关
|
||||
|
||||
- [5.2 认证与授权流程](/zh/core/auth-flow.html)
|
||||
- [8.7 Token 使用说明](/zh/api-spec/token.html)
|
||||
|
||||
@@ -1,572 +1,74 @@
|
||||
# 9.12 新增页面开发步骤
|
||||
# 9.13 新增页面开发步骤
|
||||
|
||||
:::tip 概述
|
||||
以 example 模块为例,演示从 API 到视图的完整开发步骤。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
|
||||
一个标准业务页面 = api 模块函数 + views 页面五件套 + 菜单配置。本页步骤以 example 模块真实文件为模板;配合代码生成器可自动完成前三步。
|
||||
:::
|
||||
|
||||
## 开发流程
|
||||
## 步骤总览
|
||||
|
||||
```text
|
||||
1. 创建 API 接口 src/api/{module}/{name}.ts
|
||||
2. 创建表格列配置 src/views/{module}/{name}/columns.ts
|
||||
3. 创建查询表单配置 src/views/{module}/{name}/querySchemas.ts
|
||||
4. 创建列表页 src/views/{module}/{name}/index.vue
|
||||
5. 创建编辑弹窗 src/views/{module}/{name}/edit.vue
|
||||
6. 创建详情弹窗 src/views/{module}/{name}/detail.vue
|
||||
7. 配置菜单 后端菜单管理中设置组件路径
|
||||
8. 测试 刷新前端页面,点击菜单测试
|
||||
① api 模块 ui/src/api/{域}/{模块}.ts
|
||||
② 页面五件套 views/{域}/{模块}/
|
||||
index.vue / edit.vue / detail.vue / columns.ts / querySchemas.ts
|
||||
③ 菜单 管理界面配置 path + component + 权限节点
|
||||
④ 授权 角色勾选新权限节点
|
||||
⑤ 自检 页面打开、增删改查、按钮显隐
|
||||
```
|
||||
|
||||
## 完整文件结构
|
||||
## ① API 模块
|
||||
|
||||
```text
|
||||
ui/src/
|
||||
├── api/
|
||||
│ └── tool/
|
||||
│ └── example.ts # API 接口
|
||||
└── views/
|
||||
└── tool/
|
||||
└── example/
|
||||
├── index.vue # 列表页(主页面)
|
||||
├── columns.ts # 表格列配置
|
||||
├── querySchemas.ts # 查询表单配置
|
||||
├── edit.vue # 编辑弹窗(新增/修改)
|
||||
└── detail.vue # 详情弹窗
|
||||
```
|
||||
|
||||
## 步骤一:创建 API 接口
|
||||
按 [9.14 API 模块编写规范](/zh/frontend/api-module.html) 命名(get{模块}Page / {模块}Add …),写法照抄 `ui/src/api/tool/example.ts`:
|
||||
|
||||
```typescript
|
||||
// ui/src/api/tool/example.ts
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
// 分页查询
|
||||
export function getExamplePage(params?) {
|
||||
export function getExamplePage(params?: Record<string, any>) {
|
||||
return http.request({ url: '/example/page', method: 'GET', params });
|
||||
}
|
||||
|
||||
// 全量列表(下拉框数据源)
|
||||
export function getExampleList(params?) {
|
||||
return http.request({ url: '/example/list', method: 'GET', params });
|
||||
}
|
||||
|
||||
// 详情
|
||||
export function getExampleDetail(id) {
|
||||
return http.request({ url: '/example/detail/' + id, method: 'GET' });
|
||||
}
|
||||
|
||||
// 新增
|
||||
export function exampleAdd(data: any) {
|
||||
return http.request({ url: '/example/add', method: 'POST', data });
|
||||
}
|
||||
|
||||
// 修改
|
||||
export function exampleUpdate(data: any) {
|
||||
return http.request({ url: '/example/update', method: 'PUT', data });
|
||||
}
|
||||
|
||||
// 删除
|
||||
export function exampleDelete(id) {
|
||||
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
export function exampleBatchDelete(data: any) {
|
||||
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
|
||||
}
|
||||
```
|
||||
|
||||
### API 命名规范
|
||||
## ② 页面五件套
|
||||
|
||||
| 操作 | 函数名 | HTTP 方法 |
|
||||
|------|--------|----------|
|
||||
| 分页查询 | `get{Module}Page` | GET |
|
||||
| 全量列表 | `get{Module}List` | GET |
|
||||
| 详情 | `get{Module}Detail` | GET |
|
||||
| 新增 | `{module}Add` | POST |
|
||||
| 修改 | `{module}Update` | PUT |
|
||||
| 删除 | `{module}Delete` | DELETE |
|
||||
| 批量删除 | `{module}BatchDelete` | DELETE |
|
||||
直接复制 `views/tool/example/` 目录改名,逐个文件替换模块字段:
|
||||
|
||||
## 步骤二:创建表格列配置
|
||||
| 文件 | 改什么 |
|
||||
|---|---|
|
||||
| index.vue | 接口函数引用、工具栏按钮(v-perm 码)、弹窗引用 |
|
||||
| edit.vue | 表单字段与 dto json 名一致(全量提交) |
|
||||
| detail.vue | 展示字段 |
|
||||
| columns.ts | 列定义与 render 渲染 |
|
||||
| querySchemas.ts | 查询项(与后端 LikeFields/EqFields 对齐) |
|
||||
|
||||
```typescript
|
||||
// ui/src/views/tool/example/columns.ts
|
||||
import { h } from 'vue';
|
||||
import { ElAvatar, ElTag } from 'element-plus';
|
||||
列表页骨架为 `BasicForm + BasicTable + @register`(见 [9.9](/zh/frontend/table-components.html))。
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
type: 'selection', // 多选框列
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
prop: 'id',
|
||||
fixed: 'left',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
label: '名称',
|
||||
prop: 'name',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'statusText',
|
||||
minWidth: 100,
|
||||
// 选项标签渲染:按值映射文字与颜色
|
||||
render(record) {
|
||||
const statusMap = {
|
||||
0: { text: '禁用', type: 'danger' },
|
||||
1: { text: '启用', type: 'success' },
|
||||
};
|
||||
const status = statusMap[record.row.status];
|
||||
return h(ElTag, { type: status?.type || 'info' }, {
|
||||
default: () => status?.text || record.row.statusText,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '头像',
|
||||
prop: 'avatar',
|
||||
minWidth: 80,
|
||||
// 图片渲染:以方形头像形式展示
|
||||
render(record) {
|
||||
return h(ElAvatar, {
|
||||
size: 48,
|
||||
src: record.row.avatar,
|
||||
shape: 'square',
|
||||
fit: 'fill',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '创建人',
|
||||
prop: 'createUser',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'createTime',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
## ③④ 菜单与授权
|
||||
|
||||
菜单管理配置:
|
||||
|
||||
- `path`:路由路径
|
||||
- `component`:views 相对路径(如 `tool/xxx/index`)
|
||||
- 每接口一个 `permission` 节点(与 RouteSpec 逐字一致,type=1 节点挂在页面菜单下)
|
||||
|
||||
随后角色管理勾选新节点。
|
||||
|
||||
## ⑤ 自检清单
|
||||
|
||||
- [ ] 菜单出现在侧边栏,点击正常渲染(component 路径正确)
|
||||
- [ ] 分页/搜索/筛选正常(与后端 LikeFields/EqFields 对齐)
|
||||
- [ ] 新增/编辑提交成功(全量字段、json 名一致)
|
||||
- [ ] 字典列显示 xxText(SerializeMaps + 字典项存在)
|
||||
- [ ] 按钮 v-perm 显隐正确(数组入参)
|
||||
- [ ] 无权限角色访问被 403
|
||||
|
||||
## 生成器捷径
|
||||
|
||||
```bash
|
||||
make gen TABLES="gin_xxx|模块说明"
|
||||
```
|
||||
|
||||
### 列配置字段说明
|
||||
api 模块、index/edit/detail/columns/querySchemas、菜单行、路由声明块全部自动就位——手工开发用于理解机制,日常提效用生成器。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
|
||||
| `label` | string | 表头标题 |
|
||||
| `prop` | string | 数据字段名(camelCase) |
|
||||
| `fixed` | string | 固定列:`left` / `right` |
|
||||
| `width` | number | 固定宽度 |
|
||||
| `minWidth` | number | 最小宽度(自适应) |
|
||||
| `render` | function | 自定义渲染函数 |
|
||||
## 相关
|
||||
|
||||
## 步骤三:创建查询表单配置
|
||||
|
||||
```typescript
|
||||
// ui/src/views/tool/example/querySchemas.ts
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
label: '名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入名称',
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 表单组件类型
|
||||
|
||||
| component | 适用场景 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| `Input` | 字符串字段 | 文本输入框 |
|
||||
| `Select` | 枚举/选项字段 | 下拉选择框 |
|
||||
| `InputNumber` | 数字字段 | 数字输入框 |
|
||||
| `DatePicker` | 日期字段 | 日期选择器 |
|
||||
|
||||
## 步骤四:创建列表页
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/index.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<!-- 查询表单 -->
|
||||
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-row :gutter="10" style="margin-bottom: 10px;">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange" v-loading="loading">
|
||||
<el-table-column v-for="col in columns" v-bind="col" :key="col.prop || col.type">
|
||||
<template v-if="col.render" #default="scope">
|
||||
<component :is="() => col.render(scope)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" link @click="handleDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="fetchData"
|
||||
style="margin-top: 10px; justify-content: flex-end;"
|
||||
/>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EditDialog ref="editRef" @success="fetchData" />
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<DetailDialog v-model:visible="detailVisible" :id="detailId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
|
||||
import { columns } from './columns';
|
||||
import { schemas as querySchemas } from './querySchemas';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import EditDialog from './edit.vue';
|
||||
import DetailDialog from './detail.vue';
|
||||
|
||||
const editRef = ref();
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const detailVisible = ref(false);
|
||||
const detailId = ref(0);
|
||||
|
||||
// 加载数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExamplePage({
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
tableData.value = res.records;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (params) => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editRef.value.open();
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
editRef.value.open(row);
|
||||
};
|
||||
|
||||
// 详情
|
||||
const handleDetail = (row) => {
|
||||
detailId.value = row.id;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除该记录?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
|
||||
await exampleBatchDelete({ ids: selectedIds.value });
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 多选变化
|
||||
const handleSelectionChange = (rows) => {
|
||||
selectedIds.value = rows.map(row => row.id);
|
||||
};
|
||||
|
||||
onMounted(() => fetchData());
|
||||
</script>
|
||||
```
|
||||
|
||||
## 步骤五:创建编辑弹窗
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/edit.vue -->
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type">
|
||||
<el-select v-model="formData.type" placeholder="请选择类型">
|
||||
<el-option v-for="item in typeOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref();
|
||||
const typeOptions = ref([]);
|
||||
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
};
|
||||
|
||||
// 打开弹窗
|
||||
const open = async (row?) => {
|
||||
visible.value = true;
|
||||
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
|
||||
|
||||
// 加载字典选项
|
||||
typeOptions.value = await getDictItemList('example_type');
|
||||
|
||||
// 编辑时加载详情
|
||||
if (row?.id) {
|
||||
const detail = await getExampleDetail(row.id);
|
||||
Object.assign(formData.value, detail);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (formData.value.id) {
|
||||
await exampleUpdate(formData.value);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await exampleAdd(formData.value);
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
```
|
||||
|
||||
## 步骤六:创建详情弹窗
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/detail.vue -->
|
||||
<template>
|
||||
<el-dialog v-model="props.visible" title="案例详情" width="750" :close-on-click-modal="false">
|
||||
<el-descriptions column="2" border>
|
||||
<el-descriptions-item label="名称:">
|
||||
{{ formData.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型:">
|
||||
{{ formData.typeText || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态:">
|
||||
<el-tag :type="formData.status === 1 ? 'success' : 'danger'">
|
||||
{{ formData.statusText || '-' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序:">
|
||||
{{ formData.sort ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="头像:">
|
||||
<el-image
|
||||
v-if="formData.avatar"
|
||||
:src="formData.avatar"
|
||||
:preview-src-list="[formData.avatar]"
|
||||
fit="cover"
|
||||
style="width: 80px; height: 80px"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人:">
|
||||
{{ formData.createUser || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间:">
|
||||
{{ formData.createTime || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:visible', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getExampleDetail } from '@/api/tool/example';
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
id: { type: Number, required: true, default: 0 },
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
typeText: '',
|
||||
status: 0,
|
||||
statusText: '',
|
||||
sort: 0,
|
||||
avatar: '',
|
||||
createUser: '',
|
||||
createTime: '',
|
||||
});
|
||||
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
const data = await getExampleDetail(props.id);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 监听弹窗显示
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal && props.id) {
|
||||
loadDetail();
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
```
|
||||
|
||||
## 步骤七:配置菜单
|
||||
|
||||
在后端菜单管理中添加菜单项:
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| 菜单名称 | 案例管理 |
|
||||
| 组件路径 | `tool/example/index` |
|
||||
| 路由路径 | `/tool/example` |
|
||||
| 权限标识 | `sys:example:page` |
|
||||
|
||||
添加按钮权限:
|
||||
|
||||
| 按钮名称 | 权限标识 |
|
||||
|---------|---------|
|
||||
| 新增 | `sys:example:add` |
|
||||
| 修改 | `sys:example:update` |
|
||||
| 删除 | `sys:example:delete` |
|
||||
| 批量删除 | `sys:example:batchDelete` |
|
||||
| 详情 | `sys:example:detail` |
|
||||
|
||||
## 步骤八:测试
|
||||
|
||||
1. 刷新前端页面
|
||||
2. 点击菜单"案例管理"
|
||||
3. 测试搜索、新增、编辑、详情、删除、批量删除功能
|
||||
|
||||
## 代码生成器(快速创建)
|
||||
|
||||
如果模块结构简单,可使用代码生成器一键生成前后端代码:
|
||||
|
||||
1. 建好数据库表
|
||||
2. 访问 `/api/generator/page` 查看可生成的表
|
||||
3. `POST /api/generator/preview` 预览代码
|
||||
4. `POST /api/generator/generate` 生成代码
|
||||
5. 在菜单管理中配置菜单
|
||||
|
||||
详见[代码生成器](/zh/generator/overview.html)。
|
||||
- [第 3 章 模块开发实战](/zh/guide/overview.html)(后端配套)
|
||||
- [6.22 示例模块(example)](/zh/modules/example.html)
|
||||
|
||||
@@ -1,91 +1,69 @@
|
||||
# 9.1 前端工程概述
|
||||
|
||||
:::tip 概述
|
||||
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
|
||||
前端为独立 Vite 工程(`ui/`):Vue3 + TypeScript + ElementPlus + Pinia。核心机制包括 axios 信封解包、动态菜单路由、权限指令与 28 个公共组件。本章按"工程基础 → 核心机制 → 公共组件 → 开发实践"组织。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
## 技术构成
|
||||
|
||||
| 类别 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 框架 | Vue | 3.4+ | 核心 UI 框架 |
|
||||
| 语言 | TypeScript | 4.9+(实际 ^4.9.5) | 类型安全 |
|
||||
| UI 库 | ElementPlus | 2.7+ | 组件库 |
|
||||
| 构建 | Vite | 3.2+(实际 ^3.2.10) | 开发服务器与构建 |
|
||||
| 状态 | Pinia | 2.1+ | 全局状态管理 |
|
||||
| 路由 | Vue Router | 4.3+ | 路由管理 |
|
||||
| HTTP | Axios | 0.27+(实际 ^0.27.2) | HTTP 请求(注:0.27.x 版本较老) |
|
||||
| 富文本 | TinyMce | 5.10+ | 富文本编辑器 |
|
||||
| 图表 | ECharts | 5.5+ | 数据可视化 |
|
||||
| CSS | TailwindCSS | 3.4+ | 原子化 CSS |
|
||||
| 层 | 选型 |
|
||||
|---|---|
|
||||
| 框架 | Vue 3.4(Composition API)+ TypeScript 4.9 |
|
||||
| 构建 | Vite 3(开发端口 8001,/api 代理到 8000) |
|
||||
| UI | ElementPlus 2.7 + @element-plus/icons |
|
||||
| 状态 | Pinia(7 个 store 模块) |
|
||||
| 路由 | vue-router 4(静态 + 动态菜单生成) |
|
||||
| HTTP | axios 0.27(自研拦截器封装层) |
|
||||
| 富文本 | TinyMCE / vue-quill(双引擎) |
|
||||
| 其他 | echarts、xlsx、cropperjs、ali-oss、dayjs、lodash |
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 动态路由
|
||||
|
||||
从后端 `/index/getMenus` 接口获取菜单数据,自动生成前端路由,无需手动维护路由表。
|
||||
|
||||
```text
|
||||
后端菜单数据 → generator-routers.ts → Vue Router 动态路由
|
||||
```
|
||||
|
||||
### 权限控制
|
||||
|
||||
`v-perm` / `v-perms` 指令控制按钮级权限:
|
||||
|
||||
```vue
|
||||
<el-button v-perm="'sys:user:add'">新增</el-button>
|
||||
```
|
||||
|
||||
### Token 管理
|
||||
|
||||
Axios 拦截器自动处理:
|
||||
- 请求拦截:自动添加 `Authorization: Bearer <token>` 请求头
|
||||
- 响应拦截:`code === 401` 时自动跳转登录页
|
||||
|
||||
### 组件库
|
||||
|
||||
内置 20+ 个业务组件:
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| BasicTable | 数据表格(分页、排序、搜索) |
|
||||
| BasicForm | 动态表单(配置驱动) |
|
||||
| BasicModal | 弹窗(表单弹窗、详情弹窗) |
|
||||
| BasicUpload | 文件上传 |
|
||||
| Editor | 富文本编辑器(TinyMce) |
|
||||
| ImportExcel | Excel 导入 |
|
||||
| Cropper | 头像裁剪 |
|
||||
| Region | 行政区划选择 |
|
||||
| Password | 密码强度检测 |
|
||||
| Qrcode | 二维码生成 |
|
||||
|
||||
## 工程结构
|
||||
## 工程地图
|
||||
|
||||
```text
|
||||
ui/src/
|
||||
├── api/ # API 接口层(按业务域分组)
|
||||
├── components/ # 公共组件库
|
||||
├── directives/ # 自定义指令(v-perm)
|
||||
├── enums/ # 枚举常量
|
||||
├── hooks/ # 组合式函数
|
||||
├── layout/ # 布局组件
|
||||
├── plugins/ # 插件
|
||||
├── router/ # 路由配置
|
||||
├── settings/ # 项目配置
|
||||
├── store/ # Pinia 状态管理
|
||||
├── styles/ # 全局样式
|
||||
├── utils/ # 工具函数
|
||||
└── views/ # 页面视图
|
||||
├── api/ 接口模块(按业务域分组)
|
||||
├── components/ 公共组件(28 个)
|
||||
├── directives/ 指令(perm/perms/scrollBar/clickOutside)
|
||||
├── enums/ 枚举常量
|
||||
├── hooks/ 组合式函数(core/event/setting/web 分组)
|
||||
├── layout/ 布局系统
|
||||
├── router/ 静态路由 + 动态生成器 + 守卫
|
||||
├── store/ Pinia(user/asyncRoute/tabsView/design/project/lockscreen/ossConfig)
|
||||
├── styles/ 全局样式与主题变量
|
||||
├── settings/ 应用默认设置
|
||||
├── utils/ 工具(http/auth/validate/date/file...)
|
||||
└── views/ 页面(按业务域分目录)
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
## 与后端的协作形态
|
||||
|
||||
```text
|
||||
登录 → GetMenus(动态路由)→ 页面渲染
|
||||
→ api 模块发起请求(http 封装加前缀/Token)
|
||||
→ 拦截器解包信封(0 成功 / 401 刷新或跳登录 / 其余弹 msg)
|
||||
→ v-perm 控制按钮显隐 / 字典接口驱动下拉
|
||||
```
|
||||
|
||||
## 阅读路线
|
||||
|
||||
| 目标 | 页面 |
|
||||
|---|---|
|
||||
| 了解工程结构 | 9.2 目录结构详解 |
|
||||
| 接后端接口 | 9.4 HTTP 封装 + 9.14 API 模块编写规范 |
|
||||
| 做页面 | 9.13 新增页面开发步骤 |
|
||||
| 排查登录/权限问题 | 9.5 路由与动态菜单 + 9.7 权限控制 + 9.8 登录流程 |
|
||||
| 用公共组件 | 9.9–9.12 组件四篇 |
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 启动开发服务器
|
||||
pnpm build # 构建生产版本
|
||||
pnpm preview # 预览构建结果
|
||||
pnpm lint:eslint # ESLint 检查
|
||||
pnpm lint:prettier # Prettier 格式化
|
||||
```
|
||||
cd ui
|
||||
pnpm dev # 开发
|
||||
pnpm build # 生产构建
|
||||
pnpm type:check # 类型检查
|
||||
pnpm lint:eslint # ESLint
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
→ [9.2 目录结构详解](/zh/frontend/directory.html)
|
||||
|
||||
@@ -1,91 +1,62 @@
|
||||
# 9.7 权限控制
|
||||
|
||||
:::tip 概述
|
||||
前端权限控制通过 `v-perm` / `v-perms` 指令实现按钮级权限控制。
|
||||
前端权限三层:**路由层**(菜单按角色过滤生成)、**指令层**(v-perm / v-perms 控制按钮)、**逻辑层**(usePermission 组合式函数供 v-if 使用)。超管通配码 `*:*:*` 全通过。
|
||||
:::
|
||||
|
||||
## 权限指令
|
||||
## 指令
|
||||
|
||||
### v-perm(单权限)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
</template>
|
||||
```html
|
||||
<!-- ui/src/views/tool/example/index.vue 实际用法 -->
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">添加案例演示</el-button>
|
||||
<el-button type="danger" @click="handleDelete()" v-perm="['sys:example:batchDelete']">批量删除</el-button>
|
||||
```
|
||||
|
||||
### v-perms(多权限,满足任一即可)
|
||||
| 指令 | 语义 | 实现 |
|
||||
|---|---|---|
|
||||
| v-perm | 包含**其中任一**权限(some 判定) | directives/permission.ts → hasSomePermission |
|
||||
| v-perms | 包含**全部**权限(every 判定) | directives/permission.ts → hasEveryPermission |
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 拥有 sys:user:add 或 sys:user:update 任一权限即可 -->
|
||||
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
|
||||
</template>
|
||||
```
|
||||
判定基于 user store 的权限集合;命中通配码 `*:*:*` 直通(超管)。不满足时**移除 DOM 元素**(非隐藏)。
|
||||
|
||||
## 指令实现
|
||||
|
||||
```typescript
|
||||
// src/directives/permission.ts
|
||||
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
export const perm: ObjectDirective = {
|
||||
mounted(el: HTMLButtonElement, binding) {
|
||||
const { hasSomePermission } = usePermission();
|
||||
if (!hasSomePermission(binding.value)) {
|
||||
el.parentNode.removeChild(el); // 无权限则移除元素
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## usePermission Hook
|
||||
|
||||
:::tip 超级管理员
|
||||
用户权限列表包含 `*:*:*` 时,表示超级管理员,拥有所有权限。
|
||||
:::warning 入参必须是数组
|
||||
`hasSomePermission` 对非数组入参按空权限处理(`Array.isArray(accesses) ? accesses : []`)——写成 `v-perm="'sys:user:add'"` 字符串形式会导致元素**一律被移除**。正确写法 `v-perm="['sys:user:add']"`。
|
||||
:::
|
||||
|
||||
## 组合式函数(usePermission)
|
||||
|
||||
```typescript
|
||||
// src/hooks/web/usePermission.ts
|
||||
const { hasPermission, hasSomePermission, hasEveryPermission } = usePermission();
|
||||
|
||||
export function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
/**
|
||||
* 检查是否拥有指定权限
|
||||
* 超级管理员(permissions 包含 '*:*:*')拥有所有权限
|
||||
*/
|
||||
function hasSomePermission(permission: string | string[]): boolean {
|
||||
const perms = userStore.permissions;
|
||||
if (perms.includes('*:*:*')) return true;
|
||||
|
||||
if (typeof permission === 'string') {
|
||||
return perms.includes(permission);
|
||||
}
|
||||
return permission.some((p) => perms.includes(p));
|
||||
}
|
||||
|
||||
return { hasSomePermission, hasEveryPermission };
|
||||
}
|
||||
// v-if 场景
|
||||
v-if="hasPermission(['sys:user:add'])"
|
||||
```
|
||||
|
||||
| 函数 | 语义 |
|
||||
|---|---|
|
||||
| hasPermission(accesses) | 包含任一(空数组视为通过) |
|
||||
| hasSomePermission(accesses) | 包含任一(string/array 归一) |
|
||||
| hasEveryPermission(accesses) | 包含全部 |
|
||||
|
||||
## 权限数据来源
|
||||
|
||||
用户登录后,`/index/getUser` 接口返回权限列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"sys:user:page",
|
||||
"sys:user:add",
|
||||
"sys:user:update",
|
||||
"sys:user:delete",
|
||||
"sys:role:page",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```text
|
||||
登录后守卫 GetInfo → user store.getPermissions
|
||||
(后端 LoadIdentity 装配的权限节点集合,含菜单下发的节点码)
|
||||
```
|
||||
|
||||
超级管理员返回 `["*:*:*"]`,拥有所有权限。
|
||||
## 安全边界提醒
|
||||
|
||||
前端权限**只是体验层**:隐藏按钮防误操作,越权请求仍会被后端 Authz 403 拦截。任何接口的安全性以 RouteSpec.Permission 为准,前端漏配指令不构成安全洞,但会造成用户困惑——两侧权限码保持一致是联调检查项(见 [3.9 权限节点配置](/zh/guide/permission.html))。
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 检查 |
|
||||
|---|---|
|
||||
| 按钮不显示 | 菜单节点码与 v-perm 值是否一致;角色是否勾选该节点 |
|
||||
| 指令报错 | v-perm 需要字符串/数组,undefined 直接跳过 |
|
||||
| 改授权后不生效 | 重新登录(权限集合在登录后装配) |
|
||||
|
||||
## 相关
|
||||
|
||||
- [5.4 权限管理(RBAC)](/zh/core/rbac.html)
|
||||
|
||||
@@ -1,224 +1,62 @@
|
||||
# 9.5 路由与动态菜单
|
||||
|
||||
:::tip 概述
|
||||
前端路由采用动态生成机制,从后端 `/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。
|
||||
路由分两类:**静态路由**(constant,登录页/异常页等始终存在)与**动态路由**(登录后由菜单 API 生成注入)。守卫负责时序编排:token 校验 → 拉取用户信息 → 生成路由 → 放行。
|
||||
:::
|
||||
|
||||
## 路由架构
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
静态路由(固定,src/router/index.ts + src/router/base.ts)
|
||||
├── /login 登录页(LoginRoute)
|
||||
├── / 根路径,重定向到首页(RootRoute)
|
||||
├── /redirect/:path 重定向路由(RedirectRoute)
|
||||
├── /:path(.*)* 404 错误页(ErrorPageRoute,动态路由加载后注入)
|
||||
├── /monitorJob/log 关于项目(AboutPageRoute,动态路由加载后注入)
|
||||
└── /setting/profile 个人设置(SettingPageRoute,动态路由加载后注入)
|
||||
|
||||
动态路由(从后端菜单生成)
|
||||
├── /dashboard 仪表盘
|
||||
├── /system/user 用户管理
|
||||
├── /system/role 角色管理
|
||||
├── /content/article 文章管理
|
||||
└── ...
|
||||
router/
|
||||
├── constant.ts 静态常量路由
|
||||
├── base.ts 基础路由(错误页/关于/设置等固定页)
|
||||
├── generator-routers.ts 动态路由生成器(菜单树 → 路由表)
|
||||
├── router-guards.ts 全局守卫
|
||||
├── router-icons.ts 图标映射
|
||||
├── menus/ 菜单相关辅助
|
||||
└── types.ts 类型
|
||||
```
|
||||
|
||||
## 动态路由生成流程
|
||||
## 动态路由流水线
|
||||
|
||||
```text
|
||||
1. 用户登录 → 获取 Token
|
||||
2. 路由守卫检测到 Token
|
||||
3. 调用 /index/getMenus 获取菜单树
|
||||
4. generator-routers.ts 将菜单树转换为路由表
|
||||
5. router.addRoute() 动态注入路由
|
||||
6. 渲染侧边栏菜单
|
||||
登录成功存 token
|
||||
↓
|
||||
路由守卫 beforeEach:
|
||||
无 token → 白名单放行 / 其余跳登录(带 redirect)
|
||||
有 token 且未生成 → GetInfo(用户信息)→ generateRoutes(消费 GetMenus 菜单树)
|
||||
↓
|
||||
菜单行映射:path → 路由路径,component → views 下组件(懒加载)
|
||||
↓
|
||||
router.addRoute 逐条注入 → 标记 isDynamicAddedRoute
|
||||
↓
|
||||
侧边栏按路由表渲染(layout)
|
||||
```
|
||||
|
||||
## 核心代码
|
||||
## 菜单到组件的映射
|
||||
|
||||
```typescript
|
||||
// src/router/generator-routers.ts
|
||||
后端菜单表的 `component` 字段存 views 下相对路径(如 `system/user/index`),生成器将其转为 `() => import('@/views/system/user/index')` 懒加载——**新增页面只需在 views 放组件 + 菜单表配路径**,不改路由代码。
|
||||
|
||||
/**
|
||||
* 动态生成菜单
|
||||
* 调用后端 /index/getMenus 接口获取菜单数据
|
||||
*/
|
||||
export const generatorDynamicRouter = (): Promise<RouteRecordRaw[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
adminMenus()
|
||||
.then((result) => {
|
||||
const routeList = routerGenerator(result);
|
||||
asyncImportRoute(routeList);
|
||||
resolve(routeList);
|
||||
})
|
||||
.catch((err) => reject(err));
|
||||
});
|
||||
};
|
||||
## 守卫要点
|
||||
|
||||
/**
|
||||
* 格式化后端菜单为路由结构
|
||||
*/
|
||||
export const routerGenerator = (routerMap): any[] => {
|
||||
return routerMap.map((item) => {
|
||||
const currentRouter = {
|
||||
path: item.path,
|
||||
name: item.path.replaceAll('/', ''),
|
||||
component: item.component, // 如 'system/user/index'
|
||||
meta: {
|
||||
title: item.name,
|
||||
icon: item.icon,
|
||||
permissions: item.permission,
|
||||
},
|
||||
};
|
||||
| 机制 | 说明 |
|
||||
|---|---|
|
||||
| 白名单 | 登录页免 token |
|
||||
| ignoreAuth | meta 声明的免登录页 |
|
||||
| URL 带 token | `?token=xxx` 自动落 storage(单点登录跳转场景) |
|
||||
| redirect | 未登录跳登录后回跳原路径 |
|
||||
| NProgress | 导航进度条 |
|
||||
| 首路由 | findFirstRoutePath 定位登录后首个落地页 |
|
||||
|
||||
// 递归处理子菜单
|
||||
if (item.children?.length > 0) {
|
||||
currentRouter.children = routerGenerator(item.children);
|
||||
}
|
||||
## 排查
|
||||
|
||||
return currentRouter;
|
||||
});
|
||||
};
|
||||
```
|
||||
| 现象 | 检查 |
|
||||
|---|---|
|
||||
| 登录后白屏 | 菜单 component 路径与 views 文件不符(懒加载失败) |
|
||||
| 刷新 404 | 部署服务器 history 模式回退配置(try_files) |
|
||||
| 菜单少了 | 角色授权问题(看 GetMenus 返回) |
|
||||
|
||||
## 路由守卫
|
||||
## 相关
|
||||
|
||||
:::tip 动态路由流程
|
||||
详见上方 [动态路由生成流程](#动态路由生成流程) 章节,路由守卫中实现了该流程的第 2~6 步。
|
||||
:::
|
||||
|
||||
```typescript
|
||||
// src/router/router-guards.ts
|
||||
|
||||
const LOGIN_PATH = PageEnum.BASE_LOGIN;
|
||||
const whitePathList = [LOGIN_PATH]; // no redirect whitelist
|
||||
|
||||
export function createRouterGuards(router: Router) {
|
||||
const userStore = useUserStoreWidthOut();
|
||||
const asyncRouteStore = useAsyncRouteStoreWidthOut();
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 1. Token 可能来自 URL 查询参数 ?token=xxx(SSO 等场景)
|
||||
if (to.query && 'token' in to.query) {
|
||||
const tokenVal = String(to.query.token);
|
||||
storage.set(ACCESS_TOKEN, tokenVal);
|
||||
userStore.setToken(tokenVal);
|
||||
}
|
||||
|
||||
NProgress.start();
|
||||
|
||||
// 2. 白名单且未登录 → 直接放行
|
||||
const token = storage.get(ACCESS_TOKEN);
|
||||
if (whitePathList.includes(to.path as PageEnum) && !token) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 未登录处理
|
||||
if (!token) {
|
||||
// 路由 meta.ignoreAuth = true 时无需登录也可访问
|
||||
if (to.meta.ignoreAuth) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// 重定向到登录页,携带当前路径作为 redirect 参数
|
||||
const redirectData = { path: LOGIN_PATH, replace: true };
|
||||
if (to.path) {
|
||||
redirectData.query = { redirect: to.path };
|
||||
}
|
||||
next(redirectData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 已登录 + 动态路由已加载 → 直接放行
|
||||
if (asyncRouteStore.getIsDynamicAddedRoute) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 首次加载:生成动态路由
|
||||
asyncRouteStore.setDynamicAddedRoute(true);
|
||||
const userInfo = await userStore.GetInfo();
|
||||
const routes = await asyncRouteStore.generateRoutes(userInfo);
|
||||
await useOssConfigStore().setOssConfig();
|
||||
|
||||
// 动态添加可访问路由表
|
||||
routes.forEach((item) => {
|
||||
router.addRoute(item as unknown as RouteRecordRaw);
|
||||
});
|
||||
|
||||
// 添加 404 路由(避免重复注册)
|
||||
const isErrorPage = router.getRoutes().findIndex((item) => item.name === ErrorPageRoute.name);
|
||||
if (isErrorPage === -1) {
|
||||
router.addRoute(ErrorPageRoute as unknown as RouteRecordRaw);
|
||||
}
|
||||
router.addRoute(AboutPageRoute as unknown as RouteRecordRaw);
|
||||
router.addRoute(SettingPageRoute as unknown as RouteRecordRaw);
|
||||
|
||||
// 保存第一个路由路径,用于默认跳转
|
||||
const firstRoutePath = findFirstRoutePath(routes);
|
||||
storage.set(FIRST_ROUTE, firstRoutePath);
|
||||
|
||||
// 确定重定向目标
|
||||
const formPath =
|
||||
findTreeByPath(routes, from.query.redirect).length > 0 ? from.query.redirect : to.path;
|
||||
const redirect = decodeURIComponent(formPath as string);
|
||||
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect };
|
||||
|
||||
NProgress.done();
|
||||
if (nextData.path === PageEnum.BASE_HOME || (token && to.path === '/login')) {
|
||||
next(firstRoutePath);
|
||||
return;
|
||||
}
|
||||
next(nextData);
|
||||
});
|
||||
|
||||
// 6. afterEach:管理 keep-alive 缓存 + 页面标题
|
||||
router.afterEach((to, _, failure) => {
|
||||
document.title =
|
||||
(to?.meta?.documentTitle ? to?.meta?.documentTitle : (to?.meta?.title as string)) ||
|
||||
document.title;
|
||||
if (isNavigationFailure(failure)) {
|
||||
// navigation failed
|
||||
}
|
||||
const asyncRouteStore = useAsyncRouteStoreWidthOut();
|
||||
const keepAliveComponents = asyncRouteStore.keepAliveComponents;
|
||||
const currentComName: any = to.matched.find((item) => item.name == to.name)?.name;
|
||||
if (currentComName && !keepAliveComponents.includes(currentComName) && to.meta?.keepAlive) {
|
||||
keepAliveComponents.push(currentComName);
|
||||
} else if (!to.meta?.keepAlive || to.name == 'Redirect') {
|
||||
const index = asyncRouteStore.keepAliveComponents.findIndex((name) => name == currentComName);
|
||||
if (index != -1) {
|
||||
keepAliveComponents.splice(index, 1);
|
||||
}
|
||||
}
|
||||
asyncRouteStore.setKeepAliveComponents(keepAliveComponents);
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 后端菜单数据结构
|
||||
|
||||
后端 `/index/getMenus` 返回的菜单树:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "系统管理",
|
||||
"path": "/system",
|
||||
"icon": "SettingOutlined",
|
||||
"component": "LAYOUT",
|
||||
"children": [
|
||||
{
|
||||
"id": 11,
|
||||
"name": "用户管理",
|
||||
"path": "/user",
|
||||
"component": "system/user/index",
|
||||
"permission": "sys:user:page"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
- [6.3 菜单管理](/zh/modules/menu.html)(数据源头)
|
||||
- [9.8 登录流程](/zh/frontend/login.html)
|
||||
|
||||
@@ -1,86 +1,44 @@
|
||||
# 9.6 状态管理(Pinia)
|
||||
|
||||
:::tip 概述
|
||||
前端使用 Pinia 进行全局状态管理,核心 Store 包含用户信息、动态路由、项目配置等。
|
||||
全局状态集中在 7 个 Pinia 模块:用户身份、动态路由、标签页、设计设置、项目设置、锁屏、OSS 配置。页面局部状态不进 store。
|
||||
:::
|
||||
|
||||
## Store 清单
|
||||
|
||||
| Store | 文件 | 说明 |
|
||||
|-------|------|------|
|
||||
| userStore | `store/modules/user.ts` | Token / 用户信息 / 权限列表 / 登录登出 |
|
||||
| asyncRouteStore | `store/modules/asyncRoute.ts` | 动态路由 / keep-alive 缓存 |
|
||||
| projectSettingStore | `store/modules/projectSetting.ts` | 项目配置 |
|
||||
| ossConfigStore | `store/modules/ossConfig.ts` | 云存储配置 |
|
||||
| designSettingStore | `store/modules/designSetting.ts` | 主题设计 |
|
||||
| lockscreenStore | `store/modules/lockscreen.ts` | 锁屏 |
|
||||
| tabsViewStore | `store/modules/tabsView.ts` | 标签页 |
|
||||
| 模块 | 职责 | 持久化 |
|
||||
|---|---|---|
|
||||
| user | token、用户信息、权限集合 | token 经 storage 持久化 |
|
||||
| asyncRoute | 菜单树、动态路由表、是否已注入标记 | — |
|
||||
| tabsView | 标签页导航(多页签状态) | 经 storage |
|
||||
| designSetting | 主题配色、布局尺寸、组件大小 | 经 storage |
|
||||
| projectSetting | 项目级开关(水印、锁屏、面包屑等) | 经 storage |
|
||||
| lockscreen | 锁屏状态 | 经 storage |
|
||||
| ossConfig | 对象存储配置(直传场景) | — |
|
||||
|
||||
## userStore 核心代码
|
||||
## mutation-types 与 storage
|
||||
|
||||
```typescript
|
||||
// src/store/modules/user.ts
|
||||
`store/mutation-types.ts` 定义 storage 键名常量(ACCESS_TOKEN、FIRST_ROUTE 等),`utils/Storage` 提供统一持久化读写——**不散落裸字符串键**。
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: 'app-user',
|
||||
state: (): IUserState => ({
|
||||
token: Storage.get(ACCESS_TOKEN, ''),
|
||||
username: '',
|
||||
avatar: '',
|
||||
permissions: [],
|
||||
info: Storage.get(CURRENT_USER, {}),
|
||||
}),
|
||||
actions: {
|
||||
// 登录
|
||||
async login(userInfo) {
|
||||
const response = await login(userInfo);
|
||||
if (response.code === 0) {
|
||||
storage.set(ACCESS_TOKEN, response.data.access_token);
|
||||
this.setToken(response.data.access_token);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
## 使用规约
|
||||
|
||||
// 获取用户信息
|
||||
async GetInfo() {
|
||||
const res = await getUserInfo();
|
||||
this.setPermissions(res.permissions);
|
||||
this.setUserInfo(res);
|
||||
this.setAvatar(res.avatar);
|
||||
return res;
|
||||
},
|
||||
| 规约 | 说明 |
|
||||
|---|---|
|
||||
| 跨页面才进 store | 表单草稿等页面内状态用 ref |
|
||||
| 组合式风格 | `useUserStore()` 调用式获取,避免 Pinia 旧式选项冗余 |
|
||||
| 宽访问出口 | `useUserStoreWidthOut()` 供守卫等场景(脱离组件上下文) |
|
||||
| 权限只读 | 权限集合由 GetInfo 写入,业务只读判定 |
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
await logout();
|
||||
this.setPermissions([]);
|
||||
this.setUserInfo('');
|
||||
storage.remove(ACCESS_TOKEN);
|
||||
storage.remove(CURRENT_USER);
|
||||
},
|
||||
},
|
||||
});
|
||||
## 与后端的数据流
|
||||
|
||||
```text
|
||||
登录 → token 写 storage + user store
|
||||
路由守卫 → GetInfo 填 user store(含权限集合)→ generateRoutes 填 asyncRoute store
|
||||
登出/401 → 清 token 与相关 store → 跳登录
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
## 相关
|
||||
|
||||
:::tip Store 使用方式
|
||||
- 在 `setup` 中:`const userStore = useUserStore()`
|
||||
- 在 `setup` 外:`const userStore = useUserStoreWidthOut()`
|
||||
:::
|
||||
|
||||
```typescript
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 获取用户信息
|
||||
const username = userStore.username;
|
||||
const permissions = userStore.permissions;
|
||||
|
||||
// 登录
|
||||
await userStore.login({ username: 'admin', password: '123456' });
|
||||
|
||||
// 登出
|
||||
await userStore.logout();
|
||||
```
|
||||
- [9.5 路由与动态菜单](/zh/frontend/router.html)
|
||||
- [9.7 权限控制](/zh/frontend/permission.html)
|
||||
- [附录 I 前端 Store 速查](/zh/appendix/stores.html)
|
||||
|
||||
@@ -1,109 +1,40 @@
|
||||
# 9.14 样式与主题定制
|
||||
# 9.15 样式与主题定制
|
||||
|
||||
:::tip 概述
|
||||
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
|
||||
样式分三层:ElementPlus 主题变量覆盖、全局样式(styles/)、组件 scoped 样式。主题配色与布局参数经 designSetting store 运行时切换并持久化。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| TailwindCSS | 原子化 CSS 框架 |
|
||||
| SCSS | 全局样式变量和主题 |
|
||||
| ElementPlus 主题变量 | 组件样式覆盖 |
|
||||
|
||||
## 全局样式文件
|
||||
## 样式目录
|
||||
|
||||
```text
|
||||
ui/src/styles/
|
||||
├── index.scss # 全局样式入口(@import 所有子模块)
|
||||
├── var.scss # SCSS 变量定义(颜色、尺寸等)
|
||||
├── common.scss # 公共样式类
|
||||
├── tailwind.css # TailwindCSS 入口
|
||||
├── tailwindbase.css # TailwindCSS 基础重置
|
||||
├── theme/
|
||||
│ ├── light.scss # 浅色主题(含 ElementPlus 组件覆盖)
|
||||
│ └── dark.scss # 深色主题
|
||||
└── transition/
|
||||
└── index.scss # 过渡动画
|
||||
├── index.scss 入口(引入各分片)
|
||||
├── variables.scss SCSS 变量(间距、层级、断点)
|
||||
├── element.scss ElementPlus 变量覆盖
|
||||
└── transition.scss 过渡动画
|
||||
```
|
||||
|
||||
## SCSS 变量
|
||||
## 主题定制方式
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/var.scss */
|
||||
$primaryColor: #165DFF;
|
||||
$primaryColorHover: #57a3f3;
|
||||
$header-height: 64px;
|
||||
$footer-height: 70px;
|
||||
```
|
||||
| 方式 | 适用 |
|
||||
|---|---|
|
||||
| element.scss 覆盖 CSS 变量 | 主题色、圆角、组件尺寸全局调整 |
|
||||
| designSetting store | 运行时切换配色方案(用户偏好,持久化) |
|
||||
| 页面 scoped 样式 | 局部调整(不污染全局) |
|
||||
| variables.scss | 间距/层级等设计 token |
|
||||
|
||||
## 主题色切换
|
||||
## 规约
|
||||
|
||||
通过 `designSettingStore` 管理主题:
|
||||
- **优先变量**:颜色/间距用变量而非硬编码,换主题不改业务样式
|
||||
- **不深层嵌套覆盖**:避免 `!important` 大战,改用变量或类名隔离
|
||||
- **公共组件样式进组件**:不进全局 styles(防全局污染)
|
||||
- **暗色模式**:经主题变量体系统一适配,不单独写一套选择器
|
||||
|
||||
```typescript
|
||||
import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
## 与后端无关的说明
|
||||
|
||||
const designStore = useDesignSettingStore();
|
||||
主题纯前端行为,不涉及后端接口;水印、页签等开关在 projectSetting store。
|
||||
|
||||
// 切换暗色模式
|
||||
designStore.setDarkMode(true);
|
||||
## 相关
|
||||
|
||||
// 获取当前主题色
|
||||
const primaryColor = designStore.appTheme;
|
||||
```
|
||||
|
||||
## ElementPlus 主题覆盖
|
||||
|
||||
ElementPlus 组件样式覆盖定义在主题文件中,通过 `[data-theme]` 选择器区分深浅色模式:
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/theme/light.scss */
|
||||
[data-theme='light'] {
|
||||
--el-border-color-base: #e4e7ed;
|
||||
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #f5f7fa;
|
||||
--el-table-header-text-color: rgb(31 34 37);
|
||||
}
|
||||
// ...更多组件覆盖
|
||||
}
|
||||
```
|
||||
|
||||
## TailwindCSS 配置
|
||||
|
||||
```javascript
|
||||
// ui/tailwind.config.js
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{vue,ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--primary-color)',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 自定义全局样式
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/index.scss */
|
||||
|
||||
// 页面容器
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
background: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
- [9.16 布局系统](/zh/frontend/layout.html)
|
||||
- [附录 H 前端组件速查](/zh/appendix/components.html)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 9.9 表格与页面组件
|
||||
|
||||
:::tip 概述
|
||||
列表页的骨架组件:PageWrapper(页面容器)、BasicForm(查询/表单,`components/Form`)、BasicTable(数据表格,`components/Table`)。三者经 `@register` 注册模式配合使用——参考实现即 `views/tool/example/`。
|
||||
:::
|
||||
|
||||
## 组件与目录对应
|
||||
|
||||
| 使用名 | 目录 | 职责 |
|
||||
|---|---|---|
|
||||
| PageWrapper | layout / Page | 页面容器(响应高度、内容区) |
|
||||
| BasicForm | components/Form(src/BasicForm.vue) | 表单引擎(FormSchema 声明驱动,@register 注册) |
|
||||
| BasicTable | components/Table(src/Table.vue) | 表格引擎(columns 配置 + request 数据回调 + 分页 + 操作列) |
|
||||
| editDialog / detailDialog | 页面私有(views 下) | 新增/编辑与详情弹窗(example 页即此结构) |
|
||||
|
||||
## 典型组合(摘自 views/tool/example/index.vue)
|
||||
|
||||
```html
|
||||
<PageWrapper>
|
||||
<el-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</el-card>
|
||||
|
||||
<el-card :bordered="false" class="proCard">
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #tableTitle>工具栏按钮区(v-perm 控制显隐)</template>
|
||||
</BasicTable>
|
||||
</el-card>
|
||||
|
||||
<editDialog v-if="editVisible" :id="currentId" v-model:visible="editVisible" @success="reloadTable('noRefresh')" />
|
||||
</PageWrapper>
|
||||
```
|
||||
|
||||
## 配置外置
|
||||
|
||||
| 文件 | 内容 |
|
||||
|---|---|
|
||||
| columns.ts | 表格列(`{ label, prop, minWidth }`,`render(record)` 自定义渲染:ElAvatar 图片、ElTag 选项、链接等) |
|
||||
| querySchemas.ts | 查询表单(`FormSchema[]`:Input / Select / InputNumber / DatePicker 按字段类型选组件) |
|
||||
|
||||
配置外置使 index.vue 只保留交互逻辑——生成器产物即此结构,新页面照抄。
|
||||
|
||||
## 数据流
|
||||
|
||||
```text
|
||||
BasicTable :request → loadDataTable → getExamplePage(api 模块)
|
||||
→ 返回分页数据(records/total)→ 表格渲染与分页条
|
||||
actionColumn → 行内操作(编辑/详情/删除,挂 v-perm)
|
||||
selection-change → 批量删除选择集
|
||||
```
|
||||
|
||||
## 使用规约
|
||||
|
||||
- 业务页面不裸写 el-table + 手动分页,一律 BasicTable
|
||||
- 列定义进 columns.ts,查询项进 querySchemas.ts
|
||||
- 操作列按钮 v-perm 数组入参(`v-perm="['sys:example:add']"`)
|
||||
- 页面私有弹窗组件放页面目录,不进全局 components
|
||||
|
||||
## 相关
|
||||
|
||||
- [9.13 新增页面开发步骤](/zh/frontend/new-page.html)
|
||||
- [8.3 分页查询规范](/zh/api-spec/pagination.html)
|
||||
@@ -1,104 +1,47 @@
|
||||
# 9.16 工具函数
|
||||
# 9.17 工具函数
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的工具函数,包括本地存储、类型判断、URL 处理和权限检查等。
|
||||
`ui/src/utils` 收敛通用能力:http 封装、存储、鉴权、校验、日期、下载、格式化、DOM、颜色等。业务页面优先查这里,避免重复造轮子。
|
||||
:::
|
||||
|
||||
## Storage 本地存储
|
||||
## 主要工具
|
||||
|
||||
```typescript
|
||||
import { storage } from '@/utils/Storage';
|
||||
| 文件/目录 | 职责 |
|
||||
|---|---|
|
||||
| http/ | axios 封装([9.4](/zh/frontend/http.html)) |
|
||||
| Storage.ts | 持久化封装(localStorage 统一入口) |
|
||||
| auth.ts | token/权限辅助(菜单树查找 findTreeByPath 等) |
|
||||
| validate.ts | 校验(手机号、邮箱、URL、长度等) |
|
||||
| dateUtil.ts / formatTime.ts | 日期解析与格式化(dayjs 封装) |
|
||||
| downloadFile.ts | URL 文件下载 |
|
||||
| urlUtils.ts | URL 参数处理 |
|
||||
| domUtils.ts | DOM 操作辅助 |
|
||||
| color.ts | 颜色计算(主题) |
|
||||
| index.ts | 聚合出口 |
|
||||
| is/ | 类型判断 |
|
||||
| lib/ helper/ file/ | 子域工具(库封装、文件处理等) |
|
||||
| propTypes.ts | 组件 props 类型 |
|
||||
| useLockFn.ts | 防重入函数包装 |
|
||||
| lodashChunk.ts | 数组分块 |
|
||||
| wartermark.ts | 水印 |
|
||||
| log.ts / env.ts / browser-type.ts | 日志、环境、浏览器探测 |
|
||||
|
||||
// 存储(带过期时间)
|
||||
storage.set('key', value, 7 * 24 * 60 * 60 * 1000); // 7天
|
||||
## 使用规约
|
||||
|
||||
// 读取
|
||||
const value = storage.get('key', defaultValue);
|
||||
- **统一入口**:`import { xxx } from '@/utils'`(聚合出口),保持引用一致
|
||||
- **纯函数优先**:工具函数不做 IO 副作用(下载类除外)
|
||||
- **不引业务**:utils 不得 import api/views/store(防环依赖)
|
||||
- **新增前检索**:先确认 index.ts 是否已有同能力
|
||||
|
||||
// 删除
|
||||
storage.remove('key');
|
||||
## 高频组合
|
||||
|
||||
// 清空
|
||||
storage.clear();
|
||||
```
|
||||
| 场景 | 组合 |
|
||||
|---|---|
|
||||
| 导出下载 | downloadFile(配合 Export 接口返回的 URL) |
|
||||
| 时间展示 | formatTime(列表列、日志时间) |
|
||||
| 表单校验 | validate(与后端 binding 标签双侧校验) |
|
||||
| 记住页签/主题 | Storage(store 持久化底层) |
|
||||
|
||||
## 类型判断
|
||||
## 相关
|
||||
|
||||
```typescript
|
||||
import { isString, isNumber, isArray, isObject } from '@/utils/is';
|
||||
|
||||
isString('hello'); // true
|
||||
isNumber(123); // true
|
||||
isArray([1, 2]); // true
|
||||
isObject({ a: 1 }); // true
|
||||
```
|
||||
|
||||
## URL 工具
|
||||
|
||||
```typescript
|
||||
import { setObjToUrlParams } from '@/utils/urlUtils';
|
||||
|
||||
// 将对象拼接到 URL 参数
|
||||
const url = setObjToUrlParams('/api/example/page', { pageNo: 1, pageSize: 10 });
|
||||
// '/api/example/page?pageNo=1&pageSize=10'
|
||||
```
|
||||
|
||||
## 权限工具
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
const { hasSomePermission, hasEveryPermission } = usePermission();
|
||||
|
||||
// 检查单个权限
|
||||
hasSomePermission('sys:user:add');
|
||||
|
||||
// 检查多个权限(满足任一)
|
||||
hasSomePermission(['sys:user:add', 'sys:user:update']);
|
||||
```
|
||||
|
||||
## 日期工具
|
||||
|
||||
```typescript
|
||||
import { formatToDate, formatToDateTime } from '@/utils/dateUtil';
|
||||
|
||||
formatToDate(new Date()); // '2026-09-21'
|
||||
formatToDateTime(new Date()); // '2026-09-21 14:30:00'
|
||||
```
|
||||
|
||||
## 深拷贝
|
||||
|
||||
项目使用 `lodash-es` 提供的 `cloneDeep` 函数进行深拷贝:
|
||||
|
||||
```typescript
|
||||
import { cloneDeep } from 'lodash-es';
|
||||
|
||||
const obj = { a: 1, b: { c: 2 } };
|
||||
const copy = cloneDeep(obj);
|
||||
copy.b.c = 3;
|
||||
console.log(obj.b.c); // 2(原对象不受影响)
|
||||
```
|
||||
|
||||
:::warning 功能未实现
|
||||
以下工具函数尚未在项目中实现,仅供参考。
|
||||
:::
|
||||
|
||||
:::warning 注意
|
||||
项目中没有封装自定义的 `deepClone` 或 `numberUtil`、`fileUtil` 工具。数字格式化和文件大小格式化等功能建议使用 `lodash-es`、`Intl.NumberFormat` 等成熟方案自行处理。
|
||||
:::
|
||||
|
||||
## 防抖与节流
|
||||
|
||||
```typescript
|
||||
import { useDebounceFn, useThrottleFn } from '@vueuse/core';
|
||||
|
||||
// 防抖:停止输入 300ms 后执行
|
||||
const debouncedSearch = useDebounceFn((keyword: string) => {
|
||||
fetchData(keyword);
|
||||
}, 300);
|
||||
|
||||
// 节流:每 200ms 最多执行一次
|
||||
const throttledScroll = useThrottleFn(() => {
|
||||
handleScroll();
|
||||
}, 200);
|
||||
```
|
||||
- [9.18 Hooks 与枚举](/zh/frontend/hooks-enums.html)
|
||||
|
||||
Reference in New Issue
Block a user