签入版本
This commit is contained in:
@@ -104,7 +104,7 @@ export function exampleBatchDelete(data: any) {
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { getExamplePage, exampleDelete } from '@/api/tool/example';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
|
||||
const tableData = ref([]);
|
||||
|
||||
@@ -116,10 +116,15 @@ const fetchData = async () => {
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '确认删除?',
|
||||
onOk: async () => {
|
||||
await exampleDelete(id);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -8,28 +8,46 @@
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicTable @register="registerTable" />
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
scroll-x="1200"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<a-button type="primary" @click="handleAdd">新增</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" danger @click="handleDelete(record.id)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicTable, useTable } from '@/components/Table';
|
||||
import { ref } from 'vue';
|
||||
import { getExamplePage, exampleDelete } from '@/api/tool/example';
|
||||
|
||||
const [registerTable] = useTable({
|
||||
api: getExamplePage, // 数据接口
|
||||
columns: [ // 列定义
|
||||
{ title: 'ID', field: 'id', width: 80 },
|
||||
{ title: '名称', field: 'name' },
|
||||
{ title: '状态', field: 'statusText', width: 100 },
|
||||
{ title: '创建时间', field: 'createTime', width: 180 },
|
||||
],
|
||||
actionColumn: { // 操作列
|
||||
width: 200,
|
||||
actions: [
|
||||
{ label: '编辑', onClick: handleEdit },
|
||||
{ label: '删除', onClick: handleDelete, type: 'danger' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const tableRef = ref();
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '状态', dataIndex: 'statusText', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createTime', width: 180 },
|
||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, fixed: 'right' },
|
||||
];
|
||||
|
||||
// BasicTable 数据加载:返回值直接交给表格渲染
|
||||
const loadDataTable = async (params) => await getExamplePage(params);
|
||||
|
||||
const handleAdd = () => {};
|
||||
const handleEdit = (record) => {};
|
||||
const handleDelete = async (id) => {};
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ ui/
|
||||
|
||||
```bash
|
||||
# 开发服务器端口
|
||||
VITE_PORT = 8001
|
||||
VITE_PORT = 8002
|
||||
|
||||
# 公共基础路径(通常为 /)
|
||||
VITE_PUBLIC_PATH = /
|
||||
@@ -34,13 +34,13 @@ VITE_GLOB_API_URL =
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
|
||||
# 代理配置(JSON 数组格式:[["前缀","目标地址"]])
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp8.antdvue/api"]]
|
||||
|
||||
# 上传接口地址
|
||||
VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
|
||||
# 图片访问地址(用于预览上传的图片)
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp6.elevue
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.antdvue
|
||||
|
||||
# 是否在生产环境删除 console(开发环境保留)
|
||||
VITE_DROP_CONSOLE = false
|
||||
@@ -84,12 +84,12 @@ VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE = false
|
||||
### VITE_PORT — 开发服务器端口
|
||||
|
||||
```bash
|
||||
VITE_PORT = 8001
|
||||
VITE_PORT = 8002
|
||||
```
|
||||
|
||||
| 环境 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| 开发 | `8001` | 前端开发服务器端口 |
|
||||
| 开发 | `8002` | 前端开发服务器端口 |
|
||||
| 生产 | 不生效 | 生产环境由 Nginx 提供服务 |
|
||||
|
||||
### VITE_PUBLIC_PATH — 公共基础路径
|
||||
@@ -118,7 +118,7 @@ VITE_GLOB_API_URL =
|
||||
### VITE_PROXY — 开发代理配置
|
||||
|
||||
```bash
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp6.elevue/api"]]
|
||||
VITE_PROXY = [["/api","http://admin.thinkphp8.antdvue/api"]]
|
||||
```
|
||||
|
||||
**格式:** JSON 数组,每项为 `[前缀, 目标地址]`
|
||||
@@ -151,7 +151,7 @@ VITE_GLOB_UPLOAD_URL = /api/upload
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp6.elevue
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.antdvue
|
||||
|
||||
# 生产环境
|
||||
VITE_GLOB_IMG_URL = https://cdn.example.com
|
||||
@@ -208,10 +208,10 @@ export default defineConfig(({ mode }) => {
|
||||
return {
|
||||
// 开发服务器
|
||||
server: {
|
||||
port: Number(env.VITE_PORT) || 8001,
|
||||
port: Number(env.VITE_PORT) || 8002,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://admin.thinkphp6.elevue/api',
|
||||
target: 'http://admin.thinkphp8.antdvue/api',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
@@ -226,7 +226,7 @@ export default defineConfig(({ mode }) => {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vue: ['vue', 'vue-router', 'pinia'],
|
||||
element: ['element-plus'],
|
||||
ant: ['ant-design-vue'],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -242,7 +242,7 @@ export default defineConfig(({ mode }) => {
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://admin.thinkphp6.elevue/api', // 目标地址
|
||||
target: 'http://admin.thinkphp8.antdvue/api', // 目标地址
|
||||
changeOrigin: true, // 修改请求头 Origin
|
||||
rewrite: (path) => path.replace(/^\/api/, ''), // 路径重写(可选)
|
||||
},
|
||||
@@ -257,7 +257,7 @@ server: {
|
||||
│
|
||||
▼ Vite proxy
|
||||
│
|
||||
▼ 转发到: http://admin.thinkphp6.elevue/api/user/page
|
||||
▼ 转发到: http://admin.thinkphp8.antdvue/api/user/page
|
||||
```
|
||||
|
||||
## 构建命令
|
||||
@@ -291,7 +291,7 @@ ui/dist/
|
||||
│ ├── index-xxx.js # 主 JS(带 hash)
|
||||
│ ├── index-xxx.css # 主 CSS(带 hash)
|
||||
│ ├── vue-xxx.js # Vue 相关(分包)
|
||||
│ └── element-xxx.js # Element Plus(分包)
|
||||
│ └── ant-xxx.js # Ant Design Vue(分包)
|
||||
├── favicon.ico
|
||||
└── static/ # 静态资源
|
||||
```
|
||||
@@ -342,7 +342,7 @@ server {
|
||||
# .env.staging
|
||||
VITE_GLOB_API_URL =
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
VITE_GLOB_IMG_URL = https://test-cdn.example.com
|
||||
VITE_GLOB_IMG_URL = http://file.thinkphp8.antdvue
|
||||
VITE_DROP_CONSOLE = true
|
||||
```
|
||||
|
||||
@@ -356,7 +356,7 @@ pnpm build --mode staging
|
||||
|
||||
| 变量 | 开发 | 测试 | 生产 |
|
||||
|------|------|------|------|
|
||||
| `VITE_PORT` | 8001 | - | - |
|
||||
| `VITE_PORT` | 8002 | - | - |
|
||||
| `VITE_GLOB_API_URL` | 留空 | 留空 | 留空 |
|
||||
| `VITE_GLOB_API_URL_PREFIX` | /api | /api | /api |
|
||||
| `VITE_GLOB_IMG_URL` | http://file.xxx | https://test-cdn.xxx | https://cdn.xxx |
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
# 9.11 Excel 导入导出组件
|
||||
|
||||
:::tip 概述
|
||||
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImportExcel)和导出功能(Export2Excel)的使用方法。
|
||||
本节介绍前端项目中的 Excel 导入导出组件,包括导入组件(ImpExcel)和导出功能(jsonToSheetXlsx)的使用方法。
|
||||
:::
|
||||
|
||||
## 导入组件
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<el-button @click="handleImport">导入</el-button>
|
||||
<ImportExcel @success="onImportSuccess" />
|
||||
<a-button @click="handleImport">导入</a-button>
|
||||
<ImpExcel @success="onImportSuccess" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImportExcel from '@/components/Excel/src/ImportExcel.vue';
|
||||
import { ImpExcel } from '@/components/Excel';
|
||||
|
||||
const onImportSuccess = (data) => {
|
||||
// data 为解析后的数组,每项是对象
|
||||
@@ -25,7 +25,7 @@ const onImportSuccess = (data) => {
|
||||
## 导出功能
|
||||
|
||||
```typescript
|
||||
import { jsonToSheetXlsx } from '@/components/Excel/src/Export2Excel';
|
||||
import { jsonToSheetXlsx } from '@/components/Excel';
|
||||
|
||||
const handleExport = () => {
|
||||
jsonToSheetXlsx({
|
||||
|
||||
@@ -17,13 +17,13 @@ import { BasicForm, useForm } from '@/components/Form';
|
||||
const [registerForm] = useForm({
|
||||
schemas: [
|
||||
{
|
||||
field: 'name',
|
||||
name: 'name',
|
||||
label: '名称',
|
||||
component: 'Input',
|
||||
required: true,
|
||||
rules: [{ required: true, message: '请输入名称', trigger: ['blur'] }],
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
name: 'status',
|
||||
label: '状态',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
@@ -42,17 +42,21 @@ const [registerForm] = useForm({
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicModal @register="registerModal" @ok="handleSubmit" />
|
||||
<basicModal @register="registerModal" @ok="handleSubmit">
|
||||
<template #default>
|
||||
<!-- 表单内容 -->
|
||||
</template>
|
||||
</basicModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicModal, useModal } from '@/components/Modal';
|
||||
import { basicModal, useModal } from '@/components/Modal';
|
||||
|
||||
const [registerModal, { openModal, closeModal }] = useModal();
|
||||
|
||||
// 打开弹窗
|
||||
const handleAdd = () => {
|
||||
openModal(true, { isUpdate: false });
|
||||
openModal();
|
||||
};
|
||||
|
||||
// 提交
|
||||
@@ -68,12 +72,11 @@ const handleSubmit = async () => {
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<BasicUpload v-model="formData.avatar" :action="uploadUrl" />
|
||||
<UploadImg v-model:image-url="formData.avatar" name="example" :fileSize="20" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { BasicUpload } from '@/components/Upload';
|
||||
const uploadUrl = '/api/upload/uploadFile';
|
||||
import UploadImg from '@/components/Upload/Image.vue';
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -81,11 +84,18 @@ const uploadUrl = '/api/upload/uploadFile';
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Editor v-model="formData.content" />
|
||||
<Editor ref="editorRef" :height="editorHeight" name="content" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Editor from '@/components/Editor/index.vue';
|
||||
import { ref } from 'vue';
|
||||
import Editor from '@/components/Editor/tinymce.vue';
|
||||
|
||||
const editorRef = ref();
|
||||
const editorHeight = 400;
|
||||
|
||||
// 提交时读取编辑器内容(真实项目回填:editorRef.value.myValue = html)
|
||||
const getContent = () => editorRef.value?.myValue;
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -93,11 +103,13 @@ import Editor from '@/components/Editor/index.vue';
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<Password v-model="formData.password" />
|
||||
<Password :value="formData.password" :repeat="true" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Password } from '@/components/Password';
|
||||
|
||||
// 组件内部维护输入值与强度校验,仅通过 value 单向传入初始值(无 v-model)
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ config.headers['Authorization'] = `Bearer ${token}`;
|
||||
### 响应拦截器(统一错误处理)
|
||||
|
||||
```typescript
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
// 响应处理逻辑
|
||||
const { code, data, msg } = res.data;
|
||||
|
||||
@@ -43,7 +45,7 @@ if (code === 401) {
|
||||
}
|
||||
|
||||
// 其他错误,显示提示
|
||||
ElMessage.error(msg || '操作失败');
|
||||
message.error(msg || '操作失败');
|
||||
```
|
||||
|
||||
:::tip 响应处理规则
|
||||
|
||||
@@ -80,8 +80,8 @@ export default {
|
||||
<template>
|
||||
<div>
|
||||
<!-- 基础翻译 -->
|
||||
<el-button>{{ $t('common.search') }}</el-button>
|
||||
<el-button>{{ $t('common.reset') }}</el-button>
|
||||
<a-button>{{ $t('common.search') }}</a-button>
|
||||
<a-button>{{ $t('common.reset') }}</a-button>
|
||||
|
||||
<!-- 带参数翻译 -->
|
||||
<p>{{ $t('message.welcome', { name: 'Admin' }) }}</p>
|
||||
@@ -134,15 +134,17 @@ const columns = computed(() => [
|
||||
```vue
|
||||
<!-- ui/src/components/LangSwitch/index.vue -->
|
||||
<template>
|
||||
<el-dropdown @command="switchLang">
|
||||
<el-button :icon="Icon" circle />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="zh-CN">中文</el-dropdown-item>
|
||||
<el-dropdown-item command="en-US">English</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
<a-dropdown>
|
||||
<a-button>
|
||||
<GlobeOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu @click="({ key }) => switchLang(key)">
|
||||
<a-menu-item key="zh-CN">中文</a-menu-item>
|
||||
<a-menu-item key="en-US">English</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</a-dropdown>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -77,10 +77,10 @@ interface MenuItem {
|
||||
const tabsViewStore = useTabsViewStore();
|
||||
|
||||
// 添加标签
|
||||
tabsViewStore.addTab(route);
|
||||
tabsViewStore.addTabs(route);
|
||||
|
||||
// 关闭标签
|
||||
tabsViewStore.removeTab(route);
|
||||
tabsViewStore.closeCurrentTab(route);
|
||||
```
|
||||
|
||||
## 响应式布局
|
||||
|
||||
@@ -16,46 +16,122 @@
|
||||
|
||||
## 登录页面代码
|
||||
|
||||
来自 `ui/src/views/login/LoginForm.vue`(简化):
|
||||
|
||||
```vue
|
||||
<!-- src/views/login/index.vue -->
|
||||
<!-- 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.captchaCode" placeholder="验证码" />
|
||||
<img :src="captchaImage" @click="refreshCaptcha" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" @click="handleLogin">登录</el-button>
|
||||
</el-form>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:show-label="false"
|
||||
size="large"
|
||||
:model="formInline"
|
||||
:rules="rules"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input v-model:value="formInline.username" placeholder="请输入登录账号">
|
||||
<template #prefix>
|
||||
<UserOutlined style="color: #808695; font-size: 18px" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="formInline.password"
|
||||
placeholder="请输入登录密码"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined style="color: #808695; font-size: 18px" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-form-item name="code">
|
||||
<div style="display: flex">
|
||||
<a-input
|
||||
@keyup.enter="handleSubmit"
|
||||
v-model:value.trim="formInline.code"
|
||||
placeholder="验证码"
|
||||
>
|
||||
<template #prefix>
|
||||
<SafetyCertificateOutlined style="color: #808695; font-size: 18px" />
|
||||
</template>
|
||||
</a-input>
|
||||
<img
|
||||
style="width: 108px; height: 40px; border-radius: 4px; margin-left: 8px; cursor: pointer"
|
||||
@click="getCaptcha"
|
||||
v-if="captchaImg"
|
||||
:src="captchaImg"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button class="w-full" type="primary" size="large" :loading="loading" @click="handleSubmit">
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useUserStore } from '@/store/modules/user';
|
||||
import { getCaptcha } from '@/api/common/user';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { getInfoCaptcha } from '@/api/common/user';
|
||||
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const loginForm = ref({ username: '', password: '', captchaCode: '', captchaKey: '' });
|
||||
const captchaImage = ref('');
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const captchaImg = ref('');
|
||||
|
||||
const formInline = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
code: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
const rules = {
|
||||
username: { required: true, message: '请输入登录账号', trigger: 'blur' },
|
||||
password: { required: true, message: '请输入登录密码', trigger: 'blur' },
|
||||
code: { required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
};
|
||||
|
||||
// 获取验证码
|
||||
const refreshCaptcha = async () => {
|
||||
const res = await getCaptcha();
|
||||
loginForm.value.captchaKey = res.key;
|
||||
captchaImage.value = res.image;
|
||||
const getCaptcha = async () => {
|
||||
const res = await getInfoCaptcha();
|
||||
formInline.key = res.key;
|
||||
captchaImg.value = res.captcha; // 返回字段是 captcha
|
||||
};
|
||||
|
||||
// 登录
|
||||
const handleLogin = async () => {
|
||||
await userStore.login(loginForm.value);
|
||||
router.push('/');
|
||||
const handleSubmit = () => {
|
||||
if (!formRef.value) return;
|
||||
formRef.value.validate().then(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { code, msg } = await userStore.login(formInline);
|
||||
if (code === 0) {
|
||||
message.success('登录成功,即将进入系统');
|
||||
const toPath = decodeURIComponent((route.query?.redirect || '/') as string);
|
||||
router.replace(toPath);
|
||||
} else {
|
||||
getCaptcha();
|
||||
message.error(msg || '登录失败');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => refreshCaptcha());
|
||||
onMounted(() => getCaptcha());
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -72,4 +148,4 @@ config.headers['Authorization'] = `Bearer ${token}`;
|
||||
if (response.data.code === 401) {
|
||||
router.push('/login');
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
@@ -93,61 +93,61 @@ export function exampleBatchDelete(data: any) {
|
||||
```typescript
|
||||
// ui/src/views/tool/example/columns.ts
|
||||
import { h } from 'vue';
|
||||
import { ElAvatar, ElTag } from 'element-plus';
|
||||
import { Tag, Avatar } from 'ant-design-vue';
|
||||
|
||||
export const columns = [
|
||||
{
|
||||
type: 'selection', // 多选框列
|
||||
},
|
||||
{
|
||||
label: 'ID',
|
||||
prop: 'id',
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
fixed: 'left',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
label: '名称',
|
||||
prop: 'name',
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
label: '状态',
|
||||
prop: 'statusText',
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
minWidth: 100,
|
||||
// 选项标签渲染:按值映射文字与颜色
|
||||
render(record) {
|
||||
customRender({ record }) {
|
||||
const statusMap = {
|
||||
0: { text: '禁用', type: 'danger' },
|
||||
1: { text: '启用', type: 'success' },
|
||||
0: { text: '禁用', color: 'error' },
|
||||
1: { text: '启用', color: 'success' },
|
||||
};
|
||||
const status = statusMap[record.row.status];
|
||||
return h(ElTag, { type: status?.type || 'info' }, {
|
||||
default: () => status?.text || record.row.statusText,
|
||||
});
|
||||
const status = statusMap[record.status];
|
||||
return h(
|
||||
Tag,
|
||||
{ color: status?.color || 'default' },
|
||||
{ default: () => status?.text || record.statusText },
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '头像',
|
||||
prop: 'avatar',
|
||||
title: '头像',
|
||||
dataIndex: 'avatar',
|
||||
key: 'avatar',
|
||||
minWidth: 80,
|
||||
// 图片渲染:以方形头像形式展示
|
||||
render(record) {
|
||||
return h(ElAvatar, {
|
||||
customRender({ record }) {
|
||||
return h(Avatar, {
|
||||
size: 48,
|
||||
src: record.row.avatar,
|
||||
src: record.avatar,
|
||||
shape: 'square',
|
||||
fit: 'fill',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '创建人',
|
||||
prop: 'createUser',
|
||||
title: '创建人',
|
||||
dataIndex: 'createUser',
|
||||
minWidth: 100,
|
||||
},
|
||||
{
|
||||
label: '创建时间',
|
||||
prop: 'createTime',
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
width: 180,
|
||||
},
|
||||
];
|
||||
@@ -157,13 +157,17 @@ export const columns = [
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | string | 列类型:`selection`(多选框)/ `index`(序号) |
|
||||
| `label` | string | 表头标题 |
|
||||
| `prop` | string | 数据字段名(camelCase) |
|
||||
| `title` | string | 表头标题 |
|
||||
| `dataIndex` | string | 数据字段名(camelCase) |
|
||||
| `key` | string | 列唯一标识(自定义渲染时必填,供 `#bodyCell` 插槽匹配) |
|
||||
| `fixed` | string | 固定列:`left` / `right` |
|
||||
| `width` | number | 固定宽度 |
|
||||
| `minWidth` | number | 最小宽度(自适应) |
|
||||
| `render` | function | 自定义渲染函数 |
|
||||
| `customRender` | function | 自定义渲染函数,入参 `{ record, text, index }` |
|
||||
|
||||
:::tip 说明
|
||||
多选框列由表格的 `row-selection` 属性开启,无需在 columns 中配置。表格行渲染通过 `#bodyCell` 插槽配合 `column.key` 分发,详见步骤四。
|
||||
:::
|
||||
|
||||
## 步骤三:创建查询表单配置
|
||||
|
||||
@@ -173,21 +177,21 @@ import { FormSchema } from '@/components/Form/index';
|
||||
|
||||
export const schemas: FormSchema[] = [
|
||||
{
|
||||
field: 'name',
|
||||
name: 'name',
|
||||
component: 'Input',
|
||||
label: '名称',
|
||||
componentProps: {
|
||||
placeholder: '请输入名称',
|
||||
clearable: true,
|
||||
allowClear: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
name: 'status',
|
||||
component: 'Select',
|
||||
label: '状态',
|
||||
componentProps: {
|
||||
placeholder: '请选择状态',
|
||||
clearable: true,
|
||||
allowClear: true,
|
||||
options: [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
@@ -208,143 +212,179 @@ export const schemas: FormSchema[] = [
|
||||
|
||||
## 步骤四:创建列表页
|
||||
|
||||
列表页基于 `PageWrapper` + `BasicForm` + `BasicTable` 组合式组件构建:
|
||||
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/index.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<!-- 查询表单 -->
|
||||
<SearchForm :schemas="querySchemas" @search="handleSearch" @reset="handleReset" />
|
||||
<PageWrapper>
|
||||
<!-- 查询表单卡片 -->
|
||||
<a-card :bordered="false" class="pt-3 mb-3 proCard">
|
||||
<BasicForm @register="register" @submit="handleSubmit" @reset="handleReset" />
|
||||
</a-card>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<el-row :gutter="10" style="margin-bottom: 10px;">
|
||||
<el-col :span="1.5">
|
||||
<el-button type="primary" @click="handleAdd">新增</el-button>
|
||||
</el-col>
|
||||
<el-col :span="1.5">
|
||||
<el-button type="danger" :disabled="!selectedIds.length" @click="handleBatchDelete">
|
||||
批量删除
|
||||
</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" @selection-change="handleSelectionChange" v-loading="loading">
|
||||
<el-table-column v-for="col in columns" v-bind="col" :key="col.prop || col.type">
|
||||
<template v-if="col.render" #default="scope">
|
||||
<component :is="() => col.render(scope)" />
|
||||
<!-- 数据表格卡片 -->
|
||||
<a-card :bordered="false" class="proCard">
|
||||
<BasicTable
|
||||
:columns="columns"
|
||||
:request="loadDataTable"
|
||||
:row-key="(row) => row.id"
|
||||
ref="tableRef"
|
||||
scroll-x="1200"
|
||||
:row-selection="{ onChange: onSelectionChange }"
|
||||
virtual-scroll
|
||||
>
|
||||
<!-- 表格顶部工具栏 -->
|
||||
<template #tableTitle>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleAdd" v-perm="['sys:example:add']">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增
|
||||
</a-button>
|
||||
<a-button
|
||||
type="danger"
|
||||
@click="handleDelete()"
|
||||
:disabled="!selectionData.length"
|
||||
v-perm="['sys:example:batchDelete']"
|
||||
>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" link @click="handleDetail(row)">详情</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row.id)">删除</el-button>
|
||||
|
||||
<!-- 操作列渲染 -->
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleEdit(record.id)" v-perm="['sys:example:update']">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleDetail(record.id)" v-perm="['sys:example:detail']">
|
||||
<template #icon><EyeOutlined /></template>
|
||||
详情
|
||||
</a-button>
|
||||
<a-button type="primary" danger @click="handleDelete(record.id)" v-perm="['sys:example:delete']">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<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;"
|
||||
/>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<EditDialog ref="editRef" @success="fetchData" />
|
||||
</BasicTable>
|
||||
</a-card>
|
||||
|
||||
<!-- 编辑弹窗(主键为 0 时表示新增) -->
|
||||
<editDialog v-if="editVisible" :id="currentId" v-model:visible="editVisible" @success="reloadTable('noRefresh')" />
|
||||
<!-- 详情弹窗 -->
|
||||
<DetailDialog v-model:visible="detailVisible" :id="detailId" />
|
||||
</div>
|
||||
<detailDialog v-if="detailVisible" :id="currentId" v-model:visible="detailVisible" @success="reloadTable('noRefresh')" />
|
||||
</PageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 EditDialog from './edit.vue';
|
||||
import DetailDialog from './detail.vue';
|
||||
<script lang="ts" setup>
|
||||
import { reactive, ref, nextTick, defineAsyncComponent } from 'vue';
|
||||
import { useForm } from '@/components/Form/index';
|
||||
import { getExamplePage, exampleDelete, exampleBatchDelete } from '@/api/tool/example';
|
||||
import { columns } from './columns';
|
||||
import { schemas } from './querySchemas';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons-vue';
|
||||
import { Modal, message } from 'ant-design-vue';
|
||||
|
||||
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 detailVisible = ref(false);
|
||||
const detailId = ref(0);
|
||||
// 动态导入弹窗组件
|
||||
const editDialog = defineAsyncComponent(() => import('./edit.vue'));
|
||||
const detailDialog = defineAsyncComponent(() => import('./detail.vue'));
|
||||
|
||||
// 加载数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getExamplePage({
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
tableData.value = res.records;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
const currentId = ref(0);
|
||||
const editVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const selectionData = ref([]);
|
||||
const tableRef = ref();
|
||||
|
||||
// 查询参数(与搜索表单字段一一对应)
|
||||
const formParams = reactive({
|
||||
name: '',
|
||||
status: '',
|
||||
});
|
||||
|
||||
// 加载表格数据:合并搜索条件与分页参数
|
||||
const loadDataTable = async (params: any) => {
|
||||
return await getExamplePage({ ...formParams, ...params });
|
||||
};
|
||||
|
||||
// 刷新表格:传入非空值保持当前页码,否则回到第一页
|
||||
function reloadTable(noRefresh = '') {
|
||||
tableRef.value.reload(noRefresh ? {} : { pageNo: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = (params) => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
// 注册搜索表单
|
||||
const [register, { getFieldsValue }] = useForm({
|
||||
rowProps: { gutter: [16, 0] },
|
||||
colProps: { xs: 24, sm: 24, md: 12, lg: 8, xl: 6 },
|
||||
labelCol: { span: 6, offset: 0 },
|
||||
schemas,
|
||||
});
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
pageNo.value = 1;
|
||||
fetchData();
|
||||
};
|
||||
// 提交搜索:把条件写回 formParams,从第一页重新加载
|
||||
function handleSubmit(values: Recordable) {
|
||||
handleReset();
|
||||
for (const key in values) {
|
||||
formParams[key] = values[key];
|
||||
}
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editRef.value.open();
|
||||
};
|
||||
// 重置搜索条件
|
||||
function handleReset() {
|
||||
for (const key in formParams) {
|
||||
formParams[key] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row) => {
|
||||
editRef.value.open(row);
|
||||
};
|
||||
// 打开新增弹窗
|
||||
const handleAdd = async () => {
|
||||
currentId.value = 0;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
// 详情
|
||||
const handleDetail = (row) => {
|
||||
detailId.value = row.id;
|
||||
detailVisible.value = true;
|
||||
};
|
||||
// 打开编辑弹窗
|
||||
const handleEdit = async (id: number) => {
|
||||
currentId.value = id;
|
||||
await nextTick();
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (id) => {
|
||||
await ElMessageBox.confirm('确认删除该记录?', '提示', { type: 'warning' });
|
||||
await exampleDelete(id);
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
// 打开详情弹窗
|
||||
const handleDetail = async (id: number) => {
|
||||
currentId.value = id;
|
||||
await nextTick();
|
||||
detailVisible.value = true;
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = async () => {
|
||||
await ElMessageBox.confirm(`确认删除选中的 ${selectedIds.value.length} 条记录?`, '提示', { type: 'warning' });
|
||||
await exampleBatchDelete({ ids: selectedIds.value });
|
||||
ElMessage.success('删除成功');
|
||||
fetchData();
|
||||
};
|
||||
// 删除数据:不传 id 时为批量删除
|
||||
async function handleDelete(id?: number) {
|
||||
if (!id) {
|
||||
if (!selectionData.value.length) {
|
||||
message.warning('请选择要删除的数据');
|
||||
return;
|
||||
}
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '确定要删除选中的案例吗?',
|
||||
onOk: async () => {
|
||||
id ? await exampleDelete(id) : await exampleBatchDelete(selectionData.value);
|
||||
message.success('删除成功');
|
||||
reloadTable();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 多选变化
|
||||
const handleSelectionChange = (rows) => {
|
||||
selectedIds.value = rows.map(row => row.id);
|
||||
};
|
||||
|
||||
onMounted(() => fetchData());
|
||||
// 表格选中项变化
|
||||
function onSelectionChange(keys: any[]) {
|
||||
selectionData.value = keys;
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -353,93 +393,119 @@ onMounted(() => fetchData());
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/edit.vue -->
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="formData.id ? '编辑' : '新增'" width="500px">
|
||||
<el-form ref="formRef" :model="formData" :rules="rules" label-width="80px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="type">
|
||||
<el-select v-model="formData.type" placeholder="请选择类型">
|
||||
<el-option v-for="item in typeOptions" :key="item.value" :label="item.name" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-radio-group v-model="formData.status">
|
||||
<el-radio :value="1">启用</el-radio>
|
||||
<el-radio :value="0">禁用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort">
|
||||
<el-input-number v-model="formData.sort" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<a-modal
|
||||
v-model:visible="props.visible"
|
||||
:title="props.id ? '编辑案例' : '新增案例'"
|
||||
width="500px"
|
||||
:maskClosable="false"
|
||||
@cancel="dialogClose"
|
||||
>
|
||||
<a-form ref="formRef" :model="formData" :label-col="{ style: { width: '85px' } }">
|
||||
<a-form-item
|
||||
label="名称"
|
||||
name="name"
|
||||
:rules="{ required: true, message: '请输入名称', trigger: 'blur' }"
|
||||
>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入名称" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="类型"
|
||||
name="type"
|
||||
:rules="{ required: true, message: '请选择类型', trigger: 'change' }"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formData.type"
|
||||
placeholder="请选择类型"
|
||||
allow-clear
|
||||
:options="typeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" name="sort">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" :max="9999" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
<span class="dialog-footer">
|
||||
<a-button @click="dialogClose">取消</a-button>
|
||||
<a-button :loading="subLoading" type="primary" @click="submit">确定</a-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
import { ElMessage } from 'element-plus';
|
||||
<script lang="ts" setup>
|
||||
import type { FormInstance } from 'ant-design-vue';
|
||||
import { shallowRef } from 'vue';
|
||||
import { exampleAdd, exampleUpdate, getExampleDetail } from '@/api/tool/example';
|
||||
import { getDictItemList } from '@/api/common/index';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { useLockFn } from '@/utils/useLockFn';
|
||||
|
||||
const emit = defineEmits(['success']);
|
||||
const emit = defineEmits(['success', 'update:visible']);
|
||||
|
||||
const visible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref();
|
||||
const typeOptions = ref([]);
|
||||
const formRef = shallowRef<FormInstance>();
|
||||
const typeOptions = ref([]);
|
||||
|
||||
const formData = ref({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
id: { type: Number, required: true, default: 0 },
|
||||
});
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }],
|
||||
};
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: '',
|
||||
status: 1,
|
||||
sort: 0,
|
||||
});
|
||||
|
||||
// 打开弹窗
|
||||
const open = async (row?) => {
|
||||
visible.value = true;
|
||||
formData.value = { id: 0, name: '', type: '', status: 1, sort: 0 };
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 表单验证
|
||||
await formRef.value?.validate();
|
||||
|
||||
// 加载字典选项
|
||||
typeOptions.value = await getDictItemList('example_type');
|
||||
// 按主键是否存在决定调用新增或更新
|
||||
props.id ? await exampleUpdate(formData) : await exampleAdd(formData);
|
||||
|
||||
// 编辑时加载详情
|
||||
if (row?.id) {
|
||||
const detail = await getExampleDetail(row.id);
|
||||
Object.assign(formData.value, detail);
|
||||
}
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (formData.value.id) {
|
||||
await exampleUpdate(formData.value);
|
||||
ElMessage.success('修改成功');
|
||||
} else {
|
||||
await exampleAdd(formData.value);
|
||||
ElMessage.success('新增成功');
|
||||
}
|
||||
visible.value = false;
|
||||
message.success('操作成功');
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
// 防抖处理,防止重复提交
|
||||
const { isLock: subLoading, lockFn: submit } = useLockFn(handleSubmit);
|
||||
|
||||
// 关闭弹窗
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// 加载详情 / 初始化
|
||||
watch(
|
||||
() => props.visible,
|
||||
async (visible) => {
|
||||
if (!visible) return;
|
||||
// 加载字典选项
|
||||
typeOptions.value = await getDictItemList('example_type');
|
||||
// 编辑时回填数据
|
||||
if (props.id) {
|
||||
const data = await getExampleDetail(props.id);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -448,87 +514,98 @@ defineExpose({ open });
|
||||
```vue
|
||||
<!-- ui/src/views/tool/example/detail.vue -->
|
||||
<template>
|
||||
<el-dialog v-model="props.visible" title="案例详情" width="750" :close-on-click-modal="false">
|
||||
<el-descriptions column="2" border>
|
||||
<el-descriptions-item label="名称:">
|
||||
<a-modal v-model:visible="props.visible" title="案例详情" width="800px" @cancel="dialogClose">
|
||||
<a-descriptions class="margin-top" :column="2" bordered :labelStyle="{ width: '120px' }">
|
||||
<a-descriptions-item label="名称">
|
||||
{{ formData.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型:">
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
{{ formData.typeText || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态:">
|
||||
<el-tag :type="formData.status === 1 ? 'success' : 'danger'">
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="formData.status === 1 ? 'success' : 'error'">
|
||||
{{ formData.statusText || '-' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="排序:">
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="排序">
|
||||
{{ formData.sort ?? '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="头像:">
|
||||
<el-image
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="头像">
|
||||
<Image
|
||||
v-if="formData.avatar"
|
||||
:src="formData.avatar"
|
||||
:preview-src-list="[formData.avatar]"
|
||||
fit="cover"
|
||||
style="width: 80px; height: 80px"
|
||||
:width="80"
|
||||
:height="80"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人:">
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建人">
|
||||
{{ formData.createUser || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间:">
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">
|
||||
{{ formData.createTime || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:visible', false)">关闭</el-button>
|
||||
<span class="dialog-footer">
|
||||
<a-button @click="dialogClose">关闭</a-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { getExampleDetail } from '@/api/tool/example';
|
||||
import { Image } from 'ant-design-vue';
|
||||
import { getExampleDetail } from '@/api/tool/example';
|
||||
|
||||
const emit = defineEmits(['update:visible']);
|
||||
const emit = defineEmits(['update:visible', 'success']);
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
id: { type: Number, required: true, default: 0 },
|
||||
});
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, required: true, default: false },
|
||||
id: { type: Number, required: true, default: 0 },
|
||||
});
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
typeText: '',
|
||||
status: 0,
|
||||
statusText: '',
|
||||
sort: 0,
|
||||
avatar: '',
|
||||
createUser: '',
|
||||
createTime: '',
|
||||
});
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
type: '',
|
||||
typeText: '',
|
||||
status: 0,
|
||||
statusText: '',
|
||||
sort: 0,
|
||||
avatar: '',
|
||||
createUser: '',
|
||||
createTime: '',
|
||||
});
|
||||
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
const data = await getExampleDetail(props.id);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null) {
|
||||
formData[key] = data[key];
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
try {
|
||||
const data = await getExampleDetail(props.id);
|
||||
for (const key in formData) {
|
||||
if (data[key] != null) {
|
||||
formData[key] = data[key];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取详情失败', error);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// 监听弹窗显示
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal && props.id) {
|
||||
loadDetail();
|
||||
}
|
||||
}, { immediate: true });
|
||||
// 关闭弹窗
|
||||
const dialogClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// 监听弹窗显示
|
||||
watch(
|
||||
() => props.visible,
|
||||
(newVal) => {
|
||||
if (newVal && props.id) {
|
||||
loadDetail();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 9.1 前端工程概述
|
||||
|
||||
:::tip 概述
|
||||
前端基于 Vue 3 + TypeScript + Vite + ElementPlus 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
|
||||
前端基于 Vue 3 + TypeScript + Vite + AntDesign 构建,采用 Pinia 状态管理,支持动态菜单和权限控制。通过 Axios 封装统一处理请求/响应/错误,通过 v-perm 指令实现按钮级权限控制。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
@@ -10,14 +10,14 @@
|
||||
|------|------|------|------|
|
||||
| 框架 | Vue | 3.4+ | 核心 UI 框架 |
|
||||
| 语言 | TypeScript | 4.9+(实际 ^4.9.5) | 类型安全 |
|
||||
| UI 库 | ElementPlus | 2.7+ | 组件库 |
|
||||
| UI 库 | AntDesign Vue | 3.2.20 | 组件库 |
|
||||
| 构建 | 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+ | 富文本编辑器 |
|
||||
| 图表 | ECharts | 5.5+ | 数据可视化 |
|
||||
| CSS | TailwindCSS | 3.4+ | 原子化 CSS |
|
||||
| CSS | WindiCSS | ^1.9.3 | 原子化 CSS(兼容 Tailwind 语法) |
|
||||
|
||||
## 核心特性
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
`v-perm` / `v-perms` 指令控制按钮级权限:
|
||||
|
||||
```vue
|
||||
<el-button v-perm="'sys:user:add'">新增</el-button>
|
||||
<a-button v-perm="'sys:user:add'">新增</a-button>
|
||||
```
|
||||
|
||||
### Token 管理
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
```vue
|
||||
<template>
|
||||
<!-- 只有拥有 sys:user:add 权限的用户才能看到此按钮 -->
|
||||
<el-button v-perm="'sys:user:add'" type="primary">新增</el-button>
|
||||
<a-button v-perm="'sys:user:add'" type="primary">新增</a-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
```vue
|
||||
<template>
|
||||
<!-- 拥有 sys:user:add 或 sys:user:update 任一权限即可 -->
|
||||
<el-button v-perms="['sys:user:add', 'sys:user:update']">操作</el-button>
|
||||
<a-button v-perms="['sys:user:add', 'sys:user:update']">操作</a-button>
|
||||
</template>
|
||||
```
|
||||
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
| userStore | `store/modules/user.ts` | Token / 用户信息 / 权限列表 / 登录登出 |
|
||||
| asyncRouteStore | `store/modules/asyncRoute.ts` | 动态路由 / keep-alive 缓存 |
|
||||
| projectSettingStore | `store/modules/projectSetting.ts` | 项目配置 |
|
||||
| ossConfigStore | `store/modules/ossConfig.ts` | 云存储配置 |
|
||||
| designSettingStore | `store/modules/designSetting.ts` | 主题设计 |
|
||||
| lockscreenStore | `store/modules/lockscreen.ts` | 锁屏 |
|
||||
| tabsViewStore | `store/modules/tabsView.ts` | 标签页 |
|
||||
|
||||
|
||||
@@ -1,101 +1,150 @@
|
||||
# 9.14 样式与主题定制
|
||||
|
||||
:::tip 概述
|
||||
前端使用 TailwindCSS + SCSS 实现样式管理,支持主题色切换和暗色模式。
|
||||
前端使用 WindiCSS + Less 实现样式管理,基于 `@zougt/vite-plugin-theme-preprocessor` 支持主题切换和暗色模式。
|
||||
:::
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 技术 | 用途 |
|
||||
|------|------|
|
||||
| TailwindCSS | 原子化 CSS 框架 |
|
||||
| SCSS | 全局样式变量和主题 |
|
||||
| ElementPlus 主题变量 | 组件样式覆盖 |
|
||||
| WindiCSS | 原子化 CSS 框架(`vite-plugin-windicss`,兼容 Tailwind 语法) |
|
||||
| Less | 全局样式变量和主题 |
|
||||
| Ant Design 主题变量 | 组件样式覆盖(`@primary-color` 等 Less 变量) |
|
||||
|
||||
## 全局样式文件
|
||||
|
||||
```text
|
||||
ui/src/styles/
|
||||
├── index.scss # 全局样式入口(@import 所有子模块)
|
||||
├── var.scss # SCSS 变量定义(颜色、尺寸等)
|
||||
├── common.scss # 公共样式类
|
||||
├── tailwind.css # TailwindCSS 入口
|
||||
├── tailwindbase.css # TailwindCSS 基础重置
|
||||
├── index.less # 全局样式入口(@import 子模块)
|
||||
├── color.less # 颜色变量定义(@white、@content-bg、--content-bg 等)
|
||||
├── common.less # 公共样式类
|
||||
├── config.less # 配置引用(@import (reference) 'color.less')
|
||||
├── tailwind.css # Tailwind 兼容指令入口(由 WindiCSS 处理)
|
||||
├── theme/
|
||||
│ ├── light.scss # 浅色主题(含 ElementPlus 组件覆盖)
|
||||
│ └── dark.scss # 深色主题
|
||||
│ ├── theme-default.less # 默认主题(引入 Ant Design 默认 Less 主题)
|
||||
│ └── theme-dark.less # 暗色主题(引入 Ant Design 暗色 Less 主题)
|
||||
└── transition/
|
||||
└── index.scss # 过渡动画
|
||||
├── 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/color.less */
|
||||
html {
|
||||
--content-bg: #f0f2f5;
|
||||
}
|
||||
|
||||
@white: #fff;
|
||||
@content-bg: #f0f2f5;
|
||||
```
|
||||
|
||||
## 主题色切换
|
||||
Ant Design 组件主题通过标准 Less 变量覆盖,例如主色:
|
||||
|
||||
通过 `designSettingStore` 管理主题:
|
||||
```less
|
||||
/* 覆盖主色示例 */
|
||||
@primary-color: #165DFF;
|
||||
```
|
||||
|
||||
## 主题切换
|
||||
|
||||
主题切换基于 `@zougt/vite-plugin-theme-preprocessor`,两套主题文件在构建时编译为独立作用域样式,运行时通过 `toggleTheme` 切换:
|
||||
|
||||
```typescript
|
||||
import { useDesignSettingStore } from '@/store/modules/designSetting';
|
||||
// build/vite/plugin/theme.ts
|
||||
import themePreprocessorPlugin from '@zougt/vite-plugin-theme-preprocessor';
|
||||
|
||||
const designStore = useDesignSettingStore();
|
||||
|
||||
// 切换暗色模式
|
||||
designStore.setDarkMode(true);
|
||||
|
||||
// 获取当前主题色
|
||||
const primaryColor = designStore.appTheme;
|
||||
```
|
||||
|
||||
## ElementPlus 主题覆盖
|
||||
|
||||
ElementPlus 组件样式覆盖定义在主题文件中,通过 `[data-theme]` 选择器区分深浅色模式:
|
||||
|
||||
```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);
|
||||
}
|
||||
// ...更多组件覆盖
|
||||
export function configThemePlugin(): Plugin[] {
|
||||
return [
|
||||
themePreprocessorPlugin({
|
||||
themeScopes: [
|
||||
{
|
||||
scopeName: 'theme-default',
|
||||
path: path.resolve('src/styles/theme/theme-default.less'),
|
||||
},
|
||||
{
|
||||
scopeName: 'theme-dark',
|
||||
path: path.resolve('src/styles/theme/theme-dark.less'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## TailwindCSS 配置
|
||||
页面中通过「项目配置」抽屉切换明暗主题:
|
||||
|
||||
```javascript
|
||||
// ui/tailwind.config.js
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{vue,ts,tsx}'],
|
||||
```typescript
|
||||
// ui/src/layout/components/Header/ProjectSetting.vue
|
||||
import { toggleTheme } from '@zougt/vite-plugin-theme-preprocessor/dist/browser-utils';
|
||||
import { useProjectSettingStore } from '@/store/modules/projectSetting';
|
||||
|
||||
const settingStore = useProjectSettingStore();
|
||||
|
||||
function handleToggleTheme(theme: string) {
|
||||
const isDark = theme === 'darkness';
|
||||
settingStore.darkTheme = isDark;
|
||||
updateDarkTheme(isDark ? 'dark' : 'light');
|
||||
}
|
||||
|
||||
async function updateDarkTheme(mode: 'light' | 'dark') {
|
||||
toggleTheme({ scopeName: mode === 'dark' ? 'theme-dark' : 'theme-default' });
|
||||
}
|
||||
```
|
||||
|
||||
主题状态由 Pinia 的 `projectSetting` Store 管理(`darkTheme`、`navTheme`、`headerTheme`)。
|
||||
|
||||
## 主题文件
|
||||
|
||||
```less
|
||||
/* ui/src/styles/theme/theme-default.less */
|
||||
@import "ant-design-vue/lib/style/themes/default.less";
|
||||
|
||||
/* ui/src/styles/theme/theme-dark.less */
|
||||
@import "ant-design-vue/lib/style/themes/dark.less";
|
||||
```
|
||||
|
||||
自定义组件覆盖直接追加在对应主题文件中,通过生成的作用域类(`.theme-default` / `.theme-dark`)区分明暗模式。
|
||||
|
||||
## WindiCSS 配置
|
||||
|
||||
```typescript
|
||||
// ui/windi.config.ts
|
||||
import { defineConfig } from 'vite-plugin-windicss';
|
||||
|
||||
export default defineConfig({
|
||||
darkMode: 'class',
|
||||
plugins: [createEnterPlugin()],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: 'var(--primary-color)',
|
||||
zIndex: { '-1': '-1' },
|
||||
screens: {
|
||||
sm: '576px',
|
||||
md: '768px',
|
||||
lg: '992px',
|
||||
xl: '1200px',
|
||||
'2xl': '1600px',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
:::tip 说明
|
||||
项目使用 `vite-plugin-windicss`,类名语法与 TailwindCSS 兼容(`class="flex items-center justify-between"`),但配置文件是 `windi.config.ts` 而非 `tailwind.config.js`。
|
||||
:::
|
||||
|
||||
## 自定义全局样式
|
||||
|
||||
```scss
|
||||
/* ui/src/styles/index.scss */
|
||||
```less
|
||||
/* ui/src/styles/common.less */
|
||||
|
||||
// 页面容器
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
background: var(--bg-color);
|
||||
background: var(--content-bg);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -106,4 +155,4 @@ module.exports = {
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user