Skip to content

Vue 版本

HaloLight Vue 版本基于 Vue 3.5 + Vite 7 构建,采用 Composition API + TypeScript。

在线预览https://halolight-vue.h7ml.cn/

GitHubhttps://github.com/halolight/halolight-vue

特性

  • 🏗️ Composition API - Vue 3.5 组合式 API,逻辑复用更灵活
  • Vite 7 + Rolldown - 极速热更新,Rust 驱动的构建工具
  • 🎨 主题系统 - 11 种皮肤,明暗模式,View Transitions
  • 🔐 认证系统 - 完整登录/注册/找回密码流程
  • 📊 仪表盘 - 数据可视化与业务管理
  • 🛡️ 权限控制 - RBAC 细粒度权限管理
  • 📑 多标签页 - 标签栏管理
  • 命令面板 - 快捷键导航

技术栈

技术版本说明
Vue3.5.x渐进式框架
Vite7.x (Rolldown)构建工具
TypeScript5.x类型安全
Vue Router4.x路由管理
Pinia2.x状态管理
TanStack Query5.x服务端状态
VeeValidate4.x表单验证
Zod3.x数据验证
Tailwind CSS4.x原子化 CSS
shadcn-vuelatestUI 组件库
grid-layout-plus1.x拖拽布局
ECharts5.x图表可视化
Mock.js1.x数据模拟

核心特性

  • 可配置仪表盘 - 9 种小部件,拖拽布局,响应式适配
  • 多标签导航 - 浏览器式标签,右键菜单,状态缓存
  • 权限系统 - RBAC 权限控制,路由守卫,权限组件
  • 主题系统 - 11 种皮肤,明暗模式,View Transitions
  • 多账户切换 - 快速切换账户,记住登录状态
  • 命令面板 - 键盘快捷键 (⌘K),全局搜索
  • 实时通知 - WebSocket 推送,通知中心

目录结构

halolight-vue/
├── src/
│   ├── views/               # 页面视图
│   │   ├── (auth)/         # 认证页面
│   │   └── (dashboard)/    # 仪表盘页面
│   ├── components/         # 组件
│   │   ├── ui/             # 基础 UI 组件
│   │   ├── layout/         # 布局组件
│   │   └── dashboard/      # 仪表盘组件
│   ├── composables/        # 组合式函数
│   ├── stores/             # Pinia 状态管理
│   ├── lib/                # 工具库
│   ├── mocks/              # Mock 数据
│   └── types/              # 类型定义
├── public/                 # 静态资源
├── vite.config.ts
└── package.json

快速开始

环境要求

  • Node.js >= 18.0.0
  • pnpm >= 9.x

安装

bash
git clone https://github.com/halolight/halolight-vue.git
cd halolight-vue
pnpm install

环境变量

bash
cp .env.example .env.local
env
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
VITE_DEMO_EMAIL=admin@halolight.h7ml.cn
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight

启动开发

bash
pnpm dev

访问 http://localhost:5173

构建生产

bash
pnpm build
pnpm preview

演示账号

角色邮箱密码
管理员admin@halolight.h7ml.cn123456
普通用户user@halolight.h7ml.cn123456

核心功能

状态管理 (Pinia)

ts
// stores/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useAuthStore = defineStore('auth', () => {
  // State
  const user = ref<User | null>(null)
  const token = ref<string | null>(null)

  // Getters
  const isAuthenticated = computed(() => !!token.value && !!user.value)
  const permissions = computed(() => user.value?.permissions || [])

  // Actions
  async function login(credentials: LoginCredentials) {
    const response = await authService.login(credentials)
    user.value = response.user
    token.value = response.token
  }

  function logout() {
    user.value = null
    token.value = null
  }

  function hasPermission(permission: string): boolean {
    return permissions.value.some(p =>
      p === '*' || p === permission ||
      (p.endsWith(':*') && permission.startsWith(p.slice(0, -1)))
    )
  }

  return {
    user,
    token,
    isAuthenticated,
    permissions,
    login,
    logout,
    hasPermission,
  }
}, {
  persist: {
    paths: ['token', 'user'],
  },
})

数据获取 (TanStack Query)

ts
// composables/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { userService } from '@/services/users'

