签入版本

This commit is contained in:
2026-10-10 19:17:02 +08:00
parent 3423dea03f
commit 32f941b631
232 changed files with 4264 additions and 6251 deletions
@@ -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);
```
## 响应式布局
+102 -26
View File
@@ -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` | 标签页 |
+106 -57
View File
@@ -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;
}
```
```