签入版本

This commit is contained in:
2026-10-11 19:06:33 +08:00
parent 17d28de8b7
commit 4084c6bd17
674 changed files with 58855 additions and 1892 deletions
@@ -119,7 +119,7 @@ export function exampleBatchDelete(data: any) {
```vue
<script setup lang="ts">
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { ElMessage, ElMessageBox } from 'element-plus';
import { message, Modal } from 'ant-design-vue';
const tableData = ref([]);
@@ -131,16 +131,21 @@ const fetchData = async () => {
// 删除
const handleDelete = async (id) => {
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' });
await exampleDelete(id);
ElMessage.success('删除成功');
fetchData();
Modal.confirm({
title: '提示',
content: '确认删除?',
onOk: async () => {
await exampleDelete(id);
message.success('删除成功');
fetchData();
},
});
};
// 批量删除
const handleBatchDelete = async (ids) => {
await exampleBatchDelete({ ids });
ElMessage.success('删除成功');
message.success('删除成功');
fetchData();
};
</script>
@@ -1,20 +1,21 @@
# 9.2 目录结构详解
:::tip 概述
本节详细介绍前端项目(独立 EleVue 仓库)的目录结构,帮助开发者快速定位代码和理解项目组织方式。
本节详细介绍前端项目(独立 AntdVue 仓库)的目录结构,帮助开发者快速定位代码和理解项目组织方式。
:::
```text
ui/src/
├── api/ # API 接口层(按业务域分组)
│ ├── common/ # 公共接口(登录/菜单/用户信息/城市)
│ ├── system/ # 系统管理(用户/角色/菜单/部门/岗位/职级/日志/租户)
│ ├── system/ # 系统管理(用户/角色/菜单/部门/岗位/职级/租户)
│ ├── content/ # 内容管理(文章/分类/链接)
│ ├── data/ # 数据管理(城市/配置/字典/公告/参数)
│ ├── dashboard/ # 仪表盘(控制台/消息)
│ ├── file/ # 文件管理(文件模板)
│ ├── logger/ # 日志管理(登录日志/操作日志)
│ ├── monitor/ # 监控管理(定时任务)
│ ├── region/ # 行政区划
│ ├── oauth2/ # OAuth2 授权
│ ├── setting/ # 设置(个人资料/网站配置)
│ └── tool/ # 工具(示例/代码生成器)
├── components/ # 公共组件库(20+ 个)
@@ -30,7 +31,10 @@ ui/src/
│ ├── Qrcode/ # 二维码
│ └── ...
├── directives/ # 自定义指令
│ └── permission.ts # v-perm / v-perms 权限指令
│ ├── permission.ts # v-perm / v-perms 权限指令
│ ├── clickOutside.ts # 点击元素外部检测
│ ├── dragModal.ts # 弹窗拖拽
│ └── scrollBar.ts # 滚动条控制
├── enums/ # 枚举常量
│ ├── httpEnum.ts # HTTP 枚举
│ └── pageEnum.ts # 页面枚举
@@ -50,11 +54,10 @@ ui/src/
│ ├── user.ts # 用户 Store(Token/用户信息/权限)
│ ├── asyncRoute.ts # 动态路由 Store
│ ├── tabsView.ts # 标签页 Store
│ ├── projectSetting.ts # 项目配置 Store
│ ├── designSetting.ts # 主题设计 Store
│ ├── projectSetting.ts # 项目配置 Store(导航/顶栏主题等)
│ ├── lockscreen.ts # 锁屏 Store
│ └── ossConfig.ts # 云存储配置 Store
├── styles/ # 全局样式(Tailwind / 主题变量)
│ └── index.ts # Store 入口(setupStore 挂载)
├── styles/ # 全局样式(WindiCSS / Less 主题变量)
├── utils/ # 工具函数
│ ├── http/axios/ # HTTP 请求封装(拦截器)
│ └── Storage.ts # 本地存储封装
@@ -65,9 +68,12 @@ ui/src/
├── content/ # 内容管理页面
├── data/ # 数据管理页面
├── monitor/ # 监控管理页面
├── logger/ # 日志管理页面
├── oauth2/ # OAuth2 授权页面
├── file/ # 文件管理页面
├── setting/ # 设置页面(个人资料等)
├── tool/ # 工具页面(示例/代码生成器)
├── about/ # 关于页面
├── iframe/ # 内嵌 iframe 页面
├── redirect/ # 重定向页面
└── exception/ # 异常页面(403/404/500)
@@ -14,7 +14,7 @@
:row-key="(row) => row.id"
ref="tableRef"
:actionColumn="actionColumn"
@selection-change="onSelectionChange"
:row-selection="{ onChange: onSelectionChange }"
/>
</template>
@@ -36,7 +36,7 @@ const actionColumn = {
```
:::tip 分页字段约定
本项目 `BasicTable` 通过 `:request` 传入分页请求函数(没有 `useTable` hook),内部绑定后端固定分页结构 `data.records` / `data.total`。`statusText` 等 `*Text` 字段由后端 `serializeMaps` 自动补全,直接作为列 `field` 使用即可。
本项目 `BasicTable` 通过 `:request` 传入分页请求函数(没有 `useTable` hook),内部绑定后端固定分页结构 `data.records` / `data.total`。`statusText` 等 `*Text` 字段由后端 `serializeMaps` 自动补全,直接作为列 `dataIndex` 使用即可。
:::
### 列定义常用属性
@@ -44,10 +44,11 @@ const actionColumn = {
| 属性 | 说明 | 示例 |
|------|------|------|
| `title` | 列标题 | `'创建时间'` |
| `field` | 字段名(camelCase,与响应一致) | `'createTime'` |
| `dataIndex` | 数据字段名(camelCase,与响应一致) | `'createTime'` |
| `key` | 列标识(`#bodyCell` 插槽按 `column.key` 匹配) | `'action'` |
| `width` | 列宽 | `180` |
| `formatter` | 自定义格式化 | `(v) => v ?? '-'` |
| `slots` | 自定义单元格插槽 | `'statusSlot'` |
| `customRender` | 自定义渲染函数(参数 `{ record }`) | `({ record }) => h(Tag, ...)` |
| `slots` | 自定义单元格插槽 | `{ customRender: 'statusSlot' }` |
## Page 页面容器
@@ -10,10 +10,8 @@
ui/
├── .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.production # 生产构建(pnpm build / build:prod)
└── .env.test # 测试构建(pnpm build:test)
```
**加载优先级:** `.env.{mode}` > `.env`(Vite 标准加载规则,可用 `.env.{mode}.local` 做本地覆盖)
@@ -24,7 +22,7 @@ ui/
```bash
# 开发服务器端口
VITE_PORT = 8001
VITE_PORT = 8002
# 公共基础路径(通常为 /)
VITE_PUBLIC_PATH = /
@@ -36,7 +34,7 @@ VITE_USE_MOCK = false
VITE_DROP_CONSOLE = true
# 开发代理(JSON 数组格式:[["前缀","目标地址"]])
VITE_PROXY=[["/api","http://admin.laravel13.elevue/api"]]
VITE_PROXY=[["/api","http://admin.laravel13.antdvue/api"]]
# API 接口基础路径(留空,由代理处理)
VITE_GLOB_API_URL =
@@ -57,7 +55,7 @@ VITE_GLOB_API_URL_PREFIX = /api
```
:::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`。
`VITE_PROXY` 当前默认指向 Laragon 虚拟主机 `http://admin.laravel13.antdvue/api`;使用 `php artisan serve` 时改为直连 `http://admin.laravel13.antdvue/api`(见 [2.2 Laragon 环境搭建](/zh/quickstart/laragon.html))。修改后需重启 `pnpm dev`。
:::
## 生产环境配置
@@ -99,7 +97,7 @@ VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
| 环境 | 值 | 说明 |
|------|-----|------|
| 开发 | `8001` | 前端开发服务器端口 |
| 开发 | `8002` | 前端开发服务器端口 |
| 生产 | 不生效 | 生产环境由 Nginx 提供服务 |
### VITE_PUBLIC_PATH — 公共基础路径
@@ -121,10 +119,10 @@ VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
```bash
# 当前 .env.development 中的值(Laragon 虚拟主机)
VITE_PROXY=[["/api","http://admin.laravel13.elevue/api"]]
VITE_PROXY=[["/api","http://admin.laravel13.antdvue/api"]]
# 直连 artisan serve 时的写法
VITE_PROXY=[["/api","http://localhost:8000/api"]]
VITE_PROXY=[["/api","http://admin.laravel13.antdvue/api"]]
```
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
@@ -134,7 +132,7 @@ VITE_PROXY=[["/api","http://localhost:8000/api"]]
**多代理示例:**
```bash
VITE_PROXY = [["/api","http://localhost:8000/api"],["/uploads","http://localhost:8000/uploads"]]
VITE_PROXY = [["/api","http://admin.laravel13.antdvue/api"],["/uploads","http://admin.laravel13.antdvue/uploads"]]
```
### VITE_GLOB_API_URL_PREFIX — API 前缀
@@ -246,7 +244,7 @@ export function createProxy(list: ProxyList = []) {
server: {
proxy: {
'/api': {
target: 'http://localhost:8000/api', // 目标地址(VITE_PROXY 驱动,含 /api 前缀)
target: 'http://admin.laravel13.antdvue/api', // 目标地址(VITE_PROXY 驱动,含 /api 前缀)
changeOrigin: true, // 修改请求头 Origin
ws: true, // 支持 WebSocket
rewrite: (path) => path.replace(new RegExp('^/api'), ''), // 去掉前缀,由目标地址补回
@@ -262,7 +260,7 @@ server: {
│
▼ Vite proxy
│
▼ 转发到: http://localhost:8000/api/user/page
▼ 转发到: http://admin.laravel13.antdvue/api/user/page
```
## 构建命令
@@ -294,7 +292,7 @@ ui/dist/
│ ├── index-xxx.js # 主 JS(带 hash)
│ ├── index-xxx.css # 主 CSS(带 hash)
│ ├── vue-xxx.js # Vue 相关(分包)
│ └── element-xxx.js # Element Plus(分包)
│ └── antd-xxx.js # AntDesign Vue(分包)
├── favicon.ico
└── static/ # 静态资源
```
@@ -339,22 +337,19 @@ server {
## 多环境配置
### 测试 / UAT 环境(项目实际提供)
### 测试环境(项目实际提供)
```bash
# .env.test(pnpm build:test)
VITE_GLOB_API_URL =
VITE_GLOB_API_URL_PREFIX = /api
VITE_DROP_CONSOLE = true
# .env.uat(pnpm build:uat)同结构,按需调整
```
构建命令(见 `ui/package.json` scripts):
```bash
pnpm build:test # 测试环境构建
pnpm build:uat # UAT 环境构建
pnpm build:prod # 生产环境构建
```
@@ -362,10 +357,10 @@ pnpm build:prod # 生产环境构建
| 变量 | 开发 | 测试 | 生产 |
|------|------|------|------|
| `VITE_PORT` | 8001 | - | - |
| `VITE_PORT` | 8002 | - | - |
| `VITE_GLOB_API_URL` | 留空 | 留空 | 留空 |
| `VITE_GLOB_API_URL_PREFIX` | /api | /api | /api |
| `VITE_PROXY` | `[["/api","http://admin.laravel13.elevue/api"]]` | - | - |
| `VITE_PROXY` | `[["/api","http://admin.laravel13.antdvue/api"]]` | - | - |
| `VITE_GLOB_IMG_URL` | 留空 | 留空 | 留空(按需配置 CDN) |
| `VITE_DROP_CONSOLE` | true | true | true |
| `VITE_USE_MOCK` | false | false | false |
@@ -376,7 +371,7 @@ pnpm build:prod # 生产环境构建
**原因:** 代理配置错误或后端未启动。
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确,`curl http://localhost:8000/api/captcha` 验证后端;改完配置重启 `pnpm dev`。
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确,`curl http://admin.laravel13.antdvue/api/captcha` 验证后端;改完配置重启 `pnpm dev`。
### 问题 2:生产环境 API 请求跨域
@@ -8,7 +8,7 @@
```vue
<template>
<el-button @click="handleImport">导入</el-button>
<a-button @click="handleImport">导入</a-button>
<ImportExcel @success="onImportSuccess" />
</template>
@@ -24,32 +24,38 @@ const onImportSuccess = (data) => {
:::tip 前端解析 vs 后端导入
- **前端解析(本组件)**:`xlsx` 库本地解析,适合导入前预览、无业务校验的场景;
- **后端导入(推荐)**:`el-upload` 直传 `/api/{module}/import`,由 Service 逐行 `add()`(字典反查、唯一校验、事务隔离、错误行明细),见 [7.4 数据导入导出](/zh/tools/excel.html)。
- **后端导入(推荐)**:`a-upload` 直传 `/api/{module}/import`,由 Service 逐行 `add()`(字典反查、唯一校验、事务隔离、错误行明细),见 [7.4 数据导入导出](/zh/tools/excel.html)。
:::
### 后端导入对接
```vue
<template>
<el-upload
<a-upload
:action="'/api/example/import'"
:headers="{ Authorization: `Bearer ${token}` }"
:on-success="handleImportSuccess"
:show-upload-list="false"
accept=".xls,.xlsx"
@change="handleChange"
>
<el-button type="primary">导入</el-button>
</el-upload>
<a-button type="primary">导入</a-button>
</a-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: '...' }]
import { message } from 'ant-design-vue';
const handleChange = ({ file }) => {
if (file.status === 'done') {
const response = file.response;
if (response.code === 0) {
const { count, errors } = response.data;
if (errors.length === 0) {
message.success(`成功导入 ${count} 条数据`);
} else {
message.warning(`成功 ${count} 条,失败 ${errors.length} 条`);
// errors: [{ row: 3, name: '...', reason: '...' }]
}
}
}
};
@@ -17,13 +17,13 @@ import { BasicForm, useForm } from '@/components/Form';
const [registerForm] = useForm({
schemas: [
{
field: 'name',
name: 'name',
label: '名称',
component: 'Input',
required: true,
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }],
},
{
field: 'status',
name: 'status',
label: '状态',
component: 'Select',
componentProps: {
@@ -39,7 +39,7 @@ const [registerForm] = useForm({
```
:::tip 前端校验与后端 FormRequest 的关系
`schemas` 的 `required`/校验规则是**体验层**(即时提示);后端 `Http/Requests/*.php` 的 `rules()` 才是**最终防线**。两边规则应保持一致(如 max 长度、in 枚举值),以后端为准。
`schemas` 的 `rules` 校验规则是**体验层**(即时提示);后端 `Http/Requests/*.php` 的 `rules()` 才是**最终防线**。两边规则应保持一致(如 max 长度、in 枚举值),以后端为准。
:::
## Modal 弹窗组件
@@ -45,11 +45,11 @@ if (code === 401 || res.status === 401) {
if (code === 403 || res.status === 403) {
// 权限不足 / 租户异常 / 演示模式拦截
ElMessage.error(msg || '无访问权限');
message.error(msg || '无访问权限');
}
// 其他业务错误,显示提示
ElMessage.error(msg || '操作失败');
message.error(msg || '操作失败');
```
:::tip 响应处理规则
@@ -66,12 +66,17 @@ ElMessage.error(msg || '操作失败');
// code === 401(ResultEnum.TIMEOUT)
case ResultEnum.TIMEOUT:
if (router.currentRoute.value?.name === PageEnum.BASE_LOGIN_NAME) return;
ElMessageBox.confirm('登录身份已失效,请重新登录!', '提示', { type: 'warning' })
.then(() => {
Modal.warning({
title: '提示',
content: '登录身份已失效,请重新登录!',
closable: false,
maskClosable: false,
okText: '确定',
onOk: () => {
storage.clear();
window.location.href = PageEnum.BASE_LOGIN; // '/login'
})
.catch(() => {});
},
});
break;
```
@@ -19,19 +19,22 @@
```vue
<!-- src/views/login/index.vue -->
<template>
<el-form :model="loginForm" @submit.prevent="handleLogin">
<el-form-item>
<el-input v-model="loginForm.username" placeholder="用户名" />
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item>
<el-input v-model="loginForm.code" placeholder="验证码" />
<img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer" />
</el-form-item>
<el-button type="primary" @click="handleLogin">登录</el-button>
</el-form>
<a-form :model="loginForm">
<a-form-item name="username">
<a-input v-model:value="loginForm.username" placeholder="用户名" />
</a-form-item>
<a-form-item name="password">
<a-input-password v-model:value="loginForm.password" placeholder="密码" />
</a-form-item>
<a-form-item name="code">
<a-input v-model:value="loginForm.code" placeholder="验证码">
<template #suffix>
<img :src="captchaImage" @click="refreshCaptcha" style="cursor: pointer" />
</template>
</a-input>
</a-form-item>
<a-button type="primary" block @click="handleLogin">登录</a-button>
</a-form>
</template>
<script setup>
@@ -70,6 +73,8 @@ onMounted(() => refreshCaptcha());
## 完整登录示例(含失败处理)
```typescript
import { message } from 'ant-design-vue';
const loading = ref(false);
const handleLogin = async () => {
@@ -78,11 +83,11 @@ const handleLogin = async () => {
try {
await userStore.login(loginForm.value);
ElMessage.success('登录成功');
message.success('登录成功');
router.push('/');
} catch (e: any) {
// 登录失败(验证码错误/密码错误等),刷新验证码
ElMessage.error(e.message || '登录失败');
message.error(e.message || '登录失败');
refreshCaptcha();
loginForm.value.code = '';
} finally {
@@ -97,77 +97,83 @@ export function exampleBatchDelete(data: any) {
```typescript
// ui/src/views/tool/example/columns.ts
import { h } from 'vue';
import { ElAvatar, ElTag } from 'element-plus';
import { Tag, Avatar } from 'ant-design-vue';
export const columns = [
{
type: 'selection', // 多选框列
},
{
label: 'ID',
prop: 'id',
title: 'ID',
dataIndex: 'id',
fixed: 'left',
width: 50,
},
{
label: '名称',
prop: 'name',
title: '名称',
dataIndex: 'name',
minWidth: 120,
},
{
label: '状态',
prop: 'statusText',
title: '状态',
dataIndex: 'statusText',
minWidth: 100,
// 选项标签渲染:按值映射文字与颜色
render(record) {
customRender({ record }) {
const statusMap = {
2: { text: '停用', type: 'danger' },
1: { text: '在用', type: 'success' },
2: { text: '停用', color: 'error' },
1: { text: '在用', color: 'success' },
};
const status = statusMap[record.row.status];
return h(ElTag, { type: status?.type || 'info' }, {
default: () => status?.text || record.row.statusText,
const status = statusMap[record.status];
return h(Tag, { color: status?.color || 'default' }, {
default: () => status?.text || record.statusText,
});
},
},
{
label: '头像',
prop: 'avatar',
title: '头像',
dataIndex: 'avatar',
minWidth: 80,
// 图片渲染:以方形头像形式展示
render(record) {
return h(ElAvatar, {
customRender({ record }) {
return h(Avatar, {
size: 48,
src: record.row.avatar,
src: record.avatar,
shape: 'square',
fit: 'fill',
});
},
},
{
label: '创建人',
prop: 'createUser',
title: '创建人',
dataIndex: 'createUser',
minWidth: 100,
},
{
label: '创建时间',
prop: 'createTime',
title: '创建时间',
dataIndex: 'createTime',
width: 180,
},
{
title: '操作',
key: 'action',
fixed: 'right',
width: 180,
},
];
```
:::tip 多选列
AntDesign 表格的多选框列不在 `columns` 中定义,而是通过表格的 `:row-selection` 属性开启(见步骤四)。
:::
### 列配置字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
| `label` | string | 表头标题 |
| `prop` | string | 数据字段名(camelCase,与后端响应一致) |
| `title` | string | 表头标题 |
| `dataIndex` | string | 数据字段名(camelCase,与后端响应一致) |
| `key` | string | 列标识(`#bodyCell` 插槽按 `column.key` 匹配) |
| `fixed` | string | 固定列:`left` / `right` |
| `width` | number | 固定宽度 |
| `minWidth` | number | 最小宽度(自适应) |
| `render` | function | 自定义渲染函数 |
| `customRender` | function | 自定义渲染函数(参数 `{ record }`) |
:::tip 状态值对齐
`statusMap` 的值域必须与数据库/字典一致(如 example:1=在用 2=停用)。推荐直接使用后端补全的 `statusText` 字段,仅颜色按值映射。
@@ -181,21 +187,21 @@ import { FormSchema } from '@/components/Form/index';
export const schemas: FormSchema[] = [
{
field: 'name',
name: 'name',
component: 'Input',
label: '名称',
componentProps: {
placeholder: '请输入名称',
clearable: true,
allowClear: true,
},
},
{
field: 'status',
name: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
clearable: true,
allowClear: true,
options: [
{ label: '在用', value: 1 },
{ label: '停用', value: 2 },
@@ -215,7 +221,7 @@ export const schemas: FormSchema[] = [
| `DatePicker` | 日期字段 | 日期选择器 |
:::tip 查询字段须与后端配置对应
`field` 必须是后端 Service `pageLikeFields`(模糊)或 `pageEqFields`(精确)中声明的字段,否则查询条件不生效(见 [8.3 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
`name` 必须是后端 Service `pageLikeFields`(模糊)或 `pageEqFields`(精确)中声明的字段,否则查询条件不生效(见 [8.3 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
:::
## 步骤四:创建列表页
@@ -228,42 +234,42 @@ export const schemas: FormSchema[] = [
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
<!-- 操作栏 -->
<el-row :gutter="10" style="margin-bottom: 10px;">
<el-col :span="1.5">
<el-button v-perm="['sys:example:add']" type="primary" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-perm="['sys:example:batchDelete']" type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</el-button>
</el-col>
</el-row>
<a-space style="margin-bottom: 10px;">
<a-button v-perm="['sys:example:add']" type="primary" @click="handleAdd">新增</a-button>
<a-button v-perm="['sys:example:batchDelete']" danger :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</a-button>
</a-space>
<!-- 数据表格 -->
<el-table :data="tableData" @selection-change="handleSelectionChange" v-loading="loading">
<el-table-column v-for="col in columns" v-bind="col" :key="col.prop || col.type">
<template v-if="col.render" #default="scope">
<component :is="() => col.render(scope)" />
<a-spin :spinning="loading">
<a-table
:data-source="tableData"
:columns="columns"
:row-selection="{ selectedRowKeys: selectedIds, onChange: handleSelectionChange }"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-button v-perm="['sys:example:update']" type="link" @click="handleEdit(record)">编辑</a-button>
<a-button v-perm="['sys:example:detail']" type="link" @click="handleDetail(record)">详情</a-button>
<a-button v-perm="['sys:example:delete']" type="link" danger @click="handleDelete(record.id)">删除</a-button>
</template>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<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>
</a-table>
</a-spin>
<!-- 分页 -->
<el-pagination
v-model:current-page="pageNo"
<a-pagination
v-model:current="pageNo"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
:show-size-changer="true"
:page-size-options="[10, 20, 50, 100]"
:show-total="(t) => `共 ${t} 条`"
style="margin-top: 10px; text-align: right;"
@change="fetchData"
style="margin-top: 10px; justify-content: flex-end;"
/>
<!-- 编辑弹窗 -->
@@ -278,7 +284,7 @@ export const schemas: FormSchema[] = [
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
import { columns } from './columns';
import { schemas as querySchemas } from './querySchemas';
import { ElMessage, ElMessageBox } from 'element-plus';
import { message, Modal } from 'ant-design-vue';
import EditDialog from './edit.vue';
import DetailDialog from './detail.vue';
@@ -336,24 +342,34 @@ const handleDetail = (row) => {
};
// 删除
const handleDelete = async (id) => {
await ElMessageBox.confirm('确认删除该记录?', '提示', { type: 'warning' });
await exampleDelete(id);
ElMessage.success('删除成功');
fetchData();
const handleDelete = (id) => {
Modal.confirm({
title: '提示',
content: '确认删除该记录?',
onOk: async () => {
await exampleDelete(id);
message.success('删除成功');
fetchData();
},
});
};
// 批量删除(DELETE,请求体 { ids: [...] })
const handleBatchDelete = async () => {
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
await exampleBatchDelete({ ids: selectedIds.value });
ElMessage.success('删除成功');
fetchData();
const handleBatchDelete = () => {
Modal.confirm({
title: '提示',
content: `确认删除选中的 ${selectedIds.value.length} 条记录?`,
onOk: async () => {
await exampleBatchDelete({ ids: selectedIds.value });
message.success('删除成功');
fetchData();
},
});
};
// 多选变化
const handleSelectionChange = (rows) => {
selectedIds.value = rows.map(row => row.id);
// 多选变化(row-selection 的 onChange 返回选中行的 row-key 数组)
const handleSelectionChange = (keys) => {
selectedIds.value = keys;
};
onMounted(() => fetchData());
@@ -365,37 +381,39 @@ onMounted(() => fetchData());
```vue
<!-- ui/src/views/tool/example/edit.vue -->
<template>
<el-dialog v-model="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="formData.type" placeholder="请选择类型">
<el-option v-for="item in typeOptions" :key="item.value" :label="item.name" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :value="1">在用</el-radio>
<el-radio :value="2">停用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" :min="0" :max="9999" />
</el-form-item>
</el-form>
<a-modal v-model:visible="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
<a-form ref="formRef" :model="formData" :rules="rules" :label-col="{ style: { width: '80px' } }">
<a-form-item label="名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入名称" allow-clear />
</a-form-item>
<a-form-item label="类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择类型" style="width: 100%">
<a-select-option v-for="item in typeOptions" :key="item.value" :value="item.value">
{{ item.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">在用</a-radio>
<a-radio :value="2">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" :min="0" :max="9999" />
</a-form-item>
</a-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
<a-button @click="visible = false">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">确定</a-button>
</template>
</el-dialog>
</a-modal>
</template>
<script setup lang="ts">
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
import { getDictItemByCode } from '@/api/common/index';
import { ElMessage } from 'element-plus';
import { message } from 'ant-design-vue';
const emit = defineEmits(['success']);
@@ -413,8 +431,8 @@ const formData = ref({
});
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
name: { required: true, message: '请输入名称', trigger: 'blur' },
type: { required: true, message: '请选择类型', trigger: 'change' },
};
// 打开弹窗
@@ -439,10 +457,10 @@ const handleSubmit = async () => {
try {
if (formData.value.id) {
await exampleUpdate(formData.value);
ElMessage.success('修改成功');
message.success('修改成功');
} else {
await exampleAdd(formData.value);
ElMessage.success('新增成功');
message.success('新增成功');
}
visible.value = false;
emit('success');
@@ -464,43 +482,43 @@ defineExpose({ open });
```vue
<!-- ui/src/views/tool/example/detail.vue -->
<template>
<el-dialog v-model="props.visible" title="案例详情" width="750" :close-on-click-modal="false">
<el-descriptions column="2" border>
<el-descriptions-item label="名称:">
<a-modal v-model:visible="props.visible" title="案例详情" width="750px" :mask-closable="false">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="名称">
{{ formData.name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="类型:">
</a-descriptions-item>
<a-descriptions-item label="类型">
{{ formData.typeText || '-' }}
</el-descriptions-item>
<el-descriptions-item label="状态:">
<el-tag :type="formData.status === 1 ? 'success' : 'danger'">
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="formData.status === 1 ? 'success' : 'error'">
{{ formData.statusText || '-' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="排序:">
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="排序">
{{ formData.sort ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="头像:">
<el-image
</a-descriptions-item>
<a-descriptions-item label="头像">
<a-image
v-if="formData.avatar"
:src="formData.avatar"
:preview-src-list="[formData.avatar]"
fit="cover"
style="width: 80px; height: 80px"
:width="80"
:height="80"
style="object-fit: cover"
/>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="创建人:">
</a-descriptions-item>
<a-descriptions-item label="创建人">
{{ formData.createUser || '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建时间:">
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formData.createTime || '-' }}
</el-descriptions-item>
</el-descriptions>
</a-descriptions-item>
</a-descriptions>
<template #footer>
<el-button @click="emit('update:visible', false)">关闭</el-button>
<a-button @click="emit('update:visible', false)">关闭</a-button>
</template>
</el-dialog>
</a-modal>
</template>
<script lang="ts" setup>
@@ -1,7 +1,7 @@
# 9.1 前端工程概述
:::tip 概述
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 `v-perm` 指令实现按钮级权限控制。前端为本仓库 **`ui/` 目录**内的 EleVue 管理后台,与本后端通过 API 对接。
前端基于 Vue 3 + TypeScript + Vite + AntDesign 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 `v-perm` 指令实现按钮级权限控制。前端为本仓库 **`ui/` 目录**内的 AntdVue 管理后台,与本后端通过 API 对接。
:::
## 技术栈
@@ -10,7 +10,7 @@
|------|------|------|------|
| 框架 | Vue | 3.4+ | 核心 UI 框架 |
| 语言 | TypeScript | 4.9+(实际 ^4.9.5) | 类型安全 |
| UI 库 | ElementPlus | 2.7+ | 组件库 |
| UI 库 | AntDesign Vue | 3.2+(实际 3.2.20) | 组件库 |
| 构建 | Vite | 3.2+(实际 ^3.2.10) | 开发服务器与构建 |
| 状态 | Pinia | 2.1+ | 全局状态管理 |
| 路由 | Vue Router | 4.3+ | 路由管理 |
@@ -36,7 +36,7 @@
`v-perm` / `v-perms` 指令控制按钮级权限:
```vue
<el-button v-perm="['sys:user:add']">新增</el-button>
<a-button v-perm="['sys:user:add']">新增</a-button>
```
### Token 管理
@@ -96,7 +96,7 @@ ui/src/
```bash
pnpm install # 安装依赖(或 npm install)
pnpm dev # 启动开发服务器(默认 8001)
pnpm dev # 启动开发服务器(默认 8002)
pnpm build # 构建生产版本
pnpm preview # 预览构建结果
pnpm lint:eslint # ESLint 检查
@@ -11,7 +11,7 @@
```vue
<template>
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
<el-button v-perm="['sys:user:add']" type="primary">新增</el-button>
<a-button v-perm="['sys:user:add']" type="primary">新增</a-button>
</template>
```
@@ -20,7 +20,7 @@
```vue
<template>
<!-- 拥有 sys:user:add 或 sys:user:update 任一权限即可 -->
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
<a-button v-perms="['sys:user:add', 'sys:user:update']">操作</a-button>
</template>
```
@@ -10,9 +10,7 @@
|-------|------|------|
| userStore | `store/modules/user.ts` | Token / 用户信息 / 权限列表 / 登录登出 |
| asyncRouteStore | `store/modules/asyncRoute.ts` | 动态路由 / keep-alive 缓存 |
| projectSettingStore | `store/modules/projectSetting.ts` | 项目配置 |
| ossConfigStore | `store/modules/ossConfig.ts` | 云存储配置 |
| designSettingStore | `store/modules/designSetting.ts` | 主题设计 |
| projectSettingStore | `store/modules/projectSetting.ts` | 项目配置(导航/顶栏主题等) |
| lockscreenStore | `store/modules/lockscreen.ts` | 锁屏 |
| tabsViewStore | `store/modules/tabsView.ts` | 标签页 |
+101 -65
View File
@@ -1,115 +1,151 @@
# 9.14 样式与主题定制
:::tip 概述
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
前端使用 WindiCSS + Less 实现样式管理,通过主题预处理器支持默认/暗色双主题切换。
:::
## 技术栈
| 技术 | 用途 |
|------|------|
| TailwindCSS | 原子化 CSS 框架 |
| SCSS | 全局样式变量和主题 |
| ElementPlus 主题变量 | 组件样式覆盖 |
| WindiCSS | 原子化 CSS 框架(Tailwind 语法兼容) |
| Less | 全局样式变量和主题 |
| AntDesign Vue 主题变量 | 组件样式覆盖(antd.less 变量) |
## 全局样式文件
```text
ui/src/styles/
├── index.scss # 全局样式入口(@import 所有子模块)
├── var.scss # SCSS 变量定义(颜色、尺寸等)
├── common.scss # 公共样式类
├── tailwind.css # TailwindCSS 入口
├── tailwindbase.css # TailwindCSS 基础重置
├── index.less # 全局样式入口(@import 过渡动画与公共样式)
├── color.less # 颜色变量定义(内容区背景、基础色)
├── common.less # 公共样式类(页面容器、滚动条、字体)
├── config.less # 配置样式(@import 引用 color.less)
├── tailwind.css # Tailwind/Windi 兼容入口(可选引入)
├── theme/
│ ├── light.scss # 浅色主题(含 ElementPlus 组件覆盖)
│ └── dark.scss # 深色主题
└── transition/
└── index.scss # 过渡动画
│ ├── theme-default.less # 默认主题(引入 antd 默认主题变量)
│ └── theme-dark.less # 暗色主题(引入 antd 暗色主题变量)
└── transition/ # 过渡动画(index/base/fade/scale/scroll/slide/zoom)
```
## SCSS 变量
## 主题文件
```scss
/* ui/src/styles/var.scss */
$primaryColor: #165DFF;
$primaryColorHover: #57a3f3;
$header-height: 64px;
$footer-height: 70px;
主题文件直接引入 ant-design-vue 官方主题,通过 `@zougt/vite-plugin-theme-preprocessor` 编译为多套作用域变量:
```less
/* ui/src/styles/theme/theme-default.less */
@import "ant-design-vue/lib/style/themes/default.less";
```
## 主题色切换
```less
/* ui/src/styles/theme/theme-dark.less */
@import "ant-design-vue/lib/style/themes/dark.less";
```
通过 `designSettingStore` 管理主题:
构建时的主题作用域配置(`build/vite/plugin/theme.ts`):
```typescript
import { useDesignSettingStore } from '@/store/modules/designSetting';
const designStore = useDesignSettingStore();
// 切换暗色模式
designStore.setDarkMode(true);
// 获取当前主题色
const primaryColor = designStore.appTheme;
themePreprocessorPlugin({
less: {
multipleScopeVars: [
{ scopeName: 'theme-default', path: path.resolve('src/styles/theme/theme-default.less') },
{ scopeName: 'theme-dark', path: path.resolve('src/styles/theme/theme-dark.less') },
],
extract: false, // 不抽取独立主题 CSS 文件
},
});
```
## ElementPlus 主题覆盖
:::tip 双主题切换
页面挂载不同的作用域类名(`theme-default` / `theme-dark`)即可整体切换深浅色,所有 antd 组件变量随作用域自动切换。
:::
ElementPlus 组件样式覆盖定义在主题文件中,通过 `[data-theme]` 选择器区分深浅色模式:
## 颜色变量示例
```scss
/* ui/src/styles/theme/light.scss */
[data-theme='light'] {
--el-border-color-base: #e4e7ed;
```less
/* ui/src/styles/color.less */
html {
//content
--content-bg: #f0f2f5;
}
.el-table {
--el-table-header-bg-color: #f5f7fa;
--el-table-header-text-color: rgb(31 34 37);
}
// ...更多组件覆盖
@white: #fff;
@content-bg: #f0f2f5;
```
## 主题配置管理
布局主题(导航/顶栏配色)通过 `projectSetting` Store 管理:
```typescript
import { useProjectSettingStore } from '@/store/modules/projectSetting';
const projectStore = useProjectSettingStore();
// 导航主题 / 顶栏主题
projectStore.navTheme;
projectStore.headerTheme;
projectStore.setNavTheme('dark');
```
## AntDesign 组件覆盖
如需覆盖 antd 组件样式,写在作用域主题文件或 `common.less` 中,使用 `.ant-*` 选择器:
```less
/* ui/src/styles/theme/theme-default.less 或页面 scoped 样式 */
.ant-table-thead > tr > th {
background: #f5f7fa;
}
```
## TailwindCSS 配置
:::warning 覆盖要点
- antd 样式类名为 `.ant-*`(如 `.ant-btn`、`.ant-table`),与 ElementPlus 的 `.el-*` 不同;
- 优先调整主题变量(`@primary-color` 等),其次才是选择器覆盖;
- 暗色主题的覆盖要放进 `theme-dark.less` 作用域,避免深浅色互相污染。
:::
```javascript
// ui/tailwind.config.js
module.exports = {
content: ['./src/**/*.{vue,ts,tsx}'],
## WindiCSS 配置
```typescript
// ui/windi.config.ts
import { defineConfig } from 'vite-plugin-windicss';
export default defineConfig({
darkMode: 'class',
theme: {
extend: {
colors: {
primary: 'var(--primary-color)',
screens: {
sm: '576px',
md: '768px',
lg: '992px',
xl: '1200px',
'2xl': '1600px',
},
},
},
};
});
```
## 自定义全局样式
```scss
/* ui/src/styles/index.scss */
```less
/* ui/src/styles/common.less */
// 页面容器
.page-container {
padding: 16px;
background: var(--bg-color);
border-radius: 8px;
#app {
width: 100%;
height: 100%;
}
// 搜索表单
.search-form {
margin-bottom: 16px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
// 滚动条
*::-webkit-scrollbar {
width: 12px;
height: 12px;
}
```
:::tip 样式约定
- 优先使用 TailwindCSS 原子类(布局/间距/颜色)
- 组件级覆盖写在 `theme/*.scss`,避免散落在页面中
- 与后端联调时注意:列表状态 Tag 颜色应与字典值语义一致(如 1=在用 success / 2=停用 danger)
- 优先使用 WindiCSS 原子类(布局/间距/颜色)
- 组件级覆盖写进 `theme/*.less` 作用域,避免散落在页面中
- 与后端联调时注意:列表状态 Tag 颜色应与字典值语义一致(如 1=在用 success / 2=停用 error)
:::