签入版本
This commit is contained in:
@@ -119,8 +119,10 @@ 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 { useMessage, useDialog } from 'naive-ui';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const tableData = ref([]);
|
||||
|
||||
// 查询(res 即 data:{records, total, current, size, pages})
|
||||
@@ -131,16 +133,23 @@ const fetchData = async () => {
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
dialog.warning({
|
||||
title: '提示',
|
||||
content: '确认删除?',
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
await exampleDelete(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async (ids) => {
|
||||
await exampleBatchDelete({ ids });
|
||||
ElMessage.success('删除成功');
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 9.2 目录结构详解
|
||||
|
||||
:::tip 概述
|
||||
本节详细介绍前端项目(独立 EleVue 仓库)的目录结构,帮助开发者快速定位代码和理解项目组织方式。
|
||||
本节详细介绍前端项目的目录结构,帮助开发者快速定位代码和理解项目组织方式。
|
||||
:::
|
||||
|
||||
```text
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@selection-change="onSelectionChange"
|
||||
@update:checked-row-keys="onCheckedRow"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, reactive } from 'vue';
|
||||
import { TableAction } from '@/components/Table';
|
||||
import { getExamplePage } from '@/api/tool/example';
|
||||
import { columns } from './columns';
|
||||
@@ -26,17 +27,31 @@ import { columns } from './columns';
|
||||
// 分页请求函数(返回 {records, total, current, size, pages})
|
||||
const loadDataTable = (params) => getExamplePage(params);
|
||||
|
||||
const actionColumn = {
|
||||
// 操作列(render 返回 TableAction,auth 控制按钮权限)
|
||||
const actionColumn = reactive({
|
||||
width: 200,
|
||||
title: '操作',
|
||||
dataIndex: 'action',
|
||||
slots: { customRender: 'action' },
|
||||
};
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction as any, {
|
||||
actions: [
|
||||
{ label: '编辑', type: 'info', onClick: () => handleEdit(record), auth: ['sys:example:update'] },
|
||||
{ label: '删除', type: 'error', onClick: () => handleDelete(record), auth: ['sys:example:delete'] },
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// 多选变化(勾选行 key 集合)
|
||||
function onCheckedRow(keys) {
|
||||
selectedKeys.value = keys;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
:::tip 分页字段约定
|
||||
本项目 `BasicTable` 通过 `:request` 传入分页请求函数(没有 `useTable` hook),内部绑定后端固定分页结构 `data.records` / `data.total`。`statusText` 等 `*Text` 字段由后端 `serializeMaps` 自动补全,直接作为列 `field` 使用即可。
|
||||
本项目 `BasicTable` 通过 `:request` 传入分页请求函数(没有 `useTable` hook),内部绑定后端固定分页结构 `data.records` / `data.total`。`statusText` 等 `*Text` 字段由后端 `serializeMaps` 自动补全,直接作为列 `key` 使用即可。
|
||||
:::
|
||||
|
||||
### 列定义常用属性
|
||||
@@ -44,10 +59,12 @@ const actionColumn = {
|
||||
| 属性 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `title` | 列标题 | `'创建时间'` |
|
||||
| `field` | 字段名(camelCase,与响应一致) | `'createTime'` |
|
||||
| `width` | 列宽 | `180` |
|
||||
| `formatter` | 自定义格式化 | `(v) => v ?? '-'` |
|
||||
| `slots` | 自定义单元格插槽 | `'statusSlot'` |
|
||||
| `key` | 字段名(camelCase,与响应一致) | `'createTime'` |
|
||||
| `width` / `minWidth` | 固定宽度 / 最小宽度(自适应) | `180` |
|
||||
| `fixed` | 固定列:`left` / `right` | `'right'` |
|
||||
| `type` | 列类型:`selection`(多选框)/ `index`(序号) | `'selection'` |
|
||||
| `render` | 自定义渲染函数,参数为当前行对象 | `(record) => h(...)` |
|
||||
| `auth` | 按权限控制列显示 | `['sys:example:update']` |
|
||||
|
||||
## Page 页面容器
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ VITE_USE_MOCK = false
|
||||
VITE_DROP_CONSOLE = true
|
||||
|
||||
# 开发代理(JSON 数组格式:[["前缀","目标地址"]])
|
||||
VITE_PROXY=[["/api","http://admin.laravel13.elevue/api"]]
|
||||
VITE_PROXY=[["/api","http://admin.laravel13.naivevue/api"]]
|
||||
|
||||
# API 接口基础路径(留空,由代理处理)
|
||||
VITE_GLOB_API_URL =
|
||||
@@ -57,7 +57,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.naivevue/api`;使用 `php artisan serve` 时改为直连 `http://admin.laravel13.naivevue/api`(见 [2.2 Laragon 环境搭建](/zh/quickstart/laragon.html))。修改后需重启 `pnpm dev`。
|
||||
:::
|
||||
|
||||
## 生产环境配置
|
||||
@@ -121,10 +121,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.naivevue/api"]]
|
||||
|
||||
# 直连 artisan serve 时的写法
|
||||
VITE_PROXY=[["/api","http://localhost:8000/api"]]
|
||||
VITE_PROXY=[["/api","http://admin.laravel13.naivevue/api"]]
|
||||
```
|
||||
|
||||
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
|
||||
@@ -134,7 +134,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.naivevue/api"],["/uploads","http://admin.laravel13.naivevue/uploads"]]
|
||||
```
|
||||
|
||||
### VITE_GLOB_API_URL_PREFIX — API 前缀
|
||||
@@ -246,7 +246,7 @@ export function createProxy(list: ProxyList = []) {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000/api', // 目标地址(VITE_PROXY 驱动,含 /api 前缀)
|
||||
target: 'http://admin.laravel13.naivevue/api', // 目标地址(VITE_PROXY 驱动,含 /api 前缀)
|
||||
changeOrigin: true, // 修改请求头 Origin
|
||||
ws: true, // 支持 WebSocket
|
||||
rewrite: (path) => path.replace(new RegExp('^/api'), ''), // 去掉前缀,由目标地址补回
|
||||
@@ -262,7 +262,7 @@ server: {
|
||||
│
|
||||
▼ Vite proxy
|
||||
│
|
||||
▼ 转发到: http://localhost:8000/api/user/page
|
||||
▼ 转发到: http://admin.laravel13.naivevue/api/user/page
|
||||
```
|
||||
|
||||
## 构建命令
|
||||
@@ -293,8 +293,7 @@ ui/dist/
|
||||
├── assets/
|
||||
│ ├── index-xxx.js # 主 JS(带 hash)
|
||||
│ ├── index-xxx.css # 主 CSS(带 hash)
|
||||
│ ├── vue-xxx.js # Vue 相关(分包)
|
||||
│ └── element-xxx.js # Element Plus(分包)
|
||||
│ └── vue-xxx.js # Vue 相关(分包)
|
||||
├── favicon.ico
|
||||
└── static/ # 静态资源
|
||||
```
|
||||
@@ -365,7 +364,7 @@ pnpm build:prod # 生产环境构建
|
||||
| `VITE_PORT` | 8001 | - | - |
|
||||
| `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.naivevue/api"]]` | - | - |
|
||||
| `VITE_GLOB_IMG_URL` | 留空 | 留空 | 留空(按需配置 CDN) |
|
||||
| `VITE_DROP_CONSOLE` | true | true | true |
|
||||
| `VITE_USE_MOCK` | false | false | false |
|
||||
@@ -376,7 +375,7 @@ pnpm build:prod # 生产环境构建
|
||||
|
||||
**原因:** 代理配置错误或后端未启动。
|
||||
|
||||
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确,`curl http://localhost:8000/api/captcha` 验证后端;改完配置重启 `pnpm dev`。
|
||||
**解决:** 检查 `.env.development` 中 `VITE_PROXY` 的目标地址是否正确,`curl http://admin.laravel13.naivevue/api/captcha` 验证后端;改完配置重启 `pnpm dev`。
|
||||
|
||||
### 问题 2:生产环境 API 请求跨域
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button @click="handleImport">导入</el-button>
|
||||
<n-button @click="handleImport">导入</n-button>
|
||||
<ImportExcel @success="onImportSuccess" />
|
||||
</template>
|
||||
|
||||
@@ -24,38 +24,57 @@ const onImportSuccess = (data) => {
|
||||
|
||||
:::tip 前端解析 vs 后端导入
|
||||
- **前端解析(本组件)**:`xlsx` 库本地解析,适合导入前预览、无业务校验的场景;
|
||||
- **后端导入(推荐)**:`el-upload` 直传 `/api/{module}/import`,由 Service 逐行 `add()`(字典反查、唯一校验、事务隔离、错误行明细),见 [7.4 数据导入导出](/zh/tools/excel.html)。
|
||||
- **后端导入(推荐)**:`fileUpload` 导入弹窗(内部 `n-upload` 直传 `/api/{module}/import`,自动携带 Bearer 头),由 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>
|
||||
<!-- 导入按钮 -->
|
||||
<n-button @click="importVisible = true" v-perm="['sys:example:import']">导入</n-button>
|
||||
|
||||
<!-- 导入弹窗,templateCode 对应后端模板编码(来自项目实际的 example 模块) -->
|
||||
<fileUpload
|
||||
v-if="importVisible"
|
||||
v-model:visible="importVisible"
|
||||
api="/api/example/import"
|
||||
templateCode="example_import"
|
||||
dialogTitle="导入案例演示"
|
||||
@success="reloadTable()"
|
||||
/>
|
||||
</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 { ref } from 'vue';
|
||||
import fileUpload from '@/components/importFile/index.vue';
|
||||
|
||||
const importVisible = ref(false);
|
||||
</script>
|
||||
```
|
||||
|
||||
`fileUpload` 组件(`ui/src/components/importFile/index.vue`)核心为 NaiveUI 上传弹窗:
|
||||
|
||||
```vue
|
||||
<n-modal v-model:show="props.visible" preset="dialog">
|
||||
<n-upload
|
||||
:action="props.api"
|
||||
:headers="uploadHeaders"
|
||||
name="file"
|
||||
:max="1"
|
||||
v-model:file-list="fileList"
|
||||
@before-upload="beforeUpload"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<!-- 点击或将文件拖拽到这里上传 -->
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
<n-button quaternary type="info" @click="handleDownload">下载模板</n-button>
|
||||
</n-modal>
|
||||
```
|
||||
|
||||
导入结果由组件统一反馈(`useMessage` 提示成功条数 / 失败明细,`errors: [{ row, name, reason }]`),成功后触发 `@success` 回调刷新列表。
|
||||
|
||||
## 导出功能(前端导出)
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -19,13 +19,13 @@ const [registerForm] = useForm({
|
||||
{
|
||||
field: 'name',
|
||||
label: '名称',
|
||||
component: 'Input',
|
||||
component: 'NInput',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
component: 'NSelect',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: '在用', value: 1 },
|
||||
@@ -46,22 +46,27 @@ const [registerForm] = useForm({
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicModal @register="registerModal" @ok="handleSubmit" />
|
||||
<basicModal @register="registerModal" @on-ok="handleSubmit" @on-close="closeModal" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicModal, useModal } from '@/components/Modal';
|
||||
import { basicModal, useModal } from '@/components/Modal';
|
||||
|
||||
const [registerModal, { openModal, closeModal }] = useModal();
|
||||
// useModal 传入弹窗配置,返回 [register, { openModal, closeModal, setSubLoading }]
|
||||
const [registerModal, { openModal, closeModal }] = useModal({
|
||||
title: '新增案例演示',
|
||||
subBtuText: '确定',
|
||||
width: 600,
|
||||
});
|
||||
|
||||
// 打开弹窗
|
||||
const handleAdd = () => {
|
||||
openModal(true, { isUpdate: false });
|
||||
openModal();
|
||||
};
|
||||
|
||||
// 提交
|
||||
// 提交(on-ok 事件触发;getFieldsValue 来自 useForm 返回的 [register, { getFieldsValue }])
|
||||
const handleSubmit = async () => {
|
||||
const values = await getFormValues();
|
||||
const values = getFieldsValue();
|
||||
await exampleAdd(values);
|
||||
closeModal();
|
||||
};
|
||||
@@ -70,17 +75,27 @@ const handleSubmit = async () => {
|
||||
|
||||
## Upload 上传组件
|
||||
|
||||
图片字段使用 `UploadImg`(生成页面 `edit.vue` 的实际写法,`v-model:image-url` 绑定相对路径):
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicUpload v-model="formData.avatar" :action="uploadUrl" />
|
||||
<UploadImg
|
||||
v-model:image-url="formData.avatar"
|
||||
@changeFileName="(name) => (formData.avatarImgName = name)"
|
||||
name="tool"
|
||||
:fileSize="20"
|
||||
:fileType="['image/jpeg', 'image/png', 'image/jpg', 'image/gif', 'image/webp']"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicUpload } from '@/components/Upload';
|
||||
const uploadUrl = '/api/upload/uploadFile'; // 需认证 + sys:upload:file 权限
|
||||
import UploadImg from '@/components/Upload/Image.vue';
|
||||
// 上传接口:POST /api/upload/uploadFile(需认证 + sys:upload:file 权限)
|
||||
</script>
|
||||
```
|
||||
|
||||
多文件场景可用 `BasicUpload`(`:value` 传路径数组,`@uploadChange` 回传更新后的数组,见 [G. 前端组件速查](/zh/appendix/components.html))。
|
||||
|
||||
:::tip 上传字段值
|
||||
绑定值建议存 `filePath`(相对路径),Service 的 `fileFields` 归一化入库、读取时自动补全域名。详见 [7.1 文件上传全流程](/zh/tools/upload.html)。
|
||||
:::
|
||||
|
||||
@@ -32,7 +32,10 @@ config.headers['Authorization'] = `Bearer ${token}`;
|
||||
### 响应拦截器(统一错误处理)
|
||||
|
||||
```typescript
|
||||
// 响应处理逻辑
|
||||
// 响应处理逻辑(错误提示使用 NaiveUI 的离散式消息 API)
|
||||
import { useMessage } from '@/hooks/web/useMessage';
|
||||
|
||||
const $message = useMessage();
|
||||
const { code, data, msg } = res.data;
|
||||
|
||||
if (code === 0) {
|
||||
@@ -45,11 +48,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 +69,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(() => {
|
||||
$dialog.warning({
|
||||
title: '提示',
|
||||
content: '登录身份已失效,请重新登录!',
|
||||
positiveText: '确定',
|
||||
closable: false,
|
||||
maskClosable: false,
|
||||
onPositiveClick: () => {
|
||||
storage.clear();
|
||||
window.location.href = PageEnum.BASE_LOGIN; // '/login'
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
|
||||
@@ -17,46 +17,90 @@
|
||||
## 登录页面代码
|
||||
|
||||
```vue
|
||||
<!-- src/views/login/index.vue -->
|
||||
<!-- ui/src/views/login/LoginForm.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>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
:model="formInline"
|
||||
:rules="rules"
|
||||
:show-label="false"
|
||||
size="large"
|
||||
>
|
||||
<n-form-item path="username">
|
||||
<n-input v-model:value="formInline.username" placeholder="请输入登录账号" />
|
||||
</n-form-item>
|
||||
<n-form-item path="password">
|
||||
<n-input
|
||||
v-model:value="formInline.password"
|
||||
type="password"
|
||||
show-password-on="mousedown"
|
||||
placeholder="请输入登录密码"
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="code">
|
||||
<div style="display: flex">
|
||||
<n-input
|
||||
v-model:value.trim="formInline.code"
|
||||
placeholder="验证码"
|
||||
@keyup.enter="handleSubmit"
|
||||
/>
|
||||
<img
|
||||
style="width: 108px; height: 40px; margin-left: 8px; cursor: pointer"
|
||||
@click="getCaptcha"
|
||||
v-if="captchaImg"
|
||||
:src="captchaImg"
|
||||
/>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item>
|
||||
<n-button
|
||||
class="w-full"
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
登录
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { getCaptcha } from '@/api/common/user';
|
||||
import { FormRules, useMessage } from 'naive-ui';
|
||||
import { getInfoCaptcha } from '@/api/common/user';
|
||||
|
||||
const message = useMessage();
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const loginForm = ref({ username: '', password: '', code: '', key: '' });
|
||||
const captchaImage = ref('');
|
||||
const formInline = reactive({ username: '', password: '', code: '', key: '' });
|
||||
const captchaImg = ref('');
|
||||
|
||||
// 获取验证码
|
||||
// 响应字段:key = 验证码标识,captcha = Base64 图片
|
||||
const refreshCaptcha = async () => {
|
||||
const res = await getCaptcha();
|
||||
loginForm.value.key = res.key;
|
||||
captchaImage.value = res.captcha;
|
||||
const getCaptcha = async () => {
|
||||
const res = await getInfoCaptcha();
|
||||
formInline.key = res.key;
|
||||
captchaImg.value = res.captcha;
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
await userStore.login(loginForm.value);
|
||||
router.push('/');
|
||||
// 登录(校验通过后提交,成功跳转首页)
|
||||
const handleSubmit = () => {
|
||||
formRef.value?.validate().then(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { code } = await userStore.login(formInline);
|
||||
if (code == 0) router.push('/');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => refreshCaptcha());
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -70,21 +114,26 @@ onMounted(() => refreshCaptcha());
|
||||
## 完整登录示例(含失败处理)
|
||||
|
||||
```typescript
|
||||
import { useMessage } from 'naive-ui';
|
||||
|
||||
const message = useMessage();
|
||||
const loading = ref(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await userStore.login(loginForm.value);
|
||||
ElMessage.success('登录成功');
|
||||
router.push('/');
|
||||
const { code } = await userStore.login(formInline);
|
||||
if (code == 0) {
|
||||
message.success('登录成功');
|
||||
router.push('/');
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 登录失败(验证码错误/密码错误等),刷新验证码
|
||||
ElMessage.error(e.message || '登录失败');
|
||||
refreshCaptcha();
|
||||
loginForm.value.code = '';
|
||||
message.error(e.message || '登录失败');
|
||||
getCaptcha();
|
||||
formInline.code = '';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 9.12 新增页面开发步骤
|
||||
|
||||
:::tip 概述
|
||||
以 example 模块为例,演示从 API 到视图的完整开发步骤。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
|
||||
以 example 模块为例,演示从 API 到视图的完整开发步骤(代码来自项目实际的 `tool/example` 模块)。一个完整模块涉及 6 个文件:API 接口、表格列配置、查询表单配置、列表页、编辑弹窗、详情弹窗。
|
||||
:::
|
||||
|
||||
## 开发流程
|
||||
@@ -94,64 +94,60 @@ export function exampleBatchDelete(data: any) {
|
||||
|
||||
## 步骤二:创建表格列配置
|
||||
|
||||
表格列使用 `title` / `key` 字段声明表头与数据字段,`render` 函数参数为当前行对象:
|
||||
|
||||
```typescript
|
||||
// ui/src/views/tool/example/columns.ts
|
||||
// ui/src/views/tool/example/columns.ts(来自项目实际代码)
|
||||
import { h } from 'vue';
|
||||
import { ElAvatar, ElTag } from 'element-plus';
|
||||
import { NTag, NAvatar } from 'naive-ui';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
type: 'selection', // 多选框列
|
||||
title: '案例名称',
|
||||
key: 'name',
|
||||
width: 120,
|
||||
// 普通文本渲染(默认按 key 取值)
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
prop: 'id',
|
||||
fixed: 'left',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
label: '名称',
|
||||
prop: 'name',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'statusText',
|
||||
minWidth: 100,
|
||||
// 选项标签渲染:按值映射文字与颜色
|
||||
render(record) {
|
||||
const statusMap = {
|
||||
2: { text: '停用', type: 'danger' },
|
||||
1: { text: '在用', type: 'success' },
|
||||
};
|
||||
const status = statusMap[record.row.status];
|
||||
return h(ElTag, { type: status?.type || 'info' }, {
|
||||
default: () => status?.text || record.row.statusText,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '头像',
|
||||
prop: 'avatar',
|
||||
minWidth: 80,
|
||||
title: '案例图片',
|
||||
key: 'avatar',
|
||||
width: 120,
|
||||
// 图片渲染:以方形头像形式展示
|
||||
render(record) {
|
||||
return h(ElAvatar, {
|
||||
return h(NAvatar, {
|
||||
size: 48,
|
||||
src: record.row.avatar,
|
||||
shape: 'square',
|
||||
fit: 'fill',
|
||||
src: record.avatar,
|
||||
round: false,
|
||||
objectFit: 'fill',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '创建人',
|
||||
prop: 'createUser',
|
||||
minWidth: 100,
|
||||
title: '案例状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
// 选项标签渲染:按选项值映射文字与标签颜色
|
||||
render(record) {
|
||||
const statusMap = {
|
||||
1: { text: '在用', type: 'success' },
|
||||
2: { text: '停用', type: 'error' },
|
||||
};
|
||||
const status = statusMap[record.status];
|
||||
return h(
|
||||
NTag,
|
||||
{ bordered: false, type: status?.type || 'info' },
|
||||
{ default: () => status?.text || record.status },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'createTime',
|
||||
title: '创建人',
|
||||
key: 'createUser',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createTime',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
@@ -162,36 +158,55 @@ export const columns = [
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
|
||||
| `label` | string | 表头标题 |
|
||||
| `prop` | string | 数据字段名(camelCase,与后端响应一致) |
|
||||
| `title` | string | 表头标题 |
|
||||
| `key` | string | 数据字段名(camelCase,与后端响应一致) |
|
||||
| `fixed` | string | 固定列:`left` / `right` |
|
||||
| `width` | number | 固定宽度 |
|
||||
| `minWidth` | number | 最小宽度(自适应) |
|
||||
| `render` | function | 自定义渲染函数 |
|
||||
| `render` | function | 自定义渲染函数,参数为当前行对象 |
|
||||
| `auth` | string[] | 按权限控制列显示(可选) |
|
||||
|
||||
:::tip 状态值对齐
|
||||
`statusMap` 的值域必须与数据库/字典一致(如 example:1=在用 2=停用)。推荐直接使用后端补全的 `statusText` 字段,仅颜色按值映射。
|
||||
`statusMap` 的值域必须与数据库/字典一致(如 example:1=在用 2=停用),`type` 取 NaiveUI 标签类型(`success` / `error` / `warning` / `info`)。
|
||||
:::
|
||||
|
||||
## 步骤三:创建查询表单配置
|
||||
|
||||
`component` 取值为对应的组件名(`NInput`/`NSelect`/`NInputNumber`/`NDatePicker` 等):
|
||||
|
||||
```typescript
|
||||
// ui/src/views/tool/example/querySchemas.ts
|
||||
// ui/src/views/tool/example/querySchemas.ts(来自项目实际代码)
|
||||
import { FormSchema } from '@/components/Form/index';
|
||||
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
component: 'Input',
|
||||
component: 'NInput',
|
||||
label: '名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入名称',
|
||||
clearable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
component: 'NSelect',
|
||||
label: '类型',
|
||||
componentProps: {
|
||||
placeholder: '请选择类型',
|
||||
clearable: true,
|
||||
// 选项列表:值用于查询,标签用于展示
|
||||
options: [
|
||||
{ label: '类型1', value: 1 },
|
||||
{ label: '类型2', value: 2 },
|
||||
{ label: '类型3', value: 3 },
|
||||
{ label: '类型4', value: 4 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
component: 'Select',
|
||||
component: 'NSelect',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
@@ -209,10 +224,11 @@ export const schemas: FormSchema[] = [
|
||||
|
||||
| component | 适用场景 | 说明 |
|
||||
|-----------|---------|------|
|
||||
| `Input` | 字符串字段 | 文本输入框 |
|
||||
| `Select` | 枚举/选项字段 | 下拉选择框 |
|
||||
| `InputNumber` | 数字字段 | 数字输入框 |
|
||||
| `DatePicker` | 日期字段 | 日期选择器 |
|
||||
| `NInput` | 字符串字段 | 文本输入框 |
|
||||
| `NSelect` | 枚举/选项字段 | 下拉选择框(选项通过 `componentProps.options` 传入) |
|
||||
| `NInputNumber` | 数字字段 | 数字输入框 |
|
||||
| `NDatePicker` | 日期字段 | 日期选择器 |
|
||||
| `NRadioGroup` | 少量选项(≤3 个) | 单选组 |
|
||||
|
||||
:::tip 查询字段须与后端配置对应
|
||||
`field` 必须是后端 Service `pageLikeFields`(模糊)或 `pageEqFields`(精确)中声明的字段,否则查询条件不生效(见 [8.3 分页查询规范](/zh/api-spec/pagination.html))。下拉选项也可用 `getDictItemByCode(code)` 从字典动态加载。
|
||||
@@ -220,290 +236,352 @@ export const schemas: FormSchema[] = [
|
||||
|
||||
## 步骤四:创建列表页
|
||||
|
||||
列表页使用 `BasicForm`(搜索)+ `BasicTable`(表格,内置分页与操作列)组合:
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/index.vue -->
|
||||
<!-- ui/src/views/tool/example/index.vue(节选,来自项目实际代码) -->
|
||||
<template>
|
||||
<div>
|
||||
<!-- 查询表单 -->
|
||||
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-row :gutter="10" style="margin-bottom: 10px;">
|
||||
<el-col :span="1.5">
|
||||
<el-button 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>
|
||||
<n-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</n-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<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)" />
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="basicTableRef"
|
||||
:actionColumn="actionColumn"
|
||||
@update:checked-row-keys="onCheckedRow"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-space>
|
||||
<n-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">
|
||||
新增
|
||||
</n-button>
|
||||
<n-button
|
||||
type="error"
|
||||
:disabled="!rowKeys.length"
|
||||
@click="handleDelete()"
|
||||
v-perm="['sys:example:batchDelete']"
|
||||
>
|
||||
批量删除
|
||||
</n-button>
|
||||
</n-space>
|
||||
</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>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@change="fetchData"
|
||||
style="margin-top: 10px; justify-content: flex-end;"
|
||||
<!-- 编辑弹窗,主键为 0 时表示新增 -->
|
||||
<EditDialog
|
||||
ref="editRef"
|
||||
v-if="editVisible"
|
||||
v-model:visible="editVisible"
|
||||
:id="currentId"
|
||||
@success="reloadTable('noRefresh')"
|
||||
/>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EditDialog ref="editRef" @success="fetchData" />
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<DetailDialog v-model:visible="detailVisible" :id="detailId" />
|
||||
<DetailDialog v-model:visible="detailVisible" :id="currentId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h, nextTick, reactive, ref } from 'vue';
|
||||
import { useMessage, useDialog } from 'naive-ui';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
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 { schemas } from './querySchemas';
|
||||
import EditDialog from './edit.vue';
|
||||
import DetailDialog from './detail.vue';
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const basicTableRef = ref();
|
||||
const editRef = ref();
|
||||
const loading = ref(false);
|
||||
const tableData = ref([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const selectedIds = ref<number[]>([]);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const detailId = ref(0);
|
||||
const currentId = ref(0);
|
||||
const rowKeys = ref([]);
|
||||
|
||||
// 加载数据(分页结构:records / total / current / size / pages)
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExamplePage({
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
// 查询参数(与搜索表单字段一一对应,提交后合并到分页请求中)
|
||||
const formParams = reactive({ name: '', type: '', status: '' });
|
||||
|
||||
// 操作列(render 返回 TableAction,auth 控制按钮权限,与后端权限码一致)
|
||||
const actionColumn = reactive({
|
||||
width: 280,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render(record) {
|
||||
return h(TableAction as any, {
|
||||
actions: [
|
||||
{ label: '编辑', type: 'info', onClick: handleEdit.bind(null, record), auth: ['sys:example:update'] },
|
||||
{ label: '详情', type: 'info', onClick: handleDetail.bind(null, record), auth: ['sys:example:detail'] },
|
||||
{ label: '删除', type: 'error', onClick: handleDelete.bind(null, record), auth: ['sys:example:delete'] },
|
||||
],
|
||||
});
|
||||
tableData.value = res.records;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (params) => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editRef.value.open();
|
||||
};
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
editRef.value.open(row);
|
||||
};
|
||||
|
||||
// 详情
|
||||
const handleDetail = (row) => {
|
||||
detailId.value = row.id;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除该记录?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
|
||||
// 批量删除(DELETE,请求体 { ids: [...] })
|
||||
const handleBatchDelete = async () => {
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
|
||||
await exampleBatchDelete({ ids: selectedIds.value });
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
// 分页数据请求(BasicTable 内置分页,后端固定返回 records / total / current / size / pages)
|
||||
const loadDataTable = async (params) => {
|
||||
rowKeys.value = [];
|
||||
return await getExamplePage({ ...formParams, ...params });
|
||||
};
|
||||
|
||||
// 多选变化
|
||||
const handleSelectionChange = (rows) => {
|
||||
selectedIds.value = rows.map(row => row.id);
|
||||
};
|
||||
function onCheckedRow(keys) {
|
||||
rowKeys.value = keys;
|
||||
}
|
||||
|
||||
onMounted(() => fetchData());
|
||||
// 刷新表格(不传参回第一页)
|
||||
function reloadTable(noRefresh = '') {
|
||||
basicTableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
|
||||
// 注册搜索表单
|
||||
const [register] = useForm({
|
||||
gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
|
||||
labelWidth: 80,
|
||||
schemas,
|
||||
});
|
||||
|
||||
// 搜索 / 重置
|
||||
function handleSubmit(values) {
|
||||
for (const key in formParams) formParams[key] = '';
|
||||
Object.assign(formParams, values);
|
||||
reloadTable();
|
||||
}
|
||||
function handleReset() {
|
||||
for (const key in formParams) formParams[key] = '';
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
// 新增 / 编辑 / 详情
|
||||
const handleAdd = async () => {
|
||||
currentId.value = 0;
|
||||
editVisible.value = true;
|
||||
await nextTick();
|
||||
editRef.value.openModal();
|
||||
};
|
||||
async function handleEdit(record) {
|
||||
currentId.value = record.id;
|
||||
editVisible.value = true;
|
||||
await nextTick();
|
||||
editRef.value.openModal();
|
||||
}
|
||||
function handleDetail(record) {
|
||||
currentId.value = record.id;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
// 删除(单条 / 批量共用,先弹确认框;批量为 DELETE + 请求体 { ids: [] })
|
||||
function handleDelete(record?) {
|
||||
const ids = record ? [record.id] : rowKeys.value;
|
||||
if (!ids.length) {
|
||||
message.warning('请选择要删除的数据');
|
||||
return;
|
||||
}
|
||||
dialog.warning({
|
||||
title: '提示',
|
||||
content: record ? '确定删除该记录?' : `确定删除选中的 ${ids.length} 条记录?`,
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
record ? await exampleDelete(record.id) : await exampleBatchDelete({ ids });
|
||||
message.success('删除成功');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 步骤五:创建编辑弹窗
|
||||
|
||||
使用项目封装的 `basicModal`(`useModal`)+ `n-form`:
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/edit.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>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
<basicModal
|
||||
@register="modalRegister"
|
||||
ref="modalRef"
|
||||
@on-ok="handleSubmit"
|
||||
@on-close="dialogClose"
|
||||
>
|
||||
<template #default>
|
||||
<n-form class="ls-form" ref="formRef" :model="formData" label-placement="left" label-width="85px">
|
||||
<n-form-item
|
||||
label="案例名称"
|
||||
path="name"
|
||||
:rule="{ required: true, message: '请输入案例名称', trigger: 'blur' }"
|
||||
>
|
||||
<n-input v-model:value="formData.name" placeholder="请输入案例名称" clearable />
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="案例类型" path="type">
|
||||
<n-select
|
||||
v-model:value="formData.type"
|
||||
placeholder="请选择案例类型"
|
||||
clearable
|
||||
filterable
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="案例状态" path="status">
|
||||
<n-radio-group v-model:value="formData.status">
|
||||
<n-radio :value="1">在用</n-radio>
|
||||
<n-radio :value="2">停用</n-radio>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="案例排序" path="sort">
|
||||
<n-input-number v-model:value="formData.sort" :min="0" :max="99999" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</basicModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
|
||||
import { getDictItemByCode } from '@/api/common/index';
|
||||
import { ElMessage } from 'element-plus';
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { useModal } from '@/components/Modal';
|
||||
import { useLockFn } from '@/utils/useLockFn';
|
||||
import { getExampleDetail, exampleAdd, exampleUpdate } from '@/api/tool/example';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const emit = defineEmits(['success', 'update:visible']);
|
||||
const message = useMessage();
|
||||
const formRef = ref();
|
||||
const typeOptions = ref([]);
|
||||
|
||||
const formData = ref({
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
id: { type: Number, required: true, default: 0 }, // 0 表示新增
|
||||
});
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '类型1', value: 1 },
|
||||
{ label: '类型2', value: 2 },
|
||||
{ label: '类型3', value: 3 },
|
||||
{ label: '类型4', value: 4 },
|
||||
];
|
||||
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: '',
|
||||
type: 0,
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
};
|
||||
// 弹窗配置
|
||||
const [modalRegister, { openModal, setSubLoading }] = useModal({
|
||||
title: props.id ? '编辑案例演示' : '新增案例演示',
|
||||
subBtuText: '确定',
|
||||
width: 600,
|
||||
});
|
||||
|
||||
// 打开弹窗
|
||||
const open = async (row?) => {
|
||||
visible.value = true;
|
||||
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
|
||||
|
||||
// 加载字典选项(与后端字典编码一致)
|
||||
typeOptions.value = await getDictItemByCode('example_type');
|
||||
|
||||
// 编辑时加载详情
|
||||
if (row?.id) {
|
||||
const detail = await getExampleDetail(row.id);
|
||||
Object.assign(formData.value, detail);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
// 加载详情(编辑时回填)
|
||||
const setFormData = async () => {
|
||||
try {
|
||||
if (formData.value.id) {
|
||||
await exampleUpdate(formData.value);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await exampleAdd(formData.value);
|
||||
ElMessage.success('新增成功');
|
||||
const data = await getExampleDetail(props.id);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null) formData[key] = data[key];
|
||||
}
|
||||
visible.value = false;
|
||||
emit('success');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
// 提交:校验 → 新增/修改 → 提示 → 关闭并刷新
|
||||
const handleSubmit = useLockFn(async () => {
|
||||
await formRef.value?.validate();
|
||||
props.id ? await exampleUpdate(formData) : await exampleAdd(formData);
|
||||
message.success('操作成功');
|
||||
setSubLoading(false);
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
});
|
||||
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
defineExpose({ openModal, setFormData });
|
||||
</script>
|
||||
```
|
||||
|
||||
:::tip 表单规则与后端对齐
|
||||
前端 `rules`(必填/长度/枚举)应与后端 `ExampleRequest::rules()` 一致——前端是体验提示,后端是最终防线(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。
|
||||
前端 `n-form` 的 `:rule`(必填/长度/枚举)应与后端 `ExampleRequest::rules()` 一致——前端是体验提示,后端是最终防线(见 [3.4 FormRequest 验证器](/zh/guide/validator.html))。
|
||||
:::
|
||||
|
||||
## 步骤六:创建详情弹窗
|
||||
|
||||
详情弹窗使用 `n-modal`(preset="card")+ `n-descriptions`:
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/detail.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="名称:">
|
||||
<n-modal
|
||||
v-model:show="props.visible"
|
||||
preset="card"
|
||||
title="案例演示详情"
|
||||
:style="{ width: '750px' }"
|
||||
:close-on-click-modal="false"
|
||||
@close="dialogClose"
|
||||
>
|
||||
<n-descriptions :column="2" bordered label-placement="left">
|
||||
<n-descriptions-item label="名称:">
|
||||
{{ formData.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型:">
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="类型:">
|
||||
{{ formData.typeText || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态:">
|
||||
<el-tag :type="formData.status === 1 ? 'success' : 'danger'">
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="状态:">
|
||||
<n-tag :type="formData.status === 1 ? 'success' : 'error'" bordered>
|
||||
{{ formData.statusText || '-' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序:">
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="排序:">
|
||||
{{ formData.sort ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="头像:">
|
||||
<el-image
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="头像:">
|
||||
<n-image
|
||||
v-if="formData.avatar"
|
||||
:src="formData.avatar"
|
||||
:preview-src-list="[formData.avatar]"
|
||||
fit="cover"
|
||||
style="width: 80px; height: 80px"
|
||||
:preview-src="formData.avatar"
|
||||
width="80"
|
||||
height="80"
|
||||
object-fit="cover"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人:">
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建人:">
|
||||
{{ formData.createUser || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间:">
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间:">
|
||||
{{ formData.createTime || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:visible', false)">关闭</el-button>
|
||||
<div style="text-align: right">
|
||||
<n-button @click="dialogClose">关闭</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
import { NImage } from 'naive-ui';
|
||||
import { getExampleDetail } from '@/api/tool/example';
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
@@ -539,6 +617,10 @@ const loadDetail = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// 监听弹窗显示
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal && props.id) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
# 9.1 前端工程概述
|
||||
|
||||
:::tip 概述
|
||||
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 `v-perm` 指令实现按钮级权限控制。前端为本仓库 **`ui/` 目录**内的 EleVue 管理后台,与本后端通过 API 对接。
|
||||
前端基于 Vue 3 + TypeScript + Vite + NaiveUI 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 `v-perm` 指令实现按钮级权限控制。前端为本仓库 **`ui/` 目录**内的 NaiveVue 管理后台,与本后端通过 API 对接。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 版本 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 框架 | Vue | 3.4+ | 核心 UI 框架 |
|
||||
| 框架 | Vue | 3.5+(实际 ^3.5.1) | 核心 UI 框架 |
|
||||
| 语言 | TypeScript | 4.9+(实际 ^4.9.5) | 类型安全 |
|
||||
| UI 库 | ElementPlus | 2.7+ | 组件库 |
|
||||
| 构建 | Vite | 3.2+(实际 ^3.2.10) | 开发服务器与构建 |
|
||||
| 状态 | Pinia | 2.1+ | 全局状态管理 |
|
||||
| 路由 | Vue Router | 4.3+ | 路由管理 |
|
||||
| HTTP | Axios | 0.27+(实际 ^0.27.2) | HTTP 请求(注:0.27.x 版本较老) |
|
||||
| 富文本 | TinyMce | 5.10+ | 富文本编辑器 |
|
||||
| UI 库 | NaiveUI | 2.40+(实际 ^2.40.0) | 组件库 |
|
||||
| 构建 | Vite | 5.4+(实际 ^5.4.3) | 开发服务器与构建 |
|
||||
| 状态 | Pinia | 2.2+(实际 ^2.2.2) | 全局状态管理 |
|
||||
| 路由 | Vue Router | 4.4+(实际 ^4.4.3) | 路由管理 |
|
||||
| HTTP | Axios | 1.7+(实际 ^1.7.7) | HTTP 请求 |
|
||||
| 富文本 | TinyMce / Quill | 5.10+ | 富文本编辑器 |
|
||||
| 图表 | ECharts | 5.5+ | 数据可视化 |
|
||||
| CSS | TailwindCSS | 3.4+ | 原子化 CSS |
|
||||
| CSS | TailwindCSS + Less | 3.4+ | 原子化 CSS 与预处理 |
|
||||
|
||||
(版本以前端仓库 `package.json` 为准。)
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
`v-perm` / `v-perms` 指令控制按钮级权限:
|
||||
|
||||
```vue
|
||||
<el-button v-perm="['sys:user:add']">新增</el-button>
|
||||
<n-button v-perm="['sys:user:add']">新增</n-button>
|
||||
```
|
||||
|
||||
### Token 管理
|
||||
@@ -54,7 +54,7 @@ Axios 拦截器自动处理:
|
||||
|------|------|
|
||||
| BasicTable | 数据表格(分页、排序、搜索) |
|
||||
| BasicForm | 动态表单(配置驱动) |
|
||||
| BasicModal | 弹窗(表单弹窗、详情弹窗) |
|
||||
| basicModal | 弹窗(表单弹窗、详情弹窗,全局注册) |
|
||||
| BasicUpload | 文件上传 |
|
||||
| Editor | 富文本编辑器(TinyMce) |
|
||||
| ImportExcel | Excel 导入 |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="['sys:user:add']" type="primary">新增</el-button>
|
||||
<n-button v-perm="['sys:user:add']" type="primary">新增</n-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>
|
||||
<n-button v-perms="['sys:user:add', 'sys:user:update']">操作</n-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 9.14 样式与主题定制
|
||||
|
||||
:::tip 概述
|
||||
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
|
||||
前端使用 TailwindCSS + Less 实现样式管理,组件主题通过 NaiveUI 的 `NConfigProvider`(`theme` / `themeOverrides`)实现主题色切换和暗色模式。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
@@ -9,38 +9,45 @@
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| TailwindCSS | 原子化 CSS 框架 |
|
||||
| SCSS | 全局样式变量和主题 |
|
||||
| ElementPlus 主题变量 | 组件样式覆盖 |
|
||||
| Less | 全局样式变量和公共样式 |
|
||||
| NaiveUI 主题机制 | NConfigProvider 的 theme / themeOverrides 组件主题覆盖 |
|
||||
|
||||
## 全局样式文件
|
||||
|
||||
```text
|
||||
ui/src/styles/
|
||||
├── index.scss # 全局样式入口(@import 所有子模块)
|
||||
├── var.scss # SCSS 变量定义(颜色、尺寸等)
|
||||
├── common.scss # 公共样式类
|
||||
├── index.less # 全局样式入口(@import 子模块)
|
||||
├── var.less # Less 变量定义(颜色、尺寸等)
|
||||
├── common.less # 公共样式类
|
||||
├── tailwind.css # TailwindCSS 入口
|
||||
├── tailwindbase.css # TailwindCSS 基础重置
|
||||
├── theme/
|
||||
│ ├── light.scss # 浅色主题(含 ElementPlus 组件覆盖)
|
||||
│ └── dark.scss # 深色主题
|
||||
└── transition/
|
||||
└── index.scss # 过渡动画
|
||||
└── transition/ # 过渡动画
|
||||
├── index.less
|
||||
├── base.less / fade.less / scale.less
|
||||
└── slide.less / zoom.less / scroll.less
|
||||
```
|
||||
|
||||
## SCSS 变量
|
||||
## Less 变量
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/var.scss */
|
||||
$primaryColor: #165DFF;
|
||||
$primaryColorHover: #57a3f3;
|
||||
$header-height: 64px;
|
||||
$footer-height: 70px;
|
||||
```less
|
||||
/* ui/src/styles/var.less */
|
||||
@primaryColor: #2d8cf0;
|
||||
@primaryColorHover: #57a3f3;
|
||||
@header-height: 64px;
|
||||
@footer-height: 70px;
|
||||
```
|
||||
|
||||
全局样式入口:
|
||||
|
||||
```less
|
||||
/* ui/src/styles/index.less */
|
||||
@import './transition/index.less';
|
||||
@import './var.less';
|
||||
@import './common.less';
|
||||
```
|
||||
|
||||
## 主题色切换
|
||||
|
||||
通过 `designSettingStore` 管理主题:
|
||||
通过 `designSettingStore` 管理主题,`App.vue` 将主题注入 `NConfigProvider`:
|
||||
|
||||
```typescript
|
||||
import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
@@ -48,49 +55,91 @@ import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
const designStore = useDesignSettingStore();
|
||||
|
||||
// 切换暗色模式
|
||||
designStore.setDarkMode(true);
|
||||
designStore.setDarkTheme(true);
|
||||
|
||||
// 获取当前主题色
|
||||
// 获取当前主题色(可选主题色列表见 designStore.appThemeList)
|
||||
const primaryColor = designStore.appTheme;
|
||||
```
|
||||
|
||||
## ElementPlus 主题覆盖
|
||||
主题色配置来源:
|
||||
|
||||
ElementPlus 组件样式覆盖定义在主题文件中,通过 `[data-theme]` 选择器区分深浅色模式:
|
||||
```typescript
|
||||
// ui/src/settings/designSetting.ts
|
||||
export const appThemeList: string[] = ['#2d8cf0', '#0960bd', '#0084f4', /* ... */];
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/theme/light.scss */
|
||||
[data-theme='light'] {
|
||||
--el-border-color-base: #e4e7ed;
|
||||
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #f5f7fa;
|
||||
--el-table-header-text-color: rgb(31 34 37);
|
||||
}
|
||||
// ...更多组件覆盖
|
||||
}
|
||||
const setting = {
|
||||
darkTheme: false, // 深色主题开关
|
||||
appTheme: '#2d8cf0', // 系统主题色
|
||||
appThemeList, // 内置主题色列表
|
||||
};
|
||||
```
|
||||
|
||||
## NaiveUI 主题覆盖
|
||||
|
||||
`App.vue` 根主题注入 `NConfigProvider`,深色模式传 `darkTheme`,主题色通过 `themeOverrides` 覆盖:
|
||||
|
||||
```vue
|
||||
<!-- ui/src/App.vue -->
|
||||
<template>
|
||||
<NConfigProvider
|
||||
:date-locale="dateZhCN"
|
||||
:locale="zhCN"
|
||||
:theme="getDarkTheme"
|
||||
:theme-overrides="getThemeOverrides"
|
||||
>
|
||||
<!-- 应用内容 -->
|
||||
</NConfigProvider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { darkTheme, dateZhCN, zhCN } from 'naive-ui';
|
||||
import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
import { lighten } from '@/utils';
|
||||
|
||||
const designStore = useDesignSettingStore();
|
||||
|
||||
// 主题色覆盖(GlobalThemeOverrides)
|
||||
const getThemeOverrides = computed(() => {
|
||||
const appTheme = designStore.appTheme;
|
||||
const lightenStr = lighten(designStore.appTheme, 6);
|
||||
return {
|
||||
common: {
|
||||
primaryColor: appTheme,
|
||||
primaryColorSuppl: appTheme,
|
||||
primaryColorHover: lightenStr,
|
||||
colorLoading: appTheme,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 深色模式:传入 naive-ui 的 darkTheme,否则 undefined(浅色)
|
||||
const getDarkTheme = computed(() => (designStore.darkTheme ? darkTheme : undefined));
|
||||
</script>
|
||||
```
|
||||
|
||||
:::tip 说明
|
||||
NaiveUI 组件样式由 CSS 变量(`--n-*`)驱动,`themeOverrides` 会自动生成对应的 CSS 变量并应用到所有组件。
|
||||
:::
|
||||
|
||||
## TailwindCSS 配置
|
||||
|
||||
```javascript
|
||||
// ui/tailwind.config.js
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{vue,ts,tsx}'],
|
||||
content: ['./public/**/*.html', './index.html', './src/**/*.{js,jsx,ts,tsx,vue}'],
|
||||
important: true,
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--primary-color)',
|
||||
},
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
```
|
||||
|
||||
## 自定义全局样式
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/index.scss */
|
||||
```less
|
||||
/* ui/src/styles/common.less */
|
||||
|
||||
// 页面容器
|
||||
.page-container {
|
||||
@@ -108,8 +157,22 @@ module.exports = {
|
||||
}
|
||||
```
|
||||
|
||||
## 局部样式覆盖
|
||||
|
||||
组件内如需覆盖 NaiveUI 样式,可使用 NaiveUI 提供的 `--n-*` CSS 变量或 TailwindCSS:
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* 覆盖 n-card 内边距 */
|
||||
.custom-card {
|
||||
--n-padding-top: 8px;
|
||||
--n-padding-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
:::tip 样式约定
|
||||
- 优先使用 TailwindCSS 原子类(布局/间距/颜色)
|
||||
- 组件级覆盖写在 `theme/*.scss`,避免散落在页面中
|
||||
- 组件主题统一走 `NConfigProvider` 的 `themeOverrides`,避免散落在页面中覆盖
|
||||
- 与后端联调时注意:列表状态 Tag 颜色应与字典值语义一致(如 1=在用 success / 2=停用 danger)
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user