Vue3 Admin Backend Development Standards
Trigger Conditions
- Develop Vue3 admin dashboard projects
- Implement CRUD pages
- Use Element Plus components
- Implement permission management
- Create custom components
Part 1: Tech Stack
- Vue - Frontend Framework
- Element Plus - UI Component Library
- TypeScript - Type Safety
- Vite - Build Tool
- Pinia - State Management
- Vue Router - Routing
- Axios - HTTP Client
Part 2: Directory Structure
src/
├── api/ # API 请求
│ ├── system/ # 系统模块 API
│ │ ├── user/ # 用户模块
│ │ │ ├── index.ts # API 定义
│ │ │ └── types.ts # 类型定义
│ │ └── role/ # 角色模块
│ └── index.ts # API 统一导出
├── components/ # 全局组件
│ ├── Form/ # 表单组件
│ │ ├── UserSelect.vue
│ │ └── DeptTree.vue
│ ├── Table/ # 表格组件
│ │ └── ProTable.vue
│ └── Dialog/ # 弹窗组件
│ └── FormDialog.vue
├── composables/ # 组合式函数
│ ├── useTable.ts # 表格逻辑
│ ├── useForm.ts # 表单逻辑
│ └── useDialog.ts # 弹窗逻辑
├── directives/ # 自定义指令
│ ├── permission.ts # 权限指令
│ └── loading.ts # 加载指令
├── enums/ # 枚举定义
│ ├── ResultCode.ts # 响应码
│ └── MenuType.ts # 菜单类型
├── hooks/ # 自定义 hooks
│ ├── useLoading.ts
│ └── usePermission.ts
├── layout/ # 布局组件
│ ├── components/
│ │ ├── Sidebar.vue
│ │ ├── Navbar.vue
│ │ └── AppMain.vue
│ └── index.vue
├── router/ # 路由配置
│ ├── index.ts
│ ├── staticRoutes.ts # 静态路由
│ └── dynamicRoutes.ts # 动态路由
├── stores/ # Pinia 状态
│ ├── modules/
│ │ ├── user.ts
│ │ ├── permission.ts
│ │ └── app.ts
│ └── index.ts
├── styles/ # 全局样式
│ ├── variables.scss # CSS 变量
│ ├── reset.scss # 重置样式
│ └── index.scss # 入口
├── utils/ # 工具函数
│ ├── request.ts # Axios 封装
│ ├── auth.ts # Token 管理
│ ├── permission.ts # 权限判断
│ └── format.ts # 格式化
└── views/ # 页面组件
├── system/
│ ├── user/
│ │ ├── index.vue # 列表页
│ │ ├── components/ # 页面组件
│ │ │ └── UserForm.vue
│ │ └── hooks/ # 页面 hooks
│ │ └── useUser.ts
│ └── role/
└── dashboard/
Part 3: Naming Conventions
File Naming
| Type |
Convention |
Example |
| Component |
PascalCase |
UserSelect.vue |
| Page |
kebab-case |
user-profile.vue |
| Composable Function |
camelCase + use |
useUserStore.ts |
| Utility Function |
camelCase |
formatDate.ts |
| Constant |
UPPER_SNAKE_CASE |
API_PREFIX.ts |
| Type |
PascalCase |
UserVO.ts |
Variable Naming
| Type |
Convention |
Example |
| Variable |
camelCase |
userList |
| Constant |
UPPER_SNAKE_CASE |
MAX_COUNT |
| Private Variable |
_ Prefix |
_internalState |
| Boolean |
is/has/can Prefix |
isLoading, hasPermission |
Method Naming
| Action |
Prefix |
Example |
| Fetch/Load |
fetch/load |
fetchUserList |
| Submit/Save |
submit/save |
submitForm |
| Create |
create |
createUser |
| Update |
update |
updateUser |
| Delete |
delete |
deleteUser |
| Open/Close |
open/close |
openDialog |
| Event Handling |
handle |
handleSubmit |
handle Usage Rules:
| Type |
Characteristics |
Whether to Use handle |
| Single Action |
Does Only One Thing |
No |
| Process Orchestration |
Combines Multiple Actions |
Yes |
// ✅ 单一动作 → 不用 handle
function openDialog() {
dialogState.visible = true;
}
// ✅ 流程编排 → 使用 handle
async function handleSubmit() {
const { valid } = await formRef.value.validate();
if (!valid) return;
await submitForm();
closeDialog();
}
CSS Naming (BEM)
block__element--modifier
│ │ │
│ │ └── 状态/变体(双连字符)
│ └── 组成部分(双下划线)
└── 页面前缀 + 功能实体
Block Must Have Page Prefix:
用户管理(user) → user-card / user-form / user-toolbar
角色管理(role) → role-card / role-permission / role-dialog
设备授权(device) → device-auth / device-auth__panel
仪表盘(dashboard) → dashboard-stat / dashboard-chart
Element Common Vocabulary:
| Semantics |
Standard Vocabulary |
Prohibited |
| Header |
__header |
head / top |
| Footer |
__footer |
bottom |
| Title |
__title |
name / heading |
| Description |
__desc |
text / detail |
| Icon |
__icon |
img / pic |
| Action Button |
__action |
action-btn |
| Action Area |
__actions |
btns |
| Content |
__body |
content |
| List |
__list |
items |
| List Item |
__item |
row |
| Panel |
__panel |
box |
| Tag |
__tag |
badge(use badge for badges) |
SCSS Nesting:
.user-card {
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__avatar {
width: 48px;
height: 48px;
border-radius: 50%;
}
&__name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__actions {
display: flex;
gap: 8px;
}
&--active {
border: 2px solid var(--el-color-primary);
}
&--disabled {
opacity: 0.5;
pointer-events: none;
}
}
Part 4: Component Standards
Component Priority Principle
| Requirement |
Use |
Avoid |
| Button |
<el-button> |
Custom .btn |
| Input |
<el-input> |
Custom .input |
| Table |
<el-table> |
Custom Table |
| Modal |
<el-dialog> |
Custom Modal |
| Form |
<el-form> |
Custom Form |
CRUD Page Template
<template>
<div class="app-container">
<!-- 搜索栏 -->
<el-form :model="queryParams" ref="queryFormRef" :inline="true">
<el-form-item label="关键词" prop="keywords">
<el-input
v-model="queryParams.keywords"
placeholder="请输入"
clearable
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery"> <i-ep-search />搜索 </el-button>
<el-button @click="handleReset"> <i-ep-refresh />重置 </el-button>
</el-form-item>
</el-form>
<!-- 操作栏 -->
<el-row :gutter="10" class="mb-[10px]">
<el-col :span="1.5">
<el-button type="primary" v-permission="['sys:user:add']" @click="handleAdd">
<i-ep-plus />新增
</el-button>
</el-col>
</el-row>
<!-- 数据表格 -->
<el-table v-loading="loading" :data="tableData" border>
<el-table-column label="ID" prop="id" width="80" />
<el-table-column label="用户名" prop="username" min-width="120" />
<el-table-column label="状态" prop="status" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? "正常" : "禁用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" v-permission="['sys:user:edit']" @click="handleEdit(row)">
编辑
</el-button>
<el-button
link
type="danger"
v-permission="['sys:user:delete']"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="queryParams.pageNum"
v-model:page-size="queryParams.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@change="handleQuery"
/>
</div>
</template>
<script setup lang="ts">
import type { UserVO, UserPageQuery } from "@/api/system/user/types";
defineOptions({ name: "UserList" });
const loading = ref(false);
const tableData = ref<UserVO[]>([]);
const total = ref(0);
const queryParams = reactive<UserPageQuery>({
pageNum: 1,
pageSize: 20,
keywords: "",
});
function handleQuery() {
loading.value = true;
UserAPI.getPage(queryParams)
.then(({ data }) => {
tableData.value = data.list;
total.value = data.total;
})
.finally(() => {
loading.value = false;
});
}
function handleReset() {
queryFormRef.value?.resetFields();
handleQuery();
}
function handleAdd() {
// 打开新增弹窗
}
function handleEdit(row: UserVO) {
// 打开编辑弹窗
}
async function handleDelete(row: UserVO) {
const { confirm } = await ElMessageBox.confirm("确认删除该用户?", "警告", {
type: "warning",
});
if (confirm) {
await UserAPI.deleteById(row.id);
ElMessage.success("删除成功");
handleQuery();
}
}
onMounted(() => handleQuery());
</script>
Part 5: API Standards
API Definition Template
// api/system/user/types.ts
export interface UserVO {
id: number;
username: string;
nickname: string;
mobile: string;
email: string;
status: number;
createTime: string;
}
export interface UserForm {
id?: number;
username: string;
nickname: string;
mobile: string;
email: string;
status: number;
roleIds: number[];
}
export interface UserPageQuery extends PageQuery {
username?: string;
status?: number;
deptId?: number;
}
// api/system/user/index.ts
import request from "@/utils/request";
import type { UserVO, UserForm, UserPageQuery } from "./types";
class UserAPI {
/** 获取用户分页列表 */
static getPage(params: UserPageQuery) {
return request<PageResult<UserVO>>({
url: "/api/v1/users/page",
method: "get",
params,
});
}
/** 获取用户详情 */
static getById(id: number) {
return request<Result<UserVO>>({
url: `/api/v1/users/${id}`,
method: "get",
});
}
/** 获取用户表单数据 */
static getFormData(id: number) {
return request<Result<UserForm>>({
url: `/api/v1/users/${id}/form`,
method: "get",
});
}
/** 新增用户 */
static create(data: UserForm) {
return request<Result<number>>({
url: "/api/v1/users",
method: "post",
data,
});
}
/** 更新用户 */
static update(data: UserForm) {
return request<Result<void>>({
url: "/api/v1/users",
method: "put",
data,
});
}
/** 删除用户 */
static deleteById(id: number) {
return request<Result<void>>({
url: `/api/v1/users/${id}`,
method: "delete",
});
}
/** 批量删除用户 */
static batchDelete(ids: number[]) {
return request<Result<void>>({
url: "/api/v1/users/batch",
method: "delete",
data: { ids },
});
}
/** 获取用户选项列表 */
static getOptions() {
return request<Result<OptionType[]>>({
url: "/api/v1/users/options",
method: "get",
});
}
}
export default UserAPI;
Response Format
// 标准响应
interface Result<T> {
code: number;
msg: string;
data: T;
}
// 分页响应
interface PageResult<T> {
code: number;
msg: string;
data: {
list: T[];
total: number;
};
}
// 分页查询参数
interface PageQuery {
pageNum: number;
pageSize: number;
keywords?: string;
}
// 选项类型
interface OptionType {
value: string | number;
label: string;
children?: OptionType[];
}
Part 6: Permission Control
Route Guard
// router/guard.ts
import { useUserStore } from "@/stores/modules/user";
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore();
// 登录页直接放行
if (to.path === "/login") {
return next();
}
// 未登录跳转登录页
if (!userStore.token) {
return next(`/login?redirect=${to.path}`);
}
// 已加载路由直接放行
if (userStore.routes.length > 0) {
return next();
}
// 加载动态路由
await userStore.loadRoutes();
next({ ...to, replace: true });
});
Button Permission
<template>
<!-- 指令方式 -->
<el-button v-permission="['sys:user:add']" type="primary">新增</el-button>
<!-- 函数方式 -->
<el-button v-if="hasPermission(['sys:user:edit'])">编辑</el-button>
</template>
<script setup lang="ts">
import { hasPermission } from "@/utils/permission";
</script>
Permission Directive
// directives/permission.ts
import { useUserStore } from "@/stores/modules/user";
export const permission: Directive = {
mounted(el, binding) {
const userStore = useUserStore();
const permissions = binding.value as string[];
const hasPermission = permissions.some((p) => userStore.permissions.includes(p));
if (!hasPermission) {
el.parentNode?.removeChild(el);
}
},
};
Part 8: Style Standards
Style Layering Principle
BEM defines semantics, UnoCSS supplements fine-tuning, SCSS only does the dirty work.
| Layer |
Responsibility |
Example |
| BEM |
Component semantic anchors |
user-card、user-card__header |
| UnoCSS |
Simple layout fine-tuning (≤3 properties) |
flex items-center gap-2 |
| SCSS |
Things atomic classes can't do, such as pseudo-classes/animations/penetration |
:deep()、@keyframes |
Selection decision
| Scenario |
Solution |
Example |
| Simple layout (1-2 properties) |
Atomic classes |
class="flex items-center" |
| Component styles (3+ properties) |
BEM |
class="user-card" |
| Repeated combinations |
UnoCSS Shortcuts consolidation |
class="flex-center" |
| Theme-related |
CSS variables + BEM |
background: var(--el-color-primary-light-9) |
<!-- ✅ 组件样式归 BEM,微调用原子类 -->
<div class="user-card">
<el-avatar class="user-card__avatar" :src="user.avatar" />
<span class="user-card__name">{{ user.name }}</span>
</div>
<!-- ✅ 简单间距直接用原子类 -->
<div class="mt-4 p-2">
<el-input v-model="keyword" placeholder="搜索" />
</div>
<!-- ❌ 同一元素混用过多原子类 + BEM -->
<div class="user-card flex flex-col p-4 bg-white rounded-lg shadow-sm">
</div>
Theme variables
Prefer using Element Plus built-in CSS variables, no need for a custom theme color system:
| Category |
Variable |
Description |
| Primary color |
--el-color-primary |
Theme blue |
| Primary color light |
--el-color-primary-light-3/5/7/8/9 |
Auto-generated by Element Plus |
| Success |
--el-color-success / --el-color-success-light-* |
Green family |
| Warning |
--el-color-warning / --el-color-warning-light-* |
Orange family |
| Danger |
--el-color-danger / --el-color-danger-light-* |
Red family |
| Error |
--el-color-error / --el-color-error-light-* |
Red family |
| Background color |
--el-bg-color / --el-bg-color-overlay / --el-bg-color-page |
Page/overlay/base color |
| Text color |
--el-text-color-primary / --el-text-color-regular / --el-text-color-secondary / --el-text-color-placeholder / --el-text-color-disabled |
Decreasing hierarchy |
| Border color |
--el-border-color / --el-border-color-light / --el-border-color-lighter / --el-border-color-extra-light |
Decreasing hierarchy |
| Fill color |
--el-fill-color / --el-fill-color-light / --el-fill-color-lighter / --el-fill-color-blank |
Background fill |
Custom variables(project-level, defined in _theme.scss):
| Variable |
Purpose |
--menu-background |
Sidebar background color |
--menu-text |
Menu text color |
--menu-active-text |
Menu active text color |
--menu-hover |
Menu hover background color |
// ✅ 使用 Element Plus 变量
.card { background: var(--el-bg-color); border: 1px solid var(--el-border-color-lighter); }
// ❌ 禁止硬编码
.card { background: #ffffff; border: 1px solid #e5e7eb; }
Layout classes (global)
The project defines standard layout classes in _layout.scss ,CRUD pages must use:
| Class name |
Purpose |
CSS |
.page-container |
Page container |
padding: 16px |
.page-content |
Content area (el-card root element) |
display: flex; flex: 1; flex-direction: column |
.page-search |
Search bar |
Card style + padding override |
.page-toolbar |
Toolbar |
flex + space-between + gap |
.page-toolbar__left |
Toolbar left side |
flex + gap |
.page-toolbar__right |
Toolbar right side |
flex + gap |
⚠️ Do not remove .page-content: this class provides display: flex; flex: 1; flex-direction: column, and removing it will cause the table height to collapse.
z-index layering
Only set z-index for position: fixed/sticky/absolute and elements with stacking competition; magic numbers are prohibited.
--z-dropdown: 100; // 下拉菜单、Popover
--z-sticky: 200; // 吸顶栏
--z-overlay: 300; // 遮罩层
--z-dialog: 500; // Dialog / Drawer
--z-toast: 600; // Message / Notification
Spacing and border radius
Spacing(4px grid base):
| Semantic |
Value |
Scenario |
| xs |
4px |
Icon and text spacing |
| sm |
8px |
Button spacing, compact padding |
| md |
16px |
Card padding, module spacing |
| lg |
24px |
Block spacing |
Border radius:
| Scenario |
Value |
| Small elements (Tag, Badge) |
4px |
| Buttons, input boxes |
var(--el-border-radius-base) |
| Cards, Dialog |
var(--el-border-radius-base) |
Shadows
| Level |
Value |
Scenario |
| sm |
0 1px 2px rgba(0,0,0,0.05) |
Card |
| md |
0 4px 6px rgba(0,0,0,0.1) |
Popup |
| lg |
0 10px 15px rgba(0,0,0,0.1) |
Modal |
Anti-pattern
| Anti-pattern |
Correct practice |
| Hard-coded color values |
Use var(--el-*) variables |
Remove .page-content class |
Keep it, it provides the critical flex layout |
:deep() Abuse |
Prefer Element Plus props/slots |
Block names are too generic (e.g. .card) |
Use page/feature prefixes (e.g. .user-card) |
height: 100vh |
Use min-height: 100vh |
| z-index magic numbers |
Use var(--z-*) |
SCSS large amounts of @apply |
Keep atomic classes in the template |
| More than 3 atomic classes on the same element |
Group into BEM classes |
:deep(.el-card__body) add flex |
el-card__body keeps the default block layout |
Style self-check list
- BEM class names have a page prefix
- All colors use CSS variables
-
.page-content Keep (provides flex layout)
- At most 3 atomic classes in the template; if more, group into BEM
- z-index uses
var(--z-*), normal-flow content has no z-index set
- Do not apply
.el-card__body add flex override
Part 9: Code Quality Checklist
- Use TypeScript strict mode
- Define types for all variables and functions
- Prefer Element Plus components
- Follow naming conventions
- Add JSDoc comments for public functions
- Handle loading and error states
- Implement permission checks
- Use composables to reuse logic
- Keep components within 300 lines
- Use
<script setup> syntax
- Organize APIs by module
- BEM naming with a page prefix
- Colors use
var(--el-*) variables
- Keep
.page-content layout class
- At most 3 atomic classes; if more, use BEM