签入版本
This commit is contained in:
@@ -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)。)
|
||||
|
||||
Reference in New Issue
Block a user