签入版本
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
# 9.13 API 模块编写规范
|
||||
|
||||
:::tip 概述
|
||||
本节定义前端 API 模块的编写规范,包括命名规范、请求方式和文件组织。
|
||||
:::
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 类型 | 规范 | 示例 |
|
||||
|------|------|------|
|
||||
| 文件名 | 小驼峰,与后端模块对应 | `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
|
||||
```
|
||||
|
||||
## 标准模板
|
||||
|
||||
```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 });
|
||||
}
|
||||
|
||||
// 全量列表
|
||||
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 });
|
||||
}
|
||||
```
|
||||
|
||||
## 常见扩展操作
|
||||
|
||||
| 操作 | 函数名示例 | HTTP 方法 |
|
||||
|------|-----------|----------|
|
||||
| 重置密码 | `userResetPwd(data)` | POST |
|
||||
| 导入 | `{module}Import(data: FormData)` | POST(multipart/form-data) |
|
||||
| 导出 | `{module}Export(params)` | GET |
|
||||
| 刷新缓存 | `dictRefreshCache()` | GET |
|
||||
| 获取菜单 | `getMenus()` | GET |
|
||||
| 获取字典项 | `getDictItemList(code)` | GET |
|
||||
|
||||
## 组件中使用
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { getExamplePage, exampleDelete } from '@/api/tool/example';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
|
||||
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>
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
# 9.2 目录结构详解
|
||||
|
||||
:::tip 概述
|
||||
本节详细介绍前端项目的目录结构,帮助开发者快速定位代码和理解项目组织方式。
|
||||
:::
|
||||
|
||||
```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/ # 异常页面
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# 9.9 数据展示组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的数据展示组件,包括表格(BasicTable)、页面容器(PageWrapper)和图标选择器(IconPicker)。
|
||||
:::
|
||||
|
||||
## Table 表格组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicTable @register="registerTable" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicTable, useTable } from '@/components/Table';
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
## Page 页面容器
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<!-- 页面内容 -->
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { PageWrapper } from '@/components/Page';
|
||||
</script>
|
||||
```
|
||||
|
||||
## Icon 图标选择器
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<IconPicker v-model="formData.icon" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconPicker from '@/components/icon/picker.vue';
|
||||
</script>
|
||||
```
|
||||
|
||||
:::warning 组件未实现
|
||||
Description 描述列表组件尚未在项目中实现,以下为规划示例。
|
||||
:::
|
||||
|
||||
## 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)
|
||||
@@ -0,0 +1,390 @@
|
||||
# 9.3 环境与构建配置
|
||||
|
||||
:::tip 概述
|
||||
本节说明前端项目的环境变量配置和 Vite 构建配置,包括开发环境和生产环境的差异配置。前端通过 `.env` 文件管理环境变量,Vite 自动根据模式加载对应的 `.env` 文件。
|
||||
:::
|
||||
|
||||
## 环境变量文件
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── .env # 所有环境通用配置
|
||||
├── .env.development # 开发环境配置
|
||||
├── .env.production # 生产环境配置
|
||||
└── .env.local # 本地覆盖(不提交 git)
|
||||
```
|
||||
|
||||
**加载优先级:** `.env.local` > `.env.{mode}` > `.env`
|
||||
|
||||
## 开发环境配置
|
||||
|
||||
### `.env.development`
|
||||
|
||||
```bash
|
||||
# 开发服务器端口
|
||||
VITE_PORT = 8001
|
||||
|
||||
# 公共基础路径(通常为 /)
|
||||
VITE_PUBLIC_PATH = /
|
||||
|
||||
# API 接口基础路径(留空,由代理处理)
|
||||
VITE_GLOB_API_URL =
|
||||
|
||||
# API 前缀(所有接口路径的前缀)
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
|
||||
# 代理配置(JSON 数组格式:[["前缀","目标地址"]])
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
|
||||
|
||||
# 上传接口地址
|
||||
VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
|
||||
# 图片访问地址(用于预览上传的图片)
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp6.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.thinkphp6.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.thinkphp6.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.thinkphp6.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.thinkphp6.elevue/api', // 目标地址
|
||||
changeOrigin: true, // 修改请求头 Origin
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // 路径重写(可选)
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**代理效果:**
|
||||
|
||||
```text
|
||||
前端请求: GET /api/user/page
|
||||
│
|
||||
▼ Vite proxy
|
||||
│
|
||||
▼ 转发到: http://admin.thinkphp6.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 # Element Plus(分包)
|
||||
├── 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 = https://test-cdn.example.com
|
||||
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` 分包。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 9.11 Excel 导入导出组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法。
|
||||
:::
|
||||
|
||||
## 导入组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button @click="handleImport">导入</el-button>
|
||||
<ImportExcel @success="onImportSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
|
||||
|
||||
const onImportSuccess = (data) => {
|
||||
// data 为解析后的数组,每项是对象
|
||||
console.log(data);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## 导出功能
|
||||
|
||||
```typescript
|
||||
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
|
||||
|
||||
const handleExport = () => {
|
||||
jsonToSheetXlsx({
|
||||
data: tableData.value,
|
||||
header: {
|
||||
name: '名称',
|
||||
typeText: '类型',
|
||||
statusText: '状态',
|
||||
createTime: '创建时间',
|
||||
},
|
||||
filename: '导出数据.xlsx',
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 后端导出对接
|
||||
|
||||
```typescript
|
||||
// 调用后端导出接口,返回文件 URL
|
||||
const handleExport = async () => {
|
||||
const url = await exampleExport(searchParams.value);
|
||||
// 下载文件
|
||||
window.open(url);
|
||||
};
|
||||
```
|
||||
|
||||
## 导入模板下载
|
||||
|
||||
```typescript
|
||||
// 下载导入模板
|
||||
const handleDownloadTemplate = async () => {
|
||||
const url = await exampleExportTemplate();
|
||||
window.open(url);
|
||||
};
|
||||
```
|
||||
|
||||
## 导入导出注意事项
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 大数据量导出 | 后端自动分批导出,避免内存溢出 |
|
||||
| 导入模板 | 第一行为表头,第二行起为数据 |
|
||||
| 字段映射 | 后端 `headerMap` 配置表头与字段的对应关系 |
|
||||
| 枚举翻译 | 导入时自动将文本转为枚举值,导出时自动将枚举值转为文本 |
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [数据导入导出](/zh/tools/excel.html)
|
||||
- [上传配置详解](/zh/tools/upload-config.html)
|
||||
@@ -0,0 +1,114 @@
|
||||
# 9.10 数据录入组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的数据录入组件,包括表单(BasicForm)、弹窗表单和上传组件的使用方法。
|
||||
:::
|
||||
|
||||
## Form 表单组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicForm @register="registerForm" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
## Modal 弹窗组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicModal @register="registerModal" @ok="handleSubmit" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicModal, useModal } from '@/components/Modal';
|
||||
|
||||
const [registerModal, { openModal, closeModal }] = useModal();
|
||||
|
||||
// 打开弹窗
|
||||
const handleAdd = () => {
|
||||
openModal(true, { isUpdate: false });
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const values = await getFormValues();
|
||||
await exampleAdd(values);
|
||||
closeModal();
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Upload 上传组件
|
||||
|
||||
```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>
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# 9.4 HTTP 请求封装
|
||||
|
||||
:::tip 概述
|
||||
前端使用 Axios 封装 HTTP 请求,自动处理 Token 携带、响应码判断、错误提示。
|
||||
:::
|
||||
|
||||
## 封装结构
|
||||
|
||||
```text
|
||||
src/utils/http/axios/
|
||||
├── index.ts # Axios 实例配置和拦截器
|
||||
├── Axios.ts # VAxios 类封装
|
||||
├── axiosTransform.ts # 数据转换接口
|
||||
├── checkStatus.ts # HTTP 状态码处理
|
||||
├── helper.ts # 辅助函数
|
||||
└── types.ts # 类型定义
|
||||
```
|
||||
|
||||
## 核心拦截器
|
||||
|
||||
### 请求拦截器(自动携带 Token)
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
|
||||
// 请求前自动添加 Token
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
```
|
||||
|
||||
### 响应拦截器(统一错误处理)
|
||||
|
||||
```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>) {
|
||||
return http.request({
|
||||
url: '/example/page',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function exampleAdd(data: any) {
|
||||
return http.request({
|
||||
url: '/example/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
|
||||
```typescript
|
||||
import { getExamplePage, exampleAdd } from '@/api/tool/example';
|
||||
|
||||
// 获取列表
|
||||
const res = await getExamplePage({ pageNo: 1, pageSize: 10 });
|
||||
tableData.value = res.records;
|
||||
|
||||
// 新增
|
||||
await exampleAdd({ name: '测试', status: 1 });
|
||||
```
|
||||
@@ -0,0 +1,208 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,102 @@
|
||||
# 9.15 布局系统
|
||||
|
||||
:::tip 概述
|
||||
前端布局采用经典的企业后台布局:左侧菜单 + 顶部导航 + 内容区域,支持多标签页导航。
|
||||
:::
|
||||
|
||||
## 布局结构
|
||||
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 │
|
||||
│ [Logo] [面包屑] [搜索] [通知] [头像] │
|
||||
├──────────┬──────────────────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧边栏 │ 内容区域 │
|
||||
│ 菜单 │ │
|
||||
│ │ ┌──────────────────────────────────────────┐ │
|
||||
│ ──────── │ │ 标签页导航 │ │
|
||||
│ 系统管理 │ ├──────────────────────────────────────────┤ │
|
||||
│ ├ 用户 │ │ │ │
|
||||
│ ├ 角色 │ │ 页面内容 │ │
|
||||
│ └ 菜单 │ │ │ │
|
||||
│ │ │ │ │
|
||||
│ 内容管理 │ │ │ │
|
||||
│ ├ 文章 │ │ │ │
|
||||
│ └ 分类 │ │ │ │
|
||||
│ │ └──────────────────────────────────────────┘ │
|
||||
└──────────┴──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 布局组件
|
||||
|
||||
```text
|
||||
ui/src/layout/
|
||||
├── index.vue # 布局入口
|
||||
├── components/
|
||||
│ ├── header/ # 顶部导航(Logo、面包屑、搜索、通知、用户头像)
|
||||
│ ├── sider/ # 侧边栏菜单(动态菜单、折叠、主题)
|
||||
│ ├── content/ # 内容区域(keep-alive 缓存)
|
||||
│ ├── tabs/ # 标签页导航(多标签切换、关闭)
|
||||
│ └── footer/ # 页脚
|
||||
└── hooks/ # 布局相关 hooks
|
||||
```
|
||||
|
||||
## 侧边栏菜单
|
||||
|
||||
菜单数据从后端 `/index/getMenus` 接口获取,由 `asyncRouteStore` 管理:
|
||||
|
||||
```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[];
|
||||
}
|
||||
```
|
||||
|
||||
## 标签页导航
|
||||
|
||||
标签页自动记录访问过的页面,支持:
|
||||
- 点击切换
|
||||
- 关闭当前/其他/全部
|
||||
- 右键菜单操作
|
||||
- keep-alive 缓存
|
||||
|
||||
```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;
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# 9.8 登录流程
|
||||
|
||||
:::tip 概述
|
||||
本节说明前端登录流程的实现,包括登录表单提交、Token 存储、路由守卫和登出处理。
|
||||
:::
|
||||
|
||||
## 前端登录流程
|
||||
|
||||
```text
|
||||
1. 用户输入用户名、密码、验证码
|
||||
2. 调用 POST /api/login 接口
|
||||
3. 成功 → 存储 Token 到 localStorage
|
||||
4. 路由守卫检测到 Token → 获取用户信息
|
||||
5. 生成动态路由 → 跳转首页
|
||||
```
|
||||
|
||||
## 登录页面代码
|
||||
|
||||
```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>
|
||||
```
|
||||
|
||||
## Token 存储
|
||||
|
||||
```typescript
|
||||
// 登录成功后存储
|
||||
storage.set(ACCESS_TOKEN, data.access_token, 7 * 24 * 60 * 60 * 1000); // 7天过期
|
||||
|
||||
// 请求拦截器自动携带
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// 响应拦截器检测 401
|
||||
if (response.data.code === 401) {
|
||||
router.push('/login');
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,572 @@
|
||||
# 9.12 新增页面开发步骤
|
||||
|
||||
:::tip 概述
|
||||
以 example 模块为例,演示从 API 到视图的完整开发步骤。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
|
||||
:::
|
||||
|
||||
## 开发流程
|
||||
|
||||
```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. 测试 刷新前端页面,点击菜单测试
|
||||
```
|
||||
|
||||
## 完整文件结构
|
||||
|
||||
```text
|
||||
ui/src/
|
||||
├── api/
|
||||
│ └── tool/
|
||||
│ └── example.ts # API 接口
|
||||
└── views/
|
||||
└── tool/
|
||||
└── example/
|
||||
├── index.vue # 列表页(主页面)
|
||||
├── columns.ts # 表格列配置
|
||||
├── querySchemas.ts # 查询表单配置
|
||||
├── edit.vue # 编辑弹窗(新增/修改)
|
||||
└── detail.vue # 详情弹窗
|
||||
```
|
||||
|
||||
## 步骤一:创建 API 接口
|
||||
|
||||
```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 });
|
||||
}
|
||||
|
||||
// 全量列表(下拉框数据源)
|
||||
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 |
|
||||
|
||||
## 步骤二:创建表格列配置
|
||||
|
||||
```typescript
|
||||
// ui/src/views/tool/example/columns.ts
|
||||
import { h } from 'vue';
|
||||
import { ElAvatar, ElTag } from 'element-plus';
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 列配置字段说明
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `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)。
|
||||
@@ -0,0 +1,91 @@
|
||||
# 9.1 前端工程概述
|
||||
|
||||
:::tip 概述
|
||||
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 框架 | 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 |
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 动态路由
|
||||
|
||||
从后端 `/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/ # 页面视图
|
||||
```
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
pnpm install # 安装依赖
|
||||
pnpm dev # 启动开发服务器
|
||||
pnpm build # 构建生产版本
|
||||
pnpm preview # 预览构建结果
|
||||
pnpm lint:eslint # ESLint 检查
|
||||
pnpm lint:prettier # Prettier 格式化
|
||||
```
|
||||
@@ -0,0 +1,91 @@
|
||||
# 9.7 权限控制
|
||||
|
||||
:::tip 概述
|
||||
前端权限控制通过 `v-perm` / `v-perms` 指令实现按钮级权限控制。
|
||||
:::
|
||||
|
||||
## 权限指令
|
||||
|
||||
### v-perm(单权限)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
### v-perms(多权限,满足任一即可)
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- 拥有 sys:user:add 或 sys:user:update 任一权限即可 -->
|
||||
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 指令实现
|
||||
|
||||
```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 超级管理员
|
||||
用户权限列表包含 `*:*:*` 时,表示超级管理员,拥有所有权限。
|
||||
:::
|
||||
|
||||
```typescript
|
||||
// src/hooks/web/usePermission.ts
|
||||
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
## 权限数据来源
|
||||
|
||||
用户登录后,`/index/getUser` 接口返回权限列表:
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": [
|
||||
"sys:user:page",
|
||||
"sys:user:add",
|
||||
"sys:user:update",
|
||||
"sys:user:delete",
|
||||
"sys:role:page",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
超级管理员返回 `["*:*:*"]`,拥有所有权限。
|
||||
@@ -0,0 +1,224 @@
|
||||
# 9.5 路由与动态菜单
|
||||
|
||||
:::tip 概述
|
||||
前端路由采用动态生成机制,从后端 `/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。
|
||||
:::
|
||||
|
||||
## 路由架构
|
||||
|
||||
```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 文章管理
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 动态路由生成流程
|
||||
|
||||
```text
|
||||
1. 用户登录 → 获取 Token
|
||||
2. 路由守卫检测到 Token
|
||||
3. 调用 /index/getMenus 获取菜单树
|
||||
4. generator-routers.ts 将菜单树转换为路由表
|
||||
5. router.addRoute() 动态注入路由
|
||||
6. 渲染侧边栏菜单
|
||||
```
|
||||
|
||||
## 核心代码
|
||||
|
||||
```typescript
|
||||
// src/router/generator-routers.ts
|
||||
|
||||
/**
|
||||
* 动态生成菜单
|
||||
* 调用后端 /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,
|
||||
},
|
||||
};
|
||||
|
||||
// 递归处理子菜单
|
||||
if (item.children?.length > 0) {
|
||||
currentRouter.children = routerGenerator(item.children);
|
||||
}
|
||||
|
||||
return currentRouter;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 路由守卫
|
||||
|
||||
:::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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# 9.6 状态管理(Pinia)
|
||||
|
||||
:::tip 概述
|
||||
前端使用 Pinia 进行全局状态管理,核心 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` | 标签页 |
|
||||
|
||||
## userStore 核心代码
|
||||
|
||||
```typescript
|
||||
// src/store/modules/user.ts
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
await logout();
|
||||
this.setPermissions([]);
|
||||
this.setUserInfo('');
|
||||
storage.remove(ACCESS_TOKEN);
|
||||
storage.remove(CURRENT_USER);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
|
||||
:::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();
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# 9.14 样式与主题定制
|
||||
|
||||
:::tip 概述
|
||||
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| 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 # 过渡动画
|
||||
```
|
||||
|
||||
## SCSS 变量
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/var.scss */
|
||||
$primaryColor: #165DFF;
|
||||
$primaryColorHover: #57a3f3;
|
||||
$header-height: 64px;
|
||||
$footer-height: 70px;
|
||||
```
|
||||
|
||||
## 主题色切换
|
||||
|
||||
通过 `designSettingStore` 管理主题:
|
||||
|
||||
```typescript
|
||||
import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
|
||||
const designStore = useDesignSettingStore();
|
||||
|
||||
// 切换暗色模式
|
||||
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;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,104 @@
|
||||
# 9.16 工具函数
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中常用的工具函数,包括本地存储、类型判断、URL 处理和权限检查等。
|
||||
:::
|
||||
|
||||
## Storage 本地存储
|
||||
|
||||
```typescript
|
||||
import { storage } from '@/utils/Storage';
|
||||
|
||||
// 存储(带过期时间)
|
||||
storage.set('key', value, 7 * 24 * 60 * 60 * 1000); // 7天
|
||||
|
||||
// 读取
|
||||
const value = storage.get('key', defaultValue);
|
||||
|
||||
// 删除
|
||||
storage.remove('key');
|
||||
|
||||
// 清空
|
||||
storage.clear();
|
||||
```
|
||||
|
||||
## 类型判断
|
||||
|
||||
```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);
|
||||
```
|
||||
Reference in New Issue
Block a user