一、项目初始化
1.1 使用 create-vue 创建项目
Vue 官方推荐使用 create-vue 脚手架来初始化项目,它基于 Vite 构建,支持 TypeScript、JSX、Router、Pinia、Vitest 等一系列现代前端工具链的一键集成。
# 全局安装 create-vue(如果尚未安装)
npm create vue@latest
# 或者使用 pnpm(推荐)
pnpm create vue@latest执行命令后,交互式选择配置项:
✔ Project name: … vue3-admin-dashboard
✔ Add TypeScript? … Yes
✔ Add JSX Support? … Yes
✔ Add Vue Router for Single Page Application development? … Yes
✔ Add Pinia for state management? … Yes
✔ Add Vitest for Unit Testing? … Yes
✔ Add an End-to-End Testing Solution? › Playwright
✔ Add ESLint for code quality? … Yes
✔ Add Prettier for code formatting? … Yes进入项目并启动开发服务器:
cd vue3-admin-dashboard
pnpm install
pnpm dev此时浏览器访问 http://localhost:5173 即可看到 Vite + Vue 3 的欢迎页面。
1.2 工程目录结构设计
一个结构清晰、约定明确的目录结构是大型项目可持续维护的基石。以下是我推荐的实战级目录结构:
vue3-admin-dashboard/
├── public/ # 静态资源(不经过构建处理)
│ ├── favicon.ico
│ └── robots.txt
├── src/
│ ├── api/ # API 请求层
│ │ ├── modules/ # 按业务模块划分的 API
│ │ │ ├── auth.ts
│ │ │ └── articles.ts
│ │ ├── request.ts # axios 实例与拦截器
│ │ └── types.ts # API 响应类型
│ ├── assets/ # 静态资源(经过构建处理)
│ │ ├── images/
│ │ ├── icons/
│ │ └── styles/
│ │ ├── variables.scss # 全局变量
│ │ └── global.scss # 全局样式
│ ├── components/ # 通用组件
│ │ ├── common/ # 基础通用组件
│ │ │ ├── DataTable.vue
│ │ │ ├── FormModal.vue
│ │ │ └── Pagination.vue
│ │ └── layout/ # 布局组件
│ │ ├── Sidebar.vue
│ │ ├── Header.vue
│ │ └── MainLayout.vue
│ ├── composables/ # 组合式函数
│ │ ├── useAuth.ts
│ │ └── usePagination.ts
│ ├── directives/ # 自定义指令
│ │ ├── v-permission.ts
│ │ └── v-debounce.ts
│ ├── layouts/ # 布局页面
│ │ └── DefaultLayout.vue
│ ├── router/ # 路由配置
│ │ ├── index.ts
│ │ └── guards.ts # 路由守卫
│ ├── stores/ # Pinia 状态管理
│ │ ├── auth.ts
│ │ ├── articles.ts
│ │ └── app.ts
│ ├── types/ # 全局类型定义
│ │ ├── article.ts
│ │ ├── user.ts
│ │ └── api.ts
│ ├── utils/ # 工具函数
│ │ ├── format.ts
│ │ ├── storage.ts
│ │ └── validate.ts
│ ├── views/ # 页面组件
│ │ ├── login/
│ │ │ └── LoginView.vue
│ │ ├── dashboard/
│ │ │ └── DashboardView.vue
│ │ ├── articles/
│ │ │ ├── ArticleList.vue
│ │ │ └── ArticleEdit.vue
│ │ ├── settings/
│ │ │ └── SettingsView.vue
│ │ └── error/
│ │ └── NotFound.vue
│ ├── App.vue
│ └── main.ts
├── .env # 公共环境变量
├── .env.development # 开发环境变量
├── .env.production # 生产环境变量
├── .eslintrc.cjs # ESLint 配置
├── .prettierrc.json # Prettier 配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
├── vitest.config.ts # Vitest 配置
├── package.json
└── pnpm-lock.yaml设计原则:
- 按功能模块划分 API:每个业务模块一个文件,避免单个 API 文件膨胀到上千行
- 组件分层:
components/common/放通用组件,components/下放业务组件 - Composables 集中管理:所有组合式函数统一放在
composables/目录 - 类型定义独立:
types/目录集中管理全局类型,按模块分文件
1.3 代码规范:ESLint + Prettier + Husky + lint-staged
ESLint + Prettier 配置
create-vue 已经集成了 ESLint 和 Prettier 的基础配置,我们在此基础上做微调:
// .eslintrc.cjs
module.exports = {
root: true,
env: { browser: true, es2020: true, node: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended',
'prettier', // 必须放在最后,覆盖之前的格式规则
],
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: ['@typescript-eslint'],
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
},
};// .prettierrc.json
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}Husky + lint-staged 配置
在提交代码前自动执行 lint 检查和格式化,防止不规范代码进入仓库:
# 安装 husky 和 lint-staged
pnpm add -D husky lint-staged
# 初始化 husky
npx husky init
# 添加 pre-commit 钩子
echo 'npx lint-staged' > .husky/pre-commit
# 使钩子可执行
chmod +x .husky/pre-commit// package.json 中配置 lint-staged
{
"lint-staged": {
"*.{js,ts,vue,jsx,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,scss,json,md}": [
"prettier --write"
]
}
}这样配置后,每次 git commit 时都会自动对暂存区文件执行 lint 检查和格式化,只有通过检查的代码才能提交。
1.4 包管理器选型(pnpm)
为什么选择 pnpm 而非 npm 或 yarn?
- 磁盘空间节省:pnpm 使用硬链接 + 符号链接,相同版本的依赖只存储一份
- 安装速度:并行安装,比 npm 快 2-3 倍
- 严格性:
node_modules目录结构严格,防止非法访问未声明的包 - Monorepo 支持:内置 workspace 协议,天然支持 monorepo
# 全局安装 pnpm
npm install -g pnpm
# 常用命令
pnpm install # 安装所有依赖
pnpm add vue # 安装运行时依赖
pnpm add -D vitest # 安装开发依赖
pnpm dev # 启动开发服务器
pnpm build # 构建生产版本
pnpm upgrade # 升级依赖1.5 TypeScript 配置
合理的 TypeScript 配置能让开发体验更流畅:
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForEmit": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"],
"references": [{ "path": "./tsconfig.node.json" }]
}// tsconfig.node.json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}1.6 路径别名配置
配置 @ 指向 src/ 目录,避免深层嵌套的 ../../../ 地狱。
Vite 侧配置(vite.config.ts):
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});TypeScript 侧配置(已在 tsconfig.json 中配置 paths):
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}使用示例:
// 之前:import { formatDate } from '../../../utils/format'
// 现在:
import { formatDate } from '@/utils/format';
import { useAuthStore } from '@/stores/auth';
import ArticleList from '@/views/articles/ArticleList.vue';二、项目架构设计
2.1 组件目录组织:按功能 vs 按类型
对于中等以上规模的项目(>20 个页面),推荐 先按功能模块分层,再按类型细分 的组织方式:
components/
├── common/ # 跨业务通用的基础组件
│ ├── DataTable.vue
│ ├── FormModal.vue
│ └── Pagination.vue
├── business/ # 业务相关组件
│ ├── article/ # 文章模块组件
│ │ ├── ArticleCard.vue
│ │ └── ArticleFilter.vue
│ └── user/ # 用户模块组件
│ ├── UserAvatar.vue
│ └── UserDropdown.vue
└── layout/ # 布局组件
├── Sidebar.vue
├── Header.vue
└── MainLayout.vue原则:
- 通用组件(按钮、表格、弹窗)放
common/,全局注册或按需引入 - 业务组件按模块放在
business/{module}/目录下 - 布局组件独立成
layout/,与业务解耦
2.2 API 层封装
API 层是所有前端请求的入口,一个好的封装能统一处理鉴权、错误、请求取消等横切关注点。
axios 实例与拦截器
// src/api/request.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/stores/auth';
import router from '@/router';
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器:自动携带 Token
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error) => Promise.reject(error),
);
// 响应拦截器:统一错误处理 + Token 自动刷新
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
const processQueue = (error: unknown, token: string | null = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token!);
}
});
failedQueue = [];
};
request.interceptors.response.use(
(response) => response.data,
async (error: AxiosError<{ message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// 401 未授权 —— 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 等待刷新完成后重试
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(request(originalRequest));
},
reject,
});
});
}
originalRequest._retry = true;
isRefreshing = true;
const authStore = useAuthStore();
try {
const newToken = await authStore.refreshToken();
processQueue(null, newToken);
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return request(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
authStore.logout();
router.push('/login');
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// 统一错误提示
const message = error.response?.data?.message || error.message || '请求失败';
// 这里可以接入全局消息提示组件,例如 ElMessage.error(message)
console.error(`[API Error] ${message}`);
return Promise.reject(error);
},
);
export default request;API 响应类型
// src/api/types.ts
/** 通用分页参数 */
export interface PaginationParams {
page: number;
pageSize: number;
}
/** 通用列表响应 */
export interface ListResponse<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
/** 通用 API 响应 */
export interface ApiResponse<T = unknown> {
code: number;
data: T;
message: string;
}按模块划分的 API 文件
// src/api/modules/articles.ts
import request from '@/api/request';
import type { ApiResponse, ListResponse, PaginationParams } from '@/api/types';
export interface Article {
id: number;
title: string;
summary: string;
content: string;
status: 'draft' | 'published';
categoryId: number;
tags: string[];
createdAt: string;
updatedAt: string;
}
export interface ArticleQueryParams extends PaginationParams {
keyword?: string;
status?: 'draft' | 'published';
categoryId?: number;
}
/** 获取文章列表 */
export function fetchArticles(params: ArticleQueryParams) {
return request.get<any, ApiResponse<ListResponse<Article>>>('/articles', {
params,
});
}
/** 获取文章详情 */
export function fetchArticleById(id: number) {
return request.get<any, ApiResponse<Article>>(`/articles/${id}`);
}
/** 创建文章 */
export function createArticle(data: Omit<Article, 'id' | 'createdAt' | 'updatedAt'>) {
return request.post<any, ApiResponse<Article>>('/articles', data);
}
/** 更新文章 */
export function updateArticle(id: number, data: Partial<Article>) {
return request.put<any, ApiResponse<Article>>(`/articles/${id}`, data);
}
/** 删除文章 */
export function deleteArticle(id: number) {
return request.delete<any, ApiResponse<null>>(`/articles/${id}`);
}// src/api/modules/auth.ts
import request from '@/api/request';
import type { ApiResponse } from '@/api/types';
export interface LoginParams {
username: string;
password: string;
}
export interface LoginResult {
token: string;
refreshToken: string;
user: {
id: number;
username: string;
avatar: string;
role: string;
};
}
/** 登录 */
export function login(data: LoginParams) {
return request.post<any, ApiResponse<LoginResult>>('/auth/login', data);
}
/** 刷新 Token */
export function refreshToken(refreshToken: string) {
return request.post<any, ApiResponse<{ token: string; refreshToken: string }>>(
'/auth/refresh',
{ refreshToken },
);
}
/** 获取用户信息 */
export function fetchUserInfo() {
return request.get<any, ApiResponse<LoginResult['user']>>('/auth/userinfo');
}
/** 退出登录 */
export function logout() {
return request.post<any, ApiResponse<null>>('/auth/logout');
}2.3 Store 层设计
使用 Pinia 管理全局状态,按业务模块拆分 Store:
// src/stores/auth.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { login as loginApi, logout as logoutApi, fetchUserInfo } from '@/api/modules/auth';
import router from '@/router';
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '@/utils/storage';
export const useAuthStore = defineStore('auth', () => {
// state
const token = ref<string | null>(localStorage.getItem(ACCESS_TOKEN_KEY));
const refreshTokenValue = ref<string | null>(localStorage.getItem(REFRESH_TOKEN_KEY));
const user = ref<{ id: number; username: string; avatar: string; role: string } | null>(null);
// getters
const isAuthenticated = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.role === 'admin');
const username = computed(() => user.value?.username ?? '');
// actions
async function loginAction(credentials: { username: string; password: string }) {
const res = await loginApi(credentials);
const { token: accessToken, refreshToken: refToken, user: userInfo } = res.data;
token.value = accessToken;
refreshTokenValue.value = refToken;
user.value = userInfo;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refToken);
}
async function fetchUserInfoAction() {
const res = await fetchUserInfo();
user.value = res.data;
}
async function refreshTokenAction(): Promise<string> {
if (!refreshTokenValue.value) throw new Error('No refresh token');
const res = await import('@/api/modules/auth').then((m) =>
m.refreshToken(refreshTokenValue.value!),
);
token.value = res.data.token;
refreshTokenValue.value = res.data.refreshToken;
localStorage.setItem(ACCESS_TOKEN_KEY, res.data.token);
localStorage.setItem(REFRESH_TOKEN_KEY, res.data.refreshToken);
return res.data.token;
}
function logoutAction() {
logoutApi().catch(() => {
/* ignore API error on logout */
});
token.value = null;
refreshTokenValue.value = null;
user.value = null;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
router.push('/login');
}
return {
token,
refreshTokenValue,
user,
isAuthenticated,
isAdmin,
username,
loginAction,
fetchUserInfoAction,
refreshTokenAction,
logoutAction,
};
});// src/stores/articles.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import {
fetchArticles,
fetchArticleById,
createArticle,
updateArticle,
deleteArticle,
type Article,
type ArticleQueryParams,
} from '@/api/modules/articles';
import { usePagination } from '@/composables/usePagination';
export const useArticlesStore = defineStore('articles', () => {
const articles = ref<Article[]>([]);
const currentArticle = ref<Article | null>(null);
const loading = ref(false);
const { page, pageSize, total, setPagination } = usePagination();
const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
async function fetchArticleList(params?: Partial<ArticleQueryParams>) {
loading.value = true;
try {
const res = await fetchArticles({
page: page.value,
pageSize: pageSize.value,
...params,
});
articles.value = res.data.list;
total.value = res.data.total;
} finally {
loading.value = false;
}
}
async function fetchArticle(id: number) {
const res = await fetchArticleById(id);
currentArticle.value = res.data;
return res.data;
}
async function saveArticle(data: Parameters<typeof createArticle>[0]) {
const res = await createArticle(data);
await fetchArticleList();
return res.data;
}
async function editArticle(id: number, data: Parameters<typeof updateArticle>[1]) {
const res = await updateArticle(id, data);
await fetchArticleList();
return res.data;
}
async function removeArticle(id: number) {
await deleteArticle(id);
await fetchArticleList();
}
return {
articles,
currentArticle,
loading,
page,
pageSize,
total,
totalPages,
fetchArticleList,
fetchArticle,
saveArticle,
editArticle,
removeArticle,
setPagination,
};
});// src/stores/app.ts
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
export const useAppStore = defineStore('app', () => {
const sidebarCollapsed = ref(false);
const darkMode = ref(localStorage.getItem('darkMode') === 'true');
const themeColor = ref(localStorage.getItem('themeColor') || '#409eff');
// 同步暗色模式到 HTML 标签
watch(darkMode, (val) => {
document.documentElement.classList.toggle('dark', val);
localStorage.setItem('darkMode', String(val));
});
// 同步主题色到 CSS 变量
watch(themeColor, (val) => {
document.documentElement.style.setProperty('--color-primary', val);
localStorage.setItem('themeColor', val);
});
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleDarkMode() {
darkMode.value = !darkMode.value;
}
return {
sidebarCollapsed,
darkMode,
themeColor,
toggleSidebar,
toggleDarkMode,
};
});2.4 路由配置与权限控制
路由定义
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/',
component: () => import('@/layouts/DefaultLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/dashboard',
},
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/DashboardView.vue'),
meta: { title: '仪表盘', icon: 'dashboard' },
},
{
path: 'articles',
name: 'Articles',
component: () => import('@/views/articles/ArticleList.vue'),
meta: { title: '文章列表', icon: 'document', permission: 'article:list' },
},
{
path: 'articles/create',
name: 'ArticleCreate',
component: () => import('@/views/articles/ArticleEdit.vue'),
meta: { title: '创建文章', permission: 'article:create' },
},
{
path: 'articles/:id/edit',
name: 'ArticleEdit',
component: () => import('@/views/articles/ArticleEdit.vue'),
meta: { title: '编辑文章', permission: 'article:edit' },
},
{
path: 'settings',
name: 'Settings',
component: () => import('@/views/settings/SettingsView.vue'),
meta: { title: '系统设置', icon: 'setting' },
},
],
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/NotFound.vue'),
meta: { requiresAuth: false },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 }),
});
export default router;路由守卫
// src/router/guards.ts
import type { Router } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
export function setupGuards(router: Router) {
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// 白名单:不需要登录的页面
const whiteList = ['/login'];
if (to.meta.requiresAuth !== false && !authStore.isAuthenticated) {
// 需要登录但未登录 —> 跳转登录页
if (!whiteList.includes(to.path)) {
next(`/login?redirect=${to.fullPath}`);
return;
}
}
if (to.path === '/login' && authStore.isAuthenticated) {
// 已登录用户访问登录页 —> 重定向到首页
next('/');
return;
}
// 权限校验
const requiredPermission = to.meta.permission as string | undefined;
if (requiredPermission && !authStore.isAdmin) {
// 权限不足,跳转到 403 或首页
next('/');
return;
}
// 设置页面标题
const title = (to.meta.title as string) || 'Vue 3 Admin';
document.title = `${title} — Vue 3 实战`;
next();
});
}在 main.ts 中调用守卫安装:
// src/main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import { setupGuards } from './router/guards';
import './assets/styles/global.scss';
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
// 安装路由守卫
setupGuards(router);
app.mount('#app');2.5 工具函数目录
// src/utils/storage.ts
export const ACCESS_TOKEN_KEY = 'access_token';
export const REFRESH_TOKEN_KEY = 'refresh_token';
export function getStorageItem<T = string>(key: string): T | null {
try {
const item = localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : null;
} catch {
return localStorage.getItem(key) as T | null;
}
}
export function setStorageItem<T>(key: string, value: T): void {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('Storage set error:', e);
}
}
export function removeStorageItem(key: string): void {
localStorage.removeItem(key);
}// src/utils/format.ts
export function formatDate(date: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
const d = new Date(date);
const map: Record<string, number> = {
YYYY: d.getFullYear(),
MM: d.getMonth() + 1,
DD: d.getDate(),
HH: d.getHours(),
mm: d.getMinutes(),
ss: d.getSeconds(),
};
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, (match) =>
String(map[match]).padStart(2, '0'),
);
}
export function truncate(text: string, length = 100): string {
if (text.length <= length) return text;
return text.slice(0, length) + '...';
}// src/utils/validate.ts
export function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export function isValidPassword(password: string): boolean {
// 最少 8 位,包含大小写字母和数字
return /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,32}$/.test(password);
}
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}2.6 类型定义管理
// src/types/article.ts
export type ArticleStatus = 'draft' | 'published';
export interface ArticleMeta {
views: number;
likes: number;
comments: number;
}
export interface ArticleCategory {
id: number;
name: string;
description?: string;
}
export interface ArticleTag {
id: number;
name: string;
color?: string;
}// src/types/user.ts
export type UserRole = 'admin' | 'editor' | 'viewer';
export interface UserProfile {
id: number;
username: string;
email: string;
avatar: string;
role: UserRole;
createdAt: string;
}2.7 环境变量配置
Vite 使用 import.meta.env 访问环境变量,变量必须以 VITE_ 前缀开头才会暴露给前端代码:
# .env # 所有环境通用的变量
VITE_APP_TITLE=Vue 3 Admin
VITE_API_BASE_URL=/api
# .env.development # 开发环境
VITE_API_BASE_URL=http://localhost:3000/api
VITE_APP_DEBUG=true
# .env.production # 生产环境
VITE_API_BASE_URL=https://api.example.com
VITE_APP_DEBUG=false在 Vue 组件或 TS 文件中使用:
const apiUrl = import.meta.env.VITE_API_BASE_URL;
const isDebug = import.meta.env.VITE_APP_DEBUG === 'true';为环境变量提供 TypeScript 类型提示:
// env.d.ts 或 src/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_API_BASE_URL: string;
readonly VITE_APP_DEBUG: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}三、项目实战:文章管理后台
3.1 路由设计
项目路由分为三大类:
| 路径 | 页面 | 权限 | 说明 |
|---|---|---|---|
/login |
登录页 | 公开 | 用户登录 |
/dashboard |
仪表盘 | 登录 | 数据概览 |
/articles |
文章列表 | 登录 | 文章 CRUD |
/articles/create |
创建文章 | 编辑以上 | 富文本编辑 |
/articles/:id/edit |
编辑文章 | 编辑以上 | 富文本编辑 |
/settings |
系统设置 | 管理员 | 站点配置 |
/:pathMatch(.*)* |
404 | 公开 | 未匹配路由 |
3.2 布局组件
布局组件是后台管理系统的骨架,包含 Sidebar(侧边栏)、Header(顶部导航栏)和 MainLayout(布局容器)。
MainLayout
<!-- src/layouts/DefaultLayout.vue -->
<script setup lang="ts">
import Sidebar from '@/components/layout/Sidebar.vue';
import Header from '@/components/layout/Header.vue';
import { useAppStore } from '@/stores/app';
const appStore = useAppStore();
</script>
<template>
<div class="layout" :class="{ 'layout--collapsed': appStore.sidebarCollapsed }">
<Sidebar class="layout__sidebar" />
<div class="layout__main">
<Header class="layout__header" />
<main class="layout__content">
<router-view />
</main>
</div>
</div>
</template>
<style scoped lang="scss">
.layout {
display: flex;
height: 100vh;
overflow: hidden;
&__sidebar {
width: 240px;
flex-shrink: 0;
transition: width 0.3s ease;
}
&--collapsed &__sidebar {
width: 64px;
}
&__main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
&__header {
height: 56px;
flex-shrink: 0;
}
&__content {
flex: 1;
padding: 24px;
overflow-y: auto;
background: #f5f7fa;
}
}
</style>Sidebar
<!-- src/components/layout/Sidebar.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/stores/app';
import { useAuthStore } from '@/stores/auth';
const route = useRoute();
const router = useRouter();
const appStore = useAppStore();
const authStore = useAuthStore();
interface MenuItem {
path: string;
title: string;
icon: string;
permission?: string;
}
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{ path: '/dashboard', title: '仪表盘', icon: '📊' },
{ path: '/articles', title: '文章管理', icon: '📝', permission: 'article:list' },
{ path: '/settings', title: '系统设置', icon: '⚙️' },
];
if (!authStore.isAdmin) {
return items.filter((item) => !item.permission || item.permission === 'article:list');
}
return items;
});
function navigate(path: string) {
router.push(path);
}
</script>
<template>
<aside class="sidebar" :class="{ 'sidebar--collapsed': appStore.sidebarCollapsed }">
<div class="sidebar__logo">
<span v-if="!appStore.sidebarCollapsed" class="sidebar__title">Vue 3 Admin</span>
<span v-else class="sidebar__logo-mini">V3</span>
</div>
<nav class="sidebar__nav">
<div
v-for="item in menuItems"
:key="item.path"
class="sidebar__item"
:class="{ 'sidebar__item--active': route.path.startsWith(item.path) }"
@click="navigate(item.path)"
>
<span class="sidebar__icon">{{ item.icon }}</span>
<span v-show="!appStore.sidebarCollapsed" class="sidebar__label">{{ item.title }}</span>
</div>
</nav>
</aside>
</template>
<style scoped lang="scss">
.sidebar {
display: flex;
flex-direction: column;
background: #1d1e1f;
color: #fff;
overflow: hidden;
transition: width 0.3s ease;
&__logo {
display: flex;
align-items: center;
justify-content: center;
height: 56px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-weight: 700;
font-size: 18px;
}
&__title {
white-space: nowrap;
}
&__nav {
flex: 1;
padding: 8px 0;
overflow-y: auto;
}
&__item {
display: flex;
align-items: center;
padding: 12px 20px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
&:hover {
background: rgba(255, 255, 255, 0.08);
}
&--active {
background: var(--color-primary, #409eff);
}
}
&__icon {
font-size: 18px;
flex-shrink: 0;
width: 24px;
text-align: center;
}
&__label {
margin-left: 12px;
font-size: 14px;
}
&--collapsed &__item {
justify-content: center;
padding: 12px 0;
}
}
</style>Header
<!-- src/components/layout/Header.vue -->
<script setup lang="ts">
import { useAppStore } from '@/stores/app';
import { useAuthStore } from '@/stores/auth';
const appStore = useAppStore();
const authStore = useAuthStore();
</script>
<template>
<header class="header">
<div class="header__left">
<button class="header__toggle" @click="appStore.toggleSidebar">
{{ appStore.sidebarCollapsed ? '☰' : '✕' }}
</button>
<h2 class="header__breadcrumb">
<router-link to="/" class="header__home">首页</router-link>
<span class="header__separator">/</span>
<span>{{ $route.meta.title || '' }}</span>
</h2>
</div>
<div class="header__right">
<button class="header__theme-btn" @click="appStore.toggleDarkMode">
{{ appStore.darkMode ? '☀️' : '🌙' }}
</button>
<span class="header__username">{{ authStore.username }}</span>
<button class="header__logout" @click="authStore.logoutAction()">退出</button>
</div>
</header>
</template>
<style scoped lang="scss">
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #e4e7ed;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
&__left {
display: flex;
align-items: center;
gap: 16px;
}
&__toggle {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px 8px;
}
&__breadcrumb {
font-size: 14px;
font-weight: 400;
color: #606266;
}
&__separator {
margin: 0 8px;
color: #c0c4cc;
}
&__home {
color: var(--color-primary, #409eff);
text-decoration: none;
}
&__right {
display: flex;
align-items: center;
gap: 16px;
}
&__theme-btn {
background: none;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
}
&__username {
font-size: 14px;
color: #303133;
}
&__logout {
background: none;
border: 1px solid #e4e7ed;
border-radius: 6px;
padding: 4px 12px;
cursor: pointer;
font-size: 13px;
color: #606266;
transition: all 0.2s;
&:hover {
color: #f56c6c;
border-color: #f56c6c;
}
}
}
</style>3.3 通用组件
DataTable 通用表格组件
<!-- src/components/common/DataTable.vue -->
<script setup lang="ts">
import type { ColumnDef } from '@/types/table';
interface Props<T = Record<string, unknown>> {
columns: ColumnDef<T>[];
data: T[];
loading?: boolean;
bordered?: boolean;
stripe?: boolean;
}
withDefaults(defineProps<Props>(), {
loading: false,
bordered: false,
stripe: false,
});
const emit = defineEmits<{
'row-click': [row: Record<string, unknown>];
}>();
</script>
<template>
<div class="data-table" :class="{ 'data-table--bordered': bordered }">
<div v-if="loading" class="data-table__loading">加载中...</div>
<table class="data-table__table">
<thead>
<tr>
<th v-for="col in columns" :key="col.key" :style="{ width: col.width }">
{{ col.title }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIdx) in data"
:key="(row as any).id ?? rowIdx"
:class="{ 'data-table__row--stripe': stripe && rowIdx % 2 === 1 }"
@click="emit('row-click', row)"
>
<td v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :row="row" :value="(row as any)[col.key]">
{{ (row as any)[col.key] }}
</slot>
</td>
</tr>
<tr v-if="data.length === 0 && !loading">
<td :colspan="columns.length" class="data-table__empty">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped lang="scss">
.data-table {
width: 100%;
overflow-x: auto;
&__loading {
text-align: center;
padding: 32px;
color: #909399;
}
&__table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
table-layout: fixed;
}
th,
td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #ebeef5;
}
th {
background: #fafafa;
color: #909399;
font-weight: 600;
white-space: nowrap;
}
td {
color: #606266;
}
tbody tr {
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f5f7fa;
}
}
&--bordered {
th,
td {
border: 1px solid #ebeef5;
}
}
&__empty {
text-align: center;
color: #c0c4cc;
padding: 40px !important;
}
}
</style>Pagination 分页组件
<!-- src/components/common/Pagination.vue -->
<script setup lang="ts">
interface Props {
currentPage: number;
pageSize: number;
total: number;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:currentPage': [page: number];
'update:pageSize': [size: number];
}>();
const totalPages = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
const visiblePages = computed(() => {
const pages: number[] = [];
const start = Math.max(1, props.currentPage - 2);
const end = Math.min(totalPages.value, props.currentPage + 2);
for (let i = start; i <= end; i++) pages.push(i);
return pages;
});
import { computed } from 'vue';
function goToPage(page: number) {
if (page < 1 || page > totalPages.value) return;
emit('update:currentPage', page);
}
const pageSizeOptions = [10, 20, 50, 100];
</script>
<template>
<div class="pagination">
<span class="pagination__info">共 {{ total }} 条</span>
<div class="pagination__buttons">
<button :disabled="currentPage <= 1" class="pagination__btn" @click="goToPage(currentPage - 1)">
上一页
</button>
<button
v-for="page in visiblePages"
:key="page"
class="pagination__btn"
:class="{ 'pagination__btn--active': page === currentPage }"
@click="goToPage(page)"
>
{{ page }}
</button>
<button :disabled="currentPage >= totalPages" class="pagination__btn" @click="goToPage(currentPage + 1)">
下一页
</button>
</div>
<select
class="pagination__size-select"
:value="pageSize"
@change="emit('update:pageSize', Number(($event.target as HTMLSelectElement).value))"
>
<option v-for="size in pageSizeOptions" :key="size" :value="size">
{{ size }} 条/页
</option>
</select>
</div>
</template>
<style scoped lang="scss">
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
padding: 16px 0;
&__info {
font-size: 13px;
color: #909399;
}
&__buttons {
display: flex;
gap: 4px;
}
&__btn {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 13px;
color: #606266;
transition: all 0.2s;
&:hover:not(:disabled) {
color: var(--color-primary, #409eff);
border-color: var(--color-primary, #409eff);
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&--active {
color: #fff;
background: var(--color-primary, #409eff);
border-color: var(--color-primary, #409eff);
}
}
&__size-select {
height: 32px;
padding: 0 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 13px;
color: #606266;
background: #fff;
cursor: pointer;
}
}
</style>FormModal 表单弹窗组件
<!-- src/components/common/FormModal.vue -->
<script setup lang="ts">
interface Props {
visible: boolean;
title: string;
width?: string;
}
withDefaults(defineProps<Props>(), {
width: '520px',
});
const emit = defineEmits<{
close: [];
confirm: [];
}>();
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="emit('close')">
<div class="modal-container" :style="{ maxWidth: width }">
<div class="modal-header">
<h3 class="modal-title">{{ title }}</h3>
<button class="modal-close" @click="emit('close')">✕</button>
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer">
<button class="modal-btn modal-btn--cancel" @click="emit('close')">取消</button>
<button class="modal-btn modal-btn--confirm" @click="emit('confirm')">确认</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped lang="scss">
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-container {
width: 100%;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
border-bottom: 1px solid #ebeef5;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
}
.modal-close {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: #909399;
padding: 4px;
&:hover {
color: #303133;
}
}
.modal-body {
padding: 24px;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid #ebeef5;
}
.modal-btn {
padding: 8px 20px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&--cancel {
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
&:hover {
color: var(--color-primary, #409eff);
border-color: var(--color-primary, #409eff);
}
}
&--confirm {
background: var(--color-primary, #409eff);
border: 1px solid var(--color-primary, #409eff);
color: #fff;
&:hover {
opacity: 0.85;
}
}
}
// Transition
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.25s ease;
.modal-container {
transition: transform 0.25s ease;
}
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
.modal-container {
transform: scale(0.95);
}
}
</style>3.4 登录功能:JWT 认证
登录页面
<!-- src/views/login/LoginView.vue -->
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { isValidPassword } from '@/utils/validate';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const form = reactive({
username: '',
password: '',
remember: false,
});
const loading = ref(false);
const errorMsg = ref('');
async function handleLogin() {
errorMsg.value = '';
if (!form.username.trim()) {
errorMsg.value = '请输入用户名';
return;
}
if (!form.password) {
errorMsg.value = '请输入密码';
return;
}
loading.value = true;
try {
await authStore.loginAction({
username: form.username,
password: form.password,
});
const redirect = (route.query.redirect as string) || '/';
router.push(redirect);
} catch (err: any) {
errorMsg.value = err?.response?.data?.message || '登录失败,请检查用户名或密码';
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login">
<div class="login__card">
<h1 class="login__title">Vue 3 Admin</h1>
<p class="login__subtitle">欢迎回来,请登录你的账户</p>
<form class="login__form" @submit.prevent="handleLogin">
<div class="login__field">
<label for="username">用户名</label>
<input
id="username"
v-model="form.username"
type="text"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div class="login__field">
<label for="password">密码</label>
<input
id="password"
v-model="form.password"
type="password"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<p v-if="errorMsg" class="login__error">{{ errorMsg }}</p>
<button type="submit" class="login__submit" :disabled="loading">
{{ loading ? '登录中...' : '登 录' }}
</button>
</form>
</div>
</div>
</template>
<style scoped lang="scss">
.login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
&__card {
width: 400px;
padding: 40px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
&__title {
text-align: center;
margin: 0 0 8px;
font-size: 28px;
color: #303133;
}
&__subtitle {
text-align: center;
color: #909399;
margin: 0 0 32px;
font-size: 14px;
}
&__field {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 6px;
font-size: 14px;
color: #606266;
font-weight: 500;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
outline: none;
border-color: var(--color-primary, #409eff);
}
&::placeholder {
color: #c0c4cc;
}
}
}
&__error {
color: #f56c6c;
font-size: 13px;
margin: -8px 0 16px;
}
&__submit {
width: 100%;
padding: 12px;
background: var(--color-primary, #409eff);
color: #fff;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
transition: opacity 0.2s;
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
</style>Token 存储与自动刷新
Token 的存储选择和自动刷新机制是后台系统安全性的关键:
- 存储位置:使用
localStorage而非内存变量。刷新页面后 token 不丢失,用户体验更佳 - 双 Token 机制:Access Token(短期,15 分钟)和 Refresh Token(长期,7 天)
- 自动刷新:401 响应触发刷新逻辑,透明处理,用户无感知
关键代码已在前面 src/api/request.ts 的响应拦截器中实现。核心流程如下:
用户请求 → 返回 401
↓
是否正在刷新? → 是 → 排队等待
↓ 否
标记 _retry = true
发起 Refresh Token 请求
↓
成功 → 更新本地 Token → 执行队列中的请求 → 重试原始请求
失败 → 清空凭证 → 跳转登录页3.5 文章 CRUD
文章列表页
<!-- src/views/articles/ArticleList.vue -->
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useArticlesStore } from '@/stores/articles';
import DataTable from '@/components/common/DataTable.vue';
import Pagination from '@/components/common/Pagination.vue';
import type { ColumnDef } from '@/types/table';
import { formatDate, truncate } from '@/utils/format';
const router = useRouter();
const articlesStore = useArticlesStore();
const keyword = ref('');
const columns: ColumnDef<any>[] = [
{ key: 'id', title: 'ID', width: '80px' },
{ key: 'title', title: '标题', width: 'auto' },
{ key: 'status', title: '状态', width: '100px' },
{ key: 'createdAt', title: '创建时间', width: '180px' },
{ key: 'actions', title: '操作', width: '200px' },
];
onMounted(() => {
articlesStore.fetchArticleList();
});
function onSearch() {
articlesStore.fetchArticleList({ keyword: keyword.value });
}
function goCreate() {
router.push('/articles/create');
}
function goEdit(id: number) {
router.push(`/articles/${id}/edit`);
}
async function handleDelete(id: number) {
if (confirm('确认删除该文章?')) {
await articlesStore.removeArticle(id);
}
}
watch(
() => articlesStore.page,
() => {
articlesStore.fetchArticleList({ keyword: keyword.value });
},
);
</script>
<template>
<div class="article-list">
<div class="article-list__toolbar">
<div class="article-list__search">
<input
v-model="keyword"
type="text"
placeholder="搜索文章标题..."
class="article-list__input"
@keyup.enter="onSearch"
/>
<button class="article-list__search-btn" @click="onSearch">搜索</button>
</div>
<button class="article-list__create-btn" @click="goCreate">+ 创建文章</button>
</div>
<DataTable
:columns="columns"
:data="articlesStore.articles"
:loading="articlesStore.loading"
stripe
>
<template #cell-title="{ row }">
<span class="article-list__title-text">{{ truncate(row.title, 40) }}</span>
</template>
<template #cell-status="{ value }">
<span class="article-list__status" :class="`article-list__status--${value}`">
{{ value === 'published' ? '已发布' : '草稿' }}
</span>
</template>
<template #cell-createdAt="{ value }">
{{ formatDate(value) }}
</template>
<template #cell-actions="{ row }">
<div class="article-list__actions">
<button class="article-list__action-btn" @click.stop="goEdit(row.id)">编辑</button>
<button class="article-list__action-btn article-list__action-btn--danger" @click.stop="handleDelete(row.id)">
删除
</button>
</div>
</template>
</DataTable>
<Pagination
:current-page="articlesStore.page"
:page-size="articlesStore.pageSize"
:total="articlesStore.total"
@update:current-page="articlesStore.page = $event"
@update:page-size="articlesStore.pageSize = $event"
/>
</div>
</template>
<style scoped lang="scss">
.article-list {
background: #fff;
border-radius: 8px;
padding: 24px;
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
&__search {
display: flex;
gap: 8px;
}
&__input {
width: 260px;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
font-size: 14px;
&:focus {
outline: none;
border-color: var(--color-primary, #409eff);
}
}
&__search-btn,
&__create-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
&__search-btn {
background: #f0f2f5;
color: #606266;
&:hover {
background: #e4e7ed;
}
}
&__create-btn {
background: var(--color-primary, #409eff);
color: #fff;
}
&__title-text {
font-weight: 500;
color: #303133;
}
&__status {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
&--published {
background: #e6f7e6;
color: #67c23a;
}
&--draft {
background: #f0f2f5;
color: #909399;
}
}
&__actions {
display: flex;
gap: 8px;
}
&__action-btn {
padding: 4px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--color-primary, #409eff);
border-color: var(--color-primary, #409eff);
}
&--danger:hover {
color: #f56c6c;
border-color: #f56c6c;
}
}
}
</style>文章编辑页(创建/编辑复用)
<!-- src/views/articles/ArticleEdit.vue -->
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useArticlesStore } from '@/stores/articles';
const route = useRoute();
const router = useRouter();
const articlesStore = useArticlesStore();
const isEdit = computed(() => !!route.params.id);
const form = ref({
title: '',
summary: '',
content: '',
status: 'draft' as 'draft' | 'published',
categoryId: 0,
tags: [] as string[],
});
const saving = ref(false);
onMounted(async () => {
if (isEdit.value) {
const id = Number(route.params.id);
const article = await articlesStore.fetchArticle(id);
if (article) {
form.value = {
title: article.title,
summary: article.summary,
content: article.content,
status: article.status,
categoryId: article.categoryId,
tags: article.tags,
};
}
}
});
async function handleSave() {
saving.value = true;
try {
if (isEdit.value) {
await articlesStore.editArticle(Number(route.params.id), { ...form.value });
} else {
await articlesStore.saveArticle({ ...form.value });
}
router.push('/articles');
} catch (e) {
console.error('Save failed:', e);
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="article-edit">
<h2 class="article-edit__title">{{ isEdit ? '编辑文章' : '创建文章' }}</h2>
<form class="article-edit__form" @submit.prevent="handleSave">
<div class="article-edit__field">
<label>标题</label>
<input v-model="form.title" type="text" placeholder="请输入文章标题" required />
</div>
<div class="article-edit__field">
<label>摘要</label>
<textarea v-model="form.summary" placeholder="请输入文章摘要" rows="3" />
</div>
<div class="article-edit__field">
<label>内容</label>
<textarea v-model="form.content" placeholder="请输入文章内容(Markdown 格式)" rows="12" />
</div>
<div class="article-edit__field">
<label>状态</label>
<select v-model="form.status">
<option value="draft">草稿</option>
<option value="published">发布</option>
</select>
</div>
<div class="article-edit__actions">
<button type="button" class="article-edit__btn article-edit__btn--cancel" @click="router.back()">
取消
</button>
<button type="submit" class="article-edit__btn article-edit__btn--save" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
</template>
<style scoped lang="scss">
.article-edit {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 800px;
&__title {
margin: 0 0 24px;
font-size: 20px;
color: #303133;
}
&__field {
margin-bottom: 20px;
label {
display: block;
margin-bottom: 6px;
font-size: 14px;
color: #606266;
font-weight: 500;
}
input,
textarea,
select {
width: 100%;
padding: 10px 12px;
border: 1px solid #dcdfe6;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
font-family: inherit;
&:focus {
outline: none;
border-color: var(--color-primary, #409eff);
}
}
textarea {
resize: vertical;
line-height: 1.6;
}
}
&__actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
&__btn {
padding: 10px 24px;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
&--cancel {
background: #fff;
border: 1px solid #dcdfe6;
color: #606266;
&:hover {
color: var(--color-primary, #409eff);
border-color: var(--color-primary, #409eff);
}
}
&--save {
background: var(--color-primary, #409eff);
border: 1px solid var(--color-primary, #409eff);
color: #fff;
&:hover:not(:disabled) {
opacity: 0.85;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
}
</style>3.6 状态管理总结
我们使用三个 Pinia Store 来管理不同维度的状态:
| Store | 职责 | 状态 |
|---|---|---|
auth |
用户认证 | token、refreshToken、user 信息 |
articles |
文章业务 | 文章列表、当前文章、分页参数、加载状态 |
app |
应用全局UI | 侧边栏折叠、暗色模式、主题色 |
设计原则:
- 每个 Store 只关注一个业务领域
- 异步操作(API 调用)放在 Store 的 action 中,组件只负责编排和展示
- 利用 Pinia 的
$reset()可在登出时重置所有状态
3.7 404 页面与错误边界
404 页面
<!-- src/views/error/NotFound.vue -->
<script setup lang="ts">
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
<div class="not-found">
<div class="not-found__code">404</div>
<h2 class="not-found__title">页面未找到</h2>
<p class="not-found__desc">您访问的页面不存在或已被移除。</p>
<button class="not-found__btn" @click="router.push('/')">返回首页</button>
</div>
</template>
<style scoped lang="scss">
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
&__code {
font-size: 80px;
font-weight: 700;
color: var(--color-primary, #409eff);
line-height: 1;
opacity: 0.6;
}
&__title {
margin: 16px 0 8px;
font-size: 24px;
color: #303133;
}
&__desc {
color: #909399;
font-size: 14px;
margin: 0 0 24px;
}
&__btn {
padding: 10px 24px;
background: var(--color-primary, #409eff);
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
&:hover {
opacity: 0.85;
}
}
}
</style>错误边界(onErrorCaptured)
在 Vue 3 中,可以使用 onErrorCaptured 生命周期钩子在组件树中捕获错误:
<!-- src/components/common/ErrorBoundary.vue -->
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue';
const hasError = ref(false);
const errorMessage = ref('');
onErrorCaptured((err: Error) => {
hasError.value = true;
errorMessage.value = err.message || '组件发生未知错误';
console.error('[ErrorBoundary]', err);
// 阻止错误继续向上冒泡
return false;
});
function resetError() {
hasError.value = false;
errorMessage.value = '';
}
</script>
<template>
<div v-if="hasError" class="error-boundary">
<p>{{ errorMessage }}</p>
<button @click="resetError">重试</button>
</div>
<slot v-else />
</template>在布局中使用:
<ErrorBoundary>
<router-view />
</ErrorBoundary>四、样式体系
4.1 CSS 变量主题系统
在 Vue 3 项目中,利用 CSS 自定义属性(CSS Variables)构建主题系统是最轻量高效的方式:
/* src/assets/styles/variables.scss */
:root {
/* 主题色 */
--color-primary: #409eff;
--color-primary-light: #66b1ff;
--color-primary-dark: #3a8ee6;
/* 功能色 */
--color-success: #67c23a;
--color-warning: #e6a23c;
--color-danger: #f56c6c;
--color-info: #909399;
/* 文字色 */
--text-primary: #303133;
--text-regular: #606266;
--text-secondary: #909399;
--text-placeholder: #c0c4cc;
/* 边框色 */
--border-base: #dcdfe6;
--border-light: #e4e7ed;
--border-lighter: #ebeef5;
/* 背景色 */
--bg-base: #f5f7fa;
--bg-white: #ffffff;
--bg-page: #f0f2f5;
/* 圆角 */
--radius-base: 4px;
--radius-medium: 6px;
--radius-large: 8px;
/* 字体 */
--font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
Arial, sans-serif;
--font-size-base: 14px;
/* 阴影 */
--shadow-base: 0 2px 12px rgba(0, 0, 0, 0.08);
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-dark: 0 4px 16px rgba(0, 0, 0, 0.12);
/* 布局 */
--sidebar-width: 240px;
--header-height: 56px;
}
/* 暗色模式 */
.dark {
--text-primary: #e5eaf3;
--text-regular: #cfd3dc;
--text-secondary: #a3a6ad;
--text-placeholder: #8d9095;
--border-base: #4c4d4f;
--border-light: #414243;
--border-lighter: #363637;
--bg-base: #1d1e1f;
--bg-white: #2c2d2e;
--bg-page: #141414;
--shadow-base: 0 2px 12px rgba(0, 0, 0, 0.3);
--shadow-light: 0 2px 4px rgba(0, 0, 0, 0.2);
--shadow-dark: 0 4px 16px rgba(0, 0, 0, 0.4);
}4.2 UnoCSS 配置(推荐替代 Tailwind CSS)
UnoCSS 是原子化 CSS 引擎,性能远超 Tailwind CSS,且兼容 Tailwind 的类名生态:
pnpm add -D unocss @unocss/preset-uno @unocss/transformer-directives// vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import UnoCSS from 'unocss/vite';
export default defineConfig({
plugins: [
vue(),
UnoCSS(), // ⚡ 零运行时原子化 CSS
],
});// uno.config.ts
import { defineConfig, presetUno, presetAttributify } from 'unocss';
export default defineConfig({
presets: [
presetUno(), // 默认预设(包含 Tailwind 兼容类)
presetAttributify(), // 属性化模式 <div text="center" />
],
shortcuts: {
'flex-center': 'flex items-center justify-center',
'text-ellipsis': 'truncate',
'card': 'bg-white rounded-lg shadow-sm p-6',
},
rules: [
[/^text-(.*)$/, ([, c]) => ({ color: c })],
],
theme: {
colors: {
primary: '#409eff',
},
},
});// main.ts
import 'virtual:uno.css';使用示例
<template>
<!-- 使用 UnoCSS 原子类 -->
<div class="flex items-center justify-between p-4 bg-white rounded-lg shadow-sm">
<h1 class="text-lg font-bold text-gray-800">{{ title }}</h1>
<button class="px-4 py-2 text-white bg-primary rounded hover:opacity-85">
新建
</button>
</div>
<!-- 属性化模式 -->
<div flex="~ items-center" p="4" bg="white" rounded="lg" shadow="sm">
<span text="lg" font="bold">Hello</span>
</div>
</template>4.3 暗色模式切换
暗色模式的核心是切换 html 标签上的 dark class。前面在 app Store 中已经实现了这一逻辑。CSS 变量会根据 .dark 选择器自动切换颜色值。
具体实现:
useAppStore().toggleDarkMode()切换darkMode状态watch监听darkMode,同步到document.documentElement.classList- 所有组件样式使用 CSS 变量引用颜色,无需手动调整
4.4 响应式布局
使用 CSS 媒体查询 + CSS 变量实现响应式:
/* 响应式断点 */
$breakpoints: (
'sm': 640px,
'md': 768px,
'lg': 1024px,
'xl': 1280px,
'2xl': 1536px,
);
@mixin respond-to($size) {
$width: map-get($breakpoints, $size);
@if $width {
@media (max-width: $width) {
@content;
}
}
}
/* 使用例 */
.layout {
&__sidebar {
width: var(--sidebar-width);
@include respond-to('md') {
width: 64px; /* 小屏自动折叠 */
}
}
&__content {
@include respond-to('sm') {
padding: 12px; /* 手机端减小内边距 */
}
}
}
/* 表格响应式 */
.data-table {
@include respond-to('md') {
overflow-x: auto; /* 小屏可横向滚动 */
}
}如果使用 UnoCSS,断点类名更加简洁:
<div class="grid grid-cols-4 lg:grid-cols-3 md:grid-cols-2 sm:grid-cols-1">五、测试
5.1 Vitest 单元测试
Vitest 与 Vite 共享配置,零配置即可运行,速度比 Jest 快 10-20 倍。
pnpm add -D vitest @vue/test-utils jsdom// vitest.config.ts
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
});// src/test/setup.ts
import '@testing-library/jest-dom';测试工具函数
// src/utils/__tests__/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate, truncate } from '../format';
describe('formatDate', () => {
it('formats a date string correctly', () => {
const result = formatDate('2024-09-09T10:30:00Z', 'YYYY-MM-DD');
expect(result).toBe('2024-09-09');
});
it('formats with time', () => {
const result = formatDate('2024-09-09T10:30:00Z', 'YYYY-MM-DD HH:mm');
expect(result).toBe('2024-09-09 10:30');
});
it('handles Date object', () => {
const result = formatDate(new Date('2024-01-15'), 'YYYY/MM/DD');
expect(result).toBe('2024/01/15');
});
});
describe('truncate', () => {
it('returns text unchanged if shorter than limit', () => {
expect(truncate('Hello', 100)).toBe('Hello');
});
it('truncates and adds ellipsis', () => {
const longText = '这是一个很长的文本,需要被截断';
expect(truncate(longText, 10)).toBe('这是一个很长的文本,...');
});
});测试 Store
// src/stores/__tests__/app.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useAppStore } from '../app';
describe('app store', () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it('starts with default state', () => {
const store = useAppStore();
expect(store.sidebarCollapsed).toBe(false);
expect(store.darkMode).toBe(
localStorage.getItem('darkMode') === 'true',
);
});
it('toggles sidebar', () => {
const store = useAppStore();
store.toggleSidebar();
expect(store.sidebarCollapsed).toBe(true);
store.toggleSidebar();
expect(store.sidebarCollapsed).toBe(false);
});
it('toggles dark mode', () => {
const store = useAppStore();
const initial = store.darkMode;
store.toggleDarkMode();
expect(store.darkMode).toBe(!initial);
});
});5.2 Vue Test Utils 组件测试
// src/components/common/__tests__/Pagination.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Pagination from '../Pagination.vue';
describe('Pagination', () => {
it('renders correct page info', () => {
const wrapper = mount(Pagination, {
props: {
currentPage: 1,
pageSize: 10,
total: 50,
},
});
expect(wrapper.text()).toContain('共 50 条');
});
it('emits update:currentPage on page click', async () => {
const wrapper = mount(Pagination, {
props: {
currentPage: 1,
pageSize: 10,
total: 50,
},
});
const buttons = wrapper.findAll('.pagination__btn');
// 点击"2"
await buttons[2].trigger('click');
expect(wrapper.emitted('update:currentPage')?.[0]).toEqual([2]);
});
it('disables prev button on first page', () => {
const wrapper = mount(Pagination, {
props: {
currentPage: 1,
pageSize: 10,
total: 50,
},
});
const prevBtn = wrapper.findAll('.pagination__btn')[0];
expect(prevBtn.attributes('disabled')).toBeDefined();
});
});// src/components/common/__tests__/FormModal.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import FormModal from '../FormModal.vue';
describe('FormModal', () => {
it('renders when visible is true', () => {
const wrapper = mount(FormModal, {
props: { visible: true, title: '测试弹窗' },
slots: { default: '表单内容' },
});
expect(wrapper.text()).toContain('测试弹窗');
expect(wrapper.text()).toContain('表单内容');
});
it('does not render when visible is false', () => {
const wrapper = mount(FormModal, {
props: { visible: false, title: '测试弹窗' },
});
expect(wrapper.find('.modal-overlay').exists()).toBe(false);
});
it('emits close on overlay click', async () => {
const wrapper = mount(FormModal, {
props: { visible: true, title: '测试' },
});
await wrapper.find('.modal-overlay').trigger('click');
expect(wrapper.emitted('close')).toBeTruthy();
});
});运行测试:
pnpm vitest run # 单次运行
pnpm vitest # 监听模式5.3 Playwright E2E 测试
Playwright 是多浏览器端到端测试框架,支持 Chromium、Firefox、WebKit。
pnpm add -D @playwright/test
npx playwright install// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test('user can log in and see dashboard', async ({ page }) => {
await page.goto('/login');
await page.fill('#username', 'admin');
await page.fill('#password', 'Admin123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('.dashboard')).toBeVisible();
});
test('redirects to login when not authenticated', async ({ page }) => {
await page.goto('/articles');
await expect(page).toHaveURL(/\/login/);
});六、构建与部署
6.1 Vite 构建配置
// vite.config.ts(完整版)
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import UnoCSS from 'unocss/vite';
import visualizer from 'rollup-plugin-visualizer';
import { createHtmlPlugin } from 'vite-plugin-html';
export default defineConfig({
plugins: [
vue(),
UnoCSS(),
createHtmlPlugin({
inject: {
data: {
title: 'Vue 3 Admin',
},
},
}),
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
build: {
target: 'es2020',
outDir: 'dist',
assetsDir: 'static',
cssCodeSplit: true,
sourcemap: false,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
rollupOptions: {
output: {
manualChunks: {
'vue-vendor': ['vue', 'vue-router', 'pinia'],
'ui-vendor': ['@vueuse/core'],
},
chunkFileNames: 'static/js/[name]-[hash].js',
entryFileNames: 'static/js/[name]-[hash].js',
assetFileNames: 'static/[ext]/[name]-[hash].[ext]',
},
},
},
});6.2 代码分割(动态 import)
Vue Router 天然支持动态导入,按路由拆分包:
// 懒加载路由组件
const DashboardView = () => import('@/views/dashboard/DashboardView.vue');
const ArticleList = () => import('@/views/articles/ArticleList.vue');
const ArticleEdit = () => import('@/views/articles/ArticleEdit.vue');
const NotFound = () => import('@/views/error/NotFound.vue');Vite 的 manualChunks 配置进一步将第三方库拆分为独立的 vendor 包,利用浏览器缓存机制,减少用户重复下载。
6.3 构建分析
rollup-plugin-visualizer 可以生成可视化的打包分析报告:
pnpm build # 构建完成后会自动打开分析报告报告以交互式饼图展示每个模块的体积,帮助你快速定位需要优化的部分。
运行构建:
pnpm build
# 输出示例:
# ✓ built in 3.2s
# dist/
# ├── index.html
# ├── static/
# │ ├── js/
# │ │ ├── index-[hash].js (主入口)
# │ │ ├── vue-vendor-[hash].js (Vue 生态)
# │ │ └── ArticleList-[hash].js (路由懒加载)
# │ └── css/
# │ └── index-[hash].css6.4 Nginx 部署配置
# /etc/nginx/conf.d/vue3-admin.conf
server {
listen 80;
server_name admin.example.com;
root /var/www/vue3-admin/dist;
index index.html;
# Gzip 压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/javascript application/json image/svg+xml;
gzip_vary on;
# 静态资源缓存(文件名含 hash,可长期缓存)
location /static/ {
expires 365d;
add_header Cache-Control "public, immutable";
}
# 非静态资源请求都返回 index.html(SPA 路由)
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
# API 反向代理
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}6.5 Docker 部署
多阶段构建
# Dockerfile
# 第一阶段:构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# 第二阶段:运行
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]# 构建镜像
docker build -t vue3-admin:latest .
# 运行容器
docker run -d \
--name vue3-admin \
-p 80:80 \
-e VITE_API_BASE_URL=https://api.example.com \
vue3-admin:latestdocker-compose.yml
version: '3.8'
services:
web:
build: .
ports:
- "80:80"
environment:
- VITE_API_BASE_URL=${API_BASE_URL}
depends_on:
- api
networks:
- app-net
api:
image: your-api-image:latest
ports:
- "8080:8080"
environment:
- DB_HOST=db
depends_on:
- db
networks:
- app-net
db:
image: postgres:15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: vue3_admin
POSTGRES_USER: admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
networks:
- app-net
volumes:
pgdata:
networks:
app-net:6.6 CI/CD 流程
使用 GitHub Actions 实现自动化构建与部署:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm test:unit
build-and-deploy:
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 8
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm build
- name: Deploy to Server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
source: "dist/*"
target: "/var/www/vue3-admin/"
strip_components: 1
- name: Reload Nginx
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_KEY }}
script: |
sudo nginx -s reload七、性能优化
7.1 打包体积优化
| 策略 | 配置 | 效果 |
|---|---|---|
| 路由懒加载 | () => import(...) |
按需加载页面代码 |
| 第三方库拆分 | manualChunks.vue-vendor |
利用浏览器缓存 |
| Tree Shaking | 使用 ESM 导出 | 移除未使用的代码 |
| 移除调试代码 | drop_console: true |
减少生产包体积 |
| 图片压缩 | 使用 WebP 格式 + vite-imagetools |
减少图片体积 |
分析工具:在 CI 中集成 rollup-plugin-visualizer,每次构建后自动生成体积报告,跟踪体积变化趋势。
7.2 首屏加载优化
1. 预加载关键资源
<!-- index.html 中添加预加载提示 -->
<link rel="preload" href="/static/js/vue-vendor-[hash].js" as="script" />
<link rel="preload" href="/static/css/index-[hash].css" as="style" />2. 路由级代码分割
Vue Router 的懒加载确保只加载当前页面需要的代码,首屏不加载后台文章编辑页等重组件。
3. 异步组件
对于非首屏可见的组件,使用 Vue 3 的 defineAsyncComponent 延迟加载:
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent(() => import('@/components/HeavyChart.vue'));
// 可选的加载占位
const HeavyChart = defineAsyncComponent({
loader: () => import('@/components/HeavyChart.vue'),
loadingComponent: () => h('div', '图表加载中...'),
delay: 200,
timeout: 10000,
});7.3 图片与静态资源优化
1. 使用 WebP 格式
<picture>
<source srcset="@/assets/images/hero.webp" type="image/webp" />
<img src="@/assets/images/hero.png" alt="Hero" />
</picture>2. 使用 vite-imagetools 自动优化
pnpm add -D vite-imagetools// vite.config.ts
import { defineConfig } from 'vite';
import { imagetools } from 'vite-imagetools';
export default defineConfig({
plugins: [
imagetools({
defaultDirectives: () => new URLSearchParams('webp&quality=80'),
}),
],
});3. SVG 图标优化
使用 vite-svg-loader 将 SVG 文件直接作为 Vue 组件使用,避免额外的网络请求:
pnpm add -D vite-svg-loader// vite.config.ts
import svgLoader from 'vite-svg-loader';
export default defineConfig({
plugins: [svgLoader()],
});<script setup lang="ts">
import IconUser from '@/assets/icons/user.svg?component';
import IconSetting from '@/assets/icons/setting.svg?component';
</script>
<template>
<IconUser class="icon" />
<IconSetting class="icon" />
</template>7.4 缓存策略
1. 浏览器缓存
Nginx 配置中已经针对带 hash 的静态资源设置了 Cache-Control: public, immutable:
location /static/ {
expires 365d;
add_header Cache-Control "public, immutable";
}immutable 指令告诉浏览器,这个资源永远不会变化,可以放心缓存,无需向服务器发起条件请求。
2. Service Worker 缓存
使用 vite-plugin-pwa 为 SPA 添加 Service Worker 支持,实现离线可用和更快再次访问:
pnpm add -D vite-plugin-pwa// vite.config.ts
import { VitePWA } from 'vite-plugin-pwa';
export default defineConfig({
plugins: [
VitePWA({
registerType: 'autoUpdate',
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp}'],
runtimeCaching: [
{
urlPattern: /^https?:\/\/api\.example\.com\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 1 天
},
},
},
],
},
}),
],
});3. API 请求缓存
在 axios 请求层添加缓存逻辑,对 GET 请求进行内存缓存:
// src/api/request.ts
const cache = new Map<string, { data: unknown; timestamp: number }>();
const CACHE_TTL = 60 * 1000; // 1 分钟
function getCacheKey(config: InternalAxiosRequestConfig): string {
return `${config.method}:${config.url}:${JSON.stringify(config.params || {})}`;
}
request.interceptors.request.use((config) => {
if (config.method === 'get') {
const cached = cache.get(getCacheKey(config));
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
return Promise.reject({
__fromCache: true,
data: cached.data,
});
}
}
return config;
});
// 在响应拦截器中缓存 GET 结果
request.interceptors.response.use(
(response) => {
if (response.config.method === 'get') {
cache.set(getCacheKey(response.config), {
data: response.data,
timestamp: Date.now(),
});
}
return response;
},
// ... 错误处理
);总结
本文从零开始,完整地构建了一个 Vue 3 + TypeScript + Vite 的文章管理后台系统,涵盖以下核心知识点:
| 模块 | 技术要点 |
|---|---|
| 项目初始化 | create-vue 脚手架、目录结构设计、ESLint + Prettier + Husky + lint-staged 代码规范 |
| 架构设计 | API 层封装、Pinia Store 设计、路由权限控制、TypeScript 类型管理 |
| 实战功能 | JWT 认证与 Token 自动刷新、文章 CRUD、通用组件(DataTable、Pagination、FormModal) |
| 样式体系 | CSS 变量主题、UnoCSS 原子化、暗色模式、响应式布局 |
| 测试 | Vitest 单元测试、Vue Test Utils 组件测试、Playwright E2E 测试 |
| 构建部署 | Vite 配置优化、代码分割、Nginx 部署、Docker 容器化、CI/CD 自动化 |
| 性能优化 | 打包体积分析、首屏加载优化、图片优化、多级缓存策略 |
这套架构和最佳实践可以直接作为实际项目的基线模板,也可根据业务需求灵活裁剪。Vue 3 的组合式 API 加上 TypeScript 的类型系统,让中大型前端项目的可维护性和开发效率都提升到了新的高度。