惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Project Zero
Project Zero
F
Fortinet All Blogs
Recent Announcements
Recent Announcements
云风的 BLOG
云风的 BLOG
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
M
MIT News - Artificial intelligence
S
SegmentFault 最新的问题
Blog — PlanetScale
Blog — PlanetScale
T
Tailwind CSS Blog
WordPress大学
WordPress大学
Engineering at Meta
Engineering at Meta
S
Schneier on Security
N
News and Events Feed by Topic
N
News | PayPal Newsroom
H
Help Net Security
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
The Exploit Database - CXSecurity.com
Attack and Defense Labs
Attack and Defense Labs
博客园 - Franky
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
J
Java Code Geeks
A
About on SuperTechFans
AWS News Blog
AWS News Blog
S
Secure Thoughts
The Cloudflare Blog
Hugging Face - Blog
Hugging Face - Blog
爱范儿
爱范儿
C
Cybersecurity and Infrastructure Security Agency CISA
V2EX - 技术
V2EX - 技术
Recorded Future
Recorded Future
Microsoft Azure Blog
Microsoft Azure Blog
博客园_首页
MyScale Blog
MyScale Blog
Martin Fowler
Martin Fowler
Help Net Security
Help Net Security
人人都是产品经理
人人都是产品经理
Latest news
Latest news
C
Cyber Attacks, Cyber Crime and Cyber Security
大猫的无限游戏
大猫的无限游戏
The Last Watchdog
The Last Watchdog
www.infosecurity-magazine.com
www.infosecurity-magazine.com
月光博客
月光博客
H
Hacker News: Front Page
P
Proofpoint News Feed
N
News and Events Feed by Topic
H
Heimdal Security Blog
L
Lohrmann on Cybersecurity
有赞技术团队
有赞技术团队
L
LangChain Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog

博客园 - 我才是银古

第16章:常见问题、排错与最佳实践 第15章:扩展生态、MCAD 与外部集成 第12章:实战案例:机械结构与 3D 打印零件 第14章:构建、测试、调试与贡献流程 第13章:OpenSCAD 源码架构与核心执行流程 第11章:预览、渲染、网格精度与性能优化 第09章:列表推导、递归与算法建模 第08章:参数化零件库与复用设计 第10章:导入导出、命令行与自动化 第06章:CSG 布尔建模方法 第07章:二维图形、拉伸、旋转与投影 第05章:基础几何、坐标系与变换 第04章:参数、变量、函数、模块与作用域 OpenSCAD 教程目录 第03章:OpenSCAD 语言基础 第02章:安装、环境配置与开发工作流 第01章:OpenSCAD 项目全景与学习路线 第02章:源码获取、编译与开发环境配置 第01章:OCCT项目全景与学习路线 第18章:二次开发实战与综合案例 第18章:综合实战案例 第17章:数据交换与协同 第16章:源码架构与二次开发 第15章:插件与自定义工作台开发 第14章:Python脚本宏与自动化 第13章:FEM仿真分析 第12章:CAM数控加工 第11章:SurfaceMesh与逆向工程 第10章:Draft二维绘图与BIM建筑 第09章:工程图TechDraw 第07章:参数化表达式与Spreadsheet 第08章:装配设计Assembly 第06章:Part工作台与几何内核 第05章:PartDesign实体特征建模 第04章:草图Sketcher约束建模 第02章:安装版本与工作环境配置 第03章:界面工作台与基础操作 第01章:项目全景与学习路线 第十二章:插件开发、研究功能与最佳实践 第十章:定时任务与自动化(Cron) 第七章:技能、记忆与自学习闭环 第八章:MCP 集成与上下文文件 第六章:工具系统与终端后端 第五章:模型供应商与配置体系 Hermes Agent 教程目录 第十一章:语音、视觉、浏览器与子代理协作 第四章:CLI/TUI 与会话管理 第十二章:学习路线、实战方案与最佳实践 第十一章:源码结构、开发调试与插件开发 第十章:自动化、远程访问、日志与排障 第九章:Control UI、节点、Canvas 与语音能力 第七章:工具、技能、插件与能力扩展 第八章:安全模型、访问控制与沙箱实践 第六章:Agent 工作区、会话与多智能体路由 第五章:多通道消息接入与聊天平台配置 第四章:配置体系、模型接入与认证管理 第三章:Gateway 架构、协议与运行机制 第二章:安装、环境准备与快速上手 第一章:OpenClaw 项目概览与核心定位 oh-my-openagent 教程目录 09-命令模型回退与配置参考 10-实战案例最佳实践与故障排除 05-工作模式-Ultrawork-Prometheus-Atlas 08-Hooks与MCP系统 06-Category与Skill系统 07-核心工具链 04-智能体全景详解 03-安装与环境配置 02-整体架构与多模型编排机制 01-项目简介与核心理念 01-项目概览与学习路线 02-安装部署与工具适配 03-Skill机制与using-superpowers 05-TDD系统化调试与完成前验证 04-需求澄清方案设计与计划编写 07-并行智能体子智能体与Git-Worktree 第六章:代码审查、反馈处理与分支收尾 08-中国特色Skills与本土团队落地 09-MCP构建工作流执行与自定义Skill 第23章:FreeCAD-Python-API Clipper2 C# 源码解读教程 第19章:PolyTree 多边形树结构 第20章:实际应用与最佳实践 第18章:Minkowski 和与差 第17章:RectClip 矩形裁剪优化 第16章:ClipperOffset 偏移类详解 第15章:填充规则详解 第14章:布尔运算执行流程 第13章:ClipperD 浮点裁剪类 第11章:OutRec 与 OutPt 输出结构 第9章:Active 活动边结构 第10章:Vertex 顶点与 LocalMinima 局部极小值 第12章:Clipper64 裁剪类详解 第7章:高精度运算与128位整数 第8章:ClipperBase 基类详解 第5章:枚举类型与常量定义 第6章:InternalClipper 内部工具类 第2章:核心数据结构 - Point64、PointD 第3章:路径与多边形表示 - Path64、PathD、Paths64、PathsD 第4章:矩形边界 - Rect64、RectD
第六章:前端Vue3开发指南
我才是银古 · 2026-06-21 · via 博客园 - 我才是银古

第六章:前端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>

{% raw %}

<!-- 子组件 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>

{% endraw %}


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使用

{% raw %}

<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>

{% endraw %}


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 图标选择器

{% raw %}

<!-- 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>

{% endraw %}

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: '角色'
  }
}

{% raw %}

<!-- 使用国际化 -->
<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>

{% endraw %}

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的重要一环。在下一章中,我们将进入二次开发实战,学习如何创建自定义业务模块。