第六章:前端Vue3开发指南
目录
1. Vue3基础与Composition API
1.1 Composition API概述
Vue3引入的Composition API是一种新的组件逻辑组织方式,相比Options API更加灵活和可复用。
Options API vs Composition API:
<!-- Options API -->
<script>
export default {
data() {
return {
count: 0
}
},
computed: {
doubleCount() {
return this.count * 2
}
},
methods: {
increment() {
this.count++
}
}
}
</script>
<!-- Composition API -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
const increment = () => count.value++
</script>
1.2 响应式数据
<script setup lang="ts">
import { ref, reactive, computed, watch, watchEffect } from 'vue'
// ref - 基本类型响应式
const count = ref(0)
const message = ref('Hello')
// reactive - 对象响应式
const user = reactive({
name: 'Admin',
age: 18,
email: 'admin@example.com'
})
// computed - 计算属性
const fullInfo = computed(() => {
return `${user.name} (${user.age}岁)`
})
// 可写计算属性
const firstName = computed({
get: () => user.name.split(' ')[0],
set: (val) => {
user.name = val + ' ' + user.name.split(' ')[1]
}
})
// watch - 侦听器
watch(count, (newVal, oldVal) => {
console.log(`count changed from ${oldVal} to ${newVal}`)
})
// 侦听多个源
watch([count, () => user.name], ([newCount, newName]) => {
console.log(`count: ${newCount}, name: ${newName}`)
})
// 深度侦听
watch(user, (newVal) => {
console.log('user changed', newVal)
}, { deep: true })
// watchEffect - 自动追踪依赖
watchEffect(() => {
console.log(`count is ${count.value}, user is ${user.name}`)
})
</script>
1.3 生命周期钩子
<script setup lang="ts">
import {
onMounted,
onUpdated,
onUnmounted,
onBeforeMount,
onBeforeUpdate,
onBeforeUnmount,
onActivated,
onDeactivated
} from 'vue'
// 挂载前
onBeforeMount(() => {
console.log('组件挂载前')
})
// 挂载后(常用)
onMounted(() => {
console.log('组件已挂载')
// 初始化数据、调用API等
loadData()
})
// 更新前
onBeforeUpdate(() => {
console.log('组件更新前')
})
// 更新后
onUpdated(() => {
console.log('组件已更新')
})
// 卸载前
onBeforeUnmount(() => {
console.log('组件卸载前')
// 清理定时器、取消订阅等
})
// 卸载后
onUnmounted(() => {
console.log('组件已卸载')
})
// keep-alive激活
onActivated(() => {
console.log('组件被激活')
})
// keep-alive停用
onDeactivated(() => {
console.log('组件被停用')
})
</script>
1.4 模板引用与组件通信
<!-- 父组件 -->
<template>
<div>
<!-- 模板引用 -->
<input ref="inputRef" />
<!-- 子组件通信 -->
<ChildComponent
:title="title"
@update="handleUpdate"
ref="childRef"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import ChildComponent from './ChildComponent.vue'
// 模板引用
const inputRef = ref<HTMLInputElement>()
const childRef = ref<InstanceType<typeof ChildComponent>>()
// Props
const title = ref('Hello')
// 事件处理
const handleUpdate = (value: string) => {
console.log('子组件更新:', value)
}
onMounted(() => {
// 访问DOM元素
inputRef.value?.focus()
// 调用子组件方法
childRef.value?.someMethod()
})
</script>
<!-- 子组件 ChildComponent.vue -->
<template>
<div>
<h1></h1>
<button @click="emitUpdate">更新</button>
</div>
</template>
<script setup lang="ts">
// 定义Props
const props = defineProps<{
title: string
}>()
// 定义Emits
const emit = defineEmits<{
(e: 'update', value: string): void
}>()
// 暴露给父组件的方法
const someMethod = () => {
console.log('子组件方法被调用')
}
defineExpose({
someMethod
})
const emitUpdate = () => {
emit('update', 'new value')
}
</script>
2. 项目结构与代码规范
2.1 目录结构详解
Web/src/
├── api/ # API接口
│ ├── model/ # 类型定义
│ │ ├── common.ts # 通用类型
│ │ └── user.ts # 用户相关类型
│ ├── system/ # 系统模块接口
│ │ ├── user.ts # 用户接口
│ │ ├── role.ts # 角色接口
│ │ └── menu.ts # 菜单接口
│ └── index.ts # 接口导出
│
├── assets/ # 静态资源
│ ├── images/ # 图片
│ ├── icons/ # 图标
│ └── styles/ # 样式
│ ├── index.scss # 全局样式
│ └── variables.scss # 变量定义
│
├── components/ # 公共组件
│ ├── form/ # 表单组件
│ │ ├── InputSearch/ # 搜索输入
│ │ └── DateRange/ # 日期范围
│ ├── table/ # 表格组件
│ │ └── ProTable/ # 高级表格
│ └── common/ # 通用组件
│ ├── Icon/ # 图标组件
│ └── Loading/ # 加载组件
│
├── directives/ # 自定义指令
│ ├── auth.ts # 权限指令
│ └── loading.ts # 加载指令
│
├── hooks/ # 组合函数
│ ├── useTable.ts # 表格Hook
│ ├── useForm.ts # 表单Hook
│ ├── useAuth.ts # 权限Hook
│ └── useDict.ts # 字典Hook
│
├── layout/ # 布局组件
│ ├── components/ # 布局子组件
│ │ ├── Header/ # 顶部导航
│ │ ├── Sidebar/ # 侧边栏
│ │ └── Main/ # 主内容区
│ └── index.vue # 布局入口
│
├── router/ # 路由配置
│ ├── index.ts # 路由实例
│ ├── routes.ts # 静态路由
│ └── guard.ts # 路由守卫
│
├── stores/ # Pinia状态
│ ├── modules/ # 状态模块
│ │ ├── user.ts # 用户状态
│ │ ├── app.ts # 应用状态
│ │ └── permission.ts # 权限状态
│ └── index.ts # 状态导出
│
├── utils/ # 工具函数
│ ├── request.ts # axios封装
│ ├── storage.ts # 存储工具
│ ├── validate.ts # 验证工具
│ └── format.ts # 格式化工具
│
├── views/ # 页面视图
│ ├── system/ # 系统管理
│ │ ├── user/ # 用户管理
│ │ │ ├── index.vue # 列表页
│ │ │ ├── form.vue # 表单页
│ │ │ └── detail.vue # 详情页
│ │ └── role/ # 角色管理
│ └── home/ # 首页
│
├── App.vue # 根组件
└── main.ts # 入口文件
2.2 代码规范
命名规范:
// 组件名:PascalCase
// 文件名:kebab-case 或 PascalCase
UserManagement.vue
user-management.vue
// 变量和函数:camelCase
const userList = ref([])
const getUserList = async () => {}
// 常量:UPPER_SNAKE_CASE
const API_BASE_URL = '/api'
const MAX_PAGE_SIZE = 100
// 类型和接口:PascalCase
interface UserInfo {
id: number
name: string
}
type Status = 'active' | 'inactive'
TypeScript类型定义:
// api/model/user.ts
/**
* 用户信息
*/
export interface UserInfo {
id: number
account: string
realName: string
phone?: string
email?: string
orgId: number
status: number
}
/**
* 用户查询参数
*/
export interface UserQuery {
account?: string
realName?: string
phone?: string
status?: number
page: number
pageSize: number
}
/**
* 添加用户参数
*/
export interface AddUserParams {
account: string
password: string
realName: string
phone?: string
email?: string
orgId: number
roleIds: number[]
}
3. 路由配置与导航守卫
3.1 路由配置
// router/index.ts
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import Layout from '@/layout/index.vue'
// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
{
path: '/login',
component: () => import('@/views/login/index.vue'),
meta: { hidden: true }
},
{
path: '/',
component: Layout,
redirect: '/home',
children: [
{
path: 'home',
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { title: '首页', icon: 'home', affix: true }
}
]
},
{
path: '/404',
component: () => import('@/views/error/404.vue'),
meta: { hidden: true }
}
]
// 动态路由(根据权限加载)
export const asyncRoutes: RouteRecordRaw[] = [
{
path: '/system',
component: Layout,
redirect: '/system/user',
meta: { title: '系统管理', icon: 'setting' },
children: [
{
path: 'user',
name: 'User',
component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户管理', icon: 'user', permission: 'sysUser:list' }
},
{
path: 'role',
name: 'Role',
component: () => import('@/views/system/role/index.vue'),
meta: { title: '角色管理', icon: 'role', permission: 'sysRole:list' }
},
{
path: 'menu',
name: 'Menu',
component: () => import('@/views/system/menu/index.vue'),
meta: { title: '菜单管理', icon: 'menu', permission: 'sysMenu:list' }
}
]
}
]
const router = createRouter({
history: createWebHashHistory(),
routes: constantRoutes,
scrollBehavior: () => ({ left: 0, top: 0 })
})
export default router
3.2 路由守卫
// router/guard.ts
import router from './index'
import { useUserStore } from '@/stores/modules/user'
import { usePermissionStore } from '@/stores/modules/permission'
import NProgress from 'nprogress'
// 白名单
const whiteList = ['/login', '/404']
router.beforeEach(async (to, from, next) => {
NProgress.start()
const userStore = useUserStore()
const permissionStore = usePermissionStore()
// 已登录
if (userStore.token) {
if (to.path === '/login') {
next({ path: '/' })
NProgress.done()
return
}
// 已获取用户信息
if (userStore.userInfo.id) {
next()
return
}
try {
// 获取用户信息
await userStore.getUserInfo()
// 生成动态路由
const accessRoutes = await permissionStore.generateRoutes()
// 添加路由
accessRoutes.forEach(route => {
router.addRoute(route)
})
next({ ...to, replace: true })
} catch (error) {
// Token过期,清除并跳转登录
await userStore.logout()
next(`/login?redirect=${to.path}`)
NProgress.done()
}
return
}
// 未登录
if (whiteList.includes(to.path)) {
next()
} else {
next(`/login?redirect=${to.path}`)
NProgress.done()
}
})
router.afterEach(() => {
NProgress.done()
})
3.3 动态路由生成
// stores/modules/permission.ts
import { defineStore } from 'pinia'
import { asyncRoutes, constantRoutes } from '@/router'
import { useUserStore } from './user'
import type { RouteRecordRaw } from 'vue-router'
/**
* 过滤有权限的路由
*/
function filterAsyncRoutes(routes: RouteRecordRaw[], permissions: string[]): RouteRecordRaw[] {
const res: RouteRecordRaw[] = []
routes.forEach(route => {
const tmp = { ...route }
if (hasPermission(tmp, permissions)) {
if (tmp.children) {
tmp.children = filterAsyncRoutes(tmp.children, permissions)
}
res.push(tmp)
}
})
return res
}
/**
* 判断是否有权限
*/
function hasPermission(route: RouteRecordRaw, permissions: string[]): boolean {
if (route.meta?.permission) {
return permissions.includes(route.meta.permission as string)
}
return true
}
export const usePermissionStore = defineStore('permission', {
state: () => ({
routes: [] as RouteRecordRaw[],
addRoutes: [] as RouteRecordRaw[]
}),
actions: {
async generateRoutes() {
const userStore = useUserStore()
const permissions = userStore.permissions
let accessedRoutes: RouteRecordRaw[]
// 超级管理员拥有所有权限
if (userStore.roles.includes('superAdmin')) {
accessedRoutes = asyncRoutes
} else {
accessedRoutes = filterAsyncRoutes(asyncRoutes, permissions)
}
// 添加404路由
accessedRoutes.push({
path: '/:pathMatch(.*)*',
redirect: '/404',
meta: { hidden: true }
})
this.addRoutes = accessedRoutes
this.routes = constantRoutes.concat(accessedRoutes)
return accessedRoutes
}
}
})
4. Pinia状态管理
4.1 Store定义
// stores/modules/user.ts
import { defineStore } from 'pinia'
import { loginApi, getUserInfoApi, logoutApi } from '@/api/system/auth'
import { Session, Local } from '@/utils/storage'
import type { UserInfo, LoginParams } from '@/api/model/user'
interface UserState {
token: string
userInfo: Partial<UserInfo>
roles: string[]
permissions: string[]
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
token: Session.get('token') || '',
userInfo: {},
roles: [],
permissions: []
}),
getters: {
// 是否已登录
isLogin: (state) => !!state.token,
// 用户名
userName: (state) => state.userInfo.realName || state.userInfo.account,
// 是否是管理员
isAdmin: (state) => state.roles.includes('admin') || state.roles.includes('superAdmin'),
// 头像
avatar: (state) => state.userInfo.avatar || '/default-avatar.png'
},
actions: {
// 设置Token
setToken(token: string) {
this.token = token
Session.set('token', token)
},
// 登录
async login(params: LoginParams) {
try {
const res = await loginApi(params)
this.setToken(res.data.accessToken)
return res
} catch (error) {
throw error
}
},
// 获取用户信息
async getUserInfo() {
try {
const res = await getUserInfoApi()
this.userInfo = res.data
this.roles = res.data.roles || []
this.permissions = res.data.permissions || []
return res
} catch (error) {
throw error
}
},
// 退出登录
async logout() {
try {
await logoutApi()
} finally {
this.resetState()
}
},
// 重置状态
resetState() {
this.token = ''
this.userInfo = {}
this.roles = []
this.permissions = []
Session.clear()
Local.clear()
}
},
// 持久化
persist: {
key: 'user',
storage: localStorage,
paths: ['token']
}
})
4.2 应用状态
// stores/modules/app.ts
import { defineStore } from 'pinia'
interface AppState {
sidebar: {
opened: boolean
withoutAnimation: boolean
}
device: 'desktop' | 'mobile'
size: 'default' | 'small' | 'large'
language: string
theme: string
}
export const useAppStore = defineStore('app', {
state: (): AppState => ({
sidebar: {
opened: true,
withoutAnimation: false
},
device: 'desktop',
size: 'default',
language: 'zh-cn',
theme: 'light'
}),
actions: {
toggleSidebar() {
this.sidebar.opened = !this.sidebar.opened
this.sidebar.withoutAnimation = false
},
closeSidebar(withoutAnimation: boolean) {
this.sidebar.opened = false
this.sidebar.withoutAnimation = withoutAnimation
},
toggleDevice(device: 'desktop' | 'mobile') {
this.device = device
},
setSize(size: 'default' | 'small' | 'large') {
this.size = size
},
setLanguage(language: string) {
this.language = language
},
setTheme(theme: string) {
this.theme = theme
document.documentElement.setAttribute('data-theme', theme)
}
},
persist: true
})
4.3 Store使用
<template>
<div class="user-info">
<img :src="userStore.avatar" alt="avatar" />
<span></span>
<el-button @click="handleLogout">退出</el-button>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { ElMessageBox } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示')
await userStore.logout()
router.push('/login')
} catch {
// 取消
}
}
</script>
5. API接口封装与调用
5.1 Axios封装
// utils/request.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import { Session } from '@/utils/storage'
// 创建实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
service.interceptors.request.use(
(config) => {
// 添加Token
const token = Session.get('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
}
// 添加租户ID
const tenantId = Session.get('tenantId')
if (tenantId) {
config.headers['X-Tenant-Id'] = tenantId
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data
// 成功
if (res.code === 200) {
return res
}
// 业务错误
ElMessage.error(res.message || '请求失败')
// Token过期
if (res.code === 401) {
handleTokenExpired()
}
return Promise.reject(new Error(res.message))
},
(error) => {
// 网络错误
if (!error.response) {
ElMessage.error('网络连接失败')
return Promise.reject(error)
}
const { status, data } = error.response
switch (status) {
case 401:
handleTokenExpired()
break
case 403:
ElMessage.error('没有权限访问')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error(data?.message || '服务器错误')
break
default:
ElMessage.error(data?.message || '请求失败')
}
return Promise.reject(error)
}
)
// Token过期处理
let isRefreshing = false
const handleTokenExpired = () => {
if (isRefreshing) return
isRefreshing = true
ElMessageBox.confirm('登录已过期,请重新登录', '提示', {
confirmButtonText: '重新登录',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
const userStore = useUserStore()
userStore.logout()
location.reload()
}).finally(() => {
isRefreshing = false
})
}
export default service
5.2 API接口定义
// api/system/user.ts
import request from '@/utils/request'
import type { UserInfo, UserQuery, AddUserParams, UpdateUserParams } from '../model/user'
/**
* 用户管理API
*/
export const userApi = {
/**
* 获取用户分页列表
*/
getPage(params: UserQuery) {
return request({
url: '/api/sysUser/page',
method: 'get',
params
})
},
/**
* 获取用户详情
*/
getDetail(id: number) {
return request<UserInfo>({
url: '/api/sysUser/detail',
method: 'get',
params: { id }
})
},
/**
* 新增用户
*/
add(data: AddUserParams) {
return request({
url: '/api/sysUser/add',
method: 'post',
data
})
},
/**
* 更新用户
*/
update(data: UpdateUserParams) {
return request({
url: '/api/sysUser/update',
method: 'post',
data
})
},
/**
* 删除用户
*/
delete(id: number) {
return request({
url: '/api/sysUser/delete',
method: 'post',
data: { id }
})
},
/**
* 重置密码
*/
resetPwd(id: number) {
return request({
url: '/api/sysUser/resetPwd',
method: 'post',
data: { id }
})
},
/**
* 修改状态
*/
setStatus(id: number, status: number) {
return request({
url: '/api/sysUser/setStatus',
method: 'post',
data: { id, status }
})
},
/**
* 导出用户
*/
export(params: UserQuery) {
return request({
url: '/api/sysUser/export',
method: 'get',
params,
responseType: 'blob'
})
}
}
5.3 API调用示例
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { userApi } from '@/api/system/user'
import { ElMessage } from 'element-plus'
import type { UserInfo, UserQuery } from '@/api/model/user'
// 查询参数
const queryParams = ref<UserQuery>({
account: '',
realName: '',
status: undefined,
page: 1,
pageSize: 10
})
// 表格数据
const tableData = ref<UserInfo[]>([])
const total = ref(0)
const loading = ref(false)
// 获取列表
const getList = async () => {
loading.value = true
try {
const res = await userApi.getPage(queryParams.value)
tableData.value = res.data.items
total.value = res.data.total
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 删除用户
const handleDelete = async (id: number) => {
try {
await userApi.delete(id)
ElMessage.success('删除成功')
getList()
} catch (error) {
console.error('删除失败:', error)
}
}
// 导出
const handleExport = async () => {
try {
const res = await userApi.export(queryParams.value)
const blob = new Blob([res as any], { type: 'application/vnd.ms-excel' })
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '用户列表.xlsx'
link.click()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('导出失败:', error)
}
}
onMounted(() => {
getList()
})
</script>
6. Element Plus组件使用
6.1 表格组件
<template>
<div class="table-container">
<!-- 搜索区域 -->
<el-form :model="queryParams" inline>
<el-form-item label="账号">
<el-input v-model="queryParams.account" placeholder="请输入账号" clearable />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="queryParams.realName" placeholder="请输入姓名" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="请选择" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
<!-- 工具栏 -->
<div class="toolbar">
<el-button type="primary" v-auth="'sysUser:add'" @click="handleAdd">
<el-icon><Plus /></el-icon>新增
</el-button>
<el-button type="danger" v-auth="'sysUser:delete'" :disabled="!selectedIds.length" @click="handleBatchDelete">
批量删除
</el-button>
<el-button v-auth="'sysUser:export'" @click="handleExport">
<el-icon><Download /></el-icon>导出
</el-button>
</div>
<!-- 表格 -->
<el-table
v-loading="loading"
:data="tableData"
border
stripe
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="account" label="账号" width="120" />
<el-table-column prop="realName" label="姓名" width="100" />
<el-table-column prop="phone" label="手机号" width="120" />
<el-table-column prop="orgName" label="所属机构" min-width="150" />
<el-table-column prop="status" label="状态" width="80">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" width="160" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
link
v-auth="'sysUser:edit'"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
type="primary"
link
v-auth="'sysUser:resetPwd'"
@click="handleResetPwd(row)"
>
重置密码
</el-button>
<el-popconfirm
title="确定要删除该用户吗?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button type="danger" link v-auth="'sysUser:delete'">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="getList"
@current-change="getList"
/>
</div>
</template>
6.2 表单组件
<template>
<el-dialog
:title="formData.id ? '编辑用户' : '新增用户'"
v-model="visible"
width="600px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="80px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="账号" prop="account">
<el-input v-model="formData.account" placeholder="请输入账号" :disabled="!!formData.id" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="!formData.id">
<el-form-item label="密码" prop="password">
<el-input v-model="formData.password" type="password" placeholder="请输入密码" show-password />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="姓名" prop="realName">
<el-input v-model="formData.realName" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="所属机构" prop="orgId">
<el-tree-select
v-model="formData.orgId"
:data="orgTree"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="请选择所属机构"
check-strictly
/>
</el-form-item>
<el-form-item label="角色" prop="roleIds">
<el-select v-model="formData.roleIds" multiple placeholder="请选择角色" style="width: 100%">
<el-option
v-for="item in roleList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { userApi } from '@/api/system/user'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
const props = defineProps<{
modelValue: boolean
data?: any
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const formRef = ref<FormInstance>()
const submitLoading = ref(false)
const formData = reactive({
id: undefined as number | undefined,
account: '',
password: '',
realName: '',
phone: '',
orgId: undefined as number | undefined,
roleIds: [] as number[],
remark: ''
})
// 表单验证规则
const rules: FormRules = {
account: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 3, max: 20, message: '账号长度为3-20个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
],
realName: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
orgId: [
{ required: true, message: '请选择所属机构', trigger: 'change' }
]
}
// 监听数据变化
watch(() => props.data, (val) => {
if (val) {
Object.assign(formData, val)
} else {
resetForm()
}
}, { immediate: true })
// 重置表单
const resetForm = () => {
formData.id = undefined
formData.account = ''
formData.password = ''
formData.realName = ''
formData.phone = ''
formData.orgId = undefined
formData.roleIds = []
formData.remark = ''
}
// 提交
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
if (formData.id) {
await userApi.update(formData)
ElMessage.success('更新成功')
} else {
await userApi.add(formData)
ElMessage.success('新增成功')
}
emit('success')
handleClose()
} finally {
submitLoading.value = false
}
}
// 关闭
const handleClose = () => {
resetForm()
formRef.value?.resetFields()
visible.value = false
}
</script>
7. 自定义组件开发
7.1 图标选择器
<!-- components/IconSelect/index.vue -->
<template>
<el-popover
v-model:visible="visible"
trigger="click"
placement="bottom-start"
:width="400"
>
<template #reference>
<el-input
v-model="modelValue"
readonly
placeholder="请选择图标"
@click="visible = true"
>
<template #prefix>
<el-icon v-if="modelValue">
<component :is="modelValue" />
</el-icon>
</template>
<template #suffix>
<el-icon v-if="modelValue" @click.stop="handleClear">
<Close />
</el-icon>
</template>
</el-input>
</template>
<el-input v-model="searchKey" placeholder="搜索图标" clearable />
<el-scrollbar height="300px" class="icon-list">
<div
v-for="icon in filteredIcons"
:key="icon"
class="icon-item"
:class="{ active: modelValue === icon }"
@click="handleSelect(icon)"
>
<el-icon><component :is="icon" /></el-icon>
<span></span>
</div>
</el-scrollbar>
</el-popover>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import * as ElementPlusIcons from '@element-plus/icons-vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const visible = ref(false)
const searchKey = ref('')
// 所有图标
const icons = Object.keys(ElementPlusIcons)
// 过滤图标
const filteredIcons = computed(() => {
if (!searchKey.value) return icons
return icons.filter(icon =>
icon.toLowerCase().includes(searchKey.value.toLowerCase())
)
})
// 选择
const handleSelect = (icon: string) => {
emit('update:modelValue', icon)
visible.value = false
}
// 清除
const handleClear = () => {
emit('update:modelValue', '')
}
</script>
<style scoped lang="scss">
.icon-list {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
.icon-item {
width: 80px;
height: 60px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
&:hover {
background: #f5f5f5;
}
&.active {
background: #e6f7ff;
color: #1890ff;
}
.el-icon {
font-size: 20px;
}
span {
font-size: 12px;
margin-top: 5px;
}
}
}
</style>
7.2 树形选择器
<!-- components/TreeSelect/index.vue -->
<template>
<el-select
v-model="selectedValue"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
:filter-method="handleFilter"
@clear="handleClear"
>
<el-option :value="selectedValue" :label="selectedLabel" style="display: none" />
<el-tree
ref="treeRef"
:data="treeData"
:props="treeProps"
:node-key="nodeKey"
:check-strictly="checkStrictly"
:default-expand-all="defaultExpandAll"
:filter-node-method="filterNode"
highlight-current
@node-click="handleNodeClick"
/>
</el-select>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { ElTree } from 'element-plus'
interface Props {
modelValue: number | string | undefined
data: any[]
props?: {
label?: string
value?: string
children?: string
}
nodeKey?: string
placeholder?: string
disabled?: boolean
clearable?: boolean
filterable?: boolean
checkStrictly?: boolean
defaultExpandAll?: boolean
}
const props = withDefaults(defineProps<Props>(), {
nodeKey: 'id',
placeholder: '请选择',
clearable: true,
filterable: true,
checkStrictly: false,
defaultExpandAll: false,
props: () => ({
label: 'label',
value: 'value',
children: 'children'
})
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number | string | undefined): void
(e: 'change', value: number | string | undefined, node: any): void
}>()
const treeRef = ref<InstanceType<typeof ElTree>>()
const treeData = computed(() => props.data)
const treeProps = computed(() => ({
label: props.props.label,
children: props.props.children
}))
const selectedValue = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const selectedLabel = ref('')
// 查找节点
const findNode = (data: any[], value: any): any => {
for (const item of data) {
if (item[props.nodeKey] === value) {
return item
}
if (item[props.props.children!]) {
const found = findNode(item[props.props.children!], value)
if (found) return found
}
}
return null
}
// 监听值变化
watch(() => props.modelValue, (val) => {
if (val) {
const node = findNode(treeData.value, val)
selectedLabel.value = node?.[props.props.label!] || ''
} else {
selectedLabel.value = ''
}
}, { immediate: true })
// 节点点击
const handleNodeClick = (data: any) => {
selectedValue.value = data[props.nodeKey]
selectedLabel.value = data[props.props.label!]
emit('change', selectedValue.value, data)
}
// 过滤
const handleFilter = (val: string) => {
treeRef.value?.filter(val)
}
const filterNode = (value: string, data: any) => {
if (!value) return true
return data[props.props.label!].includes(value)
}
// 清除
const handleClear = () => {
selectedValue.value = undefined
selectedLabel.value = ''
}
</script>
8. 国际化与主题配置
8.1 国际化配置
// lang/index.ts
import { createI18n } from 'vue-i18n'
import zhCn from './zh-cn'
import en from './en'
const messages = {
'zh-cn': zhCn,
'en': en
}
const i18n = createI18n({
legacy: false,
locale: 'zh-cn',
fallbackLocale: 'en',
messages
})
export default i18n
// lang/zh-cn.ts
export default {
common: {
add: '新增',
edit: '编辑',
delete: '删除',
search: '搜索',
reset: '重置',
confirm: '确定',
cancel: '取消',
save: '保存',
export: '导出',
import: '导入'
},
user: {
account: '账号',
realName: '姓名',
phone: '手机号',
email: '邮箱',
status: '状态',
org: '所属机构',
role: '角色'
}
}
<!-- 使用国际化 -->
<template>
<div>
<el-button></el-button>
<el-button></el-button>
<el-form-item :label="$t('user.account')">
<el-input />
</el-form-item>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
// 切换语言
const changeLanguage = (lang: string) => {
locale.value = lang
}
</script>
8.2 主题配置
// styles/variables.scss
:root {
// 主色
--el-color-primary: #409eff;
// 成功色
--el-color-success: #67c23a;
// 警告色
--el-color-warning: #e6a23c;
// 危险色
--el-color-danger: #f56c6c;
// 信息色
--el-color-info: #909399;
// 背景色
--el-bg-color: #ffffff;
// 文字色
--el-text-color-primary: #303133;
}
// 暗黑模式
[data-theme='dark'] {
--el-bg-color: #141414;
--el-text-color-primary: #ffffff;
// ...
}
// 主题切换
const toggleTheme = () => {
const theme = document.documentElement.getAttribute('data-theme')
const newTheme = theme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', newTheme)
localStorage.setItem('theme', newTheme)
}
总结
本章详细介绍了Admin.NET前端Vue3开发:
- Vue3基础:Composition API、响应式数据、生命周期
- 项目结构:目录组织、代码规范、类型定义
- 路由配置:静态路由、动态路由、导航守卫
- Pinia状态:Store定义、持久化、模块化
- API封装:Axios配置、接口定义、错误处理
- Element Plus:表格、表单、对话框等组件使用
- 自定义组件:图标选择器、树形选择器等
- 国际化与主题:多语言支持、主题切换
掌握前端开发是完整掌握Admin.NET的重要一环。在下一章中,我们将进入二次开发实战,学习如何创建自定义业务模块。