签入版本

This commit is contained in:
2026-09-29 09:49:04 +08:00
commit 04eec2664c
210 changed files with 37515 additions and 0 deletions
@@ -0,0 +1,125 @@
# 9.13 API 模块编写规范
:::tip 概述
本节定义前端 API 模块的编写规范,包括命名规范、请求方式和文件组织。
:::
## 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 文件名 | 小驼峰,与后端模块对应 | `example.ts` |
| 查询函数 | `get{Module}Page` / `get{Module}List` / `get{Module}Detail` | `getExamplePage` |
| 新增函数 | `{module}Add` | `exampleAdd` |
| 修改函数 | `{module}Update` | `exampleUpdate` |
| 删除函数 | `{module}Delete` / `{module}BatchDelete` | `exampleDelete` |
| 导入函数 | `{module}Import` | `exampleImport` |
| 导出函数 | `{module}Export` | `exampleExport` |
## 请求方式
| 操作 | HTTP 方法 | 参数传递 | 说明 |
|------|----------|----------|------|
| 查询 | GET | `params`(URL 参数) | 分页/列表/详情 |
| 新增 | POST | `data`(请求体) | JSON 格式 |
| 修改 | PUT | `data`(请求体) | JSON 格式,含 id |
| 删除 | DELETE | URL 参数或 `data` | 单条用 URL,批量用 data |
## 文件组织
```text
ui/src/api/
├── common/index.ts # 公共接口(字典、验证码、菜单等)
├── index.ts # 全局接口(登录、登出、刷新Token)
├── system/ # 系统管理模块
│ ├── user.ts
│ ├── role.ts
│ ├── menu.ts
│ ├── dept.ts
│ ├── position.ts
│ └── level.ts
├── content/ # 内容管理模块
│ ├── article.ts
│ ├── category.ts
│ └── link.ts
└── tool/ # 工具模块
└── example.ts
```
## 标准模板
```typescript
// ui/src/api/tool/example.ts
import { http } from '@/utils/http/axios';
// 分页查询
export function getExamplePage(params?) {
return http.request({ url: '/example/page', method: 'GET', params });
}
// 全量列表
export function getExampleList(params?) {
return http.request({ url: '/example/list', method: 'GET', params });
}
// 详情
export function getExampleDetail(id) {
return http.request({ url: '/example/detail/' + id, method: 'GET' });
}
// 新增
export function exampleAdd(data: any) {
return http.request({ url: '/example/add', method: 'POST', data });
}
// 修改
export function exampleUpdate(data: any) {
return http.request({ url: '/example/update', method: 'PUT', data });
}
// 删除
export function exampleDelete(id) {
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
// 批量删除
export function exampleBatchDelete(data: any) {
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}
```
## 常见扩展操作
| 操作 | 函数名示例 | HTTP 方法 |
|------|-----------|----------|
| 重置密码 | `userResetPwd(data)` | POST |
| 导入 | `{module}Import(data: FormData)` | POST(multipart/form-data) |
| 导出 | `{module}Export(params)` | GET |
| 刷新缓存 | `dictRefreshCache()` | GET |
| 获取菜单 | `getMenus()` | GET |
| 获取字典项 | `getDictItemList(code)` | GET |
## 组件中使用
```vue
<script setup lang="ts">
import { getExamplePage, exampleDelete } from '@/api/tool/example';
import { ElMessage, ElMessageBox } from 'element-plus';
const tableData = ref([]);
// 查询
const fetchData = async () => {
const res = await getExamplePage({ pageNo: 1, pageSize: 20 });
tableData.value = res.records;
};
// 删除
const handleDelete = async (id) => {
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' });
await exampleDelete(id);
ElMessage.success('删除成功');
fetchData();
};
</script>
```
@@ -0,0 +1,61 @@
# 9.2 目录结构详解
:::tip 概述
本节详细介绍前端项目的目录结构,帮助开发者快速定位代码和理解项目组织方式。
:::
```text
ui/src/
├── api/ # API 接口层(按业务域分组)
│ ├── common/ # 公共接口(登录/菜单/用户信息/城市)
│ ├── system/ # 系统管理(用户/角色/菜单/部门/岗位/职级/日志/租户)
│ ├── content/ # 内容管理(文章/分类/链接)
│ ├── data/ # 数据管理(城市/配置/字典/公告/参数)
│ ├── dashboard/ # 仪表盘(控制台/消息)
│ ├── file/ # 文件管理(文件模板)
│ ├── monitor/ # 监控管理(定时任务)
│ ├── region/ # 行政区划
│ ├── setting/ # 设置(个人资料/网站配置)
│ └── tool/ # 工具(示例/代码生成器)
├── components/ # 公共组件库(20+ 个)
│ ├── Table/ # 数据表格
│ ├── Form/ # 动态表单
│ ├── Modal/ # 弹窗
│ ├── Upload/ # 文件上传
│ ├── Editor/ # 富文本编辑器
│ ├── Excel/ # Excel 导入导出
│ ├── Cropper/ # 头像裁剪
│ ├── Region/ # 行政区划
│ ├── Password/ # 密码强度
│ ├── Qrcode/ # 二维码
│ └── ...
├── directives/ # 自定义指令
│ └── permission.ts # v-perm / v-perms 权限指令
├── enums/ # 枚举常量
│ ├── httpEnum.ts # HTTP 枚举
│ └── pageEnum.ts # 页面枚举
├── hooks/ # 组合式函数
├── layout/ # 布局组件
├── router/ # 路由配置
│ ├── index.ts # 路由实例
│ ├── base.ts # 静态路由
│ ├── generator-routers.ts # 动态路由生成
│ └── router-guards.ts # 路由守卫
├── store/ # Pinia 状态管理
│ └── modules/
│ ├── user.ts # 用户 Store
│ ├── asyncRoute.ts # 动态路由 Store
│ └── ...
├── styles/ # 全局样式
├── utils/ # 工具函数
│ └── http/ # HTTP 请求封装
└── views/ # 页面视图
├── login/ # 登录页
├── dashboard/ # 仪表盘
├── system/ # 系统管理页面
├── content/ # 内容管理页面
├── data/ # 数据管理页面
├── monitor/ # 监控管理页面
├── tool/ # 工具页面
└── exception/ # 异常页面
```
@@ -0,0 +1,91 @@
# 9.9 数据展示组件
:::tip 概述
本节介绍前端项目中常用的数据展示组件,包括表格(BasicTable)、页面容器(PageWrapper)和图标选择器(IconPicker)。
:::
## Table 表格组件
```vue
<template>
<BasicTable @register="registerTable" />
</template>
<script setup>
import { BasicTable, useTable } from '@/components/Table';
const [registerTable] = useTable({
api: getExamplePage, // 数据接口
columns: [ // 列定义
{ title: 'ID', field: 'id', width: 80 },
{ title: '名称', field: 'name' },
{ title: '状态', field: 'statusText', width: 100 },
{ title: '创建时间', field: 'createTime', width: 180 },
],
actionColumn: { // 操作列
width: 200,
actions: [
{ label: '编辑', onClick: handleEdit },
{ label: '删除', onClick: handleDelete, type: 'danger' },
],
},
});
</script>
```
## Page 页面容器
```vue
<template>
<PageWrapper>
<!-- 页面内容 -->
</PageWrapper>
</template>
<script setup>
import { PageWrapper } from '@/components/Page';
</script>
```
## Icon 图标选择器
```vue
<template>
<IconPicker v-model="formData.icon" />
</template>
<script setup>
import IconPicker from '@/components/icon/picker.vue';
</script>
```
:::warning 组件未实现
Description 描述列表组件尚未在项目中实现,以下为规划示例。
:::
## Description 描述列表
```vue
<template>
<Description :data="detailData" :schema="schema" />
</template>
<script setup>
import { Description } from '@/components/Description';
const schema = [
{ field: 'username', label: '用户名' },
{ field: 'realname', label: '姓名' },
{ field: 'statusText', label: '状态' },
{ field: 'createTime', label: '创建时间' },
];
const detailData = ref({});
</script>
```
## 相关文档
- [数据录入组件](/zh/frontend/form-components.html)
- [Excel 导入导出组件](/zh/frontend/excel-components.html)
- [前端组件速查](/zh/appendix/components.html)
@@ -0,0 +1,390 @@
# 9.3 环境与构建配置
:::tip 概述
本节说明前端项目的环境变量配置和 Vite 构建配置,包括开发环境和生产环境的差异配置。前端通过 `.env` 文件管理环境变量,Vite 自动根据模式加载对应的 `.env` 文件。
:::
## 环境变量文件
```text
ui/
├── .env # 所有环境通用配置
├── .env.development # 开发环境配置
├── .env.production # 生产环境配置
└── .env.local # 本地覆盖(不提交 git)
```
**加载优先级:** `.env.local` > `.env.{mode}` > `.env`
## 开发环境配置
### `.env.development`
```bash
# 开发服务器端口
VITE_PORT = 8001
# 公共基础路径(通常为 /)
VITE_PUBLIC_PATH = /
# API 接口基础路径(留空,由代理处理)
VITE_GLOB_API_URL =
# API 前缀(所有接口路径的前缀)
VITE_GLOB_API_URL_PREFIX = /api
# 代理配置(JSON 数组格式:[["前缀","目标地址"]])
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
# 上传接口地址
VITE_GLOB_UPLOAD_URL = /api/upload
# 图片访问地址(用于预览上传的图片)
VITE_GLOB_IMG_URL = http://file.thinkphp6.elevue
# 是否在生产环境删除 console(开发环境保留)
VITE_DROP_CONSOLE = false
# 是否启用 Mock 数据(false = 使用真实后端 API)
VITE_USE_MOCK = false
```
## 生产环境配置
### `.env.production`
```bash
# 公共基础路径(通常为 /,如部署到子目录则设为 /subdir/)
VITE_PUBLIC_PATH = /
# API 接口基础路径(留空,由 Nginx 代理处理)
VITE_GLOB_API_URL =
# API 前缀
VITE_GLOB_API_URL_PREFIX = /api
# 上传接口地址
VITE_GLOB_UPLOAD_URL = /api/upload
# 图片访问地址(生产环境使用正式域名)
VITE_GLOB_IMG_URL = https://cdn.example.com
# 是否在生产环境删除 console
VITE_DROP_CONSOLE = true
# 是否启用 gzip 压缩
VITE_BUILD_COMPRESS = gzip
# 压缩后是否删除原文件
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
```
## 环境变量详解
### VITE_PORT — 开发服务器端口
```bash
VITE_PORT = 8001
```
| 环境 | 值 | 说明 |
|------|-----|------|
| 开发 | `8001` | 前端开发服务器端口 |
| 生产 | 不生效 | 生产环境由 Nginx 提供服务 |
### VITE_PUBLIC_PATH — 公共基础路径
```bash
VITE_PUBLIC_PATH = /
```
| 场景 | 值 | 说明 |
|------|-----|------|
| 根目录部署 | `/` | 默认值 |
| 子目录部署 | `/admin/` | 如 `https://example.com/admin/` |
### VITE_GLOB_API_URL — API 基础路径
```bash
VITE_GLOB_API_URL =
```
| 场景 | 值 | 说明 |
|------|-----|------|
| 开发(代理) | 留空 | 由 Vite proxy 转发 |
| 生产(同域) | 留空 | 由 Nginx 代理 |
| 生产(跨域) | `https://api.example.com` | 直接请求后端域名 |
### VITE_PROXY — 开发代理配置
```bash
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
```
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
**作用:** 将前端 `/api/*` 请求代理到后端地址,解决开发环境跨域问题。
**多代理示例:**
```bash
VITE_PROXY = [["/api","http://localhost:8080/api"],["/upload","http://localhost:8080/upload"]]
```
### VITE_GLOB_API_URL_PREFIX — API 前缀
```bash
VITE_GLOB_API_URL_PREFIX = /api
```
所有 API 请求路径的前缀,与后端路由前缀一致。
### VITE_GLOB_UPLOAD_URL — 上传接口地址
```bash
VITE_GLOB_UPLOAD_URL = /api/upload
```
文件上传组件使用的接口地址。
### VITE_GLOB_IMG_URL — 图片访问地址
```bash
# 开发环境
VITE_GLOB_IMG_URL = http://file.thinkphp6.elevue
# 生产环境
VITE_GLOB_IMG_URL = https://cdn.example.com
```
用于预览上传的图片,拼接图片的完整 URL。
### VITE_DROP_CONSOLE — 删除 console
```bash
# 开发环境:保留 console(方便调试)
VITE_DROP_CONSOLE = false
# 生产环境:删除 console(安全+减小体积)
VITE_DROP_CONSOLE = true
```
### VITE_BUILD_COMPRESS — Gzip 压缩
```bash
VITE_BUILD_COMPRESS = gzip
```
启用 gzip 压缩后,构建产物会生成 `.gz` 文件,Nginx 配置 `gzip_static on` 可直接使用。
## 在代码中访问环境变量
```typescript
// 读取环境变量(VITE_ 前缀的变量才会暴露给客户端)
const apiUrl = import.meta.env.VITE_GLOB_API_URL;
const apiPrefix = import.meta.env.VITE_GLOB_API_URL_PREFIX;
const uploadUrl = import.meta.env.VITE_GLOB_UPLOAD_URL;
const imgUrl = import.meta.env.VITE_GLOB_IMG_URL;
// 完整 API 地址
const fullApiUrl = apiUrl + apiPrefix; // 如 "/api" 或 "https://api.example.com/api"
```
:::warning VITE_ 前缀
只有以 `VITE_` 开头的环境变量才会暴露给客户端代码,其他变量(如 `PORT`)不会被访问到。
:::
## Vite 配置
### `vite.config.ts`
```typescript
import { defineConfig, loadEnv } from 'vite';
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd());
return {
// 开发服务器
server: {
port: Number(env.VITE_PORT) || 8001,
proxy: {
'/api': {
target: 'http://admin.thinkphp6.elevue/api',
changeOrigin: true,
},
},
},
// 构建配置
build: {
// 生产环境删除 console
drop: env.VITE_DROP_CONSOLE === 'true' ? ['console', 'debugger'] : [],
// 启用 gzip 压缩
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
element: ['element-plus'],
},
},
},
},
};
});
```
### 代理配置详解
```typescript
// 开发环境代理配置
server: {
proxy: {
'/api': {
target: 'http://admin.thinkphp6.elevue/api', // 目标地址
changeOrigin: true, // 修改请求头 Origin
rewrite: (path) => path.replace(/^\/api/, ''), // 路径重写(可选)
},
},
}
```
**代理效果:**
```text
前端请求: GET /api/user/page
│
▼ Vite proxy
│
▼ 转发到: http://admin.thinkphp6.elevue/api/user/page
```
## 构建命令
```bash
# 开发服务器(热更新)
pnpm dev
# 构建生产版本
pnpm build
# 预览构建结果(本地启动静态服务器)
pnpm preview
# ESLint 检查
pnpm lint:eslint
# ESLint 自动修复
pnpm lint:eslint --fix
# TypeScript 类型检查
pnpm type:check
```
### 构建产物
```text
ui/dist/
├── index.html # 入口文件
├── assets/
│ ├── index-xxx.js # 主 JS(带 hash)
│ ├── index-xxx.css # 主 CSS(带 hash)
│ ├── vue-xxx.js # Vue 相关(分包)
│ └── element-xxx.js # Element Plus(分包)
├── favicon.ico
└── static/ # 静态资源
```
## 部署配置
### Nginx 配置
```nginx
server {
listen 80;
server_name admin.example.com;
# 前端静态文件
root /data/www/admin/dist;
index index.html;
# SPA 路由(所有非文件请求都返回 index.html)
location / {
try_files $uri $uri/ /index.html;
}
# API 代理到后端
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 静态资源缓存
location /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# Gzip 压缩
gzip on;
gzip_static on; # 优先使用 .gz 文件
gzip_types text/css application/javascript application/json;
}
```
## 多环境配置
### 测试环境
```bash
# .env.staging
VITE_GLOB_API_URL =
VITE_GLOB_API_URL_PREFIX = /api
VITE_GLOB_IMG_URL = https://test-cdn.example.com
VITE_DROP_CONSOLE = true
```
构建命令:
```bash
pnpm build --mode staging
```
### 环境变量对照表
| 变量 | 开发 | 测试 | 生产 |
|------|------|------|------|
| `VITE_PORT` | 8001 | - | - |
| `VITE_GLOB_API_URL` | 留空 | 留空 | 留空 |
| `VITE_GLOB_API_URL_PREFIX` | /api | /api | /api |
| `VITE_GLOB_IMG_URL` | http://file.xxx | https://test-cdn.xxx | https://cdn.xxx |
| `VITE_DROP_CONSOLE` | false | true | true |
| `VITE_USE_MOCK` | false | false | false |
## 常见问题
### 问题 1:开发环境 API 请求 404
**原因:** 代理配置错误或后端未启动。
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确。
### 问题 2:生产环境 API 请求跨域
**原因:** `VITE_GLOB_API_URL` 配置了跨域地址但后端未配置 CORS。
**解决:** 推荐留空,由 Nginx 代理处理(同域无跨域问题)。
### 问题 3:图片不显示
**原因:** `VITE_GLOB_IMG_URL` 配置错误。
**解决:** 检查图片访问地址是否正确,确保图片 URL 可直接访问。
### 问题 4:构建后体积过大
**原因:** 未启用 gzip 压缩或未分包。
**解决:** 启用 `VITE_BUILD_COMPRESS = gzip`,配置 `manualChunks` 分包。
@@ -0,0 +1,77 @@
# 9.11 Excel 导入导出组件
:::tip 概述
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法。
:::
## 导入组件
```vue
<template>
<el-button @click="handleImport">导入</el-button>
<ImportExcel @success="onImportSuccess" />
</template>
<script setup>
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
const onImportSuccess = (data) => {
// data 为解析后的数组,每项是对象
console.log(data);
};
</script>
```
## 导出功能
```typescript
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
const handleExport = () => {
jsonToSheetXlsx({
data: tableData.value,
header: {
name: '名称',
typeText: '类型',
statusText: '状态',
createTime: '创建时间',
},
filename: '导出数据.xlsx',
});
};
```
## 后端导出对接
```typescript
// 调用后端导出接口,返回文件 URL
const handleExport = async () => {
const url = await exampleExport(searchParams.value);
// 下载文件
window.open(url);
};
```
## 导入模板下载
```typescript
// 下载导入模板
const handleDownloadTemplate = async () => {
const url = await exampleExportTemplate();
window.open(url);
};
```
## 导入导出注意事项
| 场景 | 说明 |
|------|------|
| 大数据量导出 | 后端自动分批导出,避免内存溢出 |
| 导入模板 | 第一行为表头,第二行起为数据 |
| 字段映射 | 后端 `headerMap` 配置表头与字段的对应关系 |
| 枚举翻译 | 导入时自动将文本转为枚举值,导出时自动将枚举值转为文本 |
## 相关文档
- [数据导入导出](/zh/tools/excel.html)
- [上传配置详解](/zh/tools/upload-config.html)
@@ -0,0 +1,114 @@
# 9.10 数据录入组件
:::tip 概述
本节介绍前端项目中常用的数据录入组件,包括表单(BasicForm)、弹窗表单和上传组件的使用方法。
:::
## Form 表单组件
```vue
<template>
<BasicForm @register="registerForm" />
</template>
<script setup>
import { BasicForm, useForm } from '@/components/Form';
const [registerForm] = useForm({
schemas: [
{
field: 'name',
label: '名称',
component: 'Input',
required: true,
},
{
field: 'status',
label: '状态',
component: 'Select',
componentProps: {
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
],
});
</script>
```
## Modal 弹窗组件
```vue
<template>
<BasicModal @register="registerModal" @ok="handleSubmit" />
</template>
<script setup>
import { BasicModal, useModal } from '@/components/Modal';
const [registerModal, { openModal, closeModal }] = useModal();
// 打开弹窗
const handleAdd = () => {
openModal(true, { isUpdate: false });
};
// 提交
const handleSubmit = async () => {
const values = await getFormValues();
await exampleAdd(values);
closeModal();
};
</script>
```
## Upload 上传组件
```vue
<template>
<BasicUpload v-model="formData.avatar" :action="uploadUrl" />
</template>
<script setup>
import { BasicUpload } from '@/components/Upload';
const uploadUrl = '/api/upload/uploadFile';
</script>
```
## Editor 富文本编辑器
```vue
<template>
<Editor v-model="formData.content" />
</template>
<script setup>
import Editor from '@/components/Editor/index.vue';
</script>
```
## Password 密码强度组件
```vue
<template>
<Password v-model="formData.password" />
</template>
<script setup>
import { Password } from '@/components/Password';
</script>
```
## ChinaArea 行政区划组件
```vue
<template>
<ChinaArea v-model="formData.city" />
</template>
<script setup>
import ChinaArea from '@/components/ChinaArea/index.vue';
</script>
```
@@ -0,0 +1,90 @@
# 9.4 HTTP 请求封装
:::tip 概述
前端使用 Axios 封装 HTTP 请求,自动处理 Token 携带、响应码判断、错误提示。
:::
## 封装结构
```text
src/utils/http/axios/
├── index.ts # Axios 实例配置和拦截器
├── Axios.ts # VAxios 类封装
├── axiosTransform.ts # 数据转换接口
├── checkStatus.ts # HTTP 状态码处理
├── helper.ts # 辅助函数
└── types.ts # 类型定义
```
## 核心拦截器
### 请求拦截器(自动携带 Token)
```typescript
// src/utils/http/axios/index.ts
// 请求前自动添加 Token
config.headers['Authorization'] = `Bearer ${token}`;
```
### 响应拦截器(统一错误处理)
```typescript
// 响应处理逻辑
const { code, data, msg } = res.data;
if (code === 0) {
return data; // 成功,返回 data
}
if (code === 401) {
// Token 过期,跳转登录
router.push('/login');
}
// 其他错误,显示提示
ElMessage.error(msg || '操作失败');
```
:::tip 响应处理规则
- `code === 0`:成功,返回 `data`
- `code === 401`:Token 过期,自动跳转登录页
- `code === 403`:权限不足,显示错误提示
- 其它 `code`:业务失败,显示 `msg`
:::
## API 模块编写
```typescript
// src/api/tool/example.ts
import { http } from '@/utils/http/axios';
export function getExamplePage(params?: Record<string, any>) {
return http.request({
url: '/example/page',
method: 'GET',
params,
});
}
export function exampleAdd(data: any) {
return http.request({
url: '/example/add',
method: 'POST',
data,
});
}
```
## 在组件中使用
```typescript
import { getExamplePage, exampleAdd } from '@/api/tool/example';
// 获取列表
const res = await getExamplePage({ pageNo: 1, pageSize: 10 });
tableData.value = res.records;
// 新增
await exampleAdd({ name: '测试', status: 1 });
```
@@ -0,0 +1,208 @@
# 9.17 国际化(i18n)
:::danger 功能未实现
本章描述的国际化(i18n)方案为规划中的功能,核心依赖(`locales/` 目录、`LangSwitch` 组件、`locale` Store)尚未在项目中实现。请勿按本文档操作,等待后续版本发布。
:::
:::tip 概述
本节介绍前端项目的国际化方案,基于 vue-i18n 实现多语言切换。项目默认提供中文和英文两种语言,可按需扩展其他语言。
:::
## 技术方案
| 项目 | 说明 |
|------|------|
| 插件 | vue-i18n v9 |
| 默认语言 | 中文(zh-CN) |
| 支持语言 | zh-CN、en-US |
| 存储位置 | localStorage(持久化用户选择) |
## 目录结构
:::warning 下列目录尚未创建
`ui/src/locales/` 目录当前不存在,以下为规划结构。
:::
```text
ui/src/
├── locales/
│ ├── index.ts # i18n 初始化配置
│ ├── lang/
│ │ ├── zh-CN.ts # 中文语言包
│ │ └── en-US.ts # 英文语言包
│ └── helper.ts # 语言切换辅助函数
```
## 语言包结构
:::warning 下列文件尚不存在
`ui/src/locales/lang/zh-CN.ts` 当前不存在,以下为规划示例。
:::
```typescript
// ui/src/locales/lang/zh-CN.ts
export default {
common: {
search: '搜索',
reset: '重置',
add: '新增',
edit: '编辑',
delete: '删除',
export: '导出',
import: '导入',
confirm: '确认',
cancel: '取消',
success: '操作成功',
failed: '操作失败',
},
table: {
id: 'ID',
name: '名称',
status: '状态',
createTime: '创建时间',
action: '操作',
},
login: {
username: '用户名',
password: '密码',
captcha: '验证码',
submit: '登录',
rememberMe: '记住我',
},
};
```
## 使用方式
### 模板中使用
```vue
<template>
<div>
<!-- 基础翻译 -->
<el-button>{{ $t('common.search') }}</el-button>
<el-button>{{ $t('common.reset') }}</el-button>
<!-- 带参数翻译 -->
<p>{{ $t('message.welcome', { name: 'Admin' }) }}</p>
</div>
</template>
```
### Script 中使用
```vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
// 翻译
const searchText = t('common.search');
// 切换语言
const switchLang = (lang: string) => {
locale.value = lang;
localStorage.setItem('lang', lang);
};
</script>
```
### 列定义中使用
```vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const columns = computed(() => [
{ title: t('table.id'), field: 'id', width: 80 },
{ title: t('table.name'), field: 'name' },
{ title: t('table.status'), field: 'statusText', width: 100 },
{ title: t('table.createTime'), field: 'createTime', width: 180 },
]);
</script>
```
## 语言切换组件
:::warning 下列组件和 Store 尚不存在
`ui/src/components/LangSwitch/` 和 `ui/src/store/modules/locale.ts` 当前不存在,以下为规划示例。
:::
```vue
<!-- ui/src/components/LangSwitch/index.vue -->
<template>
<el-dropdown @command="switchLang">
<el-button :icon="Icon" circle />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
<el-dropdown-item command="en-US">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useLocaleStore } from '@/store/modules/locale';
const { locale } = useI18n();
const localeStore = useLocaleStore();
const switchLang = (lang: string) => {
locale.value = lang;
localeStore.setLocale(lang);
};
</script>
```
## 后端消息国际化
后端错误消息也可支持国际化,通过请求头 `Accept-Language` 判断语言:
:::warning 下列中间件尚不存在
`app/middleware/LangMiddleware.php` 当前不存在,以下为规划示例。
:::
```php
// app/middleware/LangMiddleware.php
public function handle(Request $request, \Closure $next): Response
{
$lang = $request->header('Accept-Language', 'zh-CN');
app()->setLang($lang);
return $next($request);
}
```
## 添加新语言
:::warning 以下步骤依赖未实现的功能
`ui/src/locales/` 目录尚不存在,请勿执行以下操作。
:::
1. 创建语言包文件 `ui/src/locales/lang/ja-JP.ts`
2. 复制中文语言包结构并翻译
3. 在 `ui/src/locales/index.ts` 中注册新语言
4. 在语言切换组件中添加选项
```typescript
// ui/src/locales/index.ts
import zhCN from './lang/zh-CN';
import enUS from './lang/en-US';
import jaJP from './lang/ja-JP'; // 新增
const messages = {
'zh-CN': zhCN,
'en-US': enUS,
'ja-JP': jaJP, // 新增
};
```
## 相关文档
- [前端概述](/zh/frontend/overview.html)
- [状态管理](/zh/frontend/store.html)
- [环境配置](/zh/frontend/env-config.html)
@@ -0,0 +1,102 @@
# 9.15 布局系统
:::tip 概述
前端布局采用经典的企业后台布局:左侧菜单 + 顶部导航 + 内容区域,支持多标签页导航。
:::
## 布局结构
```text
┌─────────────────────────────────────────────────────────────┐
│ 顶部导航栏 │
│ [Logo] [面包屑] [搜索] [通知] [头像] │
├──────────┬──────────────────────────────────────────────────┤
│ │ │
│ 侧边栏 │ 内容区域 │
│ 菜单 │ │
│ │ ┌──────────────────────────────────────────┐ │
│ ──────── │ │ 标签页导航 │ │
│ 系统管理 │ ├──────────────────────────────────────────┤ │
│ ├ 用户 │ │ │ │
│ ├ 角色 │ │ 页面内容 │ │
│ └ 菜单 │ │ │ │
│ │ │ │ │
│ 内容管理 │ │ │ │
│ ├ 文章 │ │ │ │
│ └ 分类 │ │ │ │
│ │ └──────────────────────────────────────────┘ │
└──────────┴──────────────────────────────────────────────────┘
```
## 布局组件
```text
ui/src/layout/
├── index.vue # 布局入口
├── components/
│ ├── header/ # 顶部导航(Logo、面包屑、搜索、通知、用户头像)
│ ├── sider/ # 侧边栏菜单(动态菜单、折叠、主题)
│ ├── content/ # 内容区域(keep-alive 缓存)
│ ├── tabs/ # 标签页导航(多标签切换、关闭)
│ └── footer/ # 页脚
└── hooks/ # 布局相关 hooks
```
## 侧边栏菜单
菜单数据从后端 `/index/getMenus` 接口获取,由 `asyncRouteStore` 管理:
```typescript
// 菜单渲染逻辑
const menuList = asyncRouteStore.getMenus;
// 菜单数据结构
interface MenuItem {
id: number;
parentId: number;
name: string;
path: string;
component: string;
icon: string;
sort: number;
hide: boolean;
children?: MenuItem[];
}
```
## 标签页导航
标签页自动记录访问过的页面,支持:
- 点击切换
- 关闭当前/其他/全部
- 右键菜单操作
- keep-alive 缓存
```typescript
// 标签页 Store
const tabsViewStore = useTabsViewStore();
// 添加标签
tabsViewStore.addTab(route);
// 关闭标签
tabsViewStore.removeTab(route);
```
## 响应式布局
布局支持桌面端和平板端:
- 桌面端:侧边栏默认展开
- 平板端:侧边栏默认折叠
- 侧边栏支持手动折叠/展开
```typescript
// 侧边栏折叠状态
const collapsed = ref(false);
// 响应式检测
const { width } = useWindowSize();
watchEffect(() => {
collapsed.value = width.value < 1024;
});
```
@@ -0,0 +1,75 @@
# 9.8 登录流程
:::tip 概述
本节说明前端登录流程的实现,包括登录表单提交、Token 存储、路由守卫和登出处理。
:::
## 前端登录流程
```text
1. 用户输入用户名、密码、验证码
2. 调用 POST /api/login 接口
3. 成功 → 存储 Token 到 localStorage
4. 路由守卫检测到 Token → 获取用户信息
5. 生成动态路由 → 跳转首页
```
## 登录页面代码
```vue
<!-- src/views/login/index.vue -->
<template>
<el-form :model="loginForm" @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="loginForm.username" placeholder="用户名" />
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.captchaCode" placeholder="验证码" />
<img :src="captchaImage" @click="refreshCaptcha" />
</el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
</el-form>
</template>
<script setup>
import { useUserStore } from '@/store/modules/user';
import { getCaptcha } from '@/api/common/user';
const userStore = useUserStore();
const loginForm = ref({ username: '', password: '', captchaCode: '', captchaKey: '' });
const captchaImage = ref('');
// 获取验证码
const refreshCaptcha = async () => {
const res = await getCaptcha();
loginForm.value.captchaKey = res.key;
captchaImage.value = res.image;
};
// 登录
const handleLogin = async () => {
await userStore.login(loginForm.value);
router.push('/');
};
onMounted(() => refreshCaptcha());
</script>
```
## Token 存储
```typescript
// 登录成功后存储
storage.set(ACCESS_TOKEN, data.access_token, 7 * 24 * 60 * 60 * 1000); // 7天过期
// 请求拦截器自动携带
config.headers['Authorization'] = `Bearer ${token}`;
// 响应拦截器检测 401
if (response.data.code === 401) {
router.push('/login');
}
```
@@ -0,0 +1,572 @@
# 9.12 新增页面开发步骤
:::tip 概述
以 example 模块为例,演示从 API 到视图的完整开发步骤。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
:::
## 开发流程
```text
1. 创建 API 接口 src/api/{module}/{name}.ts
2. 创建表格列配置 src/views/{module}/{name}/columns.ts
3. 创建查询表单配置 src/views/{module}/{name}/querySchemas.ts
4. 创建列表页 src/views/{module}/{name}/index.vue
5. 创建编辑弹窗 src/views/{module}/{name}/edit.vue
6. 创建详情弹窗 src/views/{module}/{name}/detail.vue
7. 配置菜单 后端菜单管理中设置组件路径
8. 测试 刷新前端页面,点击菜单测试
```
## 完整文件结构
```text
ui/src/
├── api/
│ └── tool/
│ └── example.ts # API 接口
└── views/
└── tool/
└── example/
├── index.vue # 列表页(主页面)
├── columns.ts # 表格列配置
├── querySchemas.ts # 查询表单配置
├── edit.vue # 编辑弹窗(新增/修改)
└── detail.vue # 详情弹窗
```
## 步骤一:创建 API 接口
```typescript
// ui/src/api/tool/example.ts
import { http } from '@/utils/http/axios';
// 分页查询
export function getExamplePage(params?) {
return http.request({ url: '/example/page', method: 'GET', params });
}
// 全量列表(下拉框数据源)
export function getExampleList(params?) {
return http.request({ url: '/example/list', method: 'GET', params });
}
// 详情
export function getExampleDetail(id) {
return http.request({ url: '/example/detail/' + id, method: 'GET' });
}
// 新增
export function exampleAdd(data: any) {
return http.request({ url: '/example/add', method: 'POST', data });
}
// 修改
export function exampleUpdate(data: any) {
return http.request({ url: '/example/update', method: 'PUT', data });
}
// 删除
export function exampleDelete(id) {
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
// 批量删除
export function exampleBatchDelete(data: any) {
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}
```
### API 命名规范
| 操作 | 函数名 | HTTP 方法 |
|------|--------|----------|
| 分页查询 | `get{Module}Page` | GET |
| 全量列表 | `get{Module}List` | GET |
| 详情 | `get{Module}Detail` | GET |
| 新增 | `{module}Add` | POST |
| 修改 | `{module}Update` | PUT |
| 删除 | `{module}Delete` | DELETE |
| 批量删除 | `{module}BatchDelete` | DELETE |
## 步骤二:创建表格列配置
```typescript
// ui/src/views/tool/example/columns.ts
import { h } from 'vue';
import { ElAvatar, ElTag } from 'element-plus';
export const columns = [
{
type: 'selection', // 多选框列
},
{
label: 'ID',
prop: 'id',
fixed: 'left',
width: 50,
},
{
label: '名称',
prop: 'name',
minWidth: 120,
},
{
label: '状态',
prop: 'statusText',
minWidth: 100,
// 选项标签渲染:按值映射文字与颜色
render(record) {
const statusMap = {
0: { text: '禁用', type: 'danger' },
1: { text: '启用', type: 'success' },
};
const status = statusMap[record.row.status];
return h(ElTag, { type: status?.type || 'info' }, {
default: () => status?.text || record.row.statusText,
});
},
},
{
label: '头像',
prop: 'avatar',
minWidth: 80,
// 图片渲染:以方形头像形式展示
render(record) {
return h(ElAvatar, {
size: 48,
src: record.row.avatar,
shape: 'square',
fit: 'fill',
});
},
},
{
label: '创建人',
prop: 'createUser',
minWidth: 100,
},
{
label: '创建时间',
prop: 'createTime',
width: 180,
},
];
```
### 列配置字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
| `label` | string | 表头标题 |
| `prop` | string | 数据字段名(camelCase) |
| `fixed` | string | 固定列:`left` / `right` |
| `width` | number | 固定宽度 |
| `minWidth` | number | 最小宽度(自适应) |
| `render` | function | 自定义渲染函数 |
## 步骤三:创建查询表单配置
```typescript
// ui/src/views/tool/example/querySchemas.ts
import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
component: 'Input',
label: '名称',
componentProps: {
placeholder: '请输入名称',
clearable: true,
},
},
{
field: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
clearable: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
];
```
### 表单组件类型
| component | 适用场景 | 说明 |
|-----------|---------|------|
| `Input` | 字符串字段 | 文本输入框 |
| `Select` | 枚举/选项字段 | 下拉选择框 |
| `InputNumber` | 数字字段 | 数字输入框 |
| `DatePicker` | 日期字段 | 日期选择器 |
## 步骤四:创建列表页
```vue
<!-- ui/src/views/tool/example/index.vue -->
<template>
<div>
<!-- 查询表单 -->
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
<!-- 操作栏 -->
<el-row :gutter="10" style="margin-bottom: 10px;">
<el-col :span="1.5">
<el-button type="primary" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</el-button>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-table :data="tableData" @selection-change="handleSelectionChange" v-loading="loading">
<el-table-column v-for="col in columns" v-bind="col" :key="col.prop || col.type">
<template v-if="col.render" #default="scope">
<component :is="() => col.render(scope)" />
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" link @click="handleDetail(row)">详情</el-button>
<el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pageNo"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@change="fetchData"
style="margin-top: 10px; justify-content: flex-end;"
/>
<!-- 编辑弹窗 -->
<EditDialog ref="editRef" @success="fetchData" />
<!-- 详情弹窗 -->
<DetailDialog v-model:visible="detailVisible" :id="detailId" />
</div>
</template>
<script setup lang="ts">
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { columns } from './columns';
import { schemas as querySchemas } from './querySchemas';
import { ElMessage, ElMessageBox } from 'element-plus';
import EditDialog from './edit.vue';
import DetailDialog from './detail.vue';
const editRef = ref();
const loading = ref(false);
const tableData = ref([]);
const pageNo = ref(1);
const pageSize = ref(20);
const total = ref(0);
const selectedIds = ref<number[]>([]);
const detailVisible = ref(false);
const detailId = ref(0);
// 加载数据
const fetchData = async () => {
loading.value = true;
try {
const res = await getExamplePage({
pageNo: pageNo.value,
pageSize: pageSize.value,
});
tableData.value = res.records;
total.value = res.total;
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = (params) => {
pageNo.value = 1;
fetchData();
};
// 重置
const handleReset = () => {
pageNo.value = 1;
fetchData();
};
// 新增
const handleAdd = () => {
editRef.value.open();
};
// 编辑
const handleEdit = (row) => {
editRef.value.open(row);
};
// 详情
const handleDetail = (row) => {
detailId.value = row.id;
detailVisible.value = true;
};
// 删除
const handleDelete = async (id) => {
await ElMessageBox.confirm('确认删除该记录?', '提示', { type: 'warning' });
await exampleDelete(id);
ElMessage.success('删除成功');
fetchData();
};
// 批量删除
const handleBatchDelete = async () => {
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
await exampleBatchDelete({ ids: selectedIds.value });
ElMessage.success('删除成功');
fetchData();
};
// 多选变化
const handleSelectionChange = (rows) => {
selectedIds.value = rows.map(row => row.id);
};
onMounted(() => fetchData());
</script>
```
## 步骤五:创建编辑弹窗
```vue
<!-- ui/src/views/tool/example/edit.vue -->
<template>
<el-dialog v-model="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择类型">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">启用</el-radio>
<el-radio :value="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="9999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
import { getDictItemList } from '@/api/common/index';
import { ElMessage } from 'element-plus';
const emit = defineEmits(['success']);
const visible = ref(false);
const submitting = ref(false);
const formRef = ref();
const typeOptions = ref([]);
const formData = ref({
id: 0,
name: '',
type: '',
status: 1,
sort: 0,
});
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
};
// 打开弹窗
const open = async (row?) => {
visible.value = true;
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
// 加载字典选项
typeOptions.value = await getDictItemList('example_type');
// 编辑时加载详情
if (row?.id) {
const detail = await getExampleDetail(row.id);
Object.assign(formData.value, detail);
}
};
// 提交
const handleSubmit = async () => {
await formRef.value.validate();
submitting.value = true;
try {
if (formData.value.id) {
await exampleUpdate(formData.value);
ElMessage.success('修改成功');
} else {
await exampleAdd(formData.value);
ElMessage.success('新增成功');
}
visible.value = false;
emit('success');
} finally {
submitting.value = false;
}
};
defineExpose({ open });
</script>
```
## 步骤六:创建详情弹窗
```vue
<!-- ui/src/views/tool/example/detail.vue -->
<template>
<el-dialog v-model="props.visible" title="案例详情" width="750" :close-on-click-modal="false">
<el-descriptions column="2" border>
<el-descriptions-item label="名称:">
{{ formData.name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="类型:">
{{ formData.typeText || '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态:">
<el-tag :type="formData.status === 1 ? 'success' : 'danger'">
{{ formData.statusText || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="排序:">
{{ formData.sort ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="头像:">
<el-image
v-if="formData.avatar"
:src="formData.avatar"
:preview-src-list="[formData.avatar]"
fit="cover"
style="width: 80px; height: 80px"
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="创建人:">
{{ formData.createUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间:">
{{ formData.createTime || '-' }}
</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="emit('update:visible', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { getExampleDetail } from '@/api/tool/example';
const emit = defineEmits(['update:visible']);
const props = defineProps({
visible: { type: Boolean, required: true, default: false },
id: { type: Number, required: true, default: 0 },
});
const formData = reactive({
name: '',
type: '',
typeText: '',
status: 0,
statusText: '',
sort: 0,
avatar: '',
createUser: '',
createTime: '',
});
// 加载详情
const loadDetail = async () => {
try {
const data = await getExampleDetail(props.id);
for (const key in formData) {
if (data[key] != null) {
formData[key] = data[key];
}
}
} catch (error) {
console.error('获取详情失败', error);
}
};
// 监听弹窗显示
watch(() => props.visible, (newVal) => {
if (newVal && props.id) {
loadDetail();
}
}, { immediate: true });
</script>
```
## 步骤七:配置菜单
在后端菜单管理中添加菜单项:
| 字段 | 值 |
|------|-----|
| 菜单名称 | 案例管理 |
| 组件路径 | `tool/example/index` |
| 路由路径 | `/tool/example` |
| 权限标识 | `sys:example:page` |
添加按钮权限:
| 按钮名称 | 权限标识 |
|---------|---------|
| 新增 | `sys:example:add` |
| 修改 | `sys:example:update` |
| 删除 | `sys:example:delete` |
| 批量删除 | `sys:example:batchDelete` |
| 详情 | `sys:example:detail` |
## 步骤八:测试
1. 刷新前端页面
2. 点击菜单"案例管理"
3. 测试搜索、新增、编辑、详情、删除、批量删除功能
## 代码生成器(快速创建)
如果模块结构简单,可使用代码生成器一键生成前后端代码:
1. 建好数据库表
2. 访问 `/api/generator/page` 查看可生成的表
3. `POST /api/generator/preview` 预览代码
4. `POST /api/generator/generate` 生成代码
5. 在菜单管理中配置菜单
详见[代码生成器](/zh/generator/overview.html)。
@@ -0,0 +1,91 @@
# 9.1 前端工程概述
:::tip 概述
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
:::
## 技术栈
| 类别 | 技术 | 版本 | 说明 |
|------|------|------|------|
| 框架 | Vue | 3.4+ | 核心 UI 框架 |
| 语言 | TypeScript | 4.9+(实际 ^4.9.5) | 类型安全 |
| UI 库 | ElementPlus | 2.7+ | 组件库 |
| 构建 | Vite | 3.2+(实际 ^3.2.10) | 开发服务器与构建 |
| 状态 | Pinia | 2.1+ | 全局状态管理 |
| 路由 | Vue Router | 4.3+ | 路由管理 |
| HTTP | Axios | 0.27+(实际 ^0.27.2) | HTTP 请求(注:0.27.x 版本较老) |
| 富文本 | TinyMce | 5.10+ | 富文本编辑器 |
| 图表 | ECharts | 5.5+ | 数据可视化 |
| CSS | TailwindCSS | 3.4+ | 原子化 CSS |
## 核心特性
### 动态路由
从后端 `/index/getMenus` 接口获取菜单数据,自动生成前端路由,无需手动维护路由表。
```text
后端菜单数据 → generator-routers.ts → Vue Router 动态路由
```
### 权限控制
`v-perm` / `v-perms` 指令控制按钮级权限:
```vue
<el-button v-perm="'sys:user:add'">新增</el-button>
```
### Token 管理
Axios 拦截器自动处理:
- 请求拦截:自动添加 `Authorization: Bearer <token>` 请求头
- 响应拦截:`code === 401` 时自动跳转登录页
### 组件库
内置 20+ 个业务组件:
| 组件 | 说明 |
|------|------|
| BasicTable | 数据表格(分页、排序、搜索) |
| BasicForm | 动态表单(配置驱动) |
| BasicModal | 弹窗(表单弹窗、详情弹窗) |
| BasicUpload | 文件上传 |
| Editor | 富文本编辑器(TinyMce) |
| ImportExcel | Excel 导入 |
| Cropper | 头像裁剪 |
| Region | 行政区划选择 |
| Password | 密码强度检测 |
| Qrcode | 二维码生成 |
## 工程结构
```text
ui/src/
├── api/ # API 接口层(按业务域分组)
├── components/ # 公共组件库
├── directives/ # 自定义指令(v-perm)
├── enums/ # 枚举常量
├── hooks/ # 组合式函数
├── layout/ # 布局组件
├── plugins/ # 插件
├── router/ # 路由配置
├── settings/ # 项目配置
├── store/ # Pinia 状态管理
├── styles/ # 全局样式
├── utils/ # 工具函数
└── views/ # 页面视图
```
## 开发命令
```bash
pnpm install # 安装依赖
pnpm dev # 启动开发服务器
pnpm build # 构建生产版本
pnpm preview # 预览构建结果
pnpm lint:eslint # ESLint 检查
pnpm lint:prettier # Prettier 格式化
```
@@ -0,0 +1,91 @@
# 9.7 权限控制
:::tip 概述
前端权限控制通过 `v-perm` / `v-perms` 指令实现按钮级权限控制。
:::
## 权限指令
### v-perm(单权限)
```vue
<template>
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
</template>
```
### v-perms(多权限,满足任一即可)
```vue
<template>
<!-- 拥有 sys:user:add 或 sys:user:update 任一权限即可 -->
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
</template>
```
## 指令实现
```typescript
// src/directives/permission.ts
import { usePermission } from '@/hooks/web/usePermission';
export const perm: ObjectDirective = {
mounted(el: HTMLButtonElement, binding) {
const { hasSomePermission } = usePermission();
if (!hasSomePermission(binding.value)) {
el.parentNode.removeChild(el); // 无权限则移除元素
}
},
};
```
## usePermission Hook
:::tip 超级管理员
用户权限列表包含 `*:*:*` 时,表示超级管理员,拥有所有权限。
:::
```typescript
// src/hooks/web/usePermission.ts
export function usePermission() {
const userStore = useUserStore();
/**
* 检查是否拥有指定权限
* 超级管理员(permissions 包含 '*:*:*')拥有所有权限
*/
function hasSomePermission(permission: string | string[]): boolean {
const perms = userStore.permissions;
if (perms.includes('*:*:*')) return true;
if (typeof permission === 'string') {
return perms.includes(permission);
}
return permission.some((p) => perms.includes(p));
}
return { hasSomePermission, hasEveryPermission };
}
```
## 权限数据来源
用户登录后,`/index/getUser` 接口返回权限列表:
```json
{
"permissions": [
"sys:user:page",
"sys:user:add",
"sys:user:update",
"sys:user:delete",
"sys:role:page",
"..."
]
}
```
超级管理员返回 `["*:*:*"]`,拥有所有权限。
@@ -0,0 +1,224 @@
# 9.5 路由与动态菜单
:::tip 概述
前端路由采用动态生成机制,从后端 `/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。
:::
## 路由架构
```text
静态路由(固定,src/router/index.ts + src/router/base.ts)
├── /login 登录页(LoginRoute)
├── / 根路径,重定向到首页(RootRoute)
├── /redirect/:path 重定向路由(RedirectRoute)
├── /:path(.*)* 404 错误页(ErrorPageRoute,动态路由加载后注入)
├── /monitorJob/log 关于项目(AboutPageRoute,动态路由加载后注入)
└── /setting/profile 个人设置(SettingPageRoute,动态路由加载后注入)
动态路由(从后端菜单生成)
├── /dashboard 仪表盘
├── /system/user 用户管理
├── /system/role 角色管理
├── /content/article 文章管理
└── ...
```
## 动态路由生成流程
```text
1. 用户登录 → 获取 Token
2. 路由守卫检测到 Token
3. 调用 /index/getMenus 获取菜单树
4. generator-routers.ts 将菜单树转换为路由表
5. router.addRoute() 动态注入路由
6. 渲染侧边栏菜单
```
## 核心代码
```typescript
// src/router/generator-routers.ts
/**
* 动态生成菜单
* 调用后端 /index/getMenus 接口获取菜单数据
*/
export const generatorDynamicRouter = (): Promise<RouteRecordRaw[]> => {
return new Promise((resolve, reject) => {
adminMenus()
.then((result) => {
const routeList = routerGenerator(result);
asyncImportRoute(routeList);
resolve(routeList);
})
.catch((err) => reject(err));
});
};
/**
* 格式化后端菜单为路由结构
*/
export const routerGenerator = (routerMap): any[] => {
return routerMap.map((item) => {
const currentRouter = {
path: item.path,
name: item.path.replaceAll('/', ''),
component: item.component, // 如 'system/user/index'
meta: {
title: item.name,
icon: item.icon,
permissions: item.permission,
},
};
// 递归处理子菜单
if (item.children?.length > 0) {
currentRouter.children = routerGenerator(item.children);
}
return currentRouter;
});
};
```
## 路由守卫
:::tip 动态路由流程
详见上方 [动态路由生成流程](#动态路由生成流程) 章节,路由守卫中实现了该流程的第 2~6 步。
:::
```typescript
// src/router/router-guards.ts
const LOGIN_PATH = PageEnum.BASE_LOGIN;
const whitePathList = [LOGIN_PATH]; // no redirect whitelist
export function createRouterGuards(router: Router) {
const userStore = useUserStoreWidthOut();
const asyncRouteStore = useAsyncRouteStoreWidthOut();
router.beforeEach(async (to, from, next) => {
// 1. Token 可能来自 URL 查询参数 ?token=xxx(SSO 等场景)
if (to.query && 'token' in to.query) {
const tokenVal = String(to.query.token);
storage.set(ACCESS_TOKEN, tokenVal);
userStore.setToken(tokenVal);
}
NProgress.start();
// 2. 白名单且未登录 → 直接放行
const token = storage.get(ACCESS_TOKEN);
if (whitePathList.includes(to.path as PageEnum) && !token) {
next();
return;
}
// 3. 未登录处理
if (!token) {
// 路由 meta.ignoreAuth = true 时无需登录也可访问
if (to.meta.ignoreAuth) {
next();
return;
}
// 重定向到登录页,携带当前路径作为 redirect 参数
const redirectData = { path: LOGIN_PATH, replace: true };
if (to.path) {
redirectData.query = { redirect: to.path };
}
next(redirectData);
return;
}
// 4. 已登录 + 动态路由已加载 → 直接放行
if (asyncRouteStore.getIsDynamicAddedRoute) {
next();
return;
}
// 5. 首次加载:生成动态路由
asyncRouteStore.setDynamicAddedRoute(true);
const userInfo = await userStore.GetInfo();
const routes = await asyncRouteStore.generateRoutes(userInfo);
await useOssConfigStore().setOssConfig();
// 动态添加可访问路由表
routes.forEach((item) => {
router.addRoute(item as unknown as RouteRecordRaw);
});
// 添加 404 路由(避免重复注册)
const isErrorPage = router.getRoutes().findIndex((item) => item.name === ErrorPageRoute.name);
if (isErrorPage === -1) {
router.addRoute(ErrorPageRoute as unknown as RouteRecordRaw);
}
router.addRoute(AboutPageRoute as unknown as RouteRecordRaw);
router.addRoute(SettingPageRoute as unknown as RouteRecordRaw);
// 保存第一个路由路径,用于默认跳转
const firstRoutePath = findFirstRoutePath(routes);
storage.set(FIRST_ROUTE, firstRoutePath);
// 确定重定向目标
const formPath =
findTreeByPath(routes, from.query.redirect).length > 0 ? from.query.redirect : to.path;
const redirect = decodeURIComponent(formPath as string);
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect };
NProgress.done();
if (nextData.path === PageEnum.BASE_HOME || (token && to.path === '/login')) {
next(firstRoutePath);
return;
}
next(nextData);
});
// 6. afterEach:管理 keep-alive 缓存 + 页面标题
router.afterEach((to, _, failure) => {
document.title =
(to?.meta?.documentTitle ? to?.meta?.documentTitle : (to?.meta?.title as string)) ||
document.title;
if (isNavigationFailure(failure)) {
// navigation failed
}
const asyncRouteStore = useAsyncRouteStoreWidthOut();
const keepAliveComponents = asyncRouteStore.keepAliveComponents;
const currentComName: any = to.matched.find((item) => item.name == to.name)?.name;
if (currentComName && !keepAliveComponents.includes(currentComName) && to.meta?.keepAlive) {
keepAliveComponents.push(currentComName);
} else if (!to.meta?.keepAlive || to.name == 'Redirect') {
const index = asyncRouteStore.keepAliveComponents.findIndex((name) => name == currentComName);
if (index != -1) {
keepAliveComponents.splice(index, 1);
}
}
asyncRouteStore.setKeepAliveComponents(keepAliveComponents);
NProgress.done();
});
}
```
## 后端菜单数据结构
后端 `/index/getMenus` 返回的菜单树:
```json
[
{
"id": 1,
"name": "系统管理",
"path": "/system",
"icon": "SettingOutlined",
"component": "LAYOUT",
"children": [
{
"id": 11,
"name": "用户管理",
"path": "/user",
"component": "system/user/index",
"permission": "sys:user:page"
}
]
}
]
```
@@ -0,0 +1,86 @@
# 9.6 状态管理(Pinia)
:::tip 概述
前端使用 Pinia 进行全局状态管理,核心 Store 包含用户信息、动态路由、项目配置等。
:::
## Store 清单
| Store | 文件 | 说明 |
|-------|------|------|
| userStore | `store/modules/user.ts` | Token / 用户信息 / 权限列表 / 登录登出 |
| asyncRouteStore | `store/modules/asyncRoute.ts` | 动态路由 / keep-alive 缓存 |
| projectSettingStore | `store/modules/projectSetting.ts` | 项目配置 |
| ossConfigStore | `store/modules/ossConfig.ts` | 云存储配置 |
| designSettingStore | `store/modules/designSetting.ts` | 主题设计 |
| lockscreenStore | `store/modules/lockscreen.ts` | 锁屏 |
| tabsViewStore | `store/modules/tabsView.ts` | 标签页 |
## userStore 核心代码
```typescript
// src/store/modules/user.ts
export const useUserStore = defineStore({
id: 'app-user',
state: (): IUserState => ({
token: Storage.get(ACCESS_TOKEN, ''),
username: '',
avatar: '',
permissions: [],
info: Storage.get(CURRENT_USER, {}),
}),
actions: {
// 登录
async login(userInfo) {
const response = await login(userInfo);
if (response.code === 0) {
storage.set(ACCESS_TOKEN, response.data.access_token);
this.setToken(response.data.access_token);
}
return response;
},
// 获取用户信息
async GetInfo() {
const res = await getUserInfo();
this.setPermissions(res.permissions);
this.setUserInfo(res);
this.setAvatar(res.avatar);
return res;
},
// 登出
async logout() {
await logout();
this.setPermissions([]);
this.setUserInfo('');
storage.remove(ACCESS_TOKEN);
storage.remove(CURRENT_USER);
},
},
});
```
## 在组件中使用
:::tip Store 使用方式
- 在 `setup` 中:`const userStore = useUserStore()`
- 在 `setup` 外:`const userStore = useUserStoreWidthOut()`
:::
```typescript
import { useUserStore } from '@/store/modules/user';
const userStore = useUserStore();
// 获取用户信息
const username = userStore.username;
const permissions = userStore.permissions;
// 登录
await userStore.login({ username: 'admin', password: '123456' });
// 登出
await userStore.logout();
```
@@ -0,0 +1,109 @@
# 9.14 样式与主题定制
:::tip 概述
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
:::
## 技术栈
| 技术 | 用途 |
|------|------|
| TailwindCSS | 原子化 CSS 框架 |
| SCSS | 全局样式变量和主题 |
| ElementPlus 主题变量 | 组件样式覆盖 |
## 全局样式文件
```text
ui/src/styles/
├── index.scss # 全局样式入口(@import 所有子模块)
├── var.scss # SCSS 变量定义(颜色、尺寸等)
├── common.scss # 公共样式类
├── tailwind.css # TailwindCSS 入口
├── tailwindbase.css # TailwindCSS 基础重置
├── theme/
│ ├── light.scss # 浅色主题(含 ElementPlus 组件覆盖)
│ └── dark.scss # 深色主题
└── transition/
└── index.scss # 过渡动画
```
## SCSS 变量
```scss
/* ui/src/styles/var.scss */
$primaryColor: #165DFF;
$primaryColorHover: #57a3f3;
$header-height: 64px;
$footer-height: 70px;
```
## 主题色切换
通过 `designSettingStore` 管理主题:
```typescript
import { useDesignSettingStore } from '@/store/modules/designSetting';
const designStore = useDesignSettingStore();
// 切换暗色模式
designStore.setDarkMode(true);
// 获取当前主题色
const primaryColor = designStore.appTheme;
```
## ElementPlus 主题覆盖
ElementPlus 组件样式覆盖定义在主题文件中,通过 `[data-theme]` 选择器区分深浅色模式:
```scss
/* ui/src/styles/theme/light.scss */
[data-theme='light'] {
--el-border-color-base: #e4e7ed;
.el-table {
--el-table-header-bg-color: #f5f7fa;
--el-table-header-text-color: rgb(31 34 37);
}
// ...更多组件覆盖
}
```
## TailwindCSS 配置
```javascript
// ui/tailwind.config.js
module.exports = {
content: ['./src/**/*.{vue,ts,tsx}'],
theme: {
extend: {
colors: {
primary: 'var(--primary-color)',
},
},
},
};
```
## 自定义全局样式
```scss
/* ui/src/styles/index.scss */
// 页面容器
.page-container {
padding: 16px;
background: var(--bg-color);
border-radius: 8px;
}
// 搜索表单
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
```
@@ -0,0 +1,104 @@
# 9.16 工具函数
:::tip 概述
本节介绍前端项目中常用的工具函数,包括本地存储、类型判断、URL 处理和权限检查等。
:::
## Storage 本地存储
```typescript
import { storage } from '@/utils/Storage';
// 存储(带过期时间)
storage.set('key', value, 7 * 24 * 60 * 60 * 1000); // 7天
// 读取
const value = storage.get('key', defaultValue);
// 删除
storage.remove('key');
// 清空
storage.clear();
```
## 类型判断
```typescript
import { isString, isNumber, isArray, isObject } from '@/utils/is';
isString('hello'); // true
isNumber(123); // true
isArray([1, 2]); // true
isObject({ a: 1 }); // true
```
## URL 工具
```typescript
import { setObjToUrlParams } from '@/utils/urlUtils';
// 将对象拼接到 URL 参数
const url = setObjToUrlParams('/api/example/page', { pageNo: 1, pageSize: 10 });
// '/api/example/page?pageNo=1&pageSize=10'
```
## 权限工具
```typescript
import { usePermission } from '@/hooks/web/usePermission';
const { hasSomePermission, hasEveryPermission } = usePermission();
// 检查单个权限
hasSomePermission('sys:user:add');
// 检查多个权限(满足任一)
hasSomePermission(['sys:user:add', 'sys:user:update']);
```
## 日期工具
```typescript
import { formatToDate, formatToDateTime } from '@/utils/dateUtil';
formatToDate(new Date()); // '2026-09-21'
formatToDateTime(new Date()); // '2026-09-21 14:30:00'
```
## 深拷贝
项目使用 `lodash-es` 提供的 `cloneDeep` 函数进行深拷贝:
```typescript
import { cloneDeep } from 'lodash-es';
const obj = { a: 1, b: { c: 2 } };
const copy = cloneDeep(obj);
copy.b.c = 3;
console.log(obj.b.c); // 2(原对象不受影响)
```
:::warning 功能未实现
以下工具函数尚未在项目中实现,仅供参考。
:::
:::warning 注意
项目中没有封装自定义的 `deepClone` 或 `numberUtil`、`fileUtil` 工具。数字格式化和文件大小格式化等功能建议使用 `lodash-es`、`Intl.NumberFormat` 等成熟方案自行处理。
:::
## 防抖与节流
```typescript
import { useDebounceFn, useThrottleFn } from '@vueuse/core';
// 防抖:停止输入 300ms 后执行
const debouncedSearch = useDebounceFn((keyword: string) => {
fetchData(keyword);
}, 300);
// 节流:每 200ms 最多执行一次
const throttledScroll = useThrottleFn(() => {
handleScroll();
}, 200);
```