export function useUsers(params?: Ref<UserQueryParams>) {
  return useQuery({
    queryKey: ['users', params],
    queryFn: () => userService.getList(unref(params)),
  })
}

export function useCreateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: userService.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
  })
}

权限控制

ts
// composables/usePermission.ts
import { useAuthStore } from '@/stores/auth'

export function usePermission() {
  const authStore = useAuthStore()

  function hasPermission(permission: string): boolean {
    return authStore.hasPermission(permission)
  }

  function hasAnyPermission(permissions: string[]): boolean {
    return permissions.some(p => hasPermission(p))
  }

  function hasAllPermissions(permissions: string[]): boolean {
    return permissions.every(p => hasPermission(p))
  }

  return {
    hasPermission,
    hasAnyPermission,
    hasAllPermissions,
  }
}
ts
// directives/permission.ts
import { useAuthStore } from '@/stores/auth'

export const vPermission = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const authStore = useAuthStore()
    if (!authStore.hasPermission(binding.value)) {
      el.parentNode?.removeChild(el)
    }
  },
}

// 注册指令
app.directive('permission', vPermission)
vue
<!-- 使用权限指令 -->
<button v-permission="'users:delete'">删除</button>

<!-- 使用权限组件 -->
<PermissionGuard permission="users:delete">
  <DeleteButton />
  <template #fallback>
    <span>无权限</span>
  </template>
</PermissionGuard>

可拖拽仪表盘

vue
<!-- components/dashboard/DashboardGrid.vue -->
<script setup lang="ts">
import { GridLayout, GridItem } from 'grid-layout-plus'
import { useDashboardStore } from '@/stores/dashboard'

const dashboardStore = useDashboardStore()
const { layout, isEditing } = storeToRefs(dashboardStore)
</script>

<template>
  <GridLayout
    v-model:layout="layout"
    :col-num="12"
    :row-height="80"
    :is-draggable="isEditing"
    :is-resizable="isEditing"
    :margin="[16, 16]"
  >
    <GridItem
      v-for="item in layout"
      :key="item.i"
      :x="item.x"
      :y="item.y"
      :w="item.w"
      :h="item.h"
    >
      <WidgetWrapper :widget="getWidget(item.i)" />
    </GridItem>
  </GridLayout>
</template>

主题系统

皮肤预设

支持 11 种预设皮肤,通过快捷设置面板切换:

皮肤主色调CSS 变量
Default紫色--primary: 51.1% 0.262 276.97
Blue蓝色--primary: 54.8% 0.243 264.05
Emerald翠绿--primary: 64.6% 0.178 142.49
Orange橙色--primary: 69.7% 0.196 49.27
Rose玫瑰--primary: 63.4% 0.243 357.61
Amber琥珀--primary: 79.1% 0.177 77.54
Cyan青色--primary: 74.4% 0.167 197.13
Violet紫罗兰--primary: 57.2% 0.267 285.75
Lime青柠--primary: 78.8% 0.184 127.38
Pink粉色--primary: 70.9% 0.254 347.58
Teal青蓝--primary: 67.8% 0.157 181.02

CSS 变量 (OKLch)

css
/* 示例变量定义 */
:root {
  --background: 100% 0 0;
  --foreground: 14.9% 0.017 285.75;
  --primary: 51.1% 0.262 276.97;
  --primary-foreground: 100% 0 0;
  --secondary: 97.3% 0.006 285.75;
  --secondary-foreground: 17.9% 0.018 285.75;
  --muted: 97.3% 0.006 285.75;
  --muted-foreground: 49.5% 0.023 285.75;
  --accent: 97.3% 0.006 285.75;
  --accent-foreground: 17.9% 0.018 285.75;
  --destructive: 59.9% 0.24 29.23;
  --destructive-foreground: 98.3% 0.002 285.75;
  --border: 91.9% 0.010 285.75;
  --input: 91.9% 0.010 285.75;
  --ring: 51.1% 0.262 276.97;
  --radius: 0.5rem;
}

主题切换

ts
// composables/useTheme.ts
import { ref, computed, watch } from 'vue'

