第六章-前端Vue3开发指南

第六章:前端Vue3开发指南

目录

  1. Vue3基础与Composition API
  2. 项目结构与代码规范
  3. 路由配置与导航守卫
  4. Pinia状态管理
  5. API接口封装与调用
  6. Element Plus组件使用
  7. 自定义组件开发
  8. 国际化与主题配置

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>{{ title }}</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>{{ userStore.userName }}</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>{{ icon }}</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>{{ $t('common.add') }}</el-button>
    <el-button>{{ $t('common.delete') }}</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开发:

  1. Vue3基础:Composition API、响应式数据、生命周期
  2. 项目结构:目录组织、代码规范、类型定义
  3. 路由配置:静态路由、动态路由、导航守卫
  4. Pinia状态:Store定义、持久化、模块化
  5. API封装:Axios配置、接口定义、错误处理
  6. Element Plus:表格、表单、对话框等组件使用
  7. 自定义组件:图标选择器、树形选择器等
  8. 国际化与主题:多语言支持、主题切换

掌握前端开发是完整掌握Admin.NET的重要一环。在下一章中,我们将进入二次开发实战,学习如何创建自定义业务模块。

posted @ 2025-11-29 13:06  我才是银古  阅读(0)  评论(0)    收藏  举报