签入版本

This commit is contained in:
2026-10-08 17:50:29 +08:00
parent 809750b47e
commit e213a9b31d
444 changed files with 39366 additions and 37533 deletions
@@ -23,7 +23,12 @@
| 查询 | GET | `params`(URL 参数) | 分页/列表/详情 |
| 新增 | POST | `data`(请求体) | JSON 格式 |
| 修改 | PUT | `data`(请求体) | JSON 格式,含 id |
| 删除 | DELETE | URL 参数或 `data` | 单条用 URL,批量用 data |
| 删除 | DELETE | URL 参数 | 单条(id 在路径中) |
| 批量删除 | **DELETE** | `data`(请求体 `{ids:[]}`) | 本项目统一 DELETE + body |
:::warning 与常见 REST 风格的差异
本项目 `batchDelete` 为 **DELETE**(请求体仍传 `ids` 数组——DELETE 方法携带 body);`resetPwd` 为 **PUT**,`account` 等扩展操作为 POST——以后端 `routes/api.php` 实际路由为准(见 [8.7 API 接口总览](/zh/api-spec/overview.html))。
:::
## 文件组织
@@ -42,6 +47,9 @@ ui/src/api/
│ ├── article.ts
│ ├── category.ts
│ └── link.ts
├── data/ # 数据管理模块(字典/配置/参数/公告/城市)
├── monitor/ # 监控模块(定时任务)
├── file/ # 文件模板
└── tool/ # 工具模块
└── example.ts
```
@@ -82,33 +90,40 @@ export function exampleDelete(id) {
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
// 批量删除
// 批量删除(DELETE + 请求体 { ids: [...] })
export function exampleBatchDelete(data: any) {
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}
```
:::tip 拦截器已解包
`http.request` 的响应拦截器在 `code === 0` 时直接返回 `data`——函数返回值即业务数据(如分页返回 `{records,total,current,size,pages}`),无需再取 `response.data.data`。
:::
## 常见扩展操作
| 操作 | 函数名示例 | HTTP 方法 |
|------|-----------|----------|
| 重置密码 | `userResetPwd(data)` | POST |
| 导入 | `{module}Import(data: FormData)` | POST(multipart/form-data) |
| 导入 | `{module}Import(data: FormData)` | POST(multipart/form-data,带 Bearer 头) |
| 导出 | `{module}Export(params)` | GET |
| 刷新缓存 | `dictRefreshCache()` | GET |
| 刷新字典缓存 | `dictRefreshCache()` | GET |
| 获取用户信息 | `getUser()` | GET |
| 获取菜单 | `getMenus()` | GET |
| 获取字典项 | `getDictItemList(code)` | GET |
| 修改密码 | `updatePassword(data)` | PUT |
| 获取字典项 | `getDictItemByCode(code)` | GET |
| 刷新 Token | `postRefresh({ refresh_token })` | POST(公开接口) |
## 组件中使用
```vue
<script setup lang="ts">
import { getExamplePage, exampleDelete } from '@/api/tool/example';
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { ElMessage, ElMessageBox } from 'element-plus';
const tableData = ref([]);
// 查询
// 查询(res 即 data:{records, total, current, size, pages})
const fetchData = async () => {
const res = await getExamplePage({ pageNo: 1, pageSize: 20 });
tableData.value = res.records;
@@ -121,5 +136,22 @@ const handleDelete = async (id) => {
ElMessage.success('删除成功');
fetchData();
};
// 批量删除
const handleBatchDelete = async (ids) => {
await exampleBatchDelete({ ids });
ElMessage.success('删除成功');
fetchData();
};
</script>
```
## 编写检查清单
| 检查项 | 说明 |
|------|------|
| URL 与后端路由一致 | 逐字符核对 `routes/api.php`(二级路径模块名如 `file/template`) |
| HTTP 方法正确 | 特别注意 `batchDelete`=DELETE(带 body)、`runOnce/pause/resume`=GET |
| 认证接口不需要带公开标记 | 拦截器自动加 Bearer 头,公开接口同样可用 |
| 导入用 FormData | `Content-Type` 交给浏览器自动带 boundary,勿手写 |
| 导出处理返回值 | 后端返回文件地址(字符串),直接打开下载 |
@@ -1,7 +1,7 @@
# 9.2 目录结构详解
:::tip 概述
本节详细介绍前端项目的目录结构,帮助开发者快速定位代码和理解项目组织方式。
本节详细介绍前端项目(独立 EleVue 仓库)的目录结构,帮助开发者快速定位代码和理解项目组织方式。
:::
```text
@@ -18,10 +18,10 @@ ui/src/
│ ├── setting/ # 设置(个人资料/网站配置)
│ └── tool/ # 工具(示例/代码生成器)
├── components/ # 公共组件库(20+ 个)
│ ├── Table/ # 数据表格
│ ├── Form/ # 动态表单
│ ├── Table/ # 数据表格(BasicTable)
│ ├── Form/ # 动态表单(BasicForm)
│ ├── Modal/ # 弹窗
│ ├── Upload/ # 文件上传
│ ├── Upload/ # 文件上传(BasicUpload)
│ ├── Editor/ # 富文本编辑器
│ ├── Excel/ # Excel 导入导出
│ ├── Cropper/ # 头像裁剪
@@ -35,20 +35,29 @@ ui/src/
│ ├── httpEnum.ts # HTTP 枚举
│ └── pageEnum.ts # 页面枚举
├── hooks/ # 组合式函数
├── layout/ # 布局组件
│ ├── core/ event/ setting/ web/
│ └── web/usePermission.ts 等 # 权限、页面、图表等复用逻辑
├── layout/ # 布局组件(侧边栏/顶栏/内容区)
├── plugins/ # 插件
├── router/ # 路由配置
│ ├── index.ts # 路由实例
│ ├── base.ts # 静态路由
│ ├── generator-routers.ts # 动态路由生成
│ └── router-guards.ts # 路由守卫
│ ├── base.ts # 静态路由(登录/404)
│ ├── generator-routers.ts # 动态路由生成(后端菜单 → 路由)
│ └── router-guards.ts # 路由守卫(登录校验/菜单加载)
├── settings/ # 项目配置(标题/主题/布局)
├── store/ # Pinia 状态管理
│ └── modules/
│ ├── user.ts # 用户 Store
│ ├── user.ts # 用户 Store(Token/用户信息/权限)
│ ├── asyncRoute.ts # 动态路由 Store
│ └── ...
├── styles/ # 全局样式
│ ├── tabsView.ts # 标签页 Store
│ ├── projectSetting.ts # 项目配置 Store
│ ├── designSetting.ts # 主题设计 Store
│ ├── lockscreen.ts # 锁屏 Store
│ └── ossConfig.ts # 云存储配置 Store
├── styles/ # 全局样式(Tailwind / 主题变量)
├── utils/ # 工具函数
│ └── http/ # HTTP 请求封装
│ ├── http/axios/ # HTTP 请求封装(拦截器)
│ └── Storage.ts # 本地存储封装
└── views/ # 页面视图
├── login/ # 登录页
├── dashboard/ # 仪表盘
@@ -56,6 +65,40 @@ ui/src/
├── content/ # 内容管理页面
├── data/ # 数据管理页面
├── monitor/ # 监控管理页面
├── tool/ # 工具页面
└── exception/ # 异常页面
```
├── file/ # 文件管理页面
├── setting/ # 设置页面(个人资料等)
├── tool/ # 工具页面(示例/代码生成器)
├── iframe/ # 内嵌 iframe 页面
├── redirect/ # 重定向页面
└── exception/ # 异常页面(403/404/500)
```
## 分层调用关系
```text
views/(页面)
│ 调用
▼
api/(接口函数)──→ utils/http/axios(拦截器 + Token + 响应解包)──→ 后端 API
│
▼
store/(全局状态:用户、权限、动态路由、标签页)
│
▼
directives/(v-perm 按权限显隐) + hooks/(复用逻辑)
```
## 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| API 文件 | 小写业务域名 | `user.ts`、`example.ts` |
| 页面目录 | 小写业务域名 | `views/system/user/` |
| 组件 | 大驼峰 | `BasicTable`、`BasicUpload` |
| Store | 驼峰 + Store 后缀引用 | `useUserStore` |
| Hooks | `use` 前缀 | `usePermission`、`usePage` |
| 枚举 | 大驼峰 | `HttpEnum`、`PageEnum` |
:::tip 页面标准文件
每个业务页面目录由 5 个文件组成(与代码生成器输出一致):`index.vue`(列表)、`edit.vue`(编辑弹窗)、`detail.vue`(详情)、`columns.ts`(表格列)、`querySchemas.ts`(查询条件)——见 [9.12 新增页面开发步骤](/zh/frontend/new-page.html)。
:::
@@ -8,31 +8,47 @@
```vue
<template>
<BasicTable @register="registerTable" />
<BasicTable
:columns="columns"
:request="loadDataTable"
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
/>
</template>
<script setup>
import { BasicTable, useTable } from '@/components/Table';
import { TableAction } from '@/components/Table';
import { getExamplePage } from '@/api/tool/example';
import { columns } from './columns';
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' },
],
},
});
// 分页请求函数(返回 {records, total, current, size, pages})
const loadDataTable = (params) => getExamplePage(params);
const actionColumn = {
width: 200,
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
};
</script>
```
:::tip 分页字段约定
本项目 `BasicTable` 通过 `:request` 传入分页请求函数(没有 `useTable` hook),内部绑定后端固定分页结构 `data.records` / `data.total`。`statusText` 等 `*Text` 字段由后端 `serializeMaps` 自动补全,直接作为列 `field` 使用即可。
:::
### 列定义常用属性
| 属性 | 说明 | 示例 |
|------|------|------|
| `title` | 列标题 | `'创建时间'` |
| `field` | 字段名(camelCase,与响应一致) | `'createTime'` |
| `width` | 列宽 | `180` |
| `formatter` | 自定义格式化 | `(v) => v ?? '-'` |
| `slots` | 自定义单元格插槽 | `'statusSlot'` |
## Page 页面容器
```vue
@@ -59,33 +75,8 @@ 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)
- [前端组件速查](/zh/appendix/components.html)
@@ -8,13 +8,15 @@
```text
ui/
├── .env # 所有环境通用配置
├── .env.development # 开发环境配置
├── .env.production # 生产环境配置
└── .env.local # 本地覆盖(不提交 git)
├── .env # 所有环境通用配置(端口、标题)
├── .env.development # 开发环境(pnpm dev,默认 mode)
├── .env.production # 生产构建(pnpm build)
├── .env.test # 测试构建(pnpm build:test)
├── .env.uat # UAT 构建(pnpm build:uat)
└── .env.dev # dev 模式构建(pnpm build:dev)
```
**加载优先级:** `.env.local` > `.env.{mode}` > `.env`
**加载优先级:** `.env.{mode}` > `.env`(Vite 标准加载规则,可用 `.env.{mode}.local` 做本地覆盖)
## 开发环境配置
@@ -27,53 +29,65 @@ VITE_PORT = 8001
# 公共基础路径(通常为 /)
VITE_PUBLIC_PATH = /
# 是否启用 Mock 数据(false = 使用真实后端 API)
VITE_USE_MOCK = false
# 是否删除 console(当前项目开发构建同样删除)
VITE_DROP_CONSOLE = true
# 开发代理(JSON 数组格式:[["前缀","目标地址"]])
VITE_PROXY=[["/api","http://admin.laravel13.elevue/api"]]
# API 接口基础路径(留空,由代理处理)
VITE_GLOB_API_URL =
# 上传接口地址(留空,走同域/代理)
VITE_GLOB_UPLOAD_URL=
# 图片前缀地址(留空,按后端返回的地址解析)
VITE_GLOB_IMG_URL=
# 构建压缩方式:none / gzip / brotli
VITE_BUILD_COMPRESS = 'none'
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
# 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
```
:::tip 代理目标地址
`VITE_PROXY` 当前默认指向 Laragon 虚拟主机 `http://admin.laravel13.elevue/api`;使用 `php artisan serve` 时改为直连 `http://localhost:8000/api`(见 [2.2 Laragon 环境搭建](/zh/quickstart/laragon.html))。修改后需重启 `pnpm dev`。
:::
## 生产环境配置
### `.env.production`
```bash
# 是否启用 Mock 数据
VITE_USE_MOCK = false
# 公共基础路径(通常为 /,如部署到子目录则设为 /subdir/)
VITE_PUBLIC_PATH = /
# 是否删除 console
VITE_DROP_CONSOLE = true
# API 接口基础路径(留空,由 Nginx 代理处理)
VITE_GLOB_API_URL =
# 上传接口地址(留空,走同域)
VITE_GLOB_UPLOAD_URL=
# 图片前缀地址(留空;跨域部署时配置 CDN/后端域名,与 FILE_DOMAIN_URL 一致)
VITE_GLOB_IMG_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
# 构建压缩方式(当前默认 none;可选 gzip / brotli)
VITE_BUILD_COMPRESS = 'none'
# 压缩后是否删除原文件
VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
@@ -83,10 +97,6 @@ VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
### VITE_PORT — 开发服务器端口
```bash
VITE_PORT = 8001
```
| 环境 | 值 | 说明 |
|------|-----|------|
| 开发 | `8001` | 前端开发服务器端口 |
@@ -94,10 +104,6 @@ VITE_PORT = 8001
### VITE_PUBLIC_PATH — 公共基础路径
```bash
VITE_PUBLIC_PATH = /
```
| 场景 | 值 | 说明 |
|------|-----|------|
| 根目录部署 | `/` | 默认值 |
@@ -105,30 +111,30 @@ VITE_PUBLIC_PATH = /
### VITE_GLOB_API_URL — API 基础路径
```bash
VITE_GLOB_API_URL =
```
| 场景 | 值 | 说明 |
|------|-----|------|
| 开发(代理) | 留空 | 由 Vite proxy 转发 |
| 生产(同域) | 留空 | 由 Nginx 代理 |
| 生产(跨域) | `https://api.example.com` | 直接请求后端域名 |
| 生产(跨域) | `https://api.example.com` | 直接请求后端域名(需后端 CORS 放行) |
### VITE_PROXY — 开发代理配置
```bash
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
# 当前 .env.development 中的值(Laragon 虚拟主机)
VITE_PROXY=[["/api","http://admin.laravel13.elevue/api"]]
# 直连 artisan serve 时的写法
VITE_PROXY=[["/api","http://localhost:8000/api"]]
```
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
**作用:** 将前端 `/api/*` 请求代理到后端地址,解决开发环境跨域问题。
**作用:** 将前端 `/api/*` 请求代理到后端地址,解决开发环境跨域问题(代理方式下不依赖后端 CORS 配置)。
**多代理示例:**
```bash
VITE_PROXY = [["/api","http://localhost:8080/api"],["/upload","http://localhost:8080/upload"]]
VITE_PROXY = [["/api","http://localhost:8000/api"],["/uploads","http://localhost:8000/uploads"]]
```
### VITE_GLOB_API_URL_PREFIX — API 前缀
@@ -137,45 +143,43 @@ VITE_PROXY = [["/api","http://localhost:8080/api"],["/upload","http://localhost:
VITE_GLOB_API_URL_PREFIX = /api
```
所有 API 请求路径的前缀,与后端路由前缀一致。
所有 API 请求路径的前缀,与后端路由前缀一致(`bootstrap/app.php` 统一添加 `/api`)。
### VITE_GLOB_UPLOAD_URL — 上传接口地址
```bash
VITE_GLOB_UPLOAD_URL = /api/upload
VITE_GLOB_UPLOAD_URL=
```
文件上传组件使用的接口地址。
文件上传组件使用的接口地址(实际调用 `/api/upload/uploadFile`)。**当前项目留空**——上传走同域/开发代理,跨域部署时再配置为后端完整地址。
### VITE_GLOB_IMG_URL — 图片访问地址
```bash
# 开发环境
VITE_GLOB_IMG_URL = http://file.thinkphp6.elevue
# 生产环境
VITE_GLOB_IMG_URL = https://cdn.example.com
VITE_GLOB_IMG_URL=
```
用于预览上传的图片,拼接图片的完整 URL。
用于预览上传的图片,拼接图片的完整 URL。**当前项目留空**(后端 `get_file_url()` 已按 `FILE_DOMAIN_URL` 补全域名);如配置,**必须与后端 `FILE_DOMAIN_URL` 保持一致**,否则同一张图片前后端解析出不同地址。
### VITE_DROP_CONSOLE — 删除 console
```bash
# 开发环境:保留 console(方便调试)
VITE_DROP_CONSOLE = false
# 生产环境:删除 console(安全+减小体积)
# 当前项目开发与生产构建均为 true(删除 console)
VITE_DROP_CONSOLE = true
# 需要调试时可在本地改为 false
```
### VITE_BUILD_COMPRESS — Gzip 压缩
```bash
VITE_BUILD_COMPRESS = gzip
```
# 当前默认关闭
VITE_BUILD_COMPRESS = 'none'
启用 gzip 压缩后,构建产物会生成 `.gz` 文件,Nginx 配置 `gzip_static on` 可直接使用。
# 可选:gzip / brotli(多个用逗号分隔),启用后构建产物生成 .gz 文件,
# Nginx 配置 gzip_static on 可直接使用
VITE_BUILD_COMPRESS = 'gzip'
```
## 在代码中访问环境变量
@@ -199,52 +203,53 @@ const fullApiUrl = apiUrl + apiPrefix; // 如 "/api" 或 "https://api.example.c
### `vite.config.ts`
```typescript
import { defineConfig, loadEnv } from 'vite';
// ui/vite.config.ts(摘自实际代码,节选)
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,
},
},
},
// VITE_PROXY 容错处理:未配置时使用空代理
let VITE_PROXY: any = viteEnv.VITE_PROXY;
if (!VITE_PROXY) {
VITE_PROXY = '[[]]'; // 默认空代理
}
if (typeof VITE_PROXY === 'string') {
VITE_PROXY = JSON.parse(VITE_PROXY);
}
// 构建配置
build: {
// 生产环境删除 console
drop: env.VITE_DROP_CONSOLE === 'true' ? ['console', 'debugger'] : [],
// 启用 gzip 压缩
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router', 'pinia'],
element: ['element-plus'],
},
},
},
return {
server: {
proxy: createProxy(VITE_PROXY), // 见 build/vite/proxy.ts
},
// ... 插件、路径别名等见 ui/vite.config.ts 全文
};
});
// ui/build/vite/proxy.ts —— 由 [前缀, 目标] 数组生成代理规则
export function createProxy(list: ProxyList = []) {
const ret: ProxyTargetList = {};
for (const [prefix, target] of list) {
ret[prefix] = {
target,
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(new RegExp(`^${prefix}`), ''), // 去掉前缀再转发
};
}
return ret;
}
```
### 代理配置详解
```typescript
// 开发环境代理配置
// createProxy 为每个 [前缀, 目标] 生成的规则(ui/build/vite/proxy.ts 实际逻辑)
server: {
proxy: {
'/api': {
target: 'http://admin.thinkphp6.elevue/api', // 目标地址
changeOrigin: true, // 修改请求头 Origin
rewrite: (path) => path.replace(/^\/api/, ''), // 路径重写(可选)
target: 'http://localhost:8000/api', // 目标地址(VITE_PROXY 驱动,含 /api 前缀)
changeOrigin: true, // 修改请求头 Origin
ws: true, // 支持 WebSocket
rewrite: (path) => path.replace(new RegExp('^/api'), ''), // 去掉前缀,由目标地址补回
},
},
}
@@ -257,7 +262,7 @@ server: {
│
▼ Vite proxy
│
▼ 转发到: http://admin.thinkphp6.elevue/api/user/page
▼ 转发到: http://localhost:8000/api/user/page
```
## 构建命令
@@ -272,10 +277,8 @@ pnpm build
# 预览构建结果(本地启动静态服务器)
pnpm preview
# ESLint 检查
# ESLint 检查 / 自动修复
pnpm lint:eslint
# ESLint 自动修复
pnpm lint:eslint --fix
# TypeScript 类型检查
@@ -316,7 +319,7 @@ server {
# API 代理到后端
location /api/ {
proxy_pass http://127.0.0.1:8080/api/;
proxy_pass http://127.0.0.1:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
@@ -336,20 +339,23 @@ server {
## 多环境配置
### 测试环境
### 测试 / UAT 环境(项目实际提供)
```bash
# .env.staging
# .env.test(pnpm build:test)
VITE_GLOB_API_URL =
VITE_GLOB_API_URL_PREFIX = /api
VITE_GLOB_IMG_URL = https://test-cdn.example.com
VITE_DROP_CONSOLE = true
# .env.uat(pnpm build:uat)同结构,按需调整
```
构建命令:
构建命令(见 `ui/package.json` scripts):
```bash
pnpm build --mode staging
pnpm build:test # 测试环境构建
pnpm build:uat # UAT 环境构建
pnpm build:prod # 生产环境构建
```
### 环境变量对照表
@@ -359,8 +365,9 @@ 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_PROXY` | `[["/api","http://admin.laravel13.elevue/api"]]` | - | - |
| `VITE_GLOB_IMG_URL` | 留空 | 留空 | 留空(按需配置 CDN) |
| `VITE_DROP_CONSOLE` | true | true | true |
| `VITE_USE_MOCK` | false | false | false |
## 常见问题
@@ -369,19 +376,19 @@ pnpm build --mode staging
**原因:** 代理配置错误或后端未启动。
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确。
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确,`curl http://localhost:8000/api/captcha` 验证后端;改完配置重启 `pnpm dev`。
### 问题 2:生产环境 API 请求跨域
**原因:** `VITE_GLOB_API_URL` 配置了跨域地址但后端未配置 CORS。
**解决:** 推荐留空,由 Nginx 代理处理(同域无跨域问题)。
**解决:** 推荐留空,由 Nginx 代理处理(同域无跨域问题);确需跨域时在后端 `.env` 配置 `CORS_ALLOW_ORIGIN`(见 [5.5 跨域资源共享](/zh/core/cors.html))。
### 问题 3:图片不显示
**原因:** `VITE_GLOB_IMG_URL` 配置错误。
**原因:** `VITE_GLOB_IMG_URL` 与后端 `FILE_DOMAIN_URL` 不一致。
**解决:** 检查图片访问地址是否正确,确保图片 URL 可直接访问。
**解决:** 两者保持一致;改后端配置需 `php artisan config:clear`。
### 问题 4:构建后体积过大
@@ -1,10 +1,10 @@
# 9.11 Excel 导入导出组件
:::tip 概述
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法。
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法,并说明前端解析与后端导入两条链路的选择。
:::
## 导入组件
## 导入组件(前端解析)
```vue
<template>
@@ -22,7 +22,41 @@ const onImportSuccess = (data) => {
</script>
```
## 导出功能
:::tip 前端解析 vs 后端导入
- **前端解析(本组件)**:`xlsx` 库本地解析,适合导入前预览、无业务校验的场景;
- **后端导入(推荐)**:`el-upload` 直传 `/api/{module}/import`,由 Service 逐行 `add()`(字典反查、唯一校验、事务隔离、错误行明细),见 [7.4 数据导入导出](/zh/tools/excel.html)。
:::
### 后端导入对接
```vue
<template>
<el-upload
:action="'/api/example/import'"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImportSuccess"
accept=".xls,.xlsx"
>
<el-button type="primary">导入</el-button>
</el-upload>
</template>
<script setup>
const handleImportSuccess = (response) => {
if (response.code === 0) {
const { count, errors } = response.data;
if (errors.length === 0) {
ElMessage.success(`成功导入 ${count} 条数据`);
} else {
ElMessage.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
// errors: [{ row: 3, name: '...', reason: '...' }]
}
}
};
</script>
```
## 导出功能(前端导出)
```typescript
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
@@ -41,17 +75,18 @@ const handleExport = () => {
};
```
## 后端导出对接
## 后端导出对接(推荐)
```typescript
// 调用后端导出接口,返回文件 URL
// 调用后端导出接口,返回文件 URL(全量数据 + 权限控制 + 字典翻译)
const handleExport = async () => {
const url = await exampleExport(searchParams.value);
// 下载文件
window.open(url);
const res = await exampleExport(searchParams.value); // 文件地址
window.open(res);
};
```
后端接口由 `Controller::_export()` 封装(`#[OperationLog(TYPE_EXPORT)]` + `#[Permission]` + `ExcelService`),大数据量时 Service 内改用 `chunkExport` 分批导出。
## 导入模板下载
```typescript
@@ -66,12 +101,20 @@ const handleDownloadTemplate = async () => {
| 场景 | 说明 |
|------|------|
| 大数据量导出 | 后端自动分批导出,避免内存溢出 |
| 导入模板 | 第一行为表头,第二行起为数据 |
| 字段映射 | 后端 `headerMap` 配置表头与字段的对应关系 |
| 枚举翻译 | 导入时自动将文本转为枚举值,导出时自动将枚举值转为文本 |
| 大数据量导出 | 后端 `chunkExport` 分批导出(回调取空终止),避免内存溢出 |
| 导入模板 | 第一行为表头,第二行起为数据(行号反馈 `row = 索引 + 2`) |
| 字段映射 | 后端 `headerMap` 配置表头与字段的对应关系(未声明列忽略) |
| 枚举翻译 | 导入时 `DictCacheService::getValue` 文本转值,导出时 `serializeMaps` 值转文本 |
| 文件限制 | 仅 `xls/xlsx`、10MB 上限、finfo MIME 检测(`_import` 内置) |
## 两种方案选择
| 方式 | 适用场景 | 校验 |
|------|---------|------|
| 前端 `ImportExcel` 解析 | 导入预览、轻量转换 | 仅格式解析 |
| 后端 `/import` 接口 | 正式导入 | FormRequest/钩子/唯一性/字典全量校验 |
## 相关文档
- [数据导入导出](/zh/tools/excel.html)
- [上传配置详解](/zh/tools/upload-config.html)
- [上传配置详解](/zh/tools/upload-config.html)
@@ -28,8 +28,8 @@ const [registerForm] = useForm({
component: 'Select',
componentProps: {
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
{ label: '在用', value: 1 },
{ label: '停用', value: 2 },
],
},
},
@@ -38,6 +38,10 @@ const [registerForm] = useForm({
</script>
```
:::tip 前端校验与后端 FormRequest 的关系
`schemas` 的 `required`/校验规则是**体验层**(即时提示);后端 `Http/Requests/*.php` 的 `rules()` 才是**最终防线**。两边规则应保持一致(如 max 长度、in 枚举值),以后端为准。
:::
## Modal 弹窗组件
```vue
@@ -73,10 +77,14 @@ const handleSubmit = async () => {
<script setup>
import { BasicUpload } from '@/components/Upload';
const uploadUrl = '/api/upload/uploadFile';
const uploadUrl = '/api/upload/uploadFile'; // 需认证 + sys:upload:file 权限
</script>
```
:::tip 上传字段值
绑定值建议存 `filePath`(相对路径),Service 的 `fileFields` 归一化入库、读取时自动补全域名。详见 [7.1 文件上传全流程](/zh/tools/upload.html)。
:::
## Editor 富文本编辑器
```vue
@@ -89,6 +97,8 @@ import Editor from '@/components/Editor/index.vue';
</script>
```
(后端需在 Service 配置 `contentFields` 启用 `[IMG_URL]` 占位处理,见 [7.3 富文本处理](/zh/tools/rich-text.html)。)
## Password 密码强度组件
```vue
@@ -101,6 +111,8 @@ import { Password } from '@/components/Password';
</script>
```
(后端密码策略为 bcrypt 双重加盐,`BCRYPT_ROUNDS=12`,见 [5.6 密码加密服务](/zh/core/password.html)。)
## ChinaArea 行政区划组件
```vue
@@ -111,4 +123,6 @@ import { Password } from '@/components/Password';
<script setup>
import ChinaArea from '@/components/ChinaArea/index.vue';
</script>
```
```
(数据来自后端 `/api/city/*` 级联接口,`city` 值为四层数组 `[省,市,区,街道]`,见 [6.9 行政区划管理](/zh/modules/city.html)。)
@@ -1,15 +1,16 @@
# 9.4 HTTP 请求封装
:::tip 概述
前端使用 Axios 封装 HTTP 请求,自动处理 Token 携带、响应码判断、错误提示。
前端使用 Axios 封装 HTTP 请求,自动处理 Token 携带、响应码判断与错误提示(401 时提示重新登录)。
:::
## 封装结构
```text
src/utils/http/axios/
ui/src/utils/http/axios/
├── index.ts # Axios 实例配置和拦截器
├── Axios.ts # VAxios 类封装
├── axiosCancel.ts # 请求取消
├── axiosTransform.ts # 数据转换接口
├── checkStatus.ts # HTTP 状态码处理
├── helper.ts # 辅助函数
@@ -21,9 +22,10 @@ src/utils/http/axios/
### 请求拦截器(自动携带 Token)
```typescript
// src/utils/http/axios/index.ts
// ui/src/utils/http/axios/index.ts
// 请求前自动添加 Token
// 请求前自动添加 Token(storage 键名 'ACCESS-TOKEN')
const token = storage.get('ACCESS-TOKEN', '');
config.headers['Authorization'] = `Bearer ${token}`;
```
@@ -34,29 +36,53 @@ config.headers['Authorization'] = `Bearer ${token}`;
const { code, data, msg } = res.data;
if (code === 0) {
return data; // 成功,返回 data
return data; // 成功,返回 data(拦截器已解包)
}
if (code === 401) {
// Token 过期,跳转登录
router.push('/login');
if (code === 401 || res.status === 401) {
// Token 过期 → 弹窗提示 → storage.clear() → 跳转登录(见下方 401 处理)
}
// 其他错误,显示提示
if (code === 403 || res.status === 403) {
// 权限不足 / 租户异常 / 演示模式拦截
ElMessage.error(msg || '无访问权限');
}
// 其他业务错误,显示提示
ElMessage.error(msg || '操作失败');
```
:::tip 响应处理规则
- `code === 0`:成功,返回 `data`
- `code === 401`:Token 过期,自动跳转登录页
- `code === 403`:权限不足,显示错误提示
- 其它 `code`:业务失败,显示 `msg`
- `code/status === 401`:Token 过期,弹窗提示重新登录 → 清除本地存储 → 跳转登录页
- `code/status === 403`:权限不足,显示错误提示(`msg` 含权限码或拦截原因)
- 其它 `code`:业务失败,显示 `msg`(`1`=业务/FormRequest 错误、`404`=数据不存在、`422`=手动校验、`500`=服务器异常)
- HTTP 状态码约定见 [8.1 统一响应格式](/zh/api-spec/response.html)
:::
### 401 处理(实际实现,摘自 `ui/src/utils/http/axios/index.ts`)
```typescript
// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { type: 'warning' })
.then(() => {
storage.clear();
window.location.href = PageEnum.BASE_LOGIN; // '/login'
})
.catch(() => {});
break;
```
:::info 自动续签能力在后端
`ui/` 当前未内置自动续签(401 直接重新登录)。后端 `POST /api/refresh` 刷新能力完整保留,详见 [5.2 认证流程实战](/zh/core/auth-flow.html) 与 [8.6 Token 使用说明](/zh/api-spec/token.html)。
:::
## API 模块编写
```typescript
// src/api/tool/example.ts
// ui/src/api/tool/example.ts
import { http } from '@/utils/http/axios';
export function getExamplePage(params?: Record<string, any>) {
@@ -74,17 +100,38 @@ export function exampleAdd(data: any) {
data,
});
}
// 注意:批量删除为 DELETE(请求体 { ids: [] })
export function exampleBatchDelete(data: any) {
return http.request({
url: '/example/batchDelete',
method: 'DELETE',
data,
});
}
```
(完整规范见 [9.13 API 模块编写规范](/zh/frontend/api-module.html)。)
## 在组件中使用
```typescript
import { getExamplePage, exampleAdd } from '@/api/tool/example';
// 获取列表
// 获取列表(res 已是 data 部分)
const res = await getExamplePage({ pageNo: 1, pageSize: 10 });
tableData.value = res.records;
tableData.value = res.records; // { records, total, current, size }
total.value = res.total;
// 新增
await exampleAdd({ name: '测试', status: 1 });
```
```
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 所有请求 401 | Token 未携带 / 已过期 | 检查拦截器与登录态;401 弹窗确认后清存储并跳登录页 |
| 请求 404 | URL 与后端路由不一致 | 核对 `routes/api.php`(注意 `batchDelete` 为 DELETE) |
| 请求跨域 | 直连后端且未配代理/CORS | 用 Vite `VITE_PROXY` 代理或后端配 `CORS_ALLOW_ORIGIN` |
| 响应取不到 data | 拦截器未解包 | 统一约定拦截器返回 `data`;排查是否绕过了 `http.request` |
@@ -1,208 +0,0 @@
# 9.17 国际化(i18n)
:::danger 功能未实现
本章描述的国际化(i18n)方案为规划中的功能,核心依赖(`locales/` 目录、`LangSwitch` 组件、`locale` Store)尚未在项目中实现。请勿按本文档操作,等待后续版本发布。
:::
:::tip 概述
本节介绍前端项目的国际化方案,基于 vue-i18n 实现多语言切换。项目默认提供中文和英文两种语言,可按需扩展其他语言。
:::
## 技术方案
| 项目 | 说明 |
|------|------|
| 插件 | vue-i18n v9 |
| 默认语言 | 中文(zh-CN) |
| 支持语言 | zh-CN、en-US |
| 存储位置 | localStorage(持久化用户选择) |
## 目录结构
:::warning 下列目录尚未创建
`ui/src/locales/` 目录当前不存在,以下为规划结构。
:::
```text
ui/src/
├── locales/
│ ├── index.ts # i18n 初始化配置
│ ├── lang/
│ │ ├── zh-CN.ts # 中文语言包
│ │ └── en-US.ts # 英文语言包
│ └── helper.ts # 语言切换辅助函数
```
## 语言包结构
:::warning 下列文件尚不存在
`ui/src/locales/lang/zh-CN.ts` 当前不存在,以下为规划示例。
:::
```typescript
// ui/src/locales/lang/zh-CN.ts
export default {
common: {
search: '搜索',
reset: '重置',
add: '新增',
edit: '编辑',
delete: '删除',
export: '导出',
import: '导入',
confirm: '确认',
cancel: '取消',
success: '操作成功',
failed: '操作失败',
},
table: {
id: 'ID',
name: '名称',
status: '状态',
createTime: '创建时间',
action: '操作',
},
login: {
username: '用户名',
password: '密码',
captcha: '验证码',
submit: '登录',
rememberMe: '记住我',
},
};
```
## 使用方式
### 模板中使用
```vue
<template>
<div>
<!-- 基础翻译 -->
<el-button>{{ $t('common.search') }}</el-button>
<el-button>{{ $t('common.reset') }}</el-button>
<!-- 带参数翻译 -->
<p>{{ $t('message.welcome', { name: 'Admin' }) }}</p>
</div>
</template>
```
### Script 中使用
```vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();
// 翻译
const searchText = t('common.search');
// 切换语言
const switchLang = (lang: string) => {
locale.value = lang;
localStorage.setItem('lang', lang);
};
</script>
```
### 列定义中使用
```vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const columns = computed(() => [
{ title: t('table.id'), field: 'id', width: 80 },
{ title: t('table.name'), field: 'name' },
{ title: t('table.status'), field: 'statusText', width: 100 },
{ title: t('table.createTime'), field: 'createTime', width: 180 },
]);
</script>
```
## 语言切换组件
:::warning 下列组件和 Store 尚不存在
`ui/src/components/LangSwitch/` 和 `ui/src/store/modules/locale.ts` 当前不存在,以下为规划示例。
:::
```vue
<!-- ui/src/components/LangSwitch/index.vue -->
<template>
<el-dropdown @command="switchLang">
<el-button :icon="Icon" circle />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
<el-dropdown-item command="en-US">English</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useLocaleStore } from '@/store/modules/locale';
const { locale } = useI18n();
const localeStore = useLocaleStore();
const switchLang = (lang: string) => {
locale.value = lang;
localeStore.setLocale(lang);
};
</script>
```
## 后端消息国际化
后端错误消息也可支持国际化,通过请求头 `Accept-Language` 判断语言:
:::warning 下列中间件尚不存在
`app/middleware/LangMiddleware.php` 当前不存在,以下为规划示例。
:::
```php
// app/middleware/LangMiddleware.php
public function handle(Request $request, \Closure $next): Response
{
$lang = $request->header('Accept-Language', 'zh-CN');
app()->setLang($lang);
return $next($request);
}
```
## 添加新语言
:::warning 以下步骤依赖未实现的功能
`ui/src/locales/` 目录尚不存在,请勿执行以下操作。
:::
1. 创建语言包文件 `ui/src/locales/lang/ja-JP.ts`
2. 复制中文语言包结构并翻译
3. 在 `ui/src/locales/index.ts` 中注册新语言
4. 在语言切换组件中添加选项
```typescript
// ui/src/locales/index.ts
import zhCN from './lang/zh-CN';
import enUS from './lang/en-US';
import jaJP from './lang/ja-JP'; // 新增
const messages = {
'zh-CN': zhCN,
'en-US': enUS,
'ja-JP': jaJP, // 新增
};
```
## 相关文档
- [前端概述](/zh/frontend/overview.html)
- [状态管理](/zh/frontend/store.html)
- [环境配置](/zh/frontend/env-config.html)
@@ -33,18 +33,19 @@
```text
ui/src/layout/
├── index.vue # 布局入口
├── components/
│ ├── header/ # 顶部导航(Logo、面包屑、搜索、通知、用户头像)
│ ├── sider/ # 侧边栏菜单(动态菜单、折叠、主题)
│ ├── content/ # 内容区域(keep-alive 缓存)
│ ├── tabs/ # 标签页导航(多标签切换、关闭)
│ └── footer/ # 页脚
└── hooks/ # 布局相关 hooks
├── parentLayout.vue # 父级布局(路由嵌套用)
└── components/
├── Header/ # 顶部导航(AmendPwd 修改密码、Notifier 通知、ProjectSetting 项目设置)
├── Sider/ # 侧边栏菜单(动态菜单、折叠、主题)
├── Main/ # 内容区域(keep-alive 缓存)
├── TagsView/ # 标签页导航(多标签切换、关闭)
├── Logo/ # 品牌标识
└── Footer/ # 页脚
```
## 侧边栏菜单
菜单数据从后端 `/index/getMenus` 接口获取,由 `asyncRouteStore` 管理:
菜单数据从后端 `GET /api/index/getMenus` 接口获取,由 `asyncRouteStore` 管理:
```typescript
// 菜单渲染逻辑
@@ -64,9 +65,14 @@ interface MenuItem {
}
```
:::tip 菜单数据即路由
侧边栏与动态路由消费**同一份** `getMenus` 数据(仅 `type=0` 可导航节点、`status=0`、`hide=0`、`is_delete=0`)——菜单管理里改 `hide` 即可隐藏入口而不删除路由。见 [9.5 路由与动态菜单](/zh/frontend/router.html)。
:::
## 标签页导航
标签页自动记录访问过的页面,支持:
- 点击切换
- 关闭当前/其他/全部
- 右键菜单操作
@@ -83,9 +89,12 @@ tabsViewStore.addTab(route);
tabsViewStore.removeTab(route);
```
(keep-alive 组件列表由路由守卫 `afterEach` 统一维护,见 [9.5 路由与动态菜单](/zh/frontend/router.html)。)
## 响应式布局
布局支持桌面端和平板端:
- 桌面端:侧边栏默认展开
- 平板端:侧边栏默认折叠
- 侧边栏支持手动折叠/展开
@@ -99,4 +108,4 @@ const { width } = useWindowSize();
watchEffect(() => {
collapsed.value = width.value < 1024;
});
```
```
@@ -1,16 +1,16 @@
# 9.8 登录流程
:::tip 概述
本节说明前端登录流程的实现,包括登录表单提交、Token 存储、路由守卫和登出处理。
本节说明前端登录流程的实现,包括登录表单提交、验证码联调、Token 存储、路由守卫和登出处理。
:::
## 前端登录流程
```text
1. 用户输入用户名、密码、验证码
2. 调用 POST /api/login 接口
3. 成功 → 存储 Token 到 localStorage
4. 路由守卫检测到 Token → 获取用户信息
2. 调用 POST /api/login 接口(参数:username / password / key / code)
3. 成功 → 存储 access_token 到 localStorage(键名 ACCESS-TOKEN,仅存访问令牌)
4. 路由守卫检测到 Token → 获取用户信息(GET /api/index/getUser)
5. 生成动态路由 → 跳转首页
```
@@ -27,8 +27,8 @@
<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-input v-model="loginForm.code" placeholder="验证码" />
<img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer" />
</el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
</el-form>
@@ -39,14 +39,15 @@ import { useUserStore } from '@/store/modules/user';
import { getCaptcha } from '@/api/common/user';
const userStore = useUserStore();
const loginForm = ref({ username: '', password: '', captchaCode: '', captchaKey: '' });
const loginForm = ref({ username: '', password: '', code: '', key: '' });
const captchaImage = ref('');
// 获取验证码
// 响应字段:key = 验证码标识,captcha = Base64 图片
const refreshCaptcha = async () => {
const res = await getCaptcha();
loginForm.value.captchaKey = res.key;
captchaImage.value = res.image;
loginForm.value.key = res.key;
captchaImage.value = res.captcha;
};
// 登录
@@ -59,17 +60,77 @@ onMounted(() => refreshCaptcha());
</script>
```
:::warning 字段命名约定(与后端一致)
- 验证码接口 `/api/captcha` 返回 **`key` + `captcha`**(不是 `image`)
- 登录接口 `/api/login` 接收 **`key` + `code`**(不是 `captchaKey` / `captchaCode`)
- 登录响应 data 为 **`access_token` / `refresh_token`**(snake_case,登录接口 raw 返回,见 [8.6 Token 使用说明](/zh/api-spec/token.html))
- 登录失败(含验证码错误)应**刷新验证码并清空输入**
:::
## 完整登录示例(含失败处理)
```typescript
const loading = ref(false);
const handleLogin = async () => {
await formRef.value.validate();
loading.value = true;
try {
await userStore.login(loginForm.value);
ElMessage.success('登录成功');
router.push('/');
} catch (e: any) {
// 登录失败(验证码错误/密码错误等),刷新验证码
ElMessage.error(e.message || '登录失败');
refreshCaptcha();
loginForm.value.code = '';
} finally {
loading.value = false;
}
};
```
### 刷新验证码的时机
| 时机 | 说明 |
|------|------|
| 页面加载时 | `onMounted` 自动获取 |
| 点击图片时 | 用户手动刷新 |
| 登录失败后 | 一次性已消费,自动刷新并清空输入 |
| 超过 5 分钟 | 验证码过期(`CAPTCHA_EXPIRE=300`),用户点击刷新 |
## Token 存储
```typescript
// 登录成功后存储
storage.set(ACCESS_TOKEN, data.access_token, 7 * 24 * 60 * 60 * 1000); // 7天过期
// 登录成功后存储(摘自 ui/src/store/modules/user.ts,仅存 access_token)
const ex = 7 * 24 * 60 * 60 * 1000;
storage.set(ACCESS_TOKEN, data.access_token, ex); // 键名 'ACCESS-TOKEN'
// 请求拦截器自动携带
config.headers['Authorization'] = `Bearer ${token}`;
// 请求拦截器自动携带(ui/src/utils/http/axios/index.ts)
config.headers['Authorization'] = `Bearer ${storage.get('ACCESS-TOKEN', '')}`;
// 响应拦截器检测 401
if (response.data.code === 401) {
router.push('/login');
}
```
// 响应拦截器检测 401 → 弹窗提示 → 清存储 → 跳转登录页
// (本项目未内置自动续签,见 8.6 Token 使用说明)
```
## 登出
```typescript
// userStore.logout():POST /api/logout(携带 access_token)
// 后端同时执行 revokeToken()(吊销 refresh_token,Redis jti 删除)
// 前端 storage.remove(ACCESS_TOKEN) / remove(CURRENT_USER) / storage.clear() → 跳转登录页
```
:::tip 登出必须调接口
本项目 refresh_token 存 Redis(有状态)——**必须调用 `/api/logout`** 才能让服务端令牌即时失效;仅清前端 localStorage 的话,旧 refresh_token 在 7 天内仍可用于续签。
:::
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 登录提示验证码错误 | 字段名不符(key/code)或已过期 | 按约定传参;失败后刷新验证码 |
| 登录成功但刷新页面回到登录页 | Token 未正确存入 Storage | 检查 `storage.set` 与拦截器读取键名 |
| 登录接口 500 | 后端 Redis 未启动 / JWT_SECRET 异常 | 见 [2.9 常见问题 FAQ](/zh/quickstart/faq.html) |
| 登录后菜单空白 | 用户无角色 | 角色管理分配菜单(见 [9.5 路由与动态菜单](/zh/frontend/router.html)) |
@@ -13,10 +13,14 @@
4. 创建列表页 src/views/{module}/{name}/index.vue
5. 创建编辑弹窗 src/views/{module}/{name}/edit.vue
6. 创建详情弹窗 src/views/{module}/{name}/detail.vue
7. 配置菜单 后端菜单管理中设置组件路径
7. 配置菜单 后端菜单管理中设置组件路径 + 权限节点
8. 测试 刷新前端页面,点击菜单测试
```
:::tip 更快的方式
以上 6 个前端文件可由后端**代码生成器**一键生成(`POST /api/generator/generate`),生成后从第 7 步开始即可。手动编写适合需要深度定制的页面。见 [第 10 章 代码生成器](/zh/generator/overview.html)。
:::
## 完整文件结构
```text
@@ -70,7 +74,7 @@ export function exampleDelete(id) {
return http.request({ url: '/example/delete/' + id, method: 'DELETE' });
}
// 批量删除
// 批量删除(本项目为 DELETE,请求体 { ids: [] })
export function exampleBatchDelete(data: any) {
return http.request({ url: '/example/batchDelete', method: 'DELETE', data });
}
@@ -86,7 +90,7 @@ export function exampleBatchDelete(data: any) {
| 新增 | `{module}Add` | POST |
| 修改 | `{module}Update` | PUT |
| 删除 | `{module}Delete` | DELETE |
| 批量删除 | `{module}BatchDelete` | DELETE |
| 批量删除 | `{module}BatchDelete` | **DELETE**(带 body) |
## 步骤二:创建表格列配置
@@ -117,8 +121,8 @@ export const columns = [
// 选项标签渲染:按值映射文字与颜色
render(record) {
const statusMap = {
0: { text: '禁用', type: 'danger' },
1: { text: '启用', type: 'success' },
2: { text: '停用', type: 'danger' },
1: { text: '在用', type: 'success' },
};
const status = statusMap[record.row.status];
return h(ElTag, { type: status?.type || 'info' }, {
@@ -159,12 +163,16 @@ export const columns = [
|------|------|------|
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
| `label` | string | 表头标题 |
| `prop` | string | 数据字段名(camelCase) |
| `prop` | string | 数据字段名(camelCase,与后端响应一致) |
| `fixed` | string | 固定列:`left` / `right` |
| `width` | number | 固定宽度 |
| `minWidth` | number | 最小宽度(自适应) |
| `render` | function | 自定义渲染函数 |
:::tip 状态值对齐
`statusMap` 的值域必须与数据库/字典一致(如 example:1=在用 2=停用)。推荐直接使用后端补全的 `statusText` 字段,仅颜色按值映射。
:::
## 步骤三:创建查询表单配置
```typescript
@@ -189,8 +197,8 @@ export const schemas: FormSchema[] = [
placeholder: '请选择状态',
clearable: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
{ label: '在用', value: 1 },
{ label: '停用', value: 2 },
],
},
},
@@ -206,6 +214,10 @@ export const schemas: FormSchema[] = [
| `InputNumber` | 数字字段 | 数字输入框 |
| `DatePicker` | 日期字段 | 日期选择器 |
:::tip 查询字段须与后端配置对应
`field` 必须是后端 Service `pageLikeFields`(模糊)或 `pageEqFields`(精确)中声明的字段,否则查询条件不生效(见 [8.3 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
:::
## 步骤四:创建列表页
```vue
@@ -218,10 +230,10 @@ export const schemas: FormSchema[] = [
<!-- 操作栏 -->
<el-row :gutter="10" style="margin-bottom: 10px;">
<el-col :span="1.5">
<el-button type="primary" @click="handleAdd">新增</el-button>
<el-button v-perm="['sys:example:add']" type="primary" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
<el-button v-perm="['sys:example:batchDelete']" type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</el-button>
</el-col>
@@ -236,9 +248,9 @@ export const schemas: FormSchema[] = [
</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>
<el-button v-perm="['sys:example:update']" type="primary" link @click="handleEdit(row)">编辑</el-button>
<el-button v-perm="['sys:example:detail']" type="primary" link @click="handleDetail(row)">详情</el-button>
<el-button v-perm="['sys:example:delete']" type="danger" link @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -280,7 +292,7 @@ const selectedIds = ref<number[]>([]);
const detailVisible = ref(false);
const detailId = ref(0);
// 加载数据
// 加载数据(分页结构:records / total / current / size / pages)
const fetchData = async () => {
loading.value = true;
try {
@@ -331,7 +343,7 @@ const handleDelete = async (id) => {
fetchData();
};
// 批量删除
// 批量删除(DELETE,请求体 { ids: [...] })
const handleBatchDelete = async () => {
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
await exampleBatchDelete({ ids: selectedIds.value });
@@ -365,8 +377,8 @@ onMounted(() => fetchData());
</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 :value="1">在用</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
@@ -382,7 +394,7 @@ onMounted(() => fetchData());
<script setup lang="ts">
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
import { getDictItemList } from '@/api/common/index';
import { getDictItemByCode } from '@/api/common/index';
import { ElMessage } from 'element-plus';
const emit = defineEmits(['success']);
@@ -410,8 +422,8 @@ const open = async (row?) => {
visible.value = true;
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
// 加载字典选项
typeOptions.value = await getDictItemList('example_type');
// 加载字典选项(与后端字典编码一致)
typeOptions.value = await getDictItemByCode('example_type');
// 编辑时加载详情
if (row?.id) {
@@ -443,6 +455,10 @@ defineExpose({ open });
</script>
```
:::tip 表单规则与后端对齐
前端 `rules`(必填/长度/枚举)应与后端 `ExampleRequest::rules()` 一致——前端是体验提示,后端是最终防线(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。
:::
## 步骤六:创建详情弹窗
```vue
@@ -509,7 +525,7 @@ const formData = reactive({
createTime: '',
});
// 加载详情
// 加载详情(typeText/statusText 由后端 serializeMaps 补全)
const loadDetail = async () => {
try {
const data = await getExampleDetail(props.id);
@@ -541,32 +557,37 @@ watch(() => props.visible, (newVal) => {
| 菜单名称 | 案例管理 |
| 组件路径 | `tool/example/index` |
| 路由路径 | `/tool/example` |
| 权限标识 | `sys:example:page` |
| 菜单类型 | 菜单/页面(type=0) |
添加按钮权限:
添加权限节点(type=1,参与接口鉴权):
| 按钮名称 | 权限标识 |
| 节点名称 | 权限标识 |
|---------|---------|
| 查看 | `sys:example:page` |
| 新增 | `sys:example:add` |
| 修改 | `sys:example:update` |
| 删除 | `sys:example:delete` |
| 批量删除 | `sys:example:batchDelete` |
| 详情 | `sys:example:detail` |
(详见 [3.8 权限节点配置](/zh/guide/permission.html),菜单类型语义见 [6.3 菜单管理](/zh/modules/menu.html)。)
## 步骤八:测试
1. 刷新前端页面
2. 点击菜单"案例管理"
3. 测试搜索、新增、编辑、详情、删除、批量删除功能
1. 为测试角色分配新菜单权限
2. 刷新前端页面(或重新登录使动态路由生效)
3. 点击菜单"案例管理"
4. 测试搜索、新增、编辑、详情、删除、批量删除功能
5. 用无权限账号登录,确认 `v-perm` 按钮隐藏、直接调接口返回 403
## 代码生成器(快速创建)
如果模块结构简单,可使用代码生成器一键生成前后端代码:
1. 建好数据库表
2. 访问 `/api/generator/page` 查看可生成的表
3. `POST /api/generator/preview` 预览代码
4. `POST /api/generator/generate` 生成代码
2. `GET /api/generator/page` 查看可生成的表
3. `GET /api/generator/preview` 预览代码
4. `POST /api/generator/generate` 生成代码(`batchGenerate` 批量)
5. 在菜单管理中配置菜单
详见[代码生成器](/zh/generator/overview.html)。
详见 [第 10 章 代码生成器](/zh/generator/overview.html)。
@@ -1,7 +1,7 @@
# 9.1 前端工程概述
:::tip 概述
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 `v-perm` 指令实现按钮级权限控制。前端为本仓库 **`ui/` 目录**内的 EleVue 管理后台,与本后端通过 API 对接。
:::
## 技术栈
@@ -19,11 +19,13 @@
| 图表 | ECharts | 5.5+ | 数据可视化 |
| CSS | TailwindCSS | 3.4+ | 原子化 CSS |
(版本以前端仓库 `package.json` 为准。)
## 核心特性
### 动态路由
从后端 `/index/getMenus` 接口获取菜单数据,自动生成前端路由,无需手动维护路由表。
从后端 `/api/index/getMenus` 接口获取菜单数据,自动生成前端路由,无需手动维护路由表。
```text
后端菜单数据 → generator-routers.ts → Vue Router 动态路由
@@ -34,14 +36,15 @@
`v-perm` / `v-perms` 指令控制按钮级权限:
```vue
<el-button v-perm="'sys:user:add'">新增</el-button>
<el-button v-perm="['sys:user:add']">新增</el-button>
```
### Token 管理
Axios 拦截器自动处理:
- 请求拦截:自动添加 `Authorization: Bearer <token>` 请求头
- 响应拦截:`code === 401` 时自动跳转登录页
- 响应拦截:`code === 401` 时弹窗提示并跳转登录页(未内置自动续签,后端刷新能力见 8.6)
### 组件库
@@ -79,13 +82,27 @@ ui/src/
└── views/ # 页面视图
```
## 与后端的对应
| 前端 | 后端 | 说明 |
|------|------|------|
| `src/api/**` | `routes/api.php` | API 函数 ↔ 路由 |
| `src/views/**` | 菜单表 `component` 字段 | 页面组件 ↔ 动态菜单 |
| `v-perm` 指令 | `#[Permission]` 注解 | 按钮级权限 ↔ 接口鉴权 |
| 响应拦截器 `{code,ok,msg,data}` | `App\Support\Result` | 统一响应协议 |
| `records/total/current/size/pages` | `Result::page()` | 分页固定结构 |
## 开发命令
```bash
pnpm install # 安装依赖
pnpm dev # 启动开发服务器
pnpm install # 安装依赖(或 npm install)
pnpm dev # 启动开发服务器(默认 8001)
pnpm build # 构建生产版本
pnpm preview # 预览构建结果
pnpm lint:eslint # ESLint 检查
pnpm lint:prettier # Prettier 格式化
```
```
:::tip 快速开始
前端启动与代理配置见 [2.4 前端服务启动](/zh/quickstart/frontend-start.html);新增页面的完整步骤见 [9.12 新增页面开发步骤](/zh/frontend/new-page.html)。
:::
@@ -1,7 +1,7 @@
# 9.7 权限控制
:::tip 概述
前端权限控制通过 `v-perm` / `v-perms` 指令实现按钮级权限控制。
前端权限控制通过 `v-perm` / `v-perms` 指令实现按钮级权限控制。**前端权限只是体验层**(隐藏按钮),真正的安全防线是后端 `#[Permission]` 注解 + `auth.jwt` 中间件。
:::
## 权限指令
@@ -11,7 +11,7 @@
```vue
<template>
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
<el-button v-perm="['sys:user:add']" type="primary">新增</el-button>
</template>
```
@@ -27,7 +27,7 @@
## 指令实现
```typescript
// src/directives/permission.ts
// ui/src/directives/permission.ts
import { usePermission } from '@/hooks/web/usePermission';
@@ -35,12 +35,16 @@ export const perm: ObjectDirective = {
mounted(el: HTMLButtonElement, binding) {
const { hasSomePermission } = usePermission();
if (!hasSomePermission(binding.value)) {
el.parentNode.removeChild(el); // 无权限则移除元素
el.parentNode.removeChild(el); // 无权限则移除元素(非隐藏)
}
},
};
```
:::tip 移除而非隐藏
无权限时元素直接从 DOM **移除**(而非 `display:none`)——避免通过开发者工具看到"隐藏"的敏感操作按钮。
:::
## usePermission Hook
:::tip 超级管理员
@@ -48,7 +52,7 @@ export const perm: ObjectDirective = {
:::
```typescript
// src/hooks/web/usePermission.ts
// ui/src/hooks/web/usePermission.ts
export function usePermission() {
const userStore = useUserStore();
@@ -73,7 +77,7 @@ export function usePermission() {
## 权限数据来源
用户登录后,`/index/getUser` 接口返回权限列表:
用户登录后,`GET /api/index/getUser` 接口返回权限列表(`IndexService::getUser` → `JwtService::getUserPermissions`,与后端鉴权同一缓存):
```json
{
@@ -82,10 +86,30 @@ export function usePermission() {
"sys:user:add",
"sys:user:update",
"sys:user:delete",
"sys:role:page",
"..."
"sys:role:page"
]
}
```
超级管理员返回 `["*:*:*"]`,拥有所有权限。
超级管理员返回 `["*:*:*"]`,拥有所有权限。
## 权限码对应关系
| 端 | 机制 | 示例 |
|----|------|------|
| 后端 | `#[Permission('sys:user:add', '添加用户')]` 注解 + 中间件反射鉴权 | 403 拦截 |
| 前端 | `v-perm="['sys:user:add']"` 指令 | 按钮移除 |
| 菜单 | `laravel_menu.permission` 字段(type=1 权限节点) | 角色树勾选 |
三处权限码必须**完全一致**(`sys:{模块}:{操作}`)。
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 按钮不显示但接口可调 | 角色有权限码但 `getUser` 未返回 | 重新登录刷新权限缓存 |
| 按钮显示但接口 403 | 菜单 permission 与注解不一致 | 逐字符比对权限码 |
| 超管按钮仍隐藏 | permissions 未含 `*:*:*` | 检查 `getUser` 返回(uid=1 应为通配) |
| 修改角色权限后前端无变化 | 前端权限缓存在 Store | 重新登录或刷新页面 |
详见 [5.4 权限管理(RBAC)](/zh/core/rbac.html)。
@@ -1,13 +1,13 @@
# 9.5 路由与动态菜单
:::tip 概述
前端路由采用动态生成机制,从后端 `/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。
前端路由采用动态生成机制,从后端 `/api/index/getMenus` 接口获取菜单数据,自动转换为 Vue Router 路由。菜单数据由后端按用户权限过滤——**用户只能看到有权限的菜单**。
:::
## 路由架构
```text
静态路由(固定,src/router/index.ts + src/router/base.ts)
静态路由(固定,ui/src/router/index.ts + ui/src/router/base.ts)
├── /login 登录页(LoginRoute)
├── / 根路径,重定向到首页(RootRoute)
├── /redirect/:path 重定向路由(RedirectRoute)
@@ -28,7 +28,8 @@
```text
1. 用户登录 → 获取 Token
2. 路由守卫检测到 Token
3. 调用 /index/getMenus 获取菜单树
3. 调用 GET /api/index/getMenus 获取菜单树
(后端过滤:type=0 可导航菜单、status=0 启用、hide=0 可见、is_delete=0)
4. generator-routers.ts 将菜单树转换为路由表
5. router.addRoute() 动态注入路由
6. 渲染侧边栏菜单
@@ -37,11 +38,11 @@
## 核心代码
```typescript
// src/router/generator-routers.ts
// ui/src/router/generator-routers.ts
/**
* 动态生成菜单
* 调用后端 /index/getMenus 接口获取菜单数据
* 调用后端 /api/index/getMenus 接口获取菜单数据
*/
export const generatorDynamicRouter = (): Promise<RouteRecordRaw[]> => {
return new Promise((resolve, reject) => {
@@ -81,14 +82,18 @@ export const routerGenerator = (routerMap): any[] => {
};
```
:::tip component 映射
后端菜单的 `component` 字段(如 `system/user/index`)由前端映射到 `views/system/user/index.vue`——菜单管理中组件路径写错会导致页面空白。见 [6.3 菜单管理](/zh/modules/menu.html)。
:::
## 路由守卫
:::tip 动态路由流程
详见上方 [动态路由生成流程](#动态路由生成流程) 章节,路由守卫中实现了该流程的第 2~6 步。
上方[动态路由生成流程](#动态路由生成流程)的第 2~6 步均在路由守卫中实现。
:::
```typescript
// src/router/router-guards.ts
// ui/src/router/router-guards.ts
const LOGIN_PATH = PageEnum.BASE_LOGIN;
const whitePathList = [LOGIN_PATH]; // no redirect whitelist
@@ -200,7 +205,7 @@ export function createRouterGuards(router: Router) {
## 后端菜单数据结构
后端 `/index/getMenus` 返回的菜单树:
`GET /api/index/getMenus` 返回的菜单树(仅 `type=0` 可导航节点):
```json
[
@@ -221,4 +226,17 @@ export function createRouterGuards(router: Router) {
]
}
]
```
```
:::info 权限节点不进菜单树
`type=1` 的权限节点(`permission` 非空)只参与后端接口鉴权(`Authenticate` 中间件),不会出现在 `getMenus` 返回中——前端菜单树与后端权限表是同一份 `laravel_menu` 数据的两个视图。
:::
## 常见问题
| 现象 | 原因 | 处理 |
|------|------|------|
| 登录后菜单为空 | 用户无角色或角色无菜单 | 角色管理分配菜单 |
| 点击菜单白屏 | `component` 路径错误 / views 下无对应文件 | 核对组件路径 |
| 改了菜单不生效 | 动态路由已缓存 | 重新登录或刷新页面 |
| 404 页覆盖真实路由 | 动态路由未加载完成就跳转 | 检查守卫第 5 步是否执行 |
@@ -19,7 +19,9 @@
## userStore 核心代码
```typescript
// src/store/modules/user.ts
// ui/src/store/modules/user.ts
import { ACCESS_TOKEN, CURRENT_USER, IS_LOCKSCREEN } from '@/store/mutation-types';
import { ResultEnum } from '@/enums/httpEnum';
export const useUserStore = defineStore({
id: 'app-user',
@@ -31,37 +33,45 @@ export const useUserStore = defineStore({
info: Storage.get(CURRENT_USER, {}),
}),
actions: {
// 登录
// 登录(key/code 为验证码参数;响应字段为 snake_case 的 access_token,见 8.6)
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);
const { code, data } = response;
if (code === ResultEnum.SUCCESS) {
const ex = 7 * 24 * 60 * 60 * 1000;
storage.set(ACCESS_TOKEN, data.access_token, ex); // 仅存 access_token
storage.set(IS_LOCKSCREEN, false);
this.setToken(data.access_token);
}
return response;
},
// 获取用户信息
// 获取用户信息(含 roles / permissions / city)
async GetInfo() {
const res = await getUserInfo();
const res = await getUserInfo(); // GET /api/index/getUser
this.setPermissions(res.permissions);
this.setUserInfo(res);
this.setAvatar(res.avatar);
return res;
},
// 登出
// 登出(后端同时吊销 refresh_token)
async logout() {
await logout();
this.setPermissions([]);
this.setUserInfo('');
storage.remove(ACCESS_TOKEN);
storage.remove(CURRENT_USER);
storage.clear();
},
},
});
```
:::tip 权限与后端一致
`GetInfo` 拿到的 `permissions` 与 `Authenticate` 中间件鉴权用的是同一份数据(复用 `JwtService::getUserPermissions` 的 Redis 缓存)——超管为 `['*:*:*']`,普通用户为角色关联的 `menu.permission` 列表。
:::
## 在组件中使用
:::tip Store 使用方式
@@ -79,8 +89,18 @@ const username = userStore.username;
const permissions = userStore.permissions;
// 登录
await userStore.login({ username: 'admin', password: '123456' });
await userStore.login({ username: 'admin', password: '123456', key, code });
// 登出
await userStore.logout();
```
```
## asyncRouteStore(动态路由)
| 状态/方法 | 说明 |
|------|------|
| `getIsDynamicAddedRoute` | 动态路由是否已加载(守卫据此跳过重复生成) |
| `generateRoutes(userInfo)` | 调用 `/api/index/getMenus` 生成路由表 |
| `keepAliveComponents` | 页面级缓存组件名列表(标签页切换保留状态) |
(生成细节见 [9.5 路由与动态菜单](/zh/frontend/router.html)。)
@@ -106,4 +106,10 @@ module.exports = {
background: #fafafa;
border-radius: 4px;
}
```
```
:::tip 样式约定
- 优先使用 TailwindCSS 原子类(布局/间距/颜色)
- 组件级覆盖写在 `theme/*.scss`,避免散落在页面中
- 与后端联调时注意:列表状态 Tag 颜色应与字典值语义一致(如 1=在用 success / 2=停用 danger)
:::
@@ -22,6 +22,10 @@ storage.remove('key');
storage.clear();
```
:::tip Token 存储约定
访问令牌通过 Storage 管理(键名常量 `ACCESS_TOKEN`,实际键 `'ACCESS-TOKEN'`;本项目前端仅存访问令牌)。登出时清除本地存储**并调用** `/api/logout`(服务端吊销 refresh_token,见 [9.8 登录流程](/zh/frontend/login.html))。
:::
## 类型判断
```typescript
@@ -62,8 +66,8 @@ 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'
formatToDate(new Date()); // '2026-10-02'
formatToDateTime(new Date()); // '2026-10-02 14:30:00'
```
## 深拷贝
@@ -101,4 +105,15 @@ const debouncedSearch = useDebounceFn((keyword: string) => {
const throttledScroll = useThrottleFn(() => {
handleScroll();
}, 200);
```
```
## 字典工具
```typescript
import { getDictItemByCode } from '@/api/common/index';
// 按编码取字典项(下拉框数据源)
const options = await getDictItemByCode('example_status');
```
(接口详情见 [5.11 字典配置与使用](/zh/core/dict-guide.html)。)