export function useTheme() {
  const theme = ref<'light' | 'dark' | 'system'>('system')
  const skin = ref<SkinPreset>('default')

  const actualTheme = computed(() => {
    if (theme.value === 'system') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark'
        : 'light'
    }
    return theme.value
  })

  async function toggleTheme(event?: MouseEvent) {
    const newTheme = actualTheme.value === 'dark' ? 'light' : 'dark'

    // View Transitions API
    if (!document.startViewTransition) {
      theme.value = newTheme
      return
    }

    await document.startViewTransition(() => {
      theme.value = newTheme
    }).ready

    // 圆形展开动画
    if (event) {
      const { clientX, clientY } = event
      const radius = Math.hypot(
        Math.max(clientX, window.innerWidth - clientX),
        Math.max(clientY, window.innerHeight - clientY)
      )

      document.documentElement.animate(
        {
          clipPath: [
            `circle(0px at ${clientX}px ${clientY}px)`,
            `circle(${radius}px at ${clientX}px ${clientY}px)`,
          ],
        },
        {
          duration: 500,
          easing: 'ease-in-out',
          pseudoElement: '::view-transition-new(root)',
        }
      )
    }
  }

  watch([theme, skin], () => {
    document.documentElement.classList.remove('light', 'dark')
    document.documentElement.classList.add(actualTheme.value)
    document.documentElement.setAttribute('data-skin', skin.value)
  }, { immediate: true })

  return { theme, skin, actualTheme, toggleTheme }
}

页面路由

路径页面权限
/重定向到 /dashboard-
/login登录公开
/register注册公开
/forgot-password忘记密码公开
/reset-password重置密码公开
/dashboard仪表盘dashboard:view
/users用户管理users:view
/analytics数据分析analytics:view
/calendar日程管理calendar:view
/documents文档管理documents:view
/files文件存储files:view
/messages消息中心messages:view
/notifications通知中心notifications:view
/settings系统设置settings:view
/profile个人资料settings:view

环境变量

配置示例

env
# .env.local
VITE_API_URL=/api
VITE_USE_MOCK=true
VITE_DEMO_EMAIL=admin@halolight.h7ml.cn
VITE_DEMO_PASSWORD=123456
VITE_SHOW_DEMO_HINT=false
VITE_APP_TITLE=Admin Pro
VITE_BRAND_NAME=Halolight

变量说明

变量名说明默认值
VITE_API_URLAPI 基础路径/api
VITE_USE_MOCK是否使用 Mock 数据true
VITE_DEMO_EMAIL演示账号邮箱admin@halolight.h7ml.cn
VITE_DEMO_PASSWORD演示账号密码123456
VITE_SHOW_DEMO_HINT是否显示演示提示false
VITE_APP_TITLE应用标题Admin Pro
VITE_BRAND_NAME品牌名称Halolight

使用方式

ts
// 在代码中使用
const apiUrl = import.meta.env.VITE_API_URL
const useMock = import.meta.env.VITE_USE_MOCK === 'true'
const appTitle = import.meta.env.VITE_APP_TITLE

常用命令

bash
pnpm dev            # 启动开发服务器
pnpm build          # 生产构建
pnpm preview        # 预览生产构建
pnpm lint           # 代码检查
pnpm lint:fix       # 自动修复
pnpm type-check     # 类型检查
pnpm test           # 运行测试
pnpm test:coverage  # 测试覆盖率

测试

bash
pnpm test           # 运行测试(watch 模式)
pnpm test:run       # 单次运行
pnpm test:coverage  # 覆盖率报告
pnpm test:ui        # Vitest UI 界面

测试示例

ts
// tests/components/Button.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/ui/Button.vue'

describe('Button', () => {
  it('renders properly', () => {
    const wrapper = mount(Button, {
      props: { variant: 'default' },
      slots: { default: 'Click me' }
    })
    expect(wrapper.text()).toContain('Click me')
  })

  it('emits click event', async () => {
    const wrapper = mount(Button)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })
})

配置

Vite 配置

ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  server: {
    port: 5173,
    open: true,
  },
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-vendor': ['vue', 'vue-router', 'pinia'],
          'ui-vendor': ['@tanstack/vue-query'],
        },
      },
    },
  },
})

部署

Vercel (推荐)

Deploy with Vercel

