签入版本

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,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)
:::