签入版本
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
# API 层开发
|
||||
|
||||
:::tip 说明
|
||||
前端 API 层位于 `src/api/` 目录,封装所有与后端的 HTTP 请求。底层基于 Axios 封装了 `VAxios` 类(位于 `src/utils/http/axios/`),提供请求拦截、响应解包、错误处理、重复请求取消等能力。
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── common/
|
||||
│ ├── user.ts # 登录、登出、获取用户信息、验证码
|
||||
│ └── menu.ts # 获取菜单(adminMenus)
|
||||
├── system/
|
||||
│ ├── position.ts # 岗位管理接口
|
||||
│ ├── user.ts # 用户管理接口
|
||||
│ ├── role.ts # 角色管理接口
|
||||
│ ├── menu.ts # 菜单管理接口
|
||||
│ ├── dept.ts # 部门管理接口
|
||||
│ ├── level.ts # 职级管理接口
|
||||
│ ├── dictionary.ts # 字典管理接口
|
||||
│ ├── loginLog.ts # 登录日志接口
|
||||
│ ├── operLog.ts # 操作日志接口
|
||||
│ └── tenant.ts # 租户管理接口
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Axios 封装架构
|
||||
|
||||
的 HTTP 层不是简单的 Axios 实例,而是一套完整的封装体系:
|
||||
|
||||
```
|
||||
src/utils/http/axios/
|
||||
├── index.ts # 入口:创建 VAxios 实例,定义 transform 处理逻辑
|
||||
├── Axios.ts # VAxios 类:封装 AxiosInstance,管理拦截器
|
||||
├── axiosTransform.ts # AxiosTransform 抽象类:定义拦截器钩子接口
|
||||
├── axiosCancel.ts # AxiosCanceler:重复请求取消(基于 CancelToken Map)
|
||||
├── checkStatus.ts # HTTP 状态码错误提示
|
||||
├── helper.ts # 工具函数(时间戳追加、日期格式化)
|
||||
└── types.ts # 类型定义(RequestOptions、Result、CreateAxiosOptions)
|
||||
```
|
||||
|
||||
### VAxios 类
|
||||
|
||||
`Axios.ts` 中的 `VAxios` 类是对 `AxiosInstance` 的封装,核心能力:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/Axios.ts
|
||||
export class VAxios {
|
||||
private axiosInstance: AxiosInstance;
|
||||
private options: CreateAxiosOptions;
|
||||
|
||||
constructor(options: CreateAxiosOptions) {
|
||||
this.options = options;
|
||||
this.axiosInstance = axios.create(options);
|
||||
this.setupInterceptors(); // 自动注册拦截器
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
request<T = any>(config: AxiosRequestConfig, options?: RequestOptions): Promise<T> {
|
||||
let conf: AxiosRequestConfig = cloneDeep(config);
|
||||
const transform = this.getTransform();
|
||||
const opt: RequestOptions = Object.assign({}, requestOptions, options);
|
||||
const { beforeRequestHook, transformRequestData } = transform || {};
|
||||
|
||||
if (beforeRequestHook) conf = beforeRequestHook(conf, opt);
|
||||
conf.requestOptions = opt;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.axiosInstance.request<any, AxiosResponse<Result>>(conf)
|
||||
.then((res) => {
|
||||
if (transformRequestData && !axios.isCancel(res)) {
|
||||
const ret = transformRequestData(res, opt);
|
||||
resolve(ret);
|
||||
}
|
||||
})
|
||||
.catch((e) => reject(e));
|
||||
});
|
||||
}
|
||||
|
||||
// 文件上传
|
||||
uploadFile<T = any>(config: AxiosRequestConfig, params: UploadFileParams) {
|
||||
const formData = new window.FormData();
|
||||
formData.append(params.name || 'file', params.file);
|
||||
|
||||
return this.axiosInstance.request<T>({
|
||||
method: 'POST',
|
||||
data: formData,
|
||||
headers: { 'Content-type': ContentTypeEnum.FORM_DATA },
|
||||
...config,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 全局实例
|
||||
|
||||
`index.ts` 导出全局单例 `http`,所有 API 函数使用此实例:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/index.ts
|
||||
export const http = createAxios();
|
||||
```
|
||||
|
||||
## API 定义示例
|
||||
|
||||
以岗位管理为例(`src/api/system/position.ts`),实际代码如下:
|
||||
|
||||
```typescript
|
||||
import { http } from '@/utils/http/axios';
|
||||
|
||||
// 分页查询岗位列表
|
||||
export function getPositionPage(params?) {
|
||||
return http.request({
|
||||
url: '/position/page',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取全部岗位列表(无分页,用于下拉选择)
|
||||
export function getPositionList(params?) {
|
||||
return http.request({
|
||||
url: '/position/list',
|
||||
method: 'GET',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
// 根据 ID 获取详情
|
||||
export function getPositionDetail(positionId) {
|
||||
return http.request({
|
||||
url: '/position/detail/' + positionId,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
// 添加岗位
|
||||
export function positionAdd(data: any) {
|
||||
return http.request({
|
||||
url: '/position/add',
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新岗位
|
||||
export function positionUpdate(data: any) {
|
||||
return http.request({
|
||||
url: '/position/update',
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除岗位
|
||||
export function positionDelete(positionId) {
|
||||
return http.request({
|
||||
url: '/position/delete/' + positionId,
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// 批量删除岗位
|
||||
export function positionBatchDelete(data: any) {
|
||||
return http.request({
|
||||
url: '/position/batchDelete',
|
||||
method: 'DELETE',
|
||||
data,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
:::tip API 路径说明
|
||||
API 函数中的 `url` 不需要写 `/api/v1` 前缀。`VAxios` 创建时配置了 `prefixUrl` 和 `requestOptions.urlPrefix`,拦截器的 `beforeRequestHook` 会自动拼接前缀。最终请求路径为 `/api/v1/position/page`。
|
||||
:::
|
||||
|
||||
## 请求拦截器
|
||||
|
||||
请求拦截器在 `index.ts` 的 `transform` 中定义,自动注入 Token:
|
||||
|
||||
```typescript
|
||||
requestInterceptors: (config, options) => {
|
||||
const token = storage.get('ACCESS-TOKEN', '');
|
||||
if (token && config.requestOptions?.withToken !== false) {
|
||||
(config as Recordable).headers.Authorization = options.authenticationScheme
|
||||
? `${options.authenticationScheme} ${token}`
|
||||
: token;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
```
|
||||
|
||||
Token 存储在 `localStorage` 中,键名为 `ACCESS-TOKEN`,通过 `storage` 工具类管理(支持 7 天过期)。
|
||||
|
||||
## 响应拦截器
|
||||
|
||||
响应解包逻辑在 `transformRequestData` 中处理:
|
||||
|
||||
```typescript
|
||||
transformRequestData: (res: AxiosResponse<Result>, options: RequestOptions) => {
|
||||
const { code, data, msg } = res.data;
|
||||
|
||||
// 业务成功(code === 0)
|
||||
if (code === ResultEnum.SUCCESS) {
|
||||
return data; // 直接返回 data 字段,调用方拿到的是业务数据
|
||||
}
|
||||
|
||||
// 业务失败(code === 1)
|
||||
if (code === ResultEnum.ERROR) {
|
||||
ElMessage.error(msg);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// 登录超时(code === 401)
|
||||
if (code === ResultEnum.TIMEOUT) {
|
||||
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { ... })
|
||||
.then(() => {
|
||||
storage.clear();
|
||||
window.location.href = '/login';
|
||||
});
|
||||
throw new Error(msg);
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
解包后的返回值约定:
|
||||
|
||||
| 接口类型 | 返回值结构 | 说明 |
|
||||
|----------|-----------|------|
|
||||
| 普通接口 | `data`(直接返回业务数据) | 拦截器已解包 `res.data.data` |
|
||||
| 分页接口 | `{ data: [...], count: 100 }` | 后端通过 `R.ok(data=list, count=total)` 返回 |
|
||||
| 登录接口 | `{ access_token: "..." }` | 使用 `isTransformResponse: false`,自行处理 |
|
||||
|
||||
## 响应头 Token 自动续签
|
||||
|
||||
后端支持滑动窗口续签,响应拦截器会检测 `x-refresh-token` 头并自动更新本地存储:
|
||||
|
||||
```typescript
|
||||
responseInterceptors: (res: AxiosResponse<any>) => {
|
||||
const newToken = res.headers['x-refresh-token'];
|
||||
if (newToken) {
|
||||
storage.set('ACCESS-TOKEN', newToken);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
```
|
||||
|
||||
## 重复请求取消
|
||||
|
||||
`AxiosCanceler` 类通过 `pendingMap` 维护请求标识,相同请求自动取消前一个:
|
||||
|
||||
```typescript
|
||||
// src/utils/http/axios/axiosCancel.ts
|
||||
|
||||
export class AxiosCanceler {
|
||||
addPending(config: AxiosRequestConfig) {
|
||||
this.removePending(config); // 先移除已有的同请求
|
||||
const url = getPendingUrl(config);
|
||||
config.cancelToken = new axios.CancelToken((cancel) => {
|
||||
if (!pendingMap.has(url)) {
|
||||
pendingMap.set(url, cancel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
removePending(config: AxiosRequestConfig) {
|
||||
const url = getPendingUrl(config);
|
||||
if (pendingMap.has(url)) {
|
||||
pendingMap.get(url)(url); // 取消请求
|
||||
pendingMap.delete(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 接口调用方式
|
||||
|
||||
在 Vue 组件中调用 API:
|
||||
|
||||
```typescript
|
||||
import { getPositionList, positionAdd } from '@/api/system/position'
|
||||
|
||||
// 查询列表(loadDataTable 传给 BasicTable 的 request 属性)
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getPositionList({ ...formParams, ...res });
|
||||
return result; // 拦截器已解包,result 即为 { data, count, msg, ok, code }
|
||||
};
|
||||
|
||||
// 添加数据
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate();
|
||||
await positionAdd(formData);
|
||||
message('操作成功');
|
||||
emit('success');
|
||||
};
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
所有 API 函数返回的是已解包的响应数据(经过拦截器处理),分页接口直接返回 `{ data, count }` 结构,调用方无需再 `.data` 解包。登录接口使用 `isTransformResponse: false` 跳过自动解包,自行处理返回值。
|
||||
:::
|
||||
|
||||
## 错误处理
|
||||
|
||||
错误处理分两层:
|
||||
|
||||
1. **业务错误**(code !== 0):拦截器统一弹出 `ElMessage.error(msg)`,并 `throw new Error`
|
||||
2. **HTTP 错误**(网络异常、超时等):`responseInterceptorsCatch` 处理
|
||||
|
||||
```typescript
|
||||
responseInterceptorsCatch: (error: any) => {
|
||||
const { response, code } = error || {};
|
||||
// 超时
|
||||
if (code === 'ECONNABORTED') {
|
||||
ElMessage.error('接口请求超时,请刷新页面重试!');
|
||||
return;
|
||||
}
|
||||
// 网络异常
|
||||
if (error.toString().includes('Network Error')) {
|
||||
ElMessageBox.confirm('请检查您的网络连接是否正常', '网络异常', { ... });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
// 其他 HTTP 状态码
|
||||
checkStatus(error.response?.status, response?.data?.msg);
|
||||
},
|
||||
```
|
||||
|
||||
`checkStatus` 函数根据 HTTP 状态码弹出对应中文提示(400/401/403/404/405/408/500/501/502/503/504/505)。
|
||||
|
||||
## 总结
|
||||
|
||||
API 层通过 `VAxios` 类封装 Axios,提供请求拦截(自动注入 Token)、响应解包(`code===0` 返回 `data`)、重复请求取消(`AxiosCanceler`)、Token 自动续签(`x-refresh-token` 响应头)、错误处理(业务错误 + HTTP 状态码)等能力。各模块按目录组织接口函数,调用方拿到的是已解包的业务数据。
|
||||
@@ -0,0 +1,218 @@
|
||||
# 前端构建
|
||||
|
||||
:::tip 说明
|
||||
前端使用 Vite 构建,支持开发服务器和生产构建。`vite.config.ts` 通过环境变量和插件系统管理构建配置,生产环境推荐使用 Nginx 部署。
|
||||
:::
|
||||
|
||||
## 构建架构
|
||||
|
||||
```
|
||||
vite.config.ts
|
||||
│
|
||||
├─ loadEnv(mode) ← 按 mode 加载 .env / .env.{mode}
|
||||
├─ wrapperEnv() ← 类型转换(string→number/boolean/JSON)
|
||||
├─ createVitePlugins() ← 组装插件链
|
||||
├─ createProxy() ← 生成代理配置
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Vite 构建 │
|
||||
│ ├─ 开发:vite dev(HMR + proxy) │
|
||||
│ └─ 生产:vite build → postBuild → app.config.js │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(HMR 热更新)
|
||||
pnpm dev
|
||||
|
||||
# 等效命令
|
||||
pnpm serve
|
||||
```
|
||||
|
||||
开发服务器默认运行在 `http://localhost:8001`(端口由 `VITE_PORT` 控制),通过 Vite 代理将 `/api` 请求转发到后端。
|
||||
|
||||
### 多环境开发命令
|
||||
|
||||
| 命令 | mode | 加载的 env 文件 | 说明 |
|
||||
|------|------|----------------|------|
|
||||
| `pnpm dev` | `development` | `.env` + `.env.development` | 本地开发(默认) |
|
||||
|
||||
---
|
||||
|
||||
## 环境变量体系
|
||||
|
||||
### env 文件加载规则
|
||||
|
||||
Vite 按 mode 加载环境变量,后面的文件覆盖前面的:
|
||||
|
||||
| 文件 | 加载时机 | 说明 |
|
||||
|------|----------|------|
|
||||
| `.env` | 所有模式 | 通用配置(端口、标题、短名) |
|
||||
| `.env.development` | `pnpm dev` | 开发配置(代理、mock) |
|
||||
| `.env.production` | `pnpm build` | 生产配置 |
|
||||
| `.env.test` | `pnpm build:test` | 测试环境 |
|
||||
| `.env.uat` | `pnpm build:uat` | UAT 环境 |
|
||||
| `.env.dev` | `pnpm build:dev` | 开发联调环境 |
|
||||
|
||||
### 环境变量完整参考
|
||||
|
||||
| 变量 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `VITE_PORT` | `number` | `8001` | 开发服务器端口 |
|
||||
| `VITE_PUBLIC_PATH` | `string` | `/` | 部署基础路径(子目录部署时修改) |
|
||||
| `VITE_GLOB_APP_TITLE` | `string` | `Flask+ElementPlus版` | 应用标题(注入 HTML) |
|
||||
| `VITE_GLOB_APP_SHORT_NAME` | `string` | `Flask+ElementPlus版` | 应用短名(用于生成运行时配置变量名) |
|
||||
| `VITE_GLOB_API_URL` | `string` | 空 | API 基础路径(留空使用相对路径) |
|
||||
| `VITE_GLOB_API_URL_PREFIX` | `string` | `/api` | API 前缀 |
|
||||
| `VITE_GLOB_FILE_URL` | `string` | - | 文件访问域名(拼接上传文件 URL) |
|
||||
| `VITE_GLOB_UPLOAD_URL` | `string` | - | 图片上传地址 |
|
||||
| `VITE_GLOB_IMG_URL` | `string` | - | 图片前缀地址 |
|
||||
| `VITE_GLOB_PROD_MOCK` | `boolean` | `false` | 生产环境是否开启 mock |
|
||||
| `VITE_USE_MOCK` | `boolean` | `false` | 开发环境是否开启 mock |
|
||||
| `VITE_DROP_CONSOLE` | `boolean` | `true` | 构建时删除 console |
|
||||
| `VITE_PROXY` | `JSON` | `[["/api","http://127.0.0.1:8021/api/v1"]]` | 跨域代理配置(JSON 数组) |
|
||||
| `VITE_BUILD_COMPRESS` | `string` | `'none'` | 压缩方式:`gzip` / `brotli` / `none` |
|
||||
| `VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE` | `boolean` | `false` | 压缩后是否删除原文件 |
|
||||
|
||||
### 代理配置格式
|
||||
|
||||
`VITE_PROXY` 是 JSON 格式的二维数组,每个元素 `[前缀, 目标地址]`:
|
||||
|
||||
```bash
|
||||
# 单个代理
|
||||
VITE_PROXY=[["/api","http://127.0.0.1:8021/api/v1"]]
|
||||
|
||||
# 多个代理(注意不要换行)
|
||||
VITE_PROXY=[["/api","http://127.0.0.1:8021/api/v1"],["/ws","ws://127.0.0.1:8021/ws"]]
|
||||
```
|
||||
|
||||
代理生成逻辑(`build/vite/proxy.ts`):
|
||||
|
||||
```typescript
|
||||
// 输入: [["/api", "http://127.0.0.1:8021/api/v1"]]
|
||||
// 输出:
|
||||
{
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8021/api/v1',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 生产构建
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# 生产环境构建
|
||||
pnpm build
|
||||
|
||||
# 指定环境构建
|
||||
pnpm build:dev # 开发联调环境
|
||||
pnpm build:test # 测试环境
|
||||
pnpm build:uat # UAT 环境
|
||||
```
|
||||
|
||||
### 构建产物
|
||||
|
||||
构建完成后,产物输出到 `dist/` 目录:
|
||||
|
||||
```
|
||||
dist/
|
||||
├── index.html # 入口 HTML
|
||||
├── favicon.ico # 图标
|
||||
├── assets/
|
||||
│ ├── js/ # JavaScript 文件
|
||||
│ ├── css/ # CSS 文件
|
||||
│ └── images/ # 图片资源
|
||||
└── runtime.config.js # 运行时配置(部署时修改)
|
||||
```
|
||||
|
||||
### 运行时配置
|
||||
|
||||
`runtime.config.js` 在构建后生成,部署时可直接修改,无需重新构建:
|
||||
|
||||
```javascript
|
||||
window['<%- appName %>'] = {
|
||||
VITE_GLOB_APP_TITLE: 'Flask+ElementPlus版',
|
||||
VITE_GLOB_API_URL: '',
|
||||
VITE_GLOB_API_URL_PREFIX: '/api',
|
||||
VITE_GLOB_FILE_URL: 'http://file.flask.elevue',
|
||||
};
|
||||
```
|
||||
|
||||
## Nginx 部署配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 前端静态文件
|
||||
root /data/apps/dist;
|
||||
index index.html;
|
||||
|
||||
# 前端路由
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 后端 API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8021/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 上传文件访问
|
||||
location /api/file/ {
|
||||
alias /data/apps/uploads/;
|
||||
expires 30d;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /assets/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 代理不生效
|
||||
|
||||
```
|
||||
1. 检查 VITE_PROXY 格式是否正确(JSON 二维数组)
|
||||
2. 检查后端是否运行在 http://127.0.0.1:8021
|
||||
3. 重启开发服务器使配置生效
|
||||
```
|
||||
|
||||
### 构建后页面空白
|
||||
|
||||
```
|
||||
1. 检查 VITE_PUBLIC_PATH 配置
|
||||
2. 检查 Nginx 是否配置了 try_files
|
||||
3. 检查浏览器控制台是否有错误
|
||||
```
|
||||
|
||||
### 环境变量不生效
|
||||
|
||||
```
|
||||
1. 确认变量名以 VITE_ 开头
|
||||
2. 确认 env 文件在正确的位置
|
||||
3. 重启开发服务器
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
前端构建使用 Vite,支持开发服务器(HMR 热更新)和生产构建。环境变量通过 `.env` 文件管理,代理配置通过 `VITE_PROXY` 设置。生产环境推荐使用 Nginx 部署,配合 `runtime.config.js` 实现运行时配置。
|
||||
@@ -0,0 +1,839 @@
|
||||
# 公共组件库
|
||||
|
||||
:::tip 说明
|
||||
项目提供丰富的公共组件,位于 `ui/src/components/` 目录,基于 ElementPlus 二次封装,覆盖表格、表单、弹窗、上传、富文本编辑等常见场景。组件通过 `src/plugins/` 中的 `setupCustomComponents` 全局注册,任何页面直接使用,无需单独 import。
|
||||
:::
|
||||
|
||||
## 组件总览
|
||||
|
||||
| 组件 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| BasicTable | `components/Table/` | 表格(列定义、分页、操作列、全屏、密度、斑马纹) |
|
||||
| TableAction | `components/Table/` | 操作列按钮组(权限控制、下拉折叠) |
|
||||
| BasicForm | `components/Form/` | 表单(schemas 配置化、动态渲染、折叠展开) |
|
||||
| BasicModal | `components/Modal/` | 弹窗(拖拽、全屏、自定义按钮) |
|
||||
| BasicUpload | `components/Upload/` | 文件/图片上传(卡片/列表、限制数量、预览) |
|
||||
| Editor | `components/Editor/` | 富文本编辑器(基于 wangeditor) |
|
||||
| CropperImage | `components/Cropper/` | 图片裁剪(圆形/矩形、实时预览) |
|
||||
| QrCode | `components/Qrcode/` | 二维码生成(canvas/img、内嵌 logo) |
|
||||
| ChinaArea | `components/ChinaArea/` | 省市区级联(懒加载、二/三级联动) |
|
||||
| Icon | `components/icon/` | 图标(支持 ElementPlus 图标 + SVG 图标) |
|
||||
| BasicSelect | `components/Select/` | 增强下拉(远程请求、本地缓存) |
|
||||
| CountTo | `components/CountTo/` | 数字动画(前缀/后缀、千分位、缓动曲线) |
|
||||
| Password | `components/Password/` | 密码强度(强度指示器、重复确认、复杂度校验) |
|
||||
| PageWrapper | `components/Page/` | 页面容器(标题卡片、固定底部) |
|
||||
| Authority | `components/Authority/` | 权限容器(按权限码控制子元素渲染) |
|
||||
| LockScreen | `components/Lockscreen/` | 锁屏组件 |
|
||||
| ImpExcel | `components/Excel/` | Excel 导入/导出 |
|
||||
| AppProvider | `components/Application/` | 应用提供者(全局配置注入) |
|
||||
| Websocket | `components/Websocket/` | WebSocket 连接 |
|
||||
| importFile | `components/importFile/` | 通用文件导入 |
|
||||
| numberInput | `components/numberInput/` | 数字输入框 |
|
||||
| priceInput | `components/priceInput/` | 金额输入框 |
|
||||
| Region | `components/Region/` | 区域选择 |
|
||||
| TableSelect | `components/TableSelect/` | 表格选择器 |
|
||||
| pagination | `components/pagination/` | 分页组件 |
|
||||
|
||||
---
|
||||
|
||||
## BasicTable 表格
|
||||
|
||||
表格组件是使用频率最高的组件,基于 `el-table` + `el-pagination` 封装,详见 [BasicTable 表格](/zh/volume4/frontend/table) 章节。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `columns` | `BasicColumn[]` | `[]`(必填) | 列定义 |
|
||||
| `request` | `Function` | `null` | 数据请求函数,自动管理分页和 loading |
|
||||
| `dataSource` | `Function \| Array` | `[]` | 数据源(与 request 二选一) |
|
||||
| `actionColumn` | `BasicColumn` | `null` | 操作列配置 |
|
||||
| `title` | `string` | `null` | 表格标题 |
|
||||
| `titleTooltip` | `string` | `null` | 标题提示 |
|
||||
| `pagination` | `Object \| Boolean` | `{}` | 分页配置,`false` 隐藏分页 |
|
||||
| `rowKey` | `string \| Function` | `undefined` | 行数据的唯一标识 |
|
||||
| `border` | `Boolean` | `false` | 是否显示边框 |
|
||||
| `striped` | `Boolean` | `false` | 斑马纹 |
|
||||
| `size` | `string` | `'default'` | 表格尺寸:`small` / `default` / `large` |
|
||||
| `tableHeight` | `Number \| String` | `null` | 固定表格高度 |
|
||||
| `maxHeight` | `Number` | - | 最大高度 |
|
||||
| `canResize` | `Boolean` | `true` | 是否自适应窗口高度 |
|
||||
| `resizeHeightOffset` | `Number` | `0` | 高度偏移量 |
|
||||
| `showTableSetting` | `Boolean` | `true` | 是否显示工具栏 |
|
||||
| `tableSetting` | `TableSetting` | 见下方 | 工具栏配置 |
|
||||
| `isKeepRowKeys` | `Boolean` | `false` | 翻页时是否保留选中行 |
|
||||
|
||||
### tableSetting 工具栏配置
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `redo` | `Boolean` | `true` | 刷新按钮 |
|
||||
| `size` | `Boolean` | `true` | 密度切换 |
|
||||
| `setting` | `Boolean` | `true` | 列设置 |
|
||||
| `fullscreen` | `Boolean` | `true` | 全屏切换 |
|
||||
| `striped` | `Boolean` | `true` | 斑马纹开关 |
|
||||
|
||||
### BasicColumn 列定义
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `label` | `string` | 列标题 |
|
||||
| `prop` | `string` | 字段名 |
|
||||
| `width` | `number` | 列宽 |
|
||||
| `align` | `string` | 对齐方式 |
|
||||
| `type` | `string` | 类型:`index` / `selection` |
|
||||
| `render` | `Function` | 自定义渲染函数 `(column, row, index) => VNode` |
|
||||
| `isSlot` | `boolean` | 是否使用插槽 |
|
||||
| `ellipsis` | `boolean` | 超出省略(tooltip) |
|
||||
| `auth` | `string[]` | 权限编码,控制列是否显示 |
|
||||
| `ifShow` | `boolean \| Function` | 业务控制是否显示 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `fetch-success` | `(data, result)` | 数据请求成功 |
|
||||
| `fetch-error` | `(error)` | 数据请求失败 |
|
||||
| `selection-change` | `(rows)` | 多选变化 |
|
||||
| `columns-change` | `(columns)` | 列设置变化 |
|
||||
| `checked-row-change` | `(rowKeys)` | 选中行变化 |
|
||||
|
||||
### Slots
|
||||
|
||||
| 插槽 | 说明 |
|
||||
|------|------|
|
||||
| `tableTitle` | 表格标题左侧区域(常放添加按钮) |
|
||||
| `toolbar` | 工具栏右侧区域 |
|
||||
| `{prop}` | 列名对应插槽,自定义列渲染 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `reload(opt?)` | 刷新表格(保留分页) |
|
||||
| `restReload(opt?)` | 重置到第一页并刷新 |
|
||||
| `reloadTable(opt?)` | 内部刷新(清空选中行) |
|
||||
| `getDataSource()` | 获取当前数据 |
|
||||
| `setTableData(data)` | 设置表格数据 |
|
||||
| `updateTableDataRecord(index, record)` | 更新指定行 |
|
||||
| `deleteTableDataRecord(index)` | 删除指定行 |
|
||||
| `getColumns()` | 获取列配置 |
|
||||
| `setColumns(columns)` | 设置列配置 |
|
||||
| `clearSelection()` | 清空多选 |
|
||||
| `setCheckedRowKeys(keys)` | 设置选中行 |
|
||||
| `redoHeight()` | 重新计算高度 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef">
|
||||
<template #tableTitle>
|
||||
<el-button v-perm="['sys:level:add']" type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增
|
||||
</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const columns = [
|
||||
{ label: '名称', prop: 'name', width: 200 },
|
||||
{ label: '状态', prop: 'status', width: 100 },
|
||||
{ label: '创建时间', prop: 'createTime', width: 180 },
|
||||
];
|
||||
|
||||
const actionColumn = {
|
||||
width: 200,
|
||||
actions: [
|
||||
{ label: '编辑', auth: ['sys:level:update'], onClick: handleEdit },
|
||||
{
|
||||
label: '删除',
|
||||
auth: ['sys:level:delete'],
|
||||
isConfirm: true,
|
||||
popConfirm: { title: '确认删除?' },
|
||||
onClick: handleDelete,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function loadDataTable({ page, pageSize }) {
|
||||
const res = await getListApi({ pageNo: page, pageSize });
|
||||
return { items: res.data, total: res.count };
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TableAction 操作列
|
||||
|
||||
操作列按钮组件,支持权限控制和下拉折叠。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `actions` | `ActionItem[]` | (必填) | 操作按钮列表 |
|
||||
| `dropDownActions` | `ActionItem[]` | `null` | 下拉折叠操作列表 |
|
||||
| `dropDownProps` | `ActionItem` | `{ label: '更多' }` | 下拉按钮配置 |
|
||||
| `select` | `Function` | `() => {}` | 下拉选择回调 |
|
||||
|
||||
### ActionItem 配置
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `label` | `string` | 按钮文字 |
|
||||
| `auth` | `string[]` | 权限编码 |
|
||||
| `onClick` | `Function` | 点击回调 |
|
||||
| `icon` | `Component` | 图标组件 |
|
||||
| `type` | `string` | 按钮类型 |
|
||||
| `disabled` | `boolean` | 是否禁用 |
|
||||
| `ifShow` | `boolean \| Function` | 是否显示 |
|
||||
| `isConfirm` | `boolean` | 是否需要确认 |
|
||||
| `popConfirm` | `object` | 气泡确认框配置 |
|
||||
|
||||
---
|
||||
|
||||
## BasicForm 表单
|
||||
|
||||
表单组件通过 schemas 配置化定义表单字段,支持动态渲染、折叠展开,详见 [BasicForm 表单](/zh/volume4/frontend/form) 章节。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `schemas` | `FormSchema[]` | `[]` | 表单字段配置 |
|
||||
| `model` | `Object` | - | 表单数据对象 |
|
||||
| `rules` | `FormRules` | - | 表单验证规则 |
|
||||
| `inline` | `Boolean` | `false` | 行内表单 |
|
||||
| `layout` | `string` | `'vertical'` | 布局方式 |
|
||||
| `labelPosition` | `string` | `'right'` | 标签位置 |
|
||||
| `labelWidth` | `Number \| String` | `80` | 标签宽度 |
|
||||
| `size` | `string` | `'default'` | 表单尺寸 |
|
||||
| `disabled` | `Boolean` | `false` | 禁用全部组件 |
|
||||
| `isFull` | `Boolean` | `true` | 组件是否 100% 宽度 |
|
||||
| `showActionButtonGroup` | `Boolean` | `true` | 显示操作按钮组 |
|
||||
| `showSubmitButton` | `Boolean` | `true` | 显示查询按钮 |
|
||||
| `showResetButton` | `Boolean` | `true` | 显示重置按钮 |
|
||||
| `showAdvancedButton` | `Boolean` | `true` | 显示展开/收起按钮 |
|
||||
| `submitButtonText` | `string` | `'查询'` | 查询按钮文字 |
|
||||
| `resetButtonText` | `string` | `'重置'` | 重置按钮文字 |
|
||||
| `collapsed` | `Boolean` | `true` | 是否默认折叠 |
|
||||
| `collapsedRows` | `Number` | `1` | 折叠时显示行数 |
|
||||
|
||||
### FormSchema 字段配置
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `field` | `string` | 字段名(必填) |
|
||||
| `label` | `string` | 标签文字 |
|
||||
| `component` | `ComponentType` | 渲染组件:`Input` / `Select` / `DatePicker` 等 |
|
||||
| `componentProps` | `object` | 组件属性 |
|
||||
| `defaultValue` | `any` | 默认值 |
|
||||
| `rules` | `object \| object[]` | 字段校验规则 |
|
||||
| `slot` | `string` | 自定义插槽名 |
|
||||
| `hidden` | `boolean \| Function` | 是否隐藏 |
|
||||
| `colProps` | `ColProps` | 栅格配置 |
|
||||
| `labelMessage` | `string` | 标签提示信息 |
|
||||
| `suffix` | `string` | 后缀文字 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `submit` | 点击查询按钮 |
|
||||
| `reset` | 点击重置按钮 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `setFieldsValue(values)` | 设置字段值 |
|
||||
| `getFieldsValue()` | 获取所有字段值 |
|
||||
| `resetFields()` | 重置字段 |
|
||||
| `validate()` | 校验表单 |
|
||||
| `clearValidate(name?)` | 清除校验 |
|
||||
| `setProps(formProps)` | 设置属性 |
|
||||
| `setSchema(schemaProps)` | 动态修改 schema |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicForm @register="registerForm" @submit="handleSearch" @reset="handleReset" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const [registerForm] = useForm({
|
||||
schemas: [
|
||||
{
|
||||
field: 'name',
|
||||
label: '名称',
|
||||
component: 'Input',
|
||||
componentProps: { placeholder: '请输入名称' },
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'dateRange',
|
||||
label: '日期范围',
|
||||
component: 'DatePicker',
|
||||
componentProps: { type: 'daterange' },
|
||||
},
|
||||
],
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BasicModal 弹窗
|
||||
|
||||
弹窗组件基于 `el-dialog` 封装,支持拖拽、自定义按钮,详见 [Modal 弹窗](/zh/volume4/frontend/modal) 章节。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `string` | `''` | 弹窗标题 |
|
||||
| `width` | `Number` | `446` | 弹窗宽度 |
|
||||
| `draggable` | `Boolean` | `true` | 是否可拖拽 |
|
||||
| `hideFooter` | `Boolean` | `false` | 隐藏底部按钮 |
|
||||
| `confirmButText` | `string` | `'确认'` | 确认按钮文字 |
|
||||
| `cancelButText` | `string` | `'取消'` | 取消按钮文字 |
|
||||
| `confirmButProps` | `Object` | - | 确认按钮属性 |
|
||||
| `cancelButProps` | `Object` | - | 取消按钮属性 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `ok` | 点击确认 |
|
||||
| `register` | 弹窗注册回调 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `setModalProps(props)` | 设置弹窗属性 |
|
||||
| `openModal()` | 打开弹窗 |
|
||||
| `closeModal()` | 关闭弹窗 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicModal title="编辑用户" :width="600" @register="registerModal" @ok="handleSubmit">
|
||||
<BasicForm @register="registerForm" />
|
||||
</BasicModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const [registerModal, { openModal, closeModal, setModalProps }] = useModal();
|
||||
|
||||
function handleEdit(record) {
|
||||
setModalProps({ title: '编辑用户' });
|
||||
openModal();
|
||||
// 回填表单数据
|
||||
nextTick(() => {
|
||||
setFieldsValue(record);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BasicUpload 上传
|
||||
|
||||
文件/图片上传组件,基于 `el-upload` 封装,支持卡片/列表模式、数量限制、图片预览。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `listType` | `string` | `'picture-card'` | 展示类型:`picture-card` / `text` / `picture` |
|
||||
| `limit` | `Number` | `Infinity` | 最大上传数量 |
|
||||
| `maxSize` | `Number` | `2` | 文件大小上限(MB) |
|
||||
| `accept` | `string` | - | 接受的文件类型 |
|
||||
| `list` | `Ifiles[]` | `[]` | 已上传文件列表 |
|
||||
| `circle` | `Boolean` | `false` | 圆形裁剪 |
|
||||
| `uploadTitle` | `string` | `'上传图片'` | 列表模式按钮文字 |
|
||||
| `helpText` | `string` | `''` | 帮助提示文字 |
|
||||
| `width` | `Number` | `104` | 卡片宽度(px) |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `uploadChange` | `(fileList)` | 上传/删除变化 |
|
||||
| `delete` | `(fileList)` | 删除文件 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<!-- 单图上传 -->
|
||||
<Upload v-model="formData.cover" :limit="1" accept="image/*" />
|
||||
|
||||
<!-- 多图上传 -->
|
||||
<Upload :list="formData.images" :limit="5" @uploadChange="handleImageChange" />
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<Upload list-type="text" accept=".pdf,.doc,.docx" uploadTitle="上传附件" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Editor 富文本编辑器
|
||||
|
||||
基于 [wangeditor](https://www.wangeditor.cn/) 封装,支持图片/视频上传、工具栏配置。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `getHtml` | `string` | - | 双向绑定(HTML 内容) |
|
||||
| `getText` | `string` | - | 双向绑定(纯文本) |
|
||||
| `height` | `string` | `'310'` | 编辑器高度 |
|
||||
| `width` | `string` | `'auto'` | 编辑器宽度 |
|
||||
| `mode` | `string` | `'default'` | 模式:`default` / `simple` |
|
||||
| `disable` | `Boolean` | `false` | 是否禁用 |
|
||||
| `placeholder` | `string` | `'请输入内容...'` | 占位文字 |
|
||||
| `uploadFileUrl` | `string` | `'/file/upload'` | 图片上传接口 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Editor v-model:getHtml="formData.content" height="400px" />
|
||||
</template>
|
||||
```
|
||||
|
||||
:::warning 注意
|
||||
编辑器内部自动处理图片上传,上传接口由 `uploadFileUrl` 配置。后端图片迁移和 XSS 清洗由 Service 层的 `rich_text_fields` 声明自动处理。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## CropperImage 图片裁剪
|
||||
|
||||
基于 [cropperjs](https://github.com/fengyuanchen/cropperjs) 封装,支持圆形/矩形裁剪、实时预览。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `src` | `string` | (必填) | 图片地址 |
|
||||
| `alt` | `string` | - | 图片描述 |
|
||||
| `circled` | `Boolean` | `false` | 圆形裁剪 |
|
||||
| `realTimePreview` | `Boolean` | `true` | 实时预览 |
|
||||
| `height` | `string \| Number` | `'360px'` | 裁剪区域高度 |
|
||||
| `imageStyle` | `CSSProperties` | `{}` | 图片样式 |
|
||||
| `options` | `Object` | `{}` | cropperjs 配置 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `cropend` | `{ imgBase64, imgInfo }` | 裁剪完成 |
|
||||
| `ready` | `(cropper)` | 裁剪器就绪 |
|
||||
| `cropendError` | - | 裁剪出错 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<CropperImage
|
||||
:src="formData.avatar"
|
||||
:circled="true"
|
||||
height="300px"
|
||||
@cropend="handleCropend"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function handleCropend({ imgBase64, imgInfo }) {
|
||||
// imgBase64: base64 格式的裁剪结果
|
||||
// imgInfo: 裁剪区域坐标和尺寸
|
||||
formData.value.avatar = imgBase64;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## QrCode 二维码生成
|
||||
|
||||
基于 [qrcode](https://github.com/soldair/node-qrcode) 封装,支持 canvas/img 渲染、内嵌 logo。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `value` | `string \| Array` | (必填) | 二维码内容 |
|
||||
| `width` | `Number` | `200` | 宽度(px) |
|
||||
| `tag` | `string` | `'canvas'` | 渲染方式:`canvas` / `img` |
|
||||
| `logo` | `string \| Object` | `''` | 中间 logo |
|
||||
| `options` | `Object` | `{}` | qrcode 配置项 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `done` | `{ url, ctx }` | 生成完成 |
|
||||
| `error` | `(error)` | 生成失败 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `download(fileName?)` | 下载二维码图片 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<QrCode value="https://www.example.com" :width="200" :logo="logoUrl" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ChinaArea 省市区级联
|
||||
|
||||
省市区三级联动选择器,基于后端城市接口懒加载。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `modelValue` | `String \| Array` | - | 双向绑定(区划编码数组) |
|
||||
| `type` | `Number` | `3` | 联动级别:`2`(省市) / `3`(省市区) |
|
||||
| `disabled` | `Boolean` | `false` | 是否禁用 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `update:modelValue` | `(codes)` | 值变化 |
|
||||
| `change` | `(text)` | 选中文本(如"浙江省杭州市西湖区") |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ChinaArea v-model="formData.cityCode" :type="3" @change="handleCityChange" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## BasicSelect 增强下拉
|
||||
|
||||
远程数据下拉选择器,支持请求函数和本地缓存。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `request` | `Function` | (必填) | 获取选项数据的异步函数 |
|
||||
| `options` | `Array` | `[]` | 静态选项(与 request 二选一) |
|
||||
| `modelValue` | `String \| Number` | - | 双向绑定 |
|
||||
| `cache` | `Boolean` | `false` | 是否缓存到 localStorage |
|
||||
| `cacheKey` | `string` | `''` | 缓存 key(开启缓存时必填) |
|
||||
| `width` | `Number` | `150` | 宽度(px) |
|
||||
| `block` | `Boolean` | `false` | 100% 父容器宽度 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `fetch()` | 重新请求数据 |
|
||||
| `getData()` | 获取当前选项数据 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicSelect
|
||||
v-model="formData.deptId"
|
||||
:request="getDeptOptions"
|
||||
:cache="true"
|
||||
cacheKey="dept-options"
|
||||
placeholder="请选择部门"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
async function getDeptOptions() {
|
||||
const res = await getDeptListApi();
|
||||
return res.data.map(item => ({ label: item.name, value: item.id }));
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CountTo 数字动画
|
||||
|
||||
数字动态递增/递减动画,支持前缀/后缀、千分位、缓动曲线。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `startVal` | `Number` | `0` | 起始值 |
|
||||
| `endVal` | `Number` | `2021` | 结束值 |
|
||||
| `duration` | `Number` | `1500` | 动画时长(ms) |
|
||||
| `autoplay` | `Boolean` | `true` | 自动播放 |
|
||||
| `decimals` | `Number` | `0` | 小数位数 |
|
||||
| `prefix` | `string` | `''` | 前缀 |
|
||||
| `suffix` | `string` | `''` | 后缀 |
|
||||
| `separator` | `string` | `','` | 千分位分隔符 |
|
||||
| `decimal` | `string` | `'.'` | 小数点符号 |
|
||||
| `color` | `string` | - | 字体颜色 |
|
||||
| `useEasing` | `Boolean` | `true` | 是否使用缓动 |
|
||||
| `transition` | `string` | `'linear'` | 缓动曲线 |
|
||||
|
||||
### Events
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `onStarted` | 动画开始 |
|
||||
| `onFinished` | 动画结束 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<CountTo :endVal="12345" :duration="2000" prefix="¥" separator="," />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password 密码强度
|
||||
|
||||
密码输入框,内置强度指示器、重复确认、复杂度校验。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `value` | `string` | - | 密码值 |
|
||||
| `repeat` | `Boolean` | `false` | 是否显示重复确认框 |
|
||||
| `required` | `Boolean` | `true` | 是否必填 |
|
||||
| `minLength` | `Number` | `6` | 最小长度 |
|
||||
| `maxLength` | `Number` | `32` | 最大长度 |
|
||||
| `complexity` | `Boolean` | `false` | 是否启用复杂度校验 |
|
||||
| `complexityTip` | `string` | - | 复杂度不满足时的提示 |
|
||||
| `width` | `Number` | `300` | 弹出框宽度 |
|
||||
| `block` | `Boolean` | `false` | 100% 宽度 |
|
||||
|
||||
### 强度等级
|
||||
|
||||
| 等级 | 条件 | 说明 |
|
||||
|------|------|------|
|
||||
| 0 | 无 | 极弱 |
|
||||
| 1 | 含小写或大写 | 弱 |
|
||||
| 2 | 含小写 + 大写,或含数字 | 中 |
|
||||
| 3 | 含小写 + 大写 + 数字 | 强 |
|
||||
| 4 | 含小写 + 大写 + 数字 + 特殊字符 | 极强 |
|
||||
|
||||
### Expose 方法
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `isValidator()` | 是否通过校验 |
|
||||
| `showValidator()` | 触发校验显示 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Password v-model:value="formData.password" :repeat="true" :complexity="true" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PageWrapper 页面容器
|
||||
|
||||
页面容器组件,提供标题卡片和固定底部区域。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `title` | `string` | `''` | 页面标题 |
|
||||
| `content` | `string` | `''` | 标题下方描述文字 |
|
||||
| `contentStyle` | `Object` | `{}` | 主体区域样式 |
|
||||
| `contentClass` | `string` | `''` | 主体区域 class |
|
||||
| `showFooter` | `Boolean` | `true` | 是否显示底部 |
|
||||
|
||||
### Slots
|
||||
|
||||
| 插槽 | 说明 |
|
||||
|------|------|
|
||||
| `headerContent` | 标题卡片内容区域 |
|
||||
| `default` | 页面主体内容 |
|
||||
| `leftFooter` | 底部左侧(常放操作按钮) |
|
||||
| `rightFooter` | 底部右侧 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageWrapper title="用户管理" content="管理系统用户信息">
|
||||
<BasicTable :columns="columns" :request="loadData" />
|
||||
|
||||
<template #leftFooter>
|
||||
<el-button @click="handleCancel">取消</el-button>
|
||||
</template>
|
||||
<template #rightFooter>
|
||||
<el-button type="primary" @click="handleSubmit">提交</el-button>
|
||||
</template>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authority 权限容器
|
||||
|
||||
按权限码控制子元素是否渲染,类似 `v-perm` 指令的组件形式。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `value` | `string[]` | `[]` | 权限编码数组 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Authority :value="['sys:user:add']">
|
||||
<el-button type="primary">新增用户</el-button>
|
||||
</Authority>
|
||||
</template>
|
||||
```
|
||||
|
||||
:::tip v-perm 指令
|
||||
除了 `Authority` 组件,项目还提供 `v-perm` 指令,功能相同但更简洁:
|
||||
```vue
|
||||
<el-button v-perm="['sys:user:add']">新增用户</el-button>
|
||||
```
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Icon 图标
|
||||
|
||||
统一图标组件,支持 ElementPlus 图标和 SVG 图标。
|
||||
|
||||
### Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `name` | `string` | (必填) | 图标名称 |
|
||||
| `size` | `string \| Number` | `'14px'` | 图标大小 |
|
||||
| `color` | `string` | `'inherit'` | 图标颜色 |
|
||||
|
||||
### 基础用法
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ElementPlus 图标 -->
|
||||
<Icon name="el-icon-Plus" size="18px" />
|
||||
<!-- SVG 图标 -->
|
||||
<Icon name="svg-icon-user" />
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ImpExcel Excel 导入/导出
|
||||
|
||||
Excel 文件导入和导出组件。
|
||||
|
||||
### 导出函数
|
||||
|
||||
```typescript
|
||||
import { jsonToSheetXlsx, aoaToSheetXlsx } from '@/components/Excel';
|
||||
|
||||
// JSON 数据导出
|
||||
jsonToSheetXlsx({
|
||||
data: [{ name: '张三', age: 25 }],
|
||||
header: { name: '姓名', age: '年龄' },
|
||||
filename: '用户列表.xlsx',
|
||||
});
|
||||
|
||||
// 二维数组导出
|
||||
aoaToSheetXlsx({
|
||||
data: [['姓名', '年龄'], ['张三', 25]],
|
||||
filename: '用户列表.xlsx',
|
||||
});
|
||||
```
|
||||
|
||||
### 导入组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<ImpExcel @success="handleImportSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
function handleImportSuccess(data) {
|
||||
// data: 导入的表格数据
|
||||
console.log(data);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 全局注册
|
||||
|
||||
所有组件在 `src/plugins/setupCustomComponents.ts` 中通过 `setupCustomComponents` 全局注册:
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
import { setupCustomComponents } from '/@/plugins';
|
||||
|
||||
setupCustomComponents(app); // 注册所有自定义组件
|
||||
```
|
||||
|
||||
全局注册后,任何组件中直接使用,无需单独 import。
|
||||
|
||||
---
|
||||
|
||||
## 权限控制体系
|
||||
|
||||
项目提供三种权限控制方式,覆盖不同场景:
|
||||
|
||||
| 方式 | 语法 | 适用场景 |
|
||||
|------|------|----------|
|
||||
| `v-perm` 指令 | `v-perm="['sys:user:add']"` | 按钮级权限,最常用 |
|
||||
| `Authority` 组件 | `<Authority :value="['sys:user:add']">` | 容器级权限,控制多个元素 |
|
||||
| `auth` 列属性 | `{ auth: ['sys:user:delete'] }` | TableAction 操作列权限 |
|
||||
|
||||
:::warning 注意
|
||||
`v-perm` 和 `Authority` 的权限码必须与后端 `@permission_required("sys:xxx:xxx")` 一致。用户 ID 1(admin)拥有所有权限。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
公共组件库基于 ElementPlus 二次封装,核心三件套 BasicTable(表格)、BasicForm(表单)、BasicModal(弹窗)覆盖列表页标准场景。辅以 Upload、Editor、Cropper 等业务组件,以及 Authority、v-perm 权限体系。通过全局注册,任何页面直接使用,无需单独 import。优先使用公共组件,避免重复开发。
|
||||
@@ -0,0 +1,240 @@
|
||||
# BasicForm 表单
|
||||
|
||||
:::tip 说明
|
||||
`BasicForm` 位于 `src/components/Form/src/BasicForm.vue`,基于 ElementPlus 的 `el-form` 二次封装,通过 schemas 配置化定义表单字段,支持查询表单和业务表单两种模式。
|
||||
:::
|
||||
|
||||
## 组件架构
|
||||
|
||||
```
|
||||
src/components/Form/
|
||||
├── src/
|
||||
│ ├── BasicForm.vue # 主组件
|
||||
│ ├── props.ts # 属性定义
|
||||
│ ├── helper.ts # 工具函数(placeholder 生成)
|
||||
│ ├── types/form.ts # 类型定义(FormSchema、FormProps)
|
||||
│ └── hooks/
|
||||
│ ├── useFormEvents.ts # 表单事件(submit、reset、validate)
|
||||
│ └── useFormValues.ts # 表单值处理(默认值初始化)
|
||||
└── index.ts # 导出 useForm、FormSchema 等
|
||||
```
|
||||
|
||||
## 查询表单(列表页搜索)
|
||||
|
||||
列表页的搜索区域使用 `useForm` 注册表单:
|
||||
|
||||
```vue
|
||||
<el-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</el-card>
|
||||
```
|
||||
|
||||
### querySchemas 定义
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/querySchemas.ts
|
||||
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
label: '岗位名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入岗位名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: '正常', value: '1' },
|
||||
{ label: '禁用', value: '2' },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### useForm 注册
|
||||
|
||||
```typescript
|
||||
import { schemas } from './querySchemas';
|
||||
import { useForm } from '@/components/Form/index';
|
||||
import { ColProps } from 'element-plus';
|
||||
|
||||
const formParams = reactive({ name: '', status: '' });
|
||||
|
||||
const [register, {}] = useForm({
|
||||
labelWidth: 80,
|
||||
layout: 'horizontal',
|
||||
colProps: { span: 6 } as ColProps,
|
||||
submitOnReset: true,
|
||||
schemas,
|
||||
});
|
||||
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) { formParams[key] = values[key]; }
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
for (const key in formParams) { formParams[key] = ''; }
|
||||
}
|
||||
```
|
||||
|
||||
`useForm` 返回 `[register, formActionType]`,`register` 传给 `@register` 事件完成表单绑定。
|
||||
|
||||
## 业务表单(编辑弹窗)
|
||||
|
||||
编辑弹窗中直接使用 `el-form` + `el-form-item` 构建表单:
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/edit.vue -->
|
||||
<template>
|
||||
<el-dialog v-model="props.visible" :title="props.positionId ? '编辑' : '新增'" width="500">
|
||||
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
|
||||
<el-form-item
|
||||
label="岗位名称"
|
||||
prop="name"
|
||||
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
|
||||
>
|
||||
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable />
|
||||
</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="2">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</template>
|
||||
```
|
||||
|
||||
## BasicForm 内部渲染机制
|
||||
|
||||
`BasicForm.vue` 根据 `schema.component` 动态渲染对应的 ElementPlus 组件:
|
||||
|
||||
```vue
|
||||
<!-- src/components/Form/src/BasicForm.vue 核心逻辑 -->
|
||||
<template v-for="schema in getSchema" :key="schema.field">
|
||||
<!-- Checkbox -->
|
||||
<template v-if="schema.component === 'Checkbox'">
|
||||
<el-checkbox-group v-model="formModel[schema.field]">
|
||||
<el-checkbox v-for="item in schema.componentProps?.options" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</template>
|
||||
|
||||
<!-- RadioGroup -->
|
||||
<template v-else-if="schema.component === 'RadioGroup'">
|
||||
<el-radio-group v-model="formModel[schema.field]">
|
||||
<el-radio v-for="item in schema.componentProps?.options" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</template>
|
||||
|
||||
<!-- Select -->
|
||||
<template v-else-if="schema.component === 'Select'">
|
||||
<el-select v-model="formModel[schema.field]">
|
||||
<el-option v-for="item in schema.componentProps?.options" :value="item.value" :label="item.label" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<!-- Daterange -->
|
||||
<template v-else-if="schema.component === 'Daterange'">
|
||||
<el-date-picker v-model="formModel[schema.field]" type="daterange" value-format="YYYY-MM-DD" />
|
||||
</template>
|
||||
|
||||
<!-- 其他组件:动态渲染 el-{component} -->
|
||||
<component v-else :is="`el-${schema.component}`" v-model="formModel[schema.field]" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## FormSchema 类型
|
||||
|
||||
```typescript
|
||||
interface FormSchema {
|
||||
field: string; // 字段名
|
||||
label: string; // 标签文本
|
||||
component: string; // 组件类型:Input / Select / RadioGroup / Checkbox / DatePicker 等
|
||||
componentProps?: any; // 组件属性(placeholder、options、clearable 等)
|
||||
required?: boolean; // 是否必填
|
||||
rules?: any[]; // 自定义校验规则
|
||||
defaultValue?: any; // 默认值
|
||||
hidden?: boolean | (({ schema, values, model }) => boolean); // 是否隐藏
|
||||
slot?: string; // 自定义插槽名
|
||||
suffix?: string; // 后缀插槽名
|
||||
labelMessage?: string; // 标签提示信息
|
||||
isFull?: boolean; // 是否全宽
|
||||
type?: string; // 组件子类型
|
||||
}
|
||||
```
|
||||
|
||||
## 表单校验
|
||||
|
||||
```typescript
|
||||
// 手动校验
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate();
|
||||
// 校验通过,提交数据
|
||||
};
|
||||
|
||||
// 清除校验
|
||||
formRef.value?.clearValidate();
|
||||
```
|
||||
|
||||
Schema 中的 `required: true` 会自动生成必填校验规则,也可以通过 `rules` 自定义:
|
||||
|
||||
```typescript
|
||||
{
|
||||
field: 'name',
|
||||
label: '岗位名称',
|
||||
component: 'Input',
|
||||
rules: [
|
||||
{ required: true, message: '请输入岗位名称', trigger: 'blur' },
|
||||
{ min: 1, max: 150, message: '长度在 1 到 150 个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## 表单赋值
|
||||
|
||||
编辑时需要将行数据赋值给表单:
|
||||
|
||||
```typescript
|
||||
const formData = reactive({ id: '', name: '', status: 1, sort: 0 });
|
||||
|
||||
const setFormData = async () => {
|
||||
const data = await getPositionDetail(props.positionId);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null && data[key] != undefined) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.positionId) setFormData();
|
||||
});
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
查询表单的字段名需要与后端 Repository 的 `page()` 方法中 `filters` / `like_filters` 对应,确保查询参数能正确传递到后端。`useForm` 返回的 `register` 函数传给 `@register` 事件完成表单绑定,`formActionType` 提供 `setFieldsValue`、`resetFields`、`validate` 等方法。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
BasicForm 通过 schemas 配置化定义表单字段,内部根据 `component` 动态渲染对应的 ElementPlus 组件。查询表单使用 `useForm` 注册并绑定 `@submit`/`@reset` 事件,业务表单直接使用 `el-form` + `reactive` 管理数据。支持多种组件类型、表单校验、数据绑定、条件隐藏等能力。
|
||||
@@ -0,0 +1,260 @@
|
||||
# Hooks 组合式函数
|
||||
|
||||
:::tip 说明
|
||||
项目封装了一系列组合式函数(Hooks),位于 `src/hooks/` 和 `src/utils/` 目录,提供权限检查、异步 loading、防重复提交、实时时钟、项目配置等可复用逻辑。
|
||||
:::
|
||||
|
||||
## Hooks 目录结构
|
||||
|
||||
```
|
||||
src/hooks/
|
||||
├── event/
|
||||
│ ├── useEventListener.ts # DOM 事件监听
|
||||
│ ├── useBreakpoint.ts # 响应式断点
|
||||
│ └── useWindowSizeFn.ts # 窗口大小变化回调
|
||||
├── setting/
|
||||
│ ├── useProjectSetting.ts # 项目配置(权限模式、水印等)
|
||||
│ └── useDesignSetting.ts # 设计配置(主题色、深色模式)
|
||||
├── web/
|
||||
│ ├── usePermission.ts # 权限检查(hasPermission、hasSomePermission)
|
||||
│ ├── useECharts.ts # ECharts 封装
|
||||
│ ├── usePage.ts # 页面跳转
|
||||
│ ├── useTags.ts # 标签页管理
|
||||
│ └── useCopyToClipboard.ts # 剪贴板复制
|
||||
├── core/
|
||||
│ └── useTimeout.ts # 定时器封装
|
||||
├── useDomWidth.ts # DOM 宽度
|
||||
├── useOnline.ts # 网络状态
|
||||
├── useBattery.ts # 电量信息
|
||||
├── useTime.ts # 实时时钟
|
||||
└── use-async.ts # 异步 loading 管理
|
||||
```
|
||||
|
||||
## usePermission
|
||||
|
||||
权限检查 Hook,从 user store 获取权限列表进行判断:
|
||||
|
||||
```typescript
|
||||
// src/hooks/web/usePermission.ts
|
||||
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
export function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
function _somePermissions(accesses: string[]) {
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] == '*:*:*') return true; // 超级管理员
|
||||
return accesses.some((item) => permissionsList.includes(item));
|
||||
}
|
||||
|
||||
// 是否包含其中某个权限(用于 v-perm 指令)
|
||||
function hasSomePermission(accesses?: string | string[]): boolean {
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] === '*:*:*') return true;
|
||||
const list = Array.isArray(accesses) ? accesses : [];
|
||||
return list.some((access) => permissionsList.includes(access));
|
||||
}
|
||||
|
||||
// 是否包含所有权限(用于 v-perms 指令)
|
||||
function hasEveryPermission(accesses?: string | string[]): boolean {
|
||||
const list = Array.isArray(accesses) ? accesses : [];
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] === '*:*:*') return true;
|
||||
return list.every((access) => permissionsList.includes(access));
|
||||
}
|
||||
|
||||
// 判断是否存在权限(可用于 v-if 显示逻辑)
|
||||
function hasPermission(accesses: string[]): boolean {
|
||||
if (!accesses || !accesses.length) return true;
|
||||
return _somePermissions(accesses);
|
||||
}
|
||||
|
||||
return { hasPermission, hasEveryPermission, hasSomePermission };
|
||||
}
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
const { hasPermission, hasSomePermission } = usePermission();
|
||||
|
||||
// v-if 条件渲染
|
||||
const showAddBtn = computed(() => hasPermission(['sys:position:add']));
|
||||
|
||||
// 检查是否有任一权限
|
||||
const canOperate = hasSomePermission(['sys:position:update', 'sys:position:delete']);
|
||||
```
|
||||
|
||||
## useLockFn
|
||||
|
||||
防重复提交 Hook,函数执行期间自动锁定,位于 `src/utils/useLockFn.ts`:
|
||||
|
||||
```typescript
|
||||
// src/utils/useLockFn.ts
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useLockFn(fn: (...args: any[]) => Promise<any>) {
|
||||
const isLock = ref(false);
|
||||
const lockFn = async (...args: any[]) => {
|
||||
if (isLock.value) return; // 锁定期间忽略调用
|
||||
isLock.value = true;
|
||||
try {
|
||||
await fn(...args);
|
||||
} finally {
|
||||
isLock.value = false; // 无论成功失败都解锁
|
||||
}
|
||||
};
|
||||
return { isLock, lockFn };
|
||||
}
|
||||
```
|
||||
|
||||
实际用法(edit.vue 中的提交按钮):
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/edit.vue
|
||||
import { useLockFn } from '@/utils/useLockFn';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate();
|
||||
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
|
||||
message('操作成功');
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
};
|
||||
|
||||
// isLock 绑定按钮的 loading 状态,lockFn 作为点击处理函数
|
||||
const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
|
||||
```
|
||||
|
||||
```vue
|
||||
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button>
|
||||
```
|
||||
|
||||
## useAsync
|
||||
|
||||
异步操作 loading 管理,位于 `src/hooks/use-async.ts`:
|
||||
|
||||
```typescript
|
||||
// src/hooks/use-async.ts
|
||||
import { isReactive, isRef } from 'vue';
|
||||
|
||||
function setLoading(loading, val) {
|
||||
if (loading != undefined && isRef(loading)) {
|
||||
loading.value = val;
|
||||
} else if (loading != undefined && isReactive(loading)) {
|
||||
loading.loading = val;
|
||||
}
|
||||
}
|
||||
|
||||
export const useAsync = async (func: Promise<any>, loading: any): Promise<any> => {
|
||||
setLoading(loading, true);
|
||||
return await func.finally(() => setLoading(loading, false));
|
||||
};
|
||||
```
|
||||
|
||||
使用示例:
|
||||
|
||||
```typescript
|
||||
const loading = ref(false);
|
||||
const fetchData = async () => {
|
||||
await useAsync(getPositionList(params), loading);
|
||||
};
|
||||
```
|
||||
|
||||
## useTime
|
||||
|
||||
实时时钟 Hook,每秒更新当前时间,位于 `src/hooks/useTime.ts`:
|
||||
|
||||
```typescript
|
||||
// src/hooks/useTime.ts
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
export function useTime() {
|
||||
let timer;
|
||||
const year = ref(0);
|
||||
const month = ref(0);
|
||||
const week = ref('');
|
||||
const day = ref(0);
|
||||
const hour = ref<number | string>(0);
|
||||
const minute = ref<number | string>(0);
|
||||
const second = ref(0);
|
||||
|
||||
const updateTime = () => {
|
||||
const date = new Date();
|
||||
year.value = date.getFullYear();
|
||||
month.value = date.getMonth() + 1;
|
||||
week.value = '日一二三四五六'.charAt(date.getDay());
|
||||
day.value = date.getDate();
|
||||
hour.value = (date.getHours() + '')?.padStart(2, '0');
|
||||
minute.value = (date.getMinutes() + '')?.padStart(2, '0');
|
||||
second.value = date.getSeconds();
|
||||
};
|
||||
|
||||
updateTime();
|
||||
onMounted(() => { timer = setInterval(() => updateTime(), 1000); });
|
||||
onUnmounted(() => { clearInterval(timer); });
|
||||
|
||||
return { month, day, hour, minute, second, week };
|
||||
}
|
||||
```
|
||||
|
||||
## useProjectSetting
|
||||
|
||||
项目配置 Hook,读取项目全局设置:
|
||||
|
||||
```typescript
|
||||
// src/hooks/setting/useProjectSetting.ts
|
||||
export function useProjectSetting() {
|
||||
const projectStore = useProjectSettingStore();
|
||||
return {
|
||||
getPermissionMode: computed(() => projectStore.permissionMode), // 'BACK' | 'FRONT'
|
||||
getIsWaterMark: computed(() => projectStore.isWaterMark),
|
||||
// ... 其他配置
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## useDesignSetting
|
||||
|
||||
设计配置 Hook,管理主题色和深色模式:
|
||||
|
||||
```typescript
|
||||
// src/hooks/setting/useDesignSetting.ts
|
||||
export function useDesignSetting() {
|
||||
return {
|
||||
getAppTheme: computed(() => designStore.appTheme),
|
||||
getDarkTheme: computed(() => designStore.darkTheme),
|
||||
// ... 其他设计配置
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## useWindowSizeFn
|
||||
|
||||
窗口大小变化回调 Hook,用于表格高度自适应:
|
||||
|
||||
```typescript
|
||||
// src/hooks/event/useWindowSizeFn.ts
|
||||
|
||||
export function useWindowSizeFn(callback: Fn, wait?: number) {
|
||||
// 监听 window.resize 事件,防抖执行回调
|
||||
}
|
||||
```
|
||||
|
||||
BasicTable 组件中使用此 Hook 自动计算表格高度:
|
||||
|
||||
```typescript
|
||||
// src/components/Table/src/Table.vue
|
||||
useWindowSizeFn(computeTableHeight, 280);
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
Hooks 是 Vue3 组合式 API 的核心模式,将可复用逻辑抽离为函数。`usePermission` 供 `v-perm` 指令和 `v-if` 条件使用,`useLockFn` 防止按钮重复点击,`useAsync` 统一管理 loading 状态。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
项目提供了 usePermission(权限检查)、useLockFn(防重复提交)、useAsync(异步 loading)、useTime(实时时钟)、useProjectSetting(项目配置)、useDesignSetting(设计配置)、useWindowSizeFn(窗口变化)等组合式函数,将常用逻辑封装为可复用的函数,避免 Mixin 的命名冲突问题。
|
||||
@@ -0,0 +1,93 @@
|
||||
# 国际化
|
||||
|
||||
:::tip 说明
|
||||
当前项目仅支持中文界面,ElementPlus 使用中文语言包(`zh-cn`),所有界面文本直接硬编码在组件中。如需扩展多语言支持,可基于 vue-i18n 实现。
|
||||
:::
|
||||
|
||||
## 当前状态
|
||||
|
||||
项目当前为中文单语言版本:
|
||||
|
||||
- ElementPlus 全局配置中文语言包:`app.use(ElementPlus, { locale: zhCn })`
|
||||
- 所有界面文本直接硬编码在组件中:`<el-button>添加岗位</el-button>`
|
||||
- 后端返回的 `msg` 字段为中文:`"操作成功"`、`"岗位名称不能重复"`
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button type="primary">添加岗位</el-button>
|
||||
<el-table-column label="岗位名称" prop="name" />
|
||||
<el-table-column label="创建时间" prop="createTime" />
|
||||
</template>
|
||||
```
|
||||
|
||||
## 扩展方案
|
||||
|
||||
如需支持多语言,推荐使用 vue-i18n:
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm add vue-i18n
|
||||
```
|
||||
|
||||
### 配置 i18n
|
||||
|
||||
```typescript
|
||||
// src/i18n/index.ts
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
import enUS from './locales/en-US.json';
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: localStorage.getItem('lang') || 'zh-CN',
|
||||
fallbackLocale: 'zh-CN',
|
||||
messages: { 'zh-CN': zhCN, 'en-US': enUS },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
```
|
||||
|
||||
### 语言文件
|
||||
|
||||
```json
|
||||
// src/i18n/locales/zh-CN.json
|
||||
{
|
||||
"common": {
|
||||
"add": "新增", "edit": "编辑", "delete": "删除",
|
||||
"search": "搜索", "reset": "重置", "confirm": "确定", "cancel": "取消"
|
||||
},
|
||||
"position": { "name": "岗位名称", "status": "状态", "sort": "排序" }
|
||||
}
|
||||
```
|
||||
|
||||
### 组件中使用
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button type="primary">{{ $t('common.add') }}</el-button>
|
||||
<el-table-column :label="$t('position.name')" prop="name" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
```
|
||||
|
||||
### ElementPlus 国际化
|
||||
|
||||
```typescript
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
import en from 'element-plus/es/locale/lang/en';
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
国际化改造工作量较大,建议在项目初期规划。如仅需中文,当前硬编码方式即可满足需求。改造时需要同时处理前端文本和后端返回的 `msg` 字段。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
当前项目为中文单语言版本,ElementPlus 使用中文语言包。如需扩展多语言支持,可基于 vue-i18n 实现前端国际化,配合 ElementPlus 内置语言包和后端消息 code 实现全链路多语言。
|
||||
@@ -0,0 +1,216 @@
|
||||
# Modal 弹窗
|
||||
|
||||
:::tip 说明
|
||||
项目提供 `BasicModal` 组件(`src/components/Modal/src/BasicModal.vue`)和原生 `el-dialog` 两种弹窗用法。编辑弹窗通过 `defineAsyncComponent` 懒加载,减少首屏渲染负担。
|
||||
:::
|
||||
|
||||
## 组件架构
|
||||
|
||||
```
|
||||
src/components/Modal/
|
||||
├── src/
|
||||
│ ├── BasicModal.vue # 主组件(基于 el-dialog)
|
||||
│ ├── props.ts # 属性定义
|
||||
│ ├── type.ts # 类型定义(ModalProps、ModalMethods)
|
||||
│ └── hooks/
|
||||
│ └── useModalContext.ts # 弹窗上下文
|
||||
└── index.ts # 导出 useModal 等
|
||||
```
|
||||
|
||||
## BasicModal 组件
|
||||
|
||||
`BasicModal` 基于 `el-dialog` 封装,提供全屏切换、自定义头部、提交按钮等能力:
|
||||
|
||||
```vue
|
||||
<BasicModal :title="'编辑'" :width="600" @register="registerModal" @ok="handleSubmit">
|
||||
<BasicForm @register="registerForm" />
|
||||
</BasicModal>
|
||||
```
|
||||
|
||||
核心功能:
|
||||
|
||||
- 自定义头部(标题 + 全屏/关闭按钮)
|
||||
- 提交按钮自动 loading(`setSubLoading`)
|
||||
- `setProps` 动态修改弹窗属性
|
||||
- `openModal` / `closeModal` 控制显隐
|
||||
|
||||
## 实际用法(position 模块)
|
||||
|
||||
以岗位管理为例,实际使用原生 `el-dialog` + `defineAsyncComponent` 懒加载:
|
||||
|
||||
### 父组件(index.vue)
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/index.vue -->
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
|
||||
|
||||
const positionId = ref(0);
|
||||
const editVisible = ref(false);
|
||||
|
||||
// 新增
|
||||
const handleAdd = async () => {
|
||||
positionId.value = 0;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = async (record: Recordable) => {
|
||||
positionId.value = record.row.id;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<editDialog
|
||||
v-if="editVisible"
|
||||
:positionId="positionId"
|
||||
v-model:visible="editVisible"
|
||||
@success="reloadTable('noRefresh')"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
`defineAsyncComponent` 实现懒加载:首屏不加载弹窗组件,首次打开时才加载,加载完成后缓存。
|
||||
|
||||
### 编辑弹窗(edit.vue)
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/edit.vue -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="props.visible"
|
||||
:title="props.positionId ? '编辑' : '新增'"
|
||||
width="500"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="dialogClose"
|
||||
>
|
||||
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
|
||||
<el-form-item
|
||||
label="岗位名称"
|
||||
prop="name"
|
||||
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
|
||||
>
|
||||
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable />
|
||||
</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="2">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogClose">取消</el-button>
|
||||
<el-button :loading="subLoading" type="primary" @click="submit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
|
||||
import { useLockFn } from '@/utils/useLockFn';
|
||||
|
||||
const emit = defineEmits(['success', 'update:visible']);
|
||||
const formRef = shallowRef<FormInstance>();
|
||||
|
||||
const formData = reactive({ id: '', name: '', status: 1, sort: 0 });
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
positionId: { type: Number, required: true, default: 0 },
|
||||
});
|
||||
|
||||
// 提交处理
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate();
|
||||
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
|
||||
message('操作成功');
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
};
|
||||
|
||||
// 关闭弹窗
|
||||
const dialogClose = () => { emit('update:visible', false); };
|
||||
|
||||
// useLockFn 防重复提交,isLock 绑定按钮 loading
|
||||
const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
|
||||
|
||||
// 编辑时加载数据
|
||||
const setFormData = async () => {
|
||||
const data = await getPositionDetail(props.positionId);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null && data[key] != undefined) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.positionId) setFormData();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## 弹窗数据流
|
||||
|
||||
```
|
||||
父组件点击编辑 → positionId = record.row.id → editVisible = true
|
||||
↓
|
||||
edit.vue onMounted → positionId != 0 → setFormData() → 获取详情填充表单
|
||||
↓
|
||||
用户修改 → submit → useLockFn(防重复) → formRef.validate() → positionUpdate(formData)
|
||||
↓
|
||||
emit('success') → 父组件 reloadTable() → 表格刷新
|
||||
```
|
||||
|
||||
## 确认弹窗
|
||||
|
||||
删除等危险操作使用 `confirm` 封装函数(来自 `src/utils/auth.ts`):
|
||||
|
||||
```typescript
|
||||
import { message, confirm } from '@/utils/auth';
|
||||
|
||||
async function handleDelete(record: Recordable) {
|
||||
let ids = [];
|
||||
if (!record) {
|
||||
ids = selectionData.value.map(({ id }) => id);
|
||||
}
|
||||
|
||||
await confirm('确定要删除?');
|
||||
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
|
||||
message('删除成功');
|
||||
reloadTable();
|
||||
}
|
||||
```
|
||||
|
||||
`confirm` 内部调用 `ElMessageBox.confirm`,返回 Promise,用户取消时抛出异常中断后续逻辑。
|
||||
|
||||
## v-model:visible 双向绑定
|
||||
|
||||
弹窗使用 `v-model:visible` 实现父子组件双向绑定:
|
||||
|
||||
```vue
|
||||
<!-- 父组件 -->
|
||||
<editDialog v-model:visible="editVisible" />
|
||||
|
||||
<!-- 子组件 -->
|
||||
<script setup>
|
||||
const emit = defineEmits(['update:visible']);
|
||||
const dialogClose = () => { emit('update:visible', false); };
|
||||
</script>
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
弹窗组件通过 `defineAsyncComponent(() => import('./edit.vue'))` 懒加载,首屏不加载弹窗代码。`useLockFn` 将 `isLock` 绑定按钮的 `:loading` 属性,防止重复提交。关闭弹窗时通过 `v-model:visible` 自动同步父子状态。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
编辑弹窗使用 `el-dialog` + `defineAsyncComponent` 懒加载,通过 `v-model:visible` 双向绑定显隐状态,`useLockFn` 防重复提交。父组件通过 `positionId` prop 传递编辑行 ID,子组件在 `onMounted` 中判断并加载详情数据。操作成功后 `emit('success')` 通知父组件刷新表格。
|
||||
@@ -0,0 +1,227 @@
|
||||
# 权限指令 v-perm
|
||||
|
||||
:::tip 说明
|
||||
`v-perm` 和 `v-perms` 是项目自定义的 Vue 指令,用于按钮级别的权限控制,无权限时自动从 DOM 中移除元素。底层通过 `usePermission` Hook 检查用户权限列表。
|
||||
:::
|
||||
|
||||
## 指令定义
|
||||
|
||||
权限指令定义在 `src/directives/permission.ts` 中:
|
||||
|
||||
```typescript
|
||||
// src/directives/permission.ts
|
||||
|
||||
import { ObjectDirective } from 'vue';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
// v-perm:满足任一权限即可显示
|
||||
export const perm: ObjectDirective = {
|
||||
mounted(el: HTMLButtonElement, binding) {
|
||||
if (binding.value == undefined) return;
|
||||
const { hasSomePermission } = usePermission();
|
||||
if (!hasSomePermission(binding.value)) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// v-perms:必须满足所有权限才显示
|
||||
export const perms: ObjectDirective = {
|
||||
mounted(el: HTMLButtonElement, binding) {
|
||||
if (binding.value == undefined) return;
|
||||
const { hasEveryPermission } = usePermission();
|
||||
if (!hasEveryPermission(binding.value)) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
指令在 `mounted` 钩子中检查当前用户的权限列表,如果不满足权限条件,直接从 DOM 中移除元素。
|
||||
|
||||
## usePermission Hook
|
||||
|
||||
权限检查的核心逻辑在 `src/hooks/web/usePermission.ts` 中:
|
||||
|
||||
```typescript
|
||||
// src/hooks/web/usePermission.ts
|
||||
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
export function usePermission() {
|
||||
const userStore = useUserStore();
|
||||
|
||||
function _somePermissions(accesses: string[]) {
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] == '*:*:*') return true; // 超级管理员通配符
|
||||
return accesses.some((item) => permissionsList.includes(item));
|
||||
}
|
||||
|
||||
// 是否包含其中某个权限
|
||||
function hasSomePermission(accesses?: string | string[]): boolean {
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] === '*:*:*') return true;
|
||||
const list = Array.isArray(accesses) ? accesses : [];
|
||||
return list.some((access) => permissionsList.includes(access));
|
||||
}
|
||||
|
||||
// 是否包含指定的所有权限
|
||||
function hasEveryPermission(accesses?: string | string[]): boolean {
|
||||
const list = Array.isArray(accesses) ? accesses : [];
|
||||
const permissionsList = userStore.getPermissions;
|
||||
if (permissionsList[0] === '*:*:*') return true;
|
||||
return list.every((access) => permissionsList.includes(access));
|
||||
}
|
||||
|
||||
// 判断是否存在权限(可用于 v-if)
|
||||
function hasPermission(accesses: string[]): boolean {
|
||||
if (!accesses || !accesses.length) return true;
|
||||
return _somePermissions(accesses);
|
||||
}
|
||||
|
||||
return { hasPermission, hasEveryPermission, hasSomePermission };
|
||||
}
|
||||
```
|
||||
|
||||
权限列表在登录后通过 `userStore.GetInfo()` 从后端获取,存储在 Pinia 的 user store 中。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### v-perm(满足任一权限)
|
||||
|
||||
```vue
|
||||
<el-button v-perm="['sys:position:add']">添加岗位</el-button>
|
||||
<el-button v-perm="['sys:position:batchDelete']">删除</el-button>
|
||||
<el-button v-perm="['sys:position:export']">导出</el-button>
|
||||
```
|
||||
|
||||
### v-perms(满足所有权限)
|
||||
|
||||
```vue
|
||||
<el-button v-perms="['sys:user:add', 'sys:user:import']">高级操作</el-button>
|
||||
```
|
||||
|
||||
### 与 v-if 结合
|
||||
|
||||
```typescript
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
// 在模板中使用
|
||||
const showAddBtn = computed(() => hasPermission(['sys:position:add']));
|
||||
```
|
||||
|
||||
```vue
|
||||
<el-button v-if="showAddBtn">添加岗位</el-button>
|
||||
```
|
||||
|
||||
## 实际用法(position 模块)
|
||||
|
||||
以岗位管理页面为例,实际代码如下:
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/index.vue -->
|
||||
<template>
|
||||
<BasicTable :columns="columns" :request="loadDataTable" ref="tableRef" :actionColumn="actionColumn">
|
||||
<template #tableTitle>
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
|
||||
<template #icon><el-icon><PlusOutlined /></el-icon></template>
|
||||
添加岗位
|
||||
</el-button>
|
||||
<el-button type="danger" @click="handleDelete()" :disabled="!selectionData.length"
|
||||
v-perm="['sys:position:batchDelete']">
|
||||
<template #icon><el-icon><Delete /></el-icon></template>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
操作列中的权限控制通过 `auth` 属性实现:
|
||||
|
||||
```typescript
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
label: '操作',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '编辑',
|
||||
icon: 'Edit',
|
||||
type: 'warning',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: ['sys:position:update'], // 权限节点
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'Delete',
|
||||
type: 'danger',
|
||||
onClick: handleDelete.bind(null, record),
|
||||
auth: ['sys:position:delete'], // 权限节点
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 权限节点命名规范
|
||||
|
||||
权限节点格式:`sys:{module}:{action}`
|
||||
|
||||
```
|
||||
sys:position:page —— 分页查询
|
||||
sys:position:detail —— 查询详情
|
||||
sys:position:list —— 获取列表
|
||||
sys:position:add —— 新增
|
||||
sys:position:update —— 编辑
|
||||
sys:position:delete —— 删除
|
||||
sys:position:batchDelete —— 批量删除
|
||||
sys:position:status —— 设置状态
|
||||
sys:position:export —— 导出
|
||||
sys:position:import —— 导入
|
||||
```
|
||||
|
||||
## 与后端对应
|
||||
|
||||
前端 `v-perm` 指令的权限节点必须与后端 `@permission_required` 装饰器的权限字符串一致:
|
||||
|
||||
```python
|
||||
# 后端
|
||||
@router.post('/add')
|
||||
@permission_required("sys:position:add")
|
||||
@check_demo
|
||||
def add(request: Request, data: PositionForm):
|
||||
return position_service.add(request, data)
|
||||
```
|
||||
|
||||
```vue
|
||||
<!-- 前端 -->
|
||||
<el-button v-perm="['sys:position:add']">添加岗位</el-button>
|
||||
```
|
||||
|
||||
## 超级管理员
|
||||
|
||||
用户权限列表中包含 `*:*:*` 通配符时,所有权限检查直接返回 `true`,等同于后端用户 ID=1 的管理员跳过权限检查。
|
||||
|
||||
:::tip 温馨提示
|
||||
`v-perm` 指令仅做 UI 层隐藏,真正的权限校验在后端 `@permission_required` 装饰器中。即使前端绕过指令限制直接调用接口,后端仍会校验权限。
|
||||
:::
|
||||
|
||||
## 指令注册
|
||||
|
||||
指令在 `src/plugins/` 中通过 `setupDirectives` 全局注册:
|
||||
|
||||
```typescript
|
||||
// 启动时注册(main.ts)
|
||||
setupDirectives(app); // 注册 v-perm、v-perms 等指令
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
`v-perm`(满足任一权限)和 `v-perms`(满足所有权限)指令通过 `usePermission` Hook 检查用户权限列表,无权限时自动从 DOM 移除元素。操作列通过 `TableAction` 组件的 `auth` 属性控制。权限节点在菜单管理中配置,需要保持前后端字符串完全一致。`*:*:*` 通配符表示超级管理员。
|
||||
@@ -0,0 +1,304 @@
|
||||
# 路由与菜单
|
||||
|
||||
:::tip 说明
|
||||
前端路由基于 Vue Router 4 实现动态路由生成,菜单数据从后端获取,通过 `generator-routers.ts` 递归生成路由表,路由守卫控制登录状态和权限。
|
||||
:::
|
||||
|
||||
## 路由配置
|
||||
|
||||
路由配置位于 `src/router/` 目录:
|
||||
|
||||
```
|
||||
src/router/
|
||||
├── index.ts # 路由实例创建,导出 router、setupRouter
|
||||
├── router-guards.ts # 全局路由守卫(beforeEach / afterEach)
|
||||
├── generator-routers.ts # 动态路由生成(后端菜单 → 路由表)
|
||||
├── router-icons.ts # 菜单图标映射
|
||||
├── constant.ts # Layout / ParentLayout 组件引用
|
||||
└── base.ts # 静态路由(ErrorPage、About、Setting)
|
||||
```
|
||||
|
||||
### 静态路由
|
||||
|
||||
无需权限即可访问的路由定义在 `base.ts` 中,包括登录页、404 错误页等。
|
||||
|
||||
### 动态路由生成
|
||||
|
||||
登录后从后端获取菜单数据,由 `generator-routers.ts` 的 `routerGenerator` 递归生成路由表:
|
||||
|
||||
```typescript
|
||||
// src/router/generator-routers.ts
|
||||
|
||||
import { adminMenus } from '@/api/common/menu';
|
||||
import { Layout, ParentLayout } from '@/router/constant';
|
||||
|
||||
const LayoutMap = new Map<string, () => Promise<typeof import('*.vue')>>();
|
||||
LayoutMap.set('LAYOUT', Layout);
|
||||
LayoutMap.set('IFRAME', () => import('@/views/iframe/index.vue'));
|
||||
|
||||
export const routerGenerator = (routerMap, parent?): any[] => {
|
||||
return routerMap.map((item) => {
|
||||
const names = item.target == 2 ? item.component : item.path.replaceAll('/', '');
|
||||
item.meta = {
|
||||
title: item.parentId == 0 && item.children.length == 0 ? '' : item.name,
|
||||
icon: item.icon,
|
||||
sort: item.sort,
|
||||
permissions: item.permission,
|
||||
hidden: item.hide ? true : false,
|
||||
isRoot: item.parentId == 0 && item.children.length == 0,
|
||||
frameSrc: item.target == 1 ? item.component : '',
|
||||
};
|
||||
|
||||
let components = '';
|
||||
if (item.parentId == 0 && item.children.length == 0) {
|
||||
components = 'LAYOUT';
|
||||
} else if (item.target == 0) {
|
||||
components = item.component;
|
||||
} else if (item.target == 1) {
|
||||
components = 'IFRAME';
|
||||
}
|
||||
|
||||
const currentRouter: any = {
|
||||
path: item.target != 2 ? item.path : '',
|
||||
name: names,
|
||||
component: components,
|
||||
meta: { ...item.meta, label: item.meta.title, icon: item.meta.icon || null },
|
||||
};
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
!item.redirect && (currentRouter.redirect = `${item.children[0].path}`);
|
||||
currentRouter.children = routerGenerator(item.children, currentRouter);
|
||||
}
|
||||
|
||||
return currentRouter;
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 组件动态导入
|
||||
|
||||
`asyncImportRoute` 函数使用 `import.meta.glob` 扫描 `views/` 目录下的所有 `.vue` 和 `.tsx` 文件,根据后端返回的 `component` 字段(如 `system/position/index`)匹配并懒加载:
|
||||
|
||||
```typescript
|
||||
export const asyncImportRoute = (routes: AppRouteRecordRaw[]): void => {
|
||||
viewsModules = viewsModules || import.meta.glob('../views/**/*.{vue,tsx}');
|
||||
routes.forEach((item) => {
|
||||
const { component } = item;
|
||||
if (component) {
|
||||
const layoutFound = LayoutMap.get(component as string);
|
||||
if (layoutFound) {
|
||||
item.component = layoutFound; // LAYOUT / IFRAME
|
||||
} else {
|
||||
item.component = dynamicImport(viewsModules, component as string);
|
||||
}
|
||||
}
|
||||
item.children && asyncImportRoute(item.children);
|
||||
});
|
||||
};
|
||||
|
||||
export const dynamicImport = (viewsModules, component: string) => {
|
||||
const keys = Object.keys(viewsModules);
|
||||
const matchKeys = keys.filter((key) => {
|
||||
let k = key.replace('../views', '');
|
||||
k = k.substring(0, k.lastIndexOf('.'));
|
||||
return k === component;
|
||||
});
|
||||
if (matchKeys?.length === 1) return viewsModules[matchKeys[0]];
|
||||
};
|
||||
```
|
||||
|
||||
匹配规则:后端 `component` 字段值 `system/position/index` 会匹配 `../views/system/position/index.vue`。
|
||||
|
||||
## 路由守卫
|
||||
|
||||
`router-guards.ts` 中定义全局路由守卫,核心流程:
|
||||
|
||||
```typescript
|
||||
// src/router/router-guards.ts
|
||||
|
||||
export function createRouterGuards(router: Router) {
|
||||
const userStore = useUserStoreWidthOut();
|
||||
const asyncRouteStore = useAsyncRouteStoreWidthOut();
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 支持 URL 参数中的 token(如 SSO 回调)
|
||||
if (to.query && 'token' in to.query) {
|
||||
storage.set(ACCESS_TOKEN, String(to.query.token));
|
||||
userStore.setToken(String(to.query.token));
|
||||
}
|
||||
NProgress.start();
|
||||
|
||||
const token = storage.get(ACCESS_TOKEN);
|
||||
|
||||
// 白名单(登录页)直接放行
|
||||
if (whitePathList.includes(to.path) && !token) {
|
||||
next(); return;
|
||||
}
|
||||
|
||||
// 无 token 跳转登录页
|
||||
if (!token) {
|
||||
if (to.meta.ignoreAuth) { next(); return; }
|
||||
next({ path: LOGIN_PATH, replace: true, query: { redirect: to.path } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 已登录且路由已添加,直接放行
|
||||
if (asyncRouteStore.getIsDynamicAddedRoute) {
|
||||
next(); return;
|
||||
}
|
||||
|
||||
// 首次登录:获取用户信息 → 生成动态路由 → 添加路由
|
||||
asyncRouteStore.setDynamicAddedRoute(true);
|
||||
const userInfo = await userStore.GetInfo();
|
||||
const routes = await asyncRouteStore.generateRoutes(userInfo);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const firstRoutePath = findFirstRoutePath(routes);
|
||||
storage.set(FIRST_ROUTE, firstRoutePath);
|
||||
|
||||
// 跳转目标页或首个路由
|
||||
next({ path: redirect, replace: true });
|
||||
});
|
||||
|
||||
// afterEach:设置页面标题、管理 keepAlive 组件缓存
|
||||
router.afterEach((to) => {
|
||||
document.title = to?.meta?.title || document.title;
|
||||
NProgress.done();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
守卫流程图:
|
||||
|
||||
```
|
||||
访问页面
|
||||
├── 有 token 查询参数?→ 存储 token
|
||||
├── 白名单?→ 放行
|
||||
├── 无 token?→ 跳转登录页(携带 redirect 参数)
|
||||
├── 路由已添加?→ 放行
|
||||
└── 首次登录 → GetInfo → generateRoutes → addRoute → 跳转
|
||||
```
|
||||
|
||||
## 动态路由生成(asyncRoute Store)
|
||||
|
||||
`asyncRouteStore` 的 `generateRoutes` 方法负责从后端获取菜单并生成路由:
|
||||
|
||||
```typescript
|
||||
// src/store/modules/asyncRoute.ts
|
||||
|
||||
async generateRoutes(data) {
|
||||
let accessedRouters;
|
||||
const permissionsList = data.permissions || [];
|
||||
const { getPermissionMode } = useProjectSetting();
|
||||
const permissionMode = unref(getPermissionMode);
|
||||
|
||||
if (permissionMode === 'BACK') {
|
||||
// 后端控制模式:调用 adminMenus() 获取菜单
|
||||
accessedRouters = await generatorDynamicRouter();
|
||||
} else {
|
||||
// 前端控制模式:根据权限过滤静态路由表
|
||||
accessedRouters = filter(asyncRoutes, routeFilter);
|
||||
}
|
||||
|
||||
this.setRouters(accessedRouters);
|
||||
this.setMenus(accessedRouters);
|
||||
return toRaw(accessedRouters);
|
||||
},
|
||||
```
|
||||
|
||||
使用 `BACK` 模式(后端控制菜单),调用 `adminMenus()` 接口获取菜单数据。
|
||||
|
||||
## 菜单数据结构
|
||||
|
||||
后端返回的菜单数据结构:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "系统管理",
|
||||
"path": "/system",
|
||||
"component": "",
|
||||
"icon": "icon-system",
|
||||
"target": 0,
|
||||
"parentId": 0,
|
||||
"sort": 1,
|
||||
"hide": false,
|
||||
"permission": "",
|
||||
"children": [
|
||||
{
|
||||
"id": 10,
|
||||
"name": "岗位管理",
|
||||
"path": "/system/position",
|
||||
"component": "system/position/index",
|
||||
"icon": "icon-position",
|
||||
"target": 0,
|
||||
"parentId": 1,
|
||||
"sort": 3,
|
||||
"hide": false,
|
||||
"permission": "sys:position:page",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `name` | 菜单名称,同时作为 `meta.title` |
|
||||
| `path` | 路由地址,如 `/system/position` |
|
||||
| `component` | 组件路径,如 `system/position/index`,对应 `views/system/position/index.vue` |
|
||||
| `target` | 打开方式:0=内部组件,1=内嵌 iframe,2=外部链接 |
|
||||
| `parentId` | 父菜单 ID,0 表示顶级 |
|
||||
| `permission` | 权限节点,如 `sys:position:page` |
|
||||
| `hide` | 是否隐藏菜单 |
|
||||
| `icon` | 菜单图标 |
|
||||
|
||||
## 菜单渲染
|
||||
|
||||
菜单数据与路由数据同源,侧边栏组件递归渲染菜单树。`meta` 中的关键字段控制渲染行为:
|
||||
|
||||
- `hidden: true`:隐藏该菜单项
|
||||
- `isRoot: true`:顶级单页菜单(无子菜单的顶级项自动包裹 Layout)
|
||||
- `frameSrc`:iframe 内嵌地址
|
||||
- `permissions`:权限字符串,用于菜单级别的权限过滤
|
||||
|
||||
## 面包屑导航
|
||||
|
||||
根据当前路由的 `matched` 自动生成面包屑,读取每级路由的 `meta.title`。
|
||||
|
||||
## KeepAlive 缓存
|
||||
|
||||
路由守卫的 `afterEach` 中自动管理组件缓存:
|
||||
|
||||
```typescript
|
||||
router.afterEach((to) => {
|
||||
const keepAliveComponents = asyncRouteStore.keepAliveComponents;
|
||||
const currentComName = 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) {
|
||||
const index = keepAliveComponents.findIndex((name) => name == currentComName);
|
||||
if (index != -1) keepAliveComponents.splice(index, 1); // 移除缓存
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
菜单的 `component` 字段对应 `src/views/` 下的组件路径,如 `system/position/index` 对应 `src/views/system/position/index.vue`。`import.meta.glob('../views/**/*.{vue,tsx}')` 会在构建时扫描所有视图文件,实现按需懒加载。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
前端路由采用动态生成方式,登录后通过 `adminMenus()` 接口获取后端菜单数据,`routerGenerator` 递归生成路由表,`asyncImportRoute` 使用 `import.meta.glob` 懒加载组件。路由守卫控制登录状态和首次路由添加,`asyncRouteStore` 管理路由状态和组件缓存。
|
||||
@@ -0,0 +1,215 @@
|
||||
# 前端环境与结构
|
||||
|
||||
:::tip 说明
|
||||
前端基于 Vue3 + ElementPlus 构建,使用 pnpm 作为包管理工具,Vite 作为构建工具,TypeScript 作为开发语言。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Vue | 3.x | 渐进式 JavaScript 框架,组合式 API |
|
||||
| ElementPlus | 2.x | Vue3 组件库 |
|
||||
| Vite | 3.x | 下一代前端构建工具 |
|
||||
| Pinia | 2.x | 状态管理(Vuex 替代方案) |
|
||||
| Vue Router | 4.x | 路由管理 |
|
||||
| Axios | 1.x | HTTP 请求库 |
|
||||
| TypeScript | 5.x | 类型安全的 JavaScript 超集 |
|
||||
| Tailwind CSS | 3.x | 原子化 CSS 框架 |
|
||||
| SCSS | - | CSS 预处理器 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
ui/
|
||||
├── src/
|
||||
│ ├── api/ # API 接口定义
|
||||
│ │ ├── common/ # 公共接口(login、menu、user)
|
||||
│ │ └── system/ # 系统模块接口(position、user、role 等)
|
||||
│ ├── assets/ # 静态资源(图片、字体等)
|
||||
│ ├── components/ # 公共组件(Table、Form、Modal 等)
|
||||
│ ├── directives/ # 自定义指令(v-perm 权限指令)
|
||||
│ ├── hooks/ # 组合式函数
|
||||
│ │ ├── event/ # 事件相关(useWindowSizeFn、useBreakpoint)
|
||||
│ │ ├── setting/ # 配置相关(useProjectSetting、useDesignSetting)
|
||||
│ │ └── web/ # Web 相关(usePermission、useECharts)
|
||||
│ ├── layout/ # 布局组件
|
||||
│ ├── router/ # 路由配置(generator-routers、router-guards)
|
||||
│ ├── store/ # Pinia 状态管理
|
||||
│ │ └── modules/ # Store 模块(user、asyncRoute、projectSetting 等)
|
||||
│ ├── styles/ # 全局样式(SCSS 变量、Tailwind)
|
||||
│ ├── utils/ # 工具函数(http、auth、Storage、dateUtil)
|
||||
│ │ └── http/axios/ # Axios 封装(VAxios 类、拦截器、取消请求)
|
||||
│ └── views/ # 页面视图
|
||||
│ └── system/ # 系统管理页面(position、user、role 等)
|
||||
├── build/ # 构建配置(vite 插件、常量、工具函数)
|
||||
├── public/ # 公共静态资源
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
└── package.json # 依赖配置
|
||||
```
|
||||
|
||||
## 启动开发
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 启动开发服务器(默认端口 8001)
|
||||
pnpm dev
|
||||
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Vite 配置
|
||||
|
||||
`vite.config.ts` 是 Vite 的核心配置文件,实际配置如下:
|
||||
|
||||
```typescript
|
||||
import type { UserConfig, ConfigEnv } from 'vite';
|
||||
import { loadEnv } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import { wrapperEnv } from './build/utils';
|
||||
import { createVitePlugins } from './build/vite/plugin';
|
||||
import { OUTPUT_DIR } from './build/constant';
|
||||
import { createProxy } from './build/vite/proxy';
|
||||
|
||||
export default ({ command, mode }: ConfigEnv): UserConfig => {
|
||||
const root = process.cwd();
|
||||
const env = loadEnv(mode, root);
|
||||
const viteEnv = wrapperEnv(env);
|
||||
const { VITE_PUBLIC_PATH, VITE_PORT, VITE_GLOB_PROD_MOCK } = viteEnv;
|
||||
|
||||
let VITE_PROXY: any = viteEnv.VITE_PROXY;
|
||||
// VITE_PROXY 容错处理
|
||||
if (!VITE_PROXY) {
|
||||
VITE_PROXY = '[[]]';
|
||||
}
|
||||
try {
|
||||
if (typeof VITE_PROXY === 'string') {
|
||||
VITE_PROXY = JSON.parse(VITE_PROXY);
|
||||
}
|
||||
} catch (e) {
|
||||
VITE_PROXY = [[]];
|
||||
}
|
||||
|
||||
const isBuild = command === 'build';
|
||||
|
||||
return {
|
||||
base: VITE_PUBLIC_PATH,
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: '@', replacement: pathResolve('src') + '/' },
|
||||
],
|
||||
dedupe: ['vue'],
|
||||
},
|
||||
plugins: createVitePlugins(viteEnv, isBuild, prodMock),
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@import "src/styles/var.scss";`,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: VITE_PORT,
|
||||
proxy: createProxy(VITE_PROXY),
|
||||
},
|
||||
build: {
|
||||
target: 'es2015',
|
||||
outDir: OUTPUT_DIR,
|
||||
reportCompressedSize: false,
|
||||
chunkSizeWarningLimit: 2000,
|
||||
},
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
关键配置说明:
|
||||
|
||||
- `@` 别名指向 `src/` 目录,代码中可用 `@/api/system/position` 引入模块
|
||||
- SCSS 全局变量通过 `additionalData` 注入,无需每个文件手动 `@import`
|
||||
- 代理配置通过 `VITE_PROXY` 环境变量传入,由 `createProxy()` 工具函数解析
|
||||
- 构建产物输出到 `OUTPUT_DIR`(默认 `dist/`)
|
||||
|
||||
## 应用入口
|
||||
|
||||
`src/main.ts` 是应用入口,负责初始化所有基础设施:
|
||||
|
||||
```typescript
|
||||
import './styles/tailwindbase.css';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles/index.scss';
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router, { setupRouter } from './router';
|
||||
import { setupStore } from '@/store';
|
||||
import { setupElement, setupDirectives, setupCustomComponents } from '@/plugins';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = createApp(App);
|
||||
|
||||
// 注册所有 ElementPlus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component);
|
||||
}
|
||||
|
||||
setupElement(app); // 全局引入 ElementPlus 组件
|
||||
setupCustomComponents(app); // 注册自定义组件
|
||||
setupDirectives(app); // 注册自定义指令(v-perm 等)
|
||||
setupStore(app); // 挂载 Pinia 状态管理
|
||||
await setupRouter(app); // 挂载路由
|
||||
await router.isReady(); // 路由准备就绪
|
||||
|
||||
app.mount('#app', true);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
```
|
||||
|
||||
初始化顺序:样式加载 -> ElementPlus -> 自定义组件 -> 自定义指令 -> Pinia Store -> Router -> 挂载。
|
||||
|
||||
## 核心目录说明
|
||||
|
||||
### api/
|
||||
|
||||
API 接口定义目录,按模块组织文件。公共接口在 `api/common/`(login、menu、user),业务接口在 `api/system/`(position、user、role、dept、level、menu、dictionary 等)。每个文件使用 `http.request()` 发起请求。
|
||||
|
||||
### components/
|
||||
|
||||
公共组件目录,基于 ElementPlus 二次封装,包含 `Table`(BasicTable 表格)、`Form`(BasicForm 表单)、`Modal`(BasicModal 弹窗)、`Upload`、`Editor`、`Cropper`、`Select` 等。
|
||||
|
||||
### directives/
|
||||
|
||||
自定义指令目录,核心是 `permission.ts` 中的 `v-perm` 和 `v-perms` 指令,用于按钮级别的权限控制。
|
||||
|
||||
### hooks/
|
||||
|
||||
组合式函数目录,包括 `usePermission`(权限检查)、`useTime`(实时时钟)、`useAsync`(异步 loading)、`useProjectSetting`(项目配置)、`useDesignSetting`(设计配置)等。
|
||||
|
||||
### store/
|
||||
|
||||
Pinia 状态管理目录,核心模块:
|
||||
|
||||
- `user`:用户登录状态、Token、用户信息、权限列表
|
||||
- `asyncRoute`:动态路由生成、菜单管理、组件缓存
|
||||
- `projectSetting`:项目配置(权限模式、水印等)
|
||||
- `designSetting`:主题色、深色模式
|
||||
- `lockscreen`:锁屏状态
|
||||
- `tabsView`:标签页管理
|
||||
|
||||
### utils/
|
||||
|
||||
工具函数目录:
|
||||
|
||||
- `http/axios/`:Axios 封装(VAxios 类、请求/响应拦截器、取消重复请求、状态码处理)
|
||||
- `auth.ts`:认证工具(token 管理、树结构操作、文件下载)
|
||||
- `Storage.ts`:本地存储封装(支持过期时间、cookie)
|
||||
- `dateUtil.ts`:日期格式化(基于 dayjs)
|
||||
|
||||
## 总结
|
||||
|
||||
前端项目基于 Vue3 + ElementPlus + Vite + TypeScript + Pinia 构建,使用 pnpm 管理依赖,Tailwind CSS + SCSS 提供样式能力。通过 `main.ts` 的 `bootstrap()` 函数按顺序初始化所有基础设施,目录结构按功能分层组织,便于维护和扩展。
|
||||
@@ -0,0 +1,245 @@
|
||||
# Store 状态管理
|
||||
|
||||
:::tip 说明
|
||||
前端使用 Pinia 进行状态管理,核心模块包括 `user`(用户信息与权限)、`asyncRoute`(动态路由与菜单)、`projectSetting`(项目配置)、`designSetting`(主题设计)等。
|
||||
:::
|
||||
|
||||
## Pinia 配置
|
||||
|
||||
```typescript
|
||||
// src/store/index.ts
|
||||
import { createPinia } from 'pinia';
|
||||
const pinia = createPinia();
|
||||
export default pinia;
|
||||
```
|
||||
|
||||
Store 模块位于 `src/store/modules/` 目录:
|
||||
|
||||
```
|
||||
src/store/modules/
|
||||
├── user.ts # 用户登录状态、Token、用户信息、权限列表
|
||||
├── asyncRoute.ts # 动态路由生成、菜单管理、组件缓存
|
||||
├── projectSetting.ts # 项目配置(权限模式、水印、动画等)
|
||||
├── designSetting.ts # 主题色、深色模式
|
||||
├── lockscreen.ts # 锁屏状态
|
||||
├── tabsView.ts # 标签页管理
|
||||
└── ossConfig.ts # OSS 配置
|
||||
```
|
||||
|
||||
## User Store
|
||||
|
||||
管理用户登录状态、Token、用户信息和权限列表,是核心 Store:
|
||||
|
||||
```typescript
|
||||
// src/store/modules/user.ts
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { createStorage } from '@/utils/Storage';
|
||||
import { ACCESS_TOKEN, CURRENT_USER } from '@/store/mutation-types';
|
||||
import { getUserInfo, login, logout } from '@/api/common/user';
|
||||
|
||||
const Storage = createStorage({ storage: localStorage });
|
||||
|
||||
export interface IUserState {
|
||||
token: string;
|
||||
username: string;
|
||||
welcome: string;
|
||||
avatar: string;
|
||||
permissions: any[];
|
||||
info: any;
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore({
|
||||
id: 'app-user',
|
||||
state: (): IUserState => ({
|
||||
token: Storage.get(ACCESS_TOKEN, ''), // 从 localStorage 恢复 token
|
||||
username: '',
|
||||
welcome: '',
|
||||
avatar: '',
|
||||
permissions: [],
|
||||
info: Storage.get(CURRENT_USER, {}), // 从 localStorage 恢复用户信息
|
||||
}),
|
||||
getters: {
|
||||
getToken(): string { return this.token; },
|
||||
getPermissions(): [any][] { return this.permissions; },
|
||||
getUserInfo(): object { return this.info; },
|
||||
},
|
||||
actions: {
|
||||
// 登录
|
||||
async login(userInfo) {
|
||||
const response = await login(userInfo);
|
||||
const { code, data } = response;
|
||||
if (code === ResultEnum.SUCCESS) {
|
||||
const ex = 7 * 24 * 60 * 60 * 1000; // 7 天过期
|
||||
storage.set(ACCESS_TOKEN, data.access_token, ex);
|
||||
this.setToken(data.access_token);
|
||||
}
|
||||
return Promise.resolve(response);
|
||||
},
|
||||
|
||||
// 获取用户信息
|
||||
GetInfo() {
|
||||
return new Promise((resolve, reject) => {
|
||||
getUserInfo().then((res) => {
|
||||
if (res.permissions && res.permissions.length) {
|
||||
this.setPermissions(res.permissions);
|
||||
}
|
||||
this.setUserInfo(res);
|
||||
this.setAvatar(res.avatar);
|
||||
resolve(res);
|
||||
}).catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
// 登出
|
||||
async logout() {
|
||||
return new Promise((resolve, reject) => {
|
||||
logout().then(() => {
|
||||
this.setPermissions([]);
|
||||
this.setUserInfo('');
|
||||
storage.remove(ACCESS_TOKEN);
|
||||
storage.remove(CURRENT_USER);
|
||||
storage.clear();
|
||||
resolve('');
|
||||
}).catch(reject);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Token 存储机制
|
||||
|
||||
Token 通过 `Storage` 工具类存储在 `localStorage` 中,键名为 `ACCESS-TOKEN`,自动大写转换,支持 7 天过期时间:
|
||||
|
||||
```typescript
|
||||
// src/utils/Storage.ts
|
||||
export const createStorage = ({ prefixKey = '', storage = localStorage } = {}) => {
|
||||
const Storage = class {
|
||||
set(key: string, value: any, expire: number | null = DEFAULT_CACHE_TIME) {
|
||||
const stringData = JSON.stringify({
|
||||
value,
|
||||
expire: expire !== null ? Date.now() + expire : null,
|
||||
});
|
||||
this.storage.setItem(this.getKey(key), stringData);
|
||||
}
|
||||
|
||||
get(key: string, def: any = null) {
|
||||
const item = this.storage.getItem(this.getKey(key));
|
||||
if (item) {
|
||||
const data = JSON.parse(item);
|
||||
const { value, expire } = data;
|
||||
if (expire === null || expire >= Date.now()) return value;
|
||||
this.remove(key); // 过期则删除
|
||||
}
|
||||
return def;
|
||||
}
|
||||
};
|
||||
return new Storage();
|
||||
};
|
||||
export const storage = createStorage();
|
||||
```
|
||||
|
||||
## AsyncRoute Store
|
||||
|
||||
管理动态路由和菜单,是路由系统的核心:
|
||||
|
||||
```typescript
|
||||
// src/store/modules/asyncRoute.ts
|
||||
export const useAsyncRouteStore = defineStore({
|
||||
id: 'app-async-route',
|
||||
state: (): IAsyncRouteState => ({
|
||||
menus: [], // 菜单列表
|
||||
routers: constantRouter, // 静态路由
|
||||
addRouters: [], // 动态路由
|
||||
keepAliveComponents: [], // 需要缓存的组件名
|
||||
isDynamicAddedRoute: false, // 路由是否已添加
|
||||
}),
|
||||
actions: {
|
||||
async generateRoutes(data) {
|
||||
const { getPermissionMode } = useProjectSetting();
|
||||
const permissionMode = unref(getPermissionMode);
|
||||
let accessedRouters;
|
||||
|
||||
if (permissionMode === 'BACK') {
|
||||
// 后端控制模式:从后端获取菜单生成路由
|
||||
accessedRouters = await generatorDynamicRouter();
|
||||
} else {
|
||||
// 前端控制模式:根据权限过滤静态路由表
|
||||
accessedRouters = filter(asyncRoutes, routeFilter);
|
||||
}
|
||||
|
||||
this.setRouters(accessedRouters);
|
||||
this.setMenus(accessedRouters);
|
||||
return toRaw(accessedRouters);
|
||||
},
|
||||
|
||||
setRouters(routers) {
|
||||
this.addRouters = routers;
|
||||
this.routers = constantRouter.concat(routers);
|
||||
},
|
||||
setMenus(menus) { this.menus = menus; },
|
||||
setDynamicAddedRoute(added: boolean) { this.isDynamicAddedRoute = added; },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Store 间协作
|
||||
|
||||
登录流程中 Store 协作:
|
||||
|
||||
```typescript
|
||||
// 路由守卫中的调用(router-guards.ts)
|
||||
const userStore = useUserStoreWidthOut();
|
||||
const asyncRouteStore = useAsyncRouteStoreWidthOut();
|
||||
|
||||
// 1. 获取用户信息(设置 permissions)
|
||||
const userInfo = await userStore.GetInfo();
|
||||
// 2. 根据用户信息生成动态路由
|
||||
const routes = await asyncRouteStore.generateRoutes(userInfo);
|
||||
// 3. 动态添加路由
|
||||
routes.forEach((item) => router.addRoute(item));
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
在组件中使用 store:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = computed(() => userStore.getUserInfo);
|
||||
|
||||
// 获取权限列表
|
||||
const permissions = computed(() => userStore.getPermissions);
|
||||
|
||||
// 登出
|
||||
const handleLogout = async () => {
|
||||
await userStore.logout();
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
在 setup 外使用 store(需要传入 pinia 实例):
|
||||
|
||||
```typescript
|
||||
import { store } from '@/store';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
|
||||
export function useUserStoreWidthOut() {
|
||||
return useUserStore(store);
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
Token 通过 `Storage` 工具类管理,支持自动过期(默认 7 天)。退出登录时 `storage.clear()` 会清除所有 localStorage 数据。权限列表 `permissions` 在 `GetInfo` 时从后端获取,存储在 user store 中供 `v-perm` 指令使用。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
Pinia 状态管理包含 user(登录与权限)、asyncRoute(动态路由与菜单)、projectSetting(项目配置)等核心模块。user store 管理 Token 和权限列表,asyncRoute store 管理动态路由生成和组件缓存。Store 之间通过路由守卫协调工作,完成登录 → 获取信息 → 生成路由 → 挂载应用的完整流程。
|
||||
@@ -0,0 +1,153 @@
|
||||
# 样式与主题
|
||||
|
||||
:::tip 说明
|
||||
前端样式方案采用 Tailwind CSS + SCSS + ElementPlus 主题覆盖,支持深色模式切换。全局 SCSS 变量通过 Vite 的 `additionalData` 注入。
|
||||
:::
|
||||
|
||||
## 样式架构
|
||||
|
||||
```
|
||||
src/styles/
|
||||
├── var.scss # SCSS 全局变量(通过 Vite additionalData 注入)
|
||||
├── index.scss # 入口文件,引入所有样式
|
||||
├── tailwindbase.css # Tailwind CSS 基础样式
|
||||
└── element-override.css # ElementPlus 主题覆盖(可选)
|
||||
```
|
||||
|
||||
## SCSS 全局变量
|
||||
|
||||
Vite 配置中通过 `additionalData` 注入全局 SCSS 变量:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@import "src/styles/var.scss";`,
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
这意味着 `var.scss` 中定义的变量在所有 `.vue` 和 `.scss` 文件中自动可用,无需手动 `@import`。
|
||||
|
||||
## Tailwind CSS
|
||||
|
||||
项目使用 Tailwind CSS 提供原子化样式能力,基础样式通过 `tailwindbase.css` 引入:
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
import './styles/tailwindbase.css';
|
||||
```
|
||||
|
||||
### 常用类名
|
||||
|
||||
```vue
|
||||
<!-- 间距 -->
|
||||
<div class="p-4 m-2">padding 1rem, margin 0.5rem</div>
|
||||
|
||||
<!-- 布局 -->
|
||||
<div class="flex items-center justify-between">Flex 布局</div>
|
||||
|
||||
<!-- 文字 -->
|
||||
<p class="text-sm text-gray-500 font-bold">文字样式</p>
|
||||
|
||||
<!-- 响应式 -->
|
||||
<div class="w-full md:w-1/2 lg:w-1/3">响应式宽度</div>
|
||||
```
|
||||
|
||||
## ElementPlus 主题
|
||||
|
||||
ElementPlus 通过 CSS 变量支持主题定制:
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
import 'element-plus/dist/index.css';
|
||||
import 'element-plus/theme-chalk/display.css';
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'; // 深色模式 CSS 变量
|
||||
```
|
||||
|
||||
### ElementPlus 全局引入
|
||||
|
||||
```typescript
|
||||
// src/plugins/setupElement.ts
|
||||
import ElementPlus from 'element-plus';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
|
||||
export function setupElement(app) {
|
||||
app.use(ElementPlus, { locale: zhCn, size: 'default' });
|
||||
}
|
||||
```
|
||||
|
||||
## 深色模式
|
||||
|
||||
通过 ElementPlus 的 `dark/css-vars.css` 和 Tailwind 的 `dark` 模式实现深色主题:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-switch v-model="isDark" @change="toggleDark" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const isDark = ref(false);
|
||||
const toggleDark = (val) => {
|
||||
document.documentElement.classList.toggle('dark', val);
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
深色模式样式:
|
||||
|
||||
```css
|
||||
.dark {
|
||||
--el-bg-color: #1d1e1f;
|
||||
--el-text-color-primary: #e5eaf3;
|
||||
--el-border-color: #4c4d4f;
|
||||
}
|
||||
```
|
||||
|
||||
## 设计配置 Store
|
||||
|
||||
深色模式和主题色通过 `designSetting` store 管理:
|
||||
|
||||
```typescript
|
||||
// src/hooks/setting/useDesignSetting.ts
|
||||
|
||||
export function useDesignSetting() {
|
||||
return {
|
||||
getAppTheme: computed(() => designStore.appTheme),
|
||||
getDarkTheme: computed(() => designStore.darkTheme),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 页面样式约定
|
||||
|
||||
### PageWrapper
|
||||
|
||||
页面使用 `PageWrapper` 组件包裹,提供统一的页面容器样式:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<PageWrapper>
|
||||
<el-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<!-- 搜索区域 -->
|
||||
</el-card>
|
||||
<el-card :bordered="false" class="proCard">
|
||||
<!-- 表格区域 -->
|
||||
</el-card>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
```
|
||||
|
||||
### proCard 样式
|
||||
|
||||
`proCard` 是项目定义的卡片样式类,用于统一卡片组件的间距和边框。
|
||||
|
||||
:::tip 温馨提示
|
||||
优先使用 Tailwind 原子类实现样式,避免编写自定义 CSS。需要全局覆盖时使用 CSS 变量。SCSS 变量通过 Vite `additionalData` 自动注入,无需每个文件手动引入。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
样式方案采用 Tailwind CSS 提供原子化样式 + SCSS 全局变量(Vite `additionalData` 注入)+ ElementPlus 主题覆盖,支持深色模式切换。`designSetting` store 管理主题配置,`PageWrapper` 组件提供统一页面容器。
|
||||
@@ -0,0 +1,266 @@
|
||||
# BasicTable 表格
|
||||
|
||||
:::tip 说明
|
||||
`BasicTable` 位于 `src/components/Table/src/Table.vue`,基于 ElementPlus 的 `el-table` 二次封装,提供列定义、数据请求、分页、操作列、多选、全屏、密度调整、列设置等能力。
|
||||
:::
|
||||
|
||||
## 组件架构
|
||||
|
||||
```
|
||||
src/components/Table/
|
||||
├── src/
|
||||
│ ├── Table.vue # 主组件
|
||||
│ ├── props.ts # 属性定义
|
||||
│ ├── types/table.ts # 类型定义
|
||||
│ ├── hooks/
|
||||
│ │ ├── useLoading.ts # loading 状态
|
||||
│ │ ├── useColumns.ts # 列管理
|
||||
│ │ ├── useDataSource.ts # 数据源管理
|
||||
│ │ ├── usePagination.ts # 分页管理
|
||||
│ │ └── useTableContext.ts # 表格上下文
|
||||
│ └── components/
|
||||
│ ├── Render.tsx # 渲染函数组件
|
||||
│ └── settings/
|
||||
│ └── ColumnSetting.vue # 列设置面板
|
||||
├── index.ts # 导出 TableAction 等
|
||||
└── src/TableAction.vue # 操作列按钮组件
|
||||
```
|
||||
|
||||
## 基本用法
|
||||
|
||||
```vue
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">添加岗位</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
```
|
||||
|
||||
## 列定义
|
||||
|
||||
`columns` 定义表格列,支持 `render` 函数自定义渲染:
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/columns.ts
|
||||
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
export const columns = [
|
||||
{ type: 'selection' },
|
||||
{ label: 'ID', prop: 'id', fixed: 'left', width: 50 },
|
||||
{ label: '岗位名称', prop: 'name', minWidth: 100 },
|
||||
{
|
||||
label: '岗位状态',
|
||||
prop: 'status',
|
||||
minWidth: 100,
|
||||
render(record) {
|
||||
return h(
|
||||
ElTag,
|
||||
{ type: record.row.status == 1 ? 'success' : 'danger' },
|
||||
{ default: () => (record.row.status == 1 ? '正常' : '停用') },
|
||||
);
|
||||
},
|
||||
},
|
||||
{ label: '排序', prop: 'sort', minWidth: 100 },
|
||||
{ label: '创建人', prop: 'createUser', minWidth: 100 },
|
||||
{ label: '创建时间', prop: 'createTime', width: 180 },
|
||||
];
|
||||
```
|
||||
|
||||
### 列配置项
|
||||
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `prop` | string | 字段名 |
|
||||
| `label` | string | 列标题 |
|
||||
| `width` | number | 固定宽度 |
|
||||
| `minWidth` | number | 最小宽度 |
|
||||
| `type` | string | 列类型:`selection` / `index` |
|
||||
| `fixed` | string | 固定列:`left` / `right` |
|
||||
| `render` | function | 自定义渲染函数,接收 `{ row, column, index }` |
|
||||
| `isSlot` | boolean | 是否使用插槽渲染 |
|
||||
| `ellipsis` | boolean | 是否溢出省略 |
|
||||
|
||||
`render` 函数中 `record.row` 是行数据,`record.$index` 是行索引。
|
||||
|
||||
## 数据请求
|
||||
|
||||
`request` 属性传入数据请求函数,组件自动管理分页和 loading:
|
||||
|
||||
```typescript
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getPositionList({ ...formParams, ...res });
|
||||
return result; // 返回 { data, count } 结构
|
||||
};
|
||||
```
|
||||
|
||||
内部通过 `useDataSource` Hook 实现:自动传入 `pageNo`、`pageSize` 参数,自动设置 `total`,自动管理 loading 状态。
|
||||
|
||||
## 操作列
|
||||
|
||||
`actionColumn` 使用 `TableAction` 组件渲染行操作按钮,支持权限控制:
|
||||
|
||||
```typescript
|
||||
import { TableAction } from '@/components/Table';
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
label: '操作',
|
||||
prop: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '编辑',
|
||||
icon: 'Edit',
|
||||
type: 'warning',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: ['sys:position:update'], // 权限节点
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'Delete',
|
||||
type: 'danger',
|
||||
onClick: handleDelete.bind(null, record),
|
||||
auth: ['sys:position:delete'], // 权限节点
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`TableAction` 的 `auth` 属性接受权限数组,内部通过 `usePermission` Hook 检查,无权限时自动隐藏按钮。
|
||||
|
||||
## 多选
|
||||
|
||||
通过 `selection-change` 事件获取选中行:
|
||||
|
||||
```typescript
|
||||
const selectionData = ref([]);
|
||||
|
||||
function onSelectionChange(value) {
|
||||
selectionData.value = value;
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
async function handleDelete(record) {
|
||||
let ids = [];
|
||||
if (!record) {
|
||||
ids = selectionData.value.map(({ id }) => id);
|
||||
}
|
||||
|
||||
await confirm('确定要删除?');
|
||||
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
|
||||
message('删除成功');
|
||||
reloadTable();
|
||||
}
|
||||
```
|
||||
|
||||
## 刷新表格
|
||||
|
||||
通过 ref 调用 `reload` 或 `reloadTable` 方法:
|
||||
|
||||
```typescript
|
||||
const tableRef = ref();
|
||||
|
||||
// 刷新到第一页
|
||||
function reloadTable(noRefresh = '') {
|
||||
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
|
||||
// 操作成功后刷新
|
||||
const handleAdd = async () => {
|
||||
await positionAdd(data);
|
||||
message('操作成功');
|
||||
reloadTable();
|
||||
};
|
||||
```
|
||||
|
||||
## 表格工具栏
|
||||
|
||||
BasicTable 内置右侧工具栏,支持:
|
||||
|
||||
- 刷新按钮(redo)
|
||||
- 密度切换(small / default / large)
|
||||
- 列设置面板(ColumnSetting)
|
||||
- 全屏切换
|
||||
- 斑马纹开关
|
||||
|
||||
通过 `showTableSetting` 和 `tableSetting` 属性控制:
|
||||
|
||||
```vue
|
||||
<BasicTable
|
||||
:showTableSetting="true"
|
||||
:tableSetting="{ redo: true, size: true, setting: true, fullscreen: true, striped: true }"
|
||||
/>
|
||||
```
|
||||
|
||||
## 搜索表单联动
|
||||
|
||||
列表页中 BasicForm 和 BasicTable 的典型联动模式:
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/index.vue
|
||||
|
||||
const formParams = reactive({ name: '', status: '' });
|
||||
|
||||
const [register, {}] = useForm({
|
||||
labelWidth: 80,
|
||||
layout: 'horizontal',
|
||||
colProps: { span: 6 } as ColProps,
|
||||
submitOnReset: true,
|
||||
schemas, // querySchemas.ts 中定义
|
||||
});
|
||||
|
||||
// 表单提交:更新查询参数,刷新表格
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) { formParams[key] = values[key]; }
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
// 表单重置:清空查询参数,刷新表格
|
||||
function handleReset() {
|
||||
for (const key in formParams) { formParams[key] = ''; }
|
||||
}
|
||||
|
||||
// 数据请求:合并表单参数和分页参数
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getPositionList({ ...formParams, ...res });
|
||||
return result;
|
||||
};
|
||||
```
|
||||
|
||||
## defineExpose
|
||||
|
||||
组件通过 `defineExpose` 暴露的方法:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `reload(opt?)` | 刷新表格(可指定 pageNo) |
|
||||
| `restReload()` | 重置后刷新 |
|
||||
| `reloadTable(opt?)` | 刷新表格(清空选中行) |
|
||||
| `clearSelection()` | 清空多选 |
|
||||
| `getDataSource()` | 获取当前数据源 |
|
||||
| `setTableData(data)` | 设置表格数据 |
|
||||
| `setColumns(columns)` | 设置列 |
|
||||
| `setLoading(loading)` | 设置 loading |
|
||||
|
||||
:::tip 温馨提示
|
||||
BasicTable 内部自动处理分页参数(pageNo/pageSize),调用方只需提供请求函数。操作列的权限控制通过 `TableAction` 的 `auth` 属性实现,与后端 `@permission_required` 对应。`render` 函数使用 Vue 的 `h()` 函数创建虚拟 DOM。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
BasicTable 封装了列定义(含 `render` 函数自定义渲染)、数据请求(自动分页和 loading)、操作列(`TableAction` + 权限控制)、多选、全屏、密度、列设置等表格常用能力。通过 `columns` 配置化定义列,`request` 函数自动管理数据加载,`actionColumn` 配置操作按钮。
|
||||
@@ -0,0 +1,241 @@
|
||||
# 工具函数
|
||||
|
||||
:::tip 说明
|
||||
前端工具函数位于 `src/utils/` 目录,提供认证管理、本地存储、日期格式化、树结构操作、文件下载等通用能力。
|
||||
:::
|
||||
|
||||
## auth.ts
|
||||
|
||||
认证与通用工具函数,位于 `src/utils/auth.ts`:
|
||||
|
||||
```typescript
|
||||
// 外部链接判断
|
||||
export function isExternal(path: string) {
|
||||
return /^(https?:|mailto:|tel:)/.test(path);
|
||||
}
|
||||
|
||||
// 路径规范化
|
||||
export function getNormalPath(path: string) {
|
||||
if (path.length === 0 || !path || path == 'undefined') return path;
|
||||
const newPath = path.replace('//', '/');
|
||||
return newPath[newPath.length - 1] === '/' ? newPath.slice(0, -1) : newPath;
|
||||
}
|
||||
|
||||
// 确认弹窗封装
|
||||
export function confirm(msg: string, type: 'warning') {
|
||||
return ElMessageBox.confirm(msg, '温馨提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: type,
|
||||
});
|
||||
}
|
||||
|
||||
// 消息提示封装
|
||||
export function message(msg: string, type = 'success') {
|
||||
ElMessage[type](msg);
|
||||
}
|
||||
|
||||
// 文件流下载
|
||||
export function streamFileDownload(file: any, fileName = '文件名称.zip') {
|
||||
const blob = new Blob([file], { type: 'application/octet-stream;charset=UTF-8' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', fileName);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
```
|
||||
|
||||
### 树结构操作
|
||||
|
||||
`auth.ts` 中还包含树结构转换工具:
|
||||
|
||||
```typescript
|
||||
// 数组转树
|
||||
export const arrayToTree = (
|
||||
data: any[],
|
||||
props = { id: 'id', parentId: 'pid', children: 'children' },
|
||||
) => {
|
||||
data = cloneDeep(data);
|
||||
const { id, parentId, children } = props;
|
||||
const result: any[] = [];
|
||||
const map = new Map();
|
||||
data.forEach((item) => {
|
||||
map.set(item[id], item);
|
||||
const parent = map.get(item[parentId]);
|
||||
if (parent) {
|
||||
parent[children] = parent[children] ?? [];
|
||||
parent[children].push(item);
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
// 树转数组(广度优先)
|
||||
export const treeToArray = (data: any[], props = { children: 'children' }) => {
|
||||
data = cloneDeep(data);
|
||||
const newData = [];
|
||||
const queue: any[] = [];
|
||||
data.forEach((child) => queue.push(child));
|
||||
while (queue.length) {
|
||||
const item = queue.shift();
|
||||
if (item[children]) {
|
||||
item[children].forEach((child) => queue.push(child));
|
||||
delete item[children];
|
||||
}
|
||||
newData.push(item);
|
||||
}
|
||||
return newData;
|
||||
};
|
||||
|
||||
// 递归查找路径
|
||||
export const findTreeByPath = (tree, path, result = []) => {
|
||||
for (const node of tree) {
|
||||
if (node.path === path) result.push(node);
|
||||
if (node.children?.length) findTreeByPath(node.children, path, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
```
|
||||
|
||||
## Storage.ts
|
||||
|
||||
本地存储封装,支持过期时间和 cookie,位于 `src/utils/Storage.ts`:
|
||||
|
||||
```typescript
|
||||
const DEFAULT_CACHE_TIME = 7 * 24 * 60 * 60 * 1000; // 7 天
|
||||
|
||||
export const createStorage = ({ prefixKey = '', storage = localStorage } = {}) => {
|
||||
const Storage = class {
|
||||
private storage = storage;
|
||||
private prefixKey?: string = prefixKey;
|
||||
|
||||
private getKey(key: string) {
|
||||
return `${this.prefixKey}${key}`.toUpperCase(); // 自动大写
|
||||
}
|
||||
|
||||
// 设置缓存(支持过期时间)
|
||||
set(key: string, value: any, expire: number | null = DEFAULT_CACHE_TIME) {
|
||||
const stringData = JSON.stringify({
|
||||
value,
|
||||
expire: expire !== null ? Date.now() + expire : null,
|
||||
});
|
||||
this.storage.setItem(this.getKey(key), stringData);
|
||||
}
|
||||
|
||||
// 读取缓存(过期自动删除)
|
||||
get(key: string, def: any = null) {
|
||||
const item = this.storage.getItem(this.getKey(key));
|
||||
if (item) {
|
||||
try {
|
||||
const data = JSON.parse(item);
|
||||
const { value, expire } = data;
|
||||
if (expire === null || expire >= Date.now()) return value;
|
||||
this.remove(key); // 过期删除
|
||||
} catch (e) { return def; }
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
remove(key: string) { this.storage.removeItem(this.getKey(key)); }
|
||||
clear(): void { this.storage.clear(); }
|
||||
};
|
||||
return new Storage();
|
||||
};
|
||||
|
||||
export const storage = createStorage();
|
||||
```
|
||||
|
||||
Store mutation-types 定义了存储键名:
|
||||
|
||||
```typescript
|
||||
// src/store/mutation-types.ts
|
||||
export const ACCESS_TOKEN = 'ACCESS-TOKEN'; // 用户 token
|
||||
export const CURRENT_USER = 'CURRENT-USER'; // 当前用户信息
|
||||
export const IS_LOCKSCREEN = 'IS-LOCKSCREEN'; // 是否锁屏
|
||||
export const TABS_ROUTES = 'TABS-ROUTES'; // 标签页
|
||||
export const FIRST_ROUTE = 'FIRST-ROUTE'; // 菜单第一个路由
|
||||
```
|
||||
|
||||
## dateUtil.ts
|
||||
|
||||
日期格式化工具,基于 dayjs,位于 `src/utils/dateUtil.ts`:
|
||||
|
||||
```typescript
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm';
|
||||
const DATE_FORMAT = 'YYYY-MM-DD ';
|
||||
|
||||
export function formatToDateTime(date: Date, formatStr = DATE_TIME_FORMAT): string {
|
||||
return dayjs(date).format(formatStr);
|
||||
}
|
||||
|
||||
export function formatToDate(date: Date, formatStr = DATE_FORMAT): string {
|
||||
return dayjs(date).format(formatStr);
|
||||
}
|
||||
```
|
||||
|
||||
`dateUtil` 也被 `vite.config.ts` 使用,记录构建时间戳:
|
||||
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
import { formatToDateTime } from './src/utils/dateUtil';
|
||||
const __APP_INFO__ = {
|
||||
pkg: { dependencies, devDependencies, name, version },
|
||||
lastBuildTime: formatToDateTime(new Date()),
|
||||
};
|
||||
```
|
||||
|
||||
## httpEnum.ts
|
||||
|
||||
HTTP 相关枚举定义,位于 `src/enums/httpEnum.ts`:
|
||||
|
||||
```typescript
|
||||
export enum ResultEnum {
|
||||
SUCCESS = 0, // 业务成功
|
||||
ERROR = 1, // 业务失败
|
||||
TIMEOUT = 401, // 登录超时
|
||||
}
|
||||
|
||||
export enum RequestEnum {
|
||||
GET = 'GET',
|
||||
POST = 'POST',
|
||||
PUT = 'PUT',
|
||||
DELETE = 'DELETE',
|
||||
}
|
||||
|
||||
export enum ContentTypeEnum {
|
||||
JSON = 'application/json;charset=UTF-8',
|
||||
FORM_URLENCODED = 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
FORM_DATA = 'multipart/form-data;charset=UTF-8',
|
||||
}
|
||||
```
|
||||
|
||||
## pageEnum.ts
|
||||
|
||||
页面路径枚举,位于 `src/enums/pageEnum.ts`:
|
||||
|
||||
```typescript
|
||||
export enum PageEnum {
|
||||
BASE_LOGIN = '/login',
|
||||
BASE_LOGIN_NAME = 'Login',
|
||||
REDIRECT = '/redirect',
|
||||
BASE_HOME = '/dashboard',
|
||||
BASE_HOME_REDIRECT = '/dashboard/console',
|
||||
ERROR_PAGE_NAME = 'ErrorPage',
|
||||
}
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`Storage` 工具类自动将键名转为大写(如 `access-token` → `ACCESS-TOKEN`),存储时包含过期时间,读取时自动检查是否过期。`auth.ts` 中的 `confirm` 和 `message` 函数是对 ElementPlus 的简封装,在业务组件中直接调用即可。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
工具函数库提供了 Storage(本地存储,支持过期)、auth(认证管理、树结构操作、文件下载)、dateUtil(日期格式化)、httpEnum/pageEnum(枚举定义)等通用能力。`confirm` 和 `message` 是 ElementPlus 的简封装,`arrayToTree`/`treeToArray` 处理菜单数据转换。
|
||||
@@ -0,0 +1,471 @@
|
||||
# 页面视图开发
|
||||
|
||||
:::tip 说明
|
||||
页面视图位于 `src/views/` 目录,每个业务模块遵循 `index.vue`(列表页)+ `edit.vue`(编辑弹窗)的标准模式,辅以 `columns.ts`(列定义)和 `querySchemas.ts`(搜索表单)配置文件。
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/views/
|
||||
├── system/
|
||||
│ ├── position/
|
||||
│ │ ├── index.vue # 岗位列表页
|
||||
│ │ ├── edit.vue # 岗位编辑弹窗
|
||||
│ │ ├── columns.ts # 表格列定义
|
||||
│ │ └── querySchemas.ts # 搜索表单定义
|
||||
│ ├── user/
|
||||
│ │ ├── index.vue
|
||||
│ │ └── edit.vue
|
||||
│ ├── role/
|
||||
│ │ ├── index.vue
|
||||
│ │ └── edit.vue
|
||||
│ └── ...
|
||||
├── cms/
|
||||
│ ├── article/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 文件职责
|
||||
|
||||
| 文件 | 职责 | 说明 |
|
||||
|------|------|------|
|
||||
| `index.vue` | 列表页 | 搜索表单 + 工具栏 + BasicTable + 编辑弹窗引用 |
|
||||
| `edit.vue` | 编辑弹窗 | el-dialog + el-form,懒加载 |
|
||||
| `columns.ts` | 表格列定义 | 定义列 prop、label、render 函数等 |
|
||||
| `querySchemas.ts` | 搜索表单定义 | FormSchema 数组,定义搜索字段 |
|
||||
|
||||
## index.vue 列表页
|
||||
|
||||
以岗位管理为例,完整结构如下:
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/index.vue -->
|
||||
<template>
|
||||
<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>
|
||||
<el-button type="primary" @click="handleAdd" v-perm="['sys:position:add']">
|
||||
<template #icon>
|
||||
<el-icon class="el-input__icon">
|
||||
<PlusOutlined />
|
||||
</el-icon>
|
||||
</template>
|
||||
添加岗位
|
||||
</el-button>
|
||||
<el-button
|
||||
type="danger"
|
||||
@click="handleDelete()"
|
||||
:disabled="!selectionData.length"
|
||||
v-perm="['sys:position:batchDelete']"
|
||||
>
|
||||
<template #icon>
|
||||
<el-icon class="el-input__icon">
|
||||
<Delete />
|
||||
</el-icon>
|
||||
</template>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</el-card>
|
||||
|
||||
<editDialog
|
||||
v-if="editVisible"
|
||||
:positionId="positionId"
|
||||
v-model:visible="editVisible"
|
||||
@success="reloadTable('noRefresh')"
|
||||
/>
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, h, nextTick, defineAsyncComponent } from 'vue';
|
||||
import { ColProps } from 'element-plus';
|
||||
import { schemas } from './querySchemas';
|
||||
import { useForm } from '@/components/Form/index';
|
||||
import { TableAction } from '@/components/Table';
|
||||
import { getPositionList, positionDelete, positionBatchDelete } from '@/api/system/position';
|
||||
import { columns } from './columns';
|
||||
import { PlusOutlined } from '@vicons/antd';
|
||||
import { message, confirm } from '@/utils/auth';
|
||||
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
|
||||
const positionId = ref(0);
|
||||
const editVisible = ref(false);
|
||||
const selectionData = ref([]);
|
||||
const tableRef = ref();
|
||||
|
||||
/**
|
||||
* 定义查询参数
|
||||
*/
|
||||
const formParams = reactive({
|
||||
name: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
/**
|
||||
* 定义操作栏
|
||||
*/
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
label: '操作',
|
||||
prop: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '编辑',
|
||||
icon: 'Edit',
|
||||
type: 'warning',
|
||||
onClick: handleEdit.bind(null, record),
|
||||
auth: ['sys:position:update'],
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
icon: 'Delete',
|
||||
type: 'danger',
|
||||
onClick: handleDelete.bind(null, record),
|
||||
auth: ['sys:position:delete'],
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 加载数据列表
|
||||
* @param res 参数
|
||||
*/
|
||||
const loadDataTable = async (res: any) => {
|
||||
const result = await getPositionList({ ...formParams, ...res });
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 刷新数据列表
|
||||
* @param noRefresh 参数
|
||||
*/
|
||||
function reloadTable(noRefresh = '') {
|
||||
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
const [register, {}] = useForm({
|
||||
labelWidth: 80,
|
||||
layout: 'horizontal',
|
||||
colProps: { span: 6 } as ColProps,
|
||||
submitOnReset: true,
|
||||
schemas,
|
||||
});
|
||||
|
||||
/**
|
||||
* 执行提交表单
|
||||
*/
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) {
|
||||
formParams[key] = values[key];
|
||||
}
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行重置
|
||||
*/
|
||||
function handleReset() {
|
||||
for (const key in formParams) {
|
||||
formParams[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行添加
|
||||
*/
|
||||
const handleAdd = async () => {
|
||||
positionId.value = 0;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行编辑
|
||||
* @param record 参数
|
||||
*/
|
||||
const handleEdit = async (record: Recordable) => {
|
||||
positionId.value = record.row.id;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* 执行删除
|
||||
* @param record 参数
|
||||
*/
|
||||
async function handleDelete(record: Recordable) {
|
||||
let ids = [];
|
||||
if (!record) {
|
||||
ids = selectionData.value.map(({ id }) => id);
|
||||
}
|
||||
await confirm('确定要删除?');
|
||||
record ? await positionDelete(record.row.id) : await positionBatchDelete(ids);
|
||||
message('删除成功');
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
/**
|
||||
* 选项发生变化
|
||||
* @param value 参数
|
||||
*/
|
||||
function onSelectionChange(value) {
|
||||
selectionData.value = value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
```
|
||||
|
||||
## edit.vue 编辑弹窗
|
||||
|
||||
```vue
|
||||
<!-- src/views/system/position/edit.vue -->
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="props.visible"
|
||||
:title="props.positionId ? '编辑' : '新增'"
|
||||
width="500"
|
||||
:close-on-click-modal="false"
|
||||
:before-close="dialogClose"
|
||||
>
|
||||
<el-form class="ls-form" ref="formRef" :model="formData" label-width="80px">
|
||||
<el-form-item
|
||||
label="岗位名称"
|
||||
prop="name"
|
||||
:rules="{ required: true, message: '请输入岗位名称', trigger: 'blur' }"
|
||||
>
|
||||
<el-input class="ls-input" v-model="formData.name" placeholder="请输入岗位名称" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位状态" prop="status">
|
||||
<el-radio-group v-model="formData.status" name="status">
|
||||
<el-radio :value="1">正常</el-radio>
|
||||
<el-radio :value="2">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="dialogClose">取消</el-button>
|
||||
<el-button :loading="subLoading" type="primary" @click="submit"> 确定 </el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import { getPositionDetail, positionAdd, positionUpdate } from '@/api/system/position';
|
||||
import { onMounted, reactive, shallowRef } from 'vue';
|
||||
import { message } from '@/utils/auth';
|
||||
import { useLockFn } from '@/utils/useLockFn';
|
||||
|
||||
const emit = defineEmits(['success', 'update:visible']);
|
||||
const formRef = shallowRef<FormInstance>();
|
||||
|
||||
/**
|
||||
* 定义表单参数
|
||||
*/
|
||||
const formData = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* 定义接收的参数
|
||||
*/
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
positionId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 执行提交表单
|
||||
*/
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate();
|
||||
props.positionId ? await positionUpdate(formData) : await positionAdd(formData);
|
||||
message('操作成功');
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭窗体
|
||||
*/
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
|
||||
|
||||
/**
|
||||
* 设置表单数据
|
||||
*/
|
||||
const setFormData = async () => {
|
||||
const data = await getPositionDetail(props.positionId);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null && data[key] != undefined) {
|
||||
//@ts-ignore
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 钩子函数
|
||||
*/
|
||||
onMounted(() => {
|
||||
if (props.positionId) {
|
||||
setFormData();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</script>
|
||||
```
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
页面加载 → BasicForm 注册 → BasicTable request 触发 → loadDataTable → API → 渲染表格
|
||||
用户搜索 → handleSubmit → 更新 formParams → reloadTable → 重新请求
|
||||
用户新增 → handleAdd → positionId=0 → editVisible=true → edit.vue 加载 → 表单填写 → 提交
|
||||
用户编辑 → handleEdit → positionId=id → editVisible=true → edit.vue 加载详情 → 修改 → 提交
|
||||
用户删除 → confirm → API → reloadTable
|
||||
```
|
||||
|
||||
## columns.ts
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/columns.ts
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
type: 'selection',
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
prop: 'id',
|
||||
fixed: 'left',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
label: '岗位名称',
|
||||
prop: 'name',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '岗位状态',
|
||||
prop: 'status',
|
||||
minWidth: 100,
|
||||
render(record) {
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
type: record.row.status == 1 ? 'success' : 'danger',
|
||||
},
|
||||
{
|
||||
default: () => (record.row.status == 1 ? '正常' : '停用'),
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '排序',
|
||||
prop: 'sort',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '创建人',
|
||||
prop: 'createUser',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'createTime',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
## querySchemas.ts
|
||||
|
||||
```typescript
|
||||
// src/views/system/position/querySchemas.ts
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
label: '岗位名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入岗位名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
options: [
|
||||
{
|
||||
label: '正常',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
label: '禁用',
|
||||
value: '2',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
:::tip 温馨提示
|
||||
`columns.ts` 和 `querySchemas.ts` 独立为配置文件,便于维护和复用。`columns` 中的 `render` 函数使用 Vue 的 `h()` 创建虚拟 DOM,可实现任意复杂的自定义渲染。`querySchemas` 中的 `options` 硬编码或通过字典 API 动态获取。
|
||||
:::
|
||||
|
||||
## 总结
|
||||
|
||||
页面视图遵循 `index.vue` + `edit.vue` + `columns.ts` + `querySchemas.ts` 标准模式。列表页使用 `BasicForm` 搜索 + `BasicTable` 展示数据 + `TableAction` 操作按钮,编辑弹窗使用 `defineAsyncComponent` 懒加载 + `useLockFn` 防重复提交。数据流清晰:搜索更新参数 → 刷新表格,操作成功 → emit 事件 → 刷新表格。
|
||||
Reference in New Issue
Block a user