Docker

bash
docker build -t halolight-vue .
docker run -p 3000:3000 halolight-vue

其他平台

CI/CD

项目配置了完整的 GitHub Actions CI 工作流:

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm type-check

  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm test:coverage
      - uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm audit --audit-level=high

高级功能

ECharts 集成

vue
<script setup lang="ts">
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, BarChart, PieChart } from 'echarts/charts'
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
import VChart from 'vue-echarts'
import { useTheme } from '@/composables/useTheme'

use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent])

const { actualTheme } = useTheme()

const option = computed(() => ({
  backgroundColor: 'transparent',
  textStyle: {
    color: actualTheme.value === 'dark' ? '#e5e5e5' : '#333',
  },
  xAxis: {
    type: 'category',
    data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
  },
  yAxis: {
    type: 'value'
  },
  series: [{
    data: [820, 932, 901, 934, 1290, 1330, 1320],
    type: 'line'
  }]
}))
</script>

<template>
  <VChart :option="option" autoresize />
</template>

路由守卫

ts
// router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [...routes]
})

router.beforeEach((to, from, next) => {
  const authStore = useAuthStore()

  // 需要认证的页面
  if (to.meta.requiresAuth && !authStore.isAuthenticated) {
    next({ name: 'login', query: { redirect: to.fullPath } })
    return
  }

  // 权限检查
  if (to.meta.permission && !authStore.hasPermission(to.meta.permission)) {
    next({ name: '403' })
    return
  }

  next()
})

export default router

性能优化

图片优化

vue
<script setup lang="ts">
const imageSrc = computed(() => {
  const { width } = useWindowSize()
  if (width.value < 768) return '/images/mobile.webp'
  if (width.value < 1024) return '/images/tablet.webp'
  return '/images/desktop.webp'
})
</script>

<template>
  <img
    :src="imageSrc"
    loading="lazy"
    decoding="async"
    alt="响应式图片"
  >
</template>

懒加载组件

ts
// router/routes.ts
const routes = [
  {
    path: '/dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/users',
    component: () => import('@/views/Users.vue'),
    meta: { requiresAuth: true, permission: 'users:view' }
  },
]

预加载

vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()

onMounted(() => {
  // 预加载常用路由
  router.resolve({ name: 'users' })
  router.resolve({ name: 'settings' })
})
</script>

常见问题

Q:如何切换主题?

A:使用 useTheme composable:

vue
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'

const { theme, toggleTheme, skin } = useTheme()

// 切换明暗主题
function handleToggle(event: MouseEvent) {
  toggleTheme(event)
}

// 切换皮肤
function changeSkin(newSkin: SkinPreset) {
  skin.value = newSkin
}
</script>

<template>
  <button @click="handleToggle">切换主题</button>
  <select v-model="skin">
    <option value="default">Default</option>
    <option value="blue">Blue</option>
    <option value="emerald">Emerald</option>
  </select>
</template>

Q:如何添加新的权限?

A:在认证响应中添加权限字符串:

ts
// types/auth.ts
interface User {
  id: string
  name: string
  email: string
  permissions: string[] // ['users:*', 'posts:view', 'posts:create']
}

// 使用通配符
// 'users:*' - 用户模块所有权限
// '*' - 所有权限
// 'users:view' - 特定权限

Q:如何自定义仪表盘布局?

A:通过 Dashboard Store 管理布局:

ts
// stores/dashboard.ts
import { defineStore } from 'pinia'

export const useDashboardStore = defineStore('dashboard', () => {
  const layout = ref([
    { i: 'widget-1', x: 0, y: 0, w: 6, h: 4 },
    { i: 'widget-2', x: 6, y: 0, w: 6, h: 4 },
  ])

  function saveLayout(newLayout: Layout[]) {
    layout.value = newLayout
    // 保存到服务器
  }

  return { layout, saveLayout }
})

与其他版本对比

特性VueNext.jsAngular
SSR/SSG❌ (需要 Nuxt)✅ (需要 Angular Universal)
状态管理PiniaZustandRxJS/Signals
路由Vue RouterApp RouterAngular Router
构建工具ViteNext.jsAngular CLI
学习曲线
生态系统丰富丰富企业级

相关链接