Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
2867 lines
63 KiB
Markdown
2867 lines
63 KiB
Markdown
# Telegram 管理系统开发规范文档
|
||
|
||
> **版本**: v2.0.0
|
||
> **更新时间**: 2024-01-20
|
||
> **维护者**: 开发团队
|
||
|
||
## 📋 文档目录
|
||
|
||
- [开发环境配置](#开发环境配置)
|
||
- [代码规范](#代码规范)
|
||
- [项目结构规范](#项目结构规范)
|
||
- [组件开发规范](#组件开发规范)
|
||
- [API开发规范](#api开发规范)
|
||
- [状态管理规范](#状态管理规范)
|
||
- [路由规范](#路由规范)
|
||
- [样式规范](#样式规范)
|
||
- [测试规范](#测试规范)
|
||
- [Git工作流](#git工作流)
|
||
- [代码审查](#代码审查)
|
||
- [性能优化规范](#性能优化规范)
|
||
|
||
---
|
||
|
||
## 🛠️ 开发环境配置
|
||
|
||
### 必备工具
|
||
|
||
#### 1. 基础开发工具
|
||
|
||
```bash
|
||
# Node.js版本管理
|
||
nvm install 18.19.0
|
||
nvm use 18.19.0
|
||
|
||
# 包管理器
|
||
npm install -g pnpm@8.15.0
|
||
|
||
# Git配置
|
||
git config --global user.name "Your Name"
|
||
git config --global user.email "your.email@company.com"
|
||
git config --global init.defaultBranch main
|
||
```
|
||
|
||
#### 2. VSCode插件配置
|
||
|
||
推荐安装以下插件:
|
||
|
||
```json
|
||
{
|
||
"recommendations": [
|
||
"vue.volar",
|
||
"vue.vscode-typescript-vue-plugin",
|
||
"bradlc.vscode-tailwindcss",
|
||
"esbenp.prettier-vscode",
|
||
"dbaeumer.vscode-eslint",
|
||
"ms-vscode.vscode-typescript-next",
|
||
"formulahendry.auto-rename-tag",
|
||
"christian-kohler.path-intellisense",
|
||
"ms-vscode.vscode-json",
|
||
"ms-playwright.playwright"
|
||
]
|
||
}
|
||
```
|
||
|
||
#### 3. VSCode设置
|
||
|
||
```json
|
||
{
|
||
"editor.codeActionsOnSave": {
|
||
"source.fixAll.eslint": true,
|
||
"source.organizeImports": true
|
||
},
|
||
"editor.formatOnSave": true,
|
||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||
"typescript.preferences.importModuleSpecifier": "relative",
|
||
"typescript.suggest.autoImports": true,
|
||
"vue.inlayHints.missingProps": true,
|
||
"vue.inlayHints.inlineHandlerLeading": true,
|
||
"vue.inlayHints.optionsWrapper": true
|
||
}
|
||
```
|
||
|
||
### 项目环境配置
|
||
|
||
#### 1. 环境变量设置
|
||
|
||
```bash
|
||
# 开发环境配置
|
||
cp .env.development.example .env.development
|
||
|
||
# 本地环境配置
|
||
cp .env.local.example .env.local
|
||
```
|
||
|
||
#### 2. 依赖安装
|
||
|
||
```bash
|
||
# 安装项目依赖
|
||
pnpm install
|
||
|
||
# 安装Playwright浏览器
|
||
pnpm test:install
|
||
|
||
# 验证环境
|
||
pnpm typecheck
|
||
pnpm lint
|
||
pnpm test:unit
|
||
```
|
||
|
||
---
|
||
|
||
## 📝 代码规范
|
||
|
||
### TypeScript规范
|
||
|
||
#### 1. 类型定义
|
||
|
||
```typescript
|
||
// ✅ 推荐:使用interface定义对象类型
|
||
interface UserInfo {
|
||
id: string;
|
||
username: string;
|
||
email: string;
|
||
profile?: UserProfile;
|
||
}
|
||
|
||
// ✅ 推荐:使用type定义联合类型
|
||
type UserStatus = 'active' | 'inactive' | 'suspended';
|
||
|
||
// ✅ 推荐:使用泛型增强类型安全
|
||
interface ApiResponse<T> {
|
||
success: boolean;
|
||
data: T;
|
||
message: string;
|
||
}
|
||
|
||
// ❌ 避免:使用any类型
|
||
const userData: any = {}; // 不推荐
|
||
|
||
// ✅ 推荐:使用具体类型
|
||
const userData: UserInfo = {
|
||
id: '123',
|
||
username: 'admin',
|
||
email: 'admin@example.com',
|
||
};
|
||
```
|
||
|
||
#### 2. 函数定义
|
||
|
||
```typescript
|
||
// ✅ 推荐:明确的参数和返回类型
|
||
async function fetchUserData(userId: string): Promise<UserInfo> {
|
||
const response = await api.get<ApiResponse<UserInfo>>(`/users/${userId}`);
|
||
return response.data.data;
|
||
}
|
||
|
||
// ✅ 推荐:使用类型守卫
|
||
function isValidUser(user: unknown): user is UserInfo {
|
||
return (
|
||
typeof user === 'object' &&
|
||
user !== null &&
|
||
typeof (user as UserInfo).id === 'string' &&
|
||
typeof (user as UserInfo).username === 'string'
|
||
);
|
||
}
|
||
|
||
// ✅ 推荐:解构参数使用类型注解
|
||
function updateUser({
|
||
id,
|
||
username,
|
||
email,
|
||
}: Pick<UserInfo, 'id' | 'username' | 'email'>): Promise<void> {
|
||
// 实现逻辑
|
||
}
|
||
```
|
||
|
||
#### 3. 组合式API类型定义
|
||
|
||
```typescript
|
||
// ✅ 推荐:为组合式函数定义返回类型
|
||
interface UseUserReturn {
|
||
userInfo: Ref<UserInfo | null>;
|
||
loading: Ref<boolean>;
|
||
error: Ref<string | null>;
|
||
fetchUser: (id: string) => Promise<void>;
|
||
updateUser: (data: Partial<UserInfo>) => Promise<void>;
|
||
}
|
||
|
||
function useUser(): UseUserReturn {
|
||
const userInfo = ref<UserInfo | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
const fetchUser = async (id: string): Promise<void> => {
|
||
// 实现逻辑
|
||
};
|
||
|
||
const updateUser = async (data: Partial<UserInfo>): Promise<void> => {
|
||
// 实现逻辑
|
||
};
|
||
|
||
return {
|
||
userInfo,
|
||
loading,
|
||
error,
|
||
fetchUser,
|
||
updateUser,
|
||
};
|
||
}
|
||
```
|
||
|
||
### ESLint规范
|
||
|
||
#### 1. 基础规则
|
||
|
||
```javascript
|
||
// .eslintrc.js
|
||
module.exports = {
|
||
extends: [
|
||
'@vben/eslint-config',
|
||
'plugin:vue/vue3-recommended',
|
||
'@vue/eslint-config-typescript',
|
||
'@vue/eslint-config-prettier',
|
||
],
|
||
rules: {
|
||
// 变量命名
|
||
camelcase: ['error', { properties: 'never' }],
|
||
'no-var': 'error',
|
||
'prefer-const': 'error',
|
||
|
||
// 函数规范
|
||
'prefer-arrow-callback': 'error',
|
||
'arrow-spacing': 'error',
|
||
'no-unused-vars': 'off',
|
||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||
|
||
// Vue特定规则
|
||
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
|
||
'vue/define-props-declaration': ['error', 'type-based'],
|
||
'vue/define-emits-declaration': ['error', 'type-based'],
|
||
'vue/no-v-html': 'warn',
|
||
|
||
// TypeScript特定规则
|
||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||
'@typescript-eslint/no-explicit-any': 'warn',
|
||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||
},
|
||
};
|
||
```
|
||
|
||
### Prettier规范
|
||
|
||
```javascript
|
||
// .prettierrc.js
|
||
module.exports = {
|
||
semi: true,
|
||
singleQuote: true,
|
||
trailingComma: 'es5',
|
||
tabWidth: 2,
|
||
useTabs: false,
|
||
printWidth: 100,
|
||
bracketSpacing: true,
|
||
arrowParens: 'avoid',
|
||
endOfLine: 'lf',
|
||
vueIndentScriptAndStyle: false,
|
||
htmlWhitespaceSensitivity: 'ignore',
|
||
};
|
||
```
|
||
|
||
### 命名规范
|
||
|
||
#### 1. 文件命名
|
||
|
||
```bash
|
||
# ✅ 推荐:组件使用PascalCase
|
||
UserProfile.vue
|
||
AccountList.vue
|
||
MessageTemplate.vue
|
||
|
||
# ✅ 推荐:工具函数使用kebab-case
|
||
user-utils.ts
|
||
api-client.ts
|
||
validation-rules.ts
|
||
|
||
# ✅ 推荐:页面文件使用kebab-case
|
||
user-list.vue
|
||
account-management.vue
|
||
private-message.vue
|
||
|
||
# ✅ 推荐:类型定义文件
|
||
types/user.ts
|
||
types/api.ts
|
||
types/common.ts
|
||
```
|
||
|
||
#### 2. 变量和函数命名
|
||
|
||
```typescript
|
||
// ✅ 推荐:变量使用camelCase
|
||
const userName = 'admin';
|
||
const isUserActive = true;
|
||
const userAccountList = [];
|
||
|
||
// ✅ 推荐:常量使用SCREAMING_SNAKE_CASE
|
||
const API_BASE_URL = 'https://api.example.com';
|
||
const MAX_RETRY_COUNT = 3;
|
||
const DEFAULT_PAGE_SIZE = 20;
|
||
|
||
// ✅ 推荐:函数使用动词开头的camelCase
|
||
function getUserInfo() {}
|
||
function validateUserInput() {}
|
||
function handleUserLogin() {}
|
||
|
||
// ✅ 推荐:布尔值使用is/has/can/should开头
|
||
const isLoading = false;
|
||
const hasPermission = true;
|
||
const canEdit = false;
|
||
const shouldUpdate = true;
|
||
|
||
// ✅ 推荐:事件处理函数使用handle/on开头
|
||
const handleClick = () => {};
|
||
const onUserSelect = () => {};
|
||
const handleFormSubmit = () => {};
|
||
```
|
||
|
||
#### 3. 组件命名
|
||
|
||
```typescript
|
||
// ✅ 推荐:组件使用PascalCase
|
||
export default defineComponent({
|
||
name: 'UserProfileCard',
|
||
// ...
|
||
});
|
||
|
||
// ✅ 推荐:组件props使用camelCase
|
||
interface Props {
|
||
userId: string;
|
||
showAvatar?: boolean;
|
||
onUserClick?: (user: UserInfo) => void;
|
||
}
|
||
|
||
// ✅ 推荐:组件emits使用kebab-case
|
||
const emit = defineEmits<{
|
||
'user-select': [user: UserInfo];
|
||
'status-change': [status: UserStatus];
|
||
}>();
|
||
```
|
||
|
||
---
|
||
|
||
## 🏗️ 项目结构规范
|
||
|
||
### 目录结构
|
||
|
||
```
|
||
src/
|
||
├── api/ # API接口层
|
||
│ ├── modules/ # 按模块分组的API
|
||
│ │ ├── auth.ts
|
||
│ │ ├── user.ts
|
||
│ │ └── message.ts
|
||
│ ├── types/ # API相关类型定义
|
||
│ └── client.ts # API客户端配置
|
||
├── assets/ # 静态资源
|
||
│ ├── images/
|
||
│ ├── icons/
|
||
│ └── styles/
|
||
├── components/ # 全局组件
|
||
│ ├── UI/ # 基础UI组件
|
||
│ ├── Business/ # 业务组件
|
||
│ └── Layout/ # 布局组件
|
||
├── composables/ # 组合式函数
|
||
│ ├── useUser.ts
|
||
│ ├── usePermission.ts
|
||
│ └── useApi.ts
|
||
├── directives/ # 自定义指令
|
||
├── hooks/ # React风格的hooks(向前兼容)
|
||
├── layouts/ # 布局组件
|
||
├── locales/ # 国际化资源
|
||
│ ├── lang/
|
||
│ │ ├── zh-CN/
|
||
│ │ └── en-US/
|
||
│ └── index.ts
|
||
├── router/ # 路由配置
|
||
│ ├── modules/
|
||
│ ├── guards/
|
||
│ └── index.ts
|
||
├── stores/ # 状态管理
|
||
│ ├── modules/
|
||
│ │ ├── user.ts
|
||
│ │ ├── auth.ts
|
||
│ │ └── permission.ts
|
||
│ └── index.ts
|
||
├── types/ # 全局类型定义
|
||
│ ├── api.ts
|
||
│ ├── user.ts
|
||
│ └── global.ts
|
||
├── utils/ # 工具函数
|
||
│ ├── request.ts
|
||
│ ├── validation.ts
|
||
│ └── format.ts
|
||
├── views/ # 页面组件
|
||
│ ├── auth/
|
||
│ ├── dashboard/
|
||
│ └── user/
|
||
├── App.vue
|
||
└── main.ts
|
||
```
|
||
|
||
### 文件命名约定
|
||
|
||
#### 1. 组件文件
|
||
|
||
```bash
|
||
# 页面组件 - PascalCase
|
||
UserList.vue
|
||
AccountManagement.vue
|
||
PrivateMessageSend.vue
|
||
|
||
# 业务组件 - PascalCase
|
||
UserCard.vue
|
||
MessageTemplate.vue
|
||
StatisticsChart.vue
|
||
|
||
# 基础组件 - 带前缀的PascalCase
|
||
BaseButton.vue
|
||
BaseTable.vue
|
||
BaseForm.vue
|
||
```
|
||
|
||
#### 2. 工具文件
|
||
|
||
```bash
|
||
# 工具函数 - kebab-case
|
||
api-client.ts
|
||
user-utils.ts
|
||
validation-rules.ts
|
||
date-format.ts
|
||
|
||
# 类型定义 - kebab-case
|
||
user-types.ts
|
||
api-types.ts
|
||
common-types.ts
|
||
|
||
# 常量文件 - kebab-case
|
||
api-constants.ts
|
||
app-constants.ts
|
||
```
|
||
|
||
#### 3. 存储和组合式函数
|
||
|
||
```bash
|
||
# Store文件 - camelCase
|
||
userStore.ts
|
||
authStore.ts
|
||
permissionStore.ts
|
||
|
||
# 组合式函数 - camelCase with use前缀
|
||
useUser.ts
|
||
useAuth.ts
|
||
usePermission.ts
|
||
useApi.ts
|
||
```
|
||
|
||
---
|
||
|
||
## 🧩 组件开发规范
|
||
|
||
### Vue 3 组合式API规范
|
||
|
||
#### 1. 组件结构
|
||
|
||
```vue
|
||
<template>
|
||
<div class="user-profile-card">
|
||
<!-- 使用语义化的HTML标签 -->
|
||
<header class="user-profile-card__header">
|
||
<h3 class="user-profile-card__title">{{ userInfo?.username }}</h3>
|
||
</header>
|
||
|
||
<main class="user-profile-card__body">
|
||
<!-- 内容区域 -->
|
||
</main>
|
||
|
||
<footer class="user-profile-card__footer" v-if="showActions">
|
||
<!-- 操作区域 -->
|
||
</footer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// 1. 类型导入
|
||
import type { UserInfo } from '@/types/user';
|
||
|
||
// 2. 组件Props定义
|
||
interface Props {
|
||
userInfo: UserInfo | null;
|
||
showActions?: boolean;
|
||
readonly?: boolean;
|
||
}
|
||
|
||
// 3. 组件Emits定义
|
||
interface Emits {
|
||
(e: 'user-click', user: UserInfo): void;
|
||
(e: 'action-click', action: string, user: UserInfo): void;
|
||
}
|
||
|
||
// 4. Props和Emits声明
|
||
const props = withDefaults(defineProps<Props>(), {
|
||
showActions: true,
|
||
readonly: false,
|
||
});
|
||
|
||
const emit = defineEmits<Emits>();
|
||
|
||
// 5. 组合式函数调用
|
||
const { t } = useI18n();
|
||
const { hasPermission } = usePermission();
|
||
|
||
// 6. 响应式数据
|
||
const loading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
// 7. 计算属性
|
||
const canEdit = computed(() => !props.readonly && hasPermission('user:update'));
|
||
|
||
const displayName = computed(
|
||
() =>
|
||
props.userInfo?.profile?.displayName ||
|
||
props.userInfo?.username ||
|
||
'Unknown',
|
||
);
|
||
|
||
// 8. 方法定义
|
||
const handleUserClick = () => {
|
||
if (props.userInfo) {
|
||
emit('user-click', props.userInfo);
|
||
}
|
||
};
|
||
|
||
const handleActionClick = (action: string) => {
|
||
if (props.userInfo) {
|
||
emit('action-click', action, props.userInfo);
|
||
}
|
||
};
|
||
|
||
// 9. 生命周期钩子
|
||
onMounted(() => {
|
||
console.log('UserProfileCard mounted');
|
||
});
|
||
|
||
// 10. 对外暴露的方法(如果需要)
|
||
defineExpose({
|
||
refresh: () => {
|
||
// 刷新逻辑
|
||
},
|
||
});
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.user-profile-card {
|
||
@apply rounded-lg border border-gray-200 bg-white shadow-sm;
|
||
|
||
&__header {
|
||
@apply border-b border-gray-100 p-4;
|
||
}
|
||
|
||
&__title {
|
||
@apply text-lg font-semibold text-gray-900;
|
||
}
|
||
|
||
&__body {
|
||
@apply p-4;
|
||
}
|
||
|
||
&__footer {
|
||
@apply flex justify-end space-x-2 border-t border-gray-100 p-4;
|
||
}
|
||
}
|
||
</style>
|
||
```
|
||
|
||
#### 2. 组合式函数规范
|
||
|
||
```typescript
|
||
// composables/useUser.ts
|
||
import { ref, computed, type Ref } from 'vue';
|
||
import type { UserInfo, CreateUserData, UpdateUserData } from '@/types/user';
|
||
import { userApi } from '@/api/modules/user';
|
||
|
||
export interface UseUserOptions {
|
||
immediate?: boolean;
|
||
}
|
||
|
||
export interface UseUserReturn {
|
||
// 状态
|
||
userList: Ref<UserInfo[]>;
|
||
currentUser: Ref<UserInfo | null>;
|
||
loading: Ref<boolean>;
|
||
error: Ref<string | null>;
|
||
|
||
// 计算属性
|
||
activeUsers: ComputedRef<UserInfo[]>;
|
||
userCount: ComputedRef<number>;
|
||
|
||
// 方法
|
||
fetchUsers: (params?: UserListParams) => Promise<void>;
|
||
fetchUser: (id: string) => Promise<UserInfo | null>;
|
||
createUser: (data: CreateUserData) => Promise<UserInfo>;
|
||
updateUser: (id: string, data: UpdateUserData) => Promise<UserInfo>;
|
||
deleteUser: (id: string) => Promise<void>;
|
||
refresh: () => Promise<void>;
|
||
}
|
||
|
||
export function useUser(options: UseUserOptions = {}): UseUserReturn {
|
||
// 响应式状态
|
||
const userList = ref<UserInfo[]>([]);
|
||
const currentUser = ref<UserInfo | null>(null);
|
||
const loading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
// 计算属性
|
||
const activeUsers = computed(() =>
|
||
userList.value.filter((user) => user.status === 'active'),
|
||
);
|
||
|
||
const userCount = computed(() => userList.value.length);
|
||
|
||
// 私有方法
|
||
const setError = (message: string) => {
|
||
error.value = message;
|
||
console.error('[useUser]', message);
|
||
};
|
||
|
||
const clearError = () => {
|
||
error.value = null;
|
||
};
|
||
|
||
// 公共方法
|
||
const fetchUsers = async (params?: UserListParams): Promise<void> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.getList(params);
|
||
userList.value = response.data.items;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to fetch users');
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const fetchUser = async (id: string): Promise<UserInfo | null> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.getById(id);
|
||
currentUser.value = response.data;
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to fetch user');
|
||
return null;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const createUser = async (data: CreateUserData): Promise<UserInfo> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.create(data);
|
||
userList.value.push(response.data);
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const updateUser = async (
|
||
id: string,
|
||
data: UpdateUserData,
|
||
): Promise<UserInfo> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.update(id, data);
|
||
const index = userList.value.findIndex((user) => user.id === id);
|
||
if (index !== -1) {
|
||
userList.value[index] = response.data;
|
||
}
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const deleteUser = async (id: string): Promise<void> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
await userApi.delete(id);
|
||
const index = userList.value.findIndex((user) => user.id === id);
|
||
if (index !== -1) {
|
||
userList.value.splice(index, 1);
|
||
}
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const refresh = async (): Promise<void> => {
|
||
await fetchUsers();
|
||
};
|
||
|
||
// 初始化
|
||
if (options.immediate) {
|
||
fetchUsers();
|
||
}
|
||
|
||
return {
|
||
// 状态
|
||
userList,
|
||
currentUser,
|
||
loading,
|
||
error,
|
||
|
||
// 计算属性
|
||
activeUsers,
|
||
userCount,
|
||
|
||
// 方法
|
||
fetchUsers,
|
||
fetchUser,
|
||
createUser,
|
||
updateUser,
|
||
deleteUser,
|
||
refresh,
|
||
};
|
||
}
|
||
```
|
||
|
||
#### 3. 组件通信规范
|
||
|
||
```typescript
|
||
// 父子组件通信 - Props和Emits
|
||
// 父组件
|
||
<template>
|
||
<UserList
|
||
:users="users"
|
||
:loading="loading"
|
||
@user-select="handleUserSelect"
|
||
@user-delete="handleUserDelete"
|
||
/>
|
||
</template>
|
||
|
||
// 子组件
|
||
interface Props {
|
||
users: UserInfo[];
|
||
loading?: boolean;
|
||
}
|
||
|
||
interface Emits {
|
||
(e: 'user-select', user: UserInfo): void;
|
||
(e: 'user-delete', id: string): void;
|
||
}
|
||
|
||
// 兄弟组件通信 - 使用Store
|
||
const userStore = useUserStore();
|
||
const selectedUser = computed(() => userStore.selectedUser);
|
||
|
||
// 跨层级通信 - Provide/Inject
|
||
// 祖先组件
|
||
provide('user-context', {
|
||
currentUser,
|
||
permissions,
|
||
updateUser
|
||
});
|
||
|
||
// 后代组件
|
||
const userContext = inject<UserContext>('user-context');
|
||
```
|
||
|
||
---
|
||
|
||
## 🌐 API开发规范
|
||
|
||
### API客户端配置
|
||
|
||
```typescript
|
||
// api/client.ts
|
||
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios';
|
||
import { useAuthStore } from '@/stores/modules/auth';
|
||
import { message } from 'ant-design-vue';
|
||
|
||
class ApiClient {
|
||
private instance: AxiosInstance;
|
||
|
||
constructor() {
|
||
this.instance = axios.create({
|
||
baseURL: import.meta.env.VITE_API_URL,
|
||
timeout: 10000,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
});
|
||
|
||
this.setupInterceptors();
|
||
}
|
||
|
||
private setupInterceptors() {
|
||
// 请求拦截器
|
||
this.instance.interceptors.request.use(
|
||
(config) => {
|
||
const authStore = useAuthStore();
|
||
const token = authStore.token;
|
||
|
||
if (token) {
|
||
config.headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
|
||
// 添加请求ID用于追踪
|
||
config.headers['X-Request-ID'] = this.generateRequestId();
|
||
|
||
return config;
|
||
},
|
||
(error) => {
|
||
console.error('[API Request Error]', error);
|
||
return Promise.reject(error);
|
||
},
|
||
);
|
||
|
||
// 响应拦截器
|
||
this.instance.interceptors.response.use(
|
||
(response) => {
|
||
return response.data;
|
||
},
|
||
(error) => {
|
||
return this.handleError(error);
|
||
},
|
||
);
|
||
}
|
||
|
||
private handleError(error: any) {
|
||
const { response } = error;
|
||
|
||
if (!response) {
|
||
message.error('网络连接失败,请检查网络设置');
|
||
return Promise.reject(new Error('Network Error'));
|
||
}
|
||
|
||
const { status, data } = response;
|
||
|
||
switch (status) {
|
||
case 401:
|
||
this.handleUnauthorized();
|
||
break;
|
||
case 403:
|
||
message.error('权限不足,无法访问该资源');
|
||
break;
|
||
case 404:
|
||
message.error('请求的资源不存在');
|
||
break;
|
||
case 422:
|
||
this.handleValidationError(data);
|
||
break;
|
||
case 500:
|
||
message.error('服务器内部错误,请稍后重试');
|
||
break;
|
||
default:
|
||
message.error(data?.message || '请求失败,请稍后重试');
|
||
}
|
||
|
||
return Promise.reject(error);
|
||
}
|
||
|
||
private handleUnauthorized() {
|
||
const authStore = useAuthStore();
|
||
authStore.logout();
|
||
|
||
// 重定向到登录页
|
||
if (window.location.pathname !== '/login') {
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
|
||
private handleValidationError(data: any) {
|
||
if (data?.errors && Array.isArray(data.errors)) {
|
||
data.errors.forEach((error: any) => {
|
||
message.error(`${error.field}: ${error.message}`);
|
||
});
|
||
} else {
|
||
message.error(data?.message || '数据验证失败');
|
||
}
|
||
}
|
||
|
||
private generateRequestId(): string {
|
||
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||
}
|
||
|
||
// HTTP方法封装
|
||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.instance.get(url, config);
|
||
}
|
||
|
||
post<T = any>(
|
||
url: string,
|
||
data?: any,
|
||
config?: AxiosRequestConfig,
|
||
): Promise<T> {
|
||
return this.instance.post(url, data, config);
|
||
}
|
||
|
||
put<T = any>(
|
||
url: string,
|
||
data?: any,
|
||
config?: AxiosRequestConfig,
|
||
): Promise<T> {
|
||
return this.instance.put(url, data, config);
|
||
}
|
||
|
||
patch<T = any>(
|
||
url: string,
|
||
data?: any,
|
||
config?: AxiosRequestConfig,
|
||
): Promise<T> {
|
||
return this.instance.patch(url, data, config);
|
||
}
|
||
|
||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||
return this.instance.delete(url, config);
|
||
}
|
||
}
|
||
|
||
export const apiClient = new ApiClient();
|
||
```
|
||
|
||
### API模块定义
|
||
|
||
```typescript
|
||
// api/modules/user.ts
|
||
import { apiClient } from '../client';
|
||
import type {
|
||
UserInfo,
|
||
UserListParams,
|
||
UserListResponse,
|
||
CreateUserData,
|
||
UpdateUserData,
|
||
} from '@/types/user';
|
||
|
||
export const userApi = {
|
||
/**
|
||
* 获取用户列表
|
||
*/
|
||
getList(params?: UserListParams): Promise<UserListResponse> {
|
||
return apiClient.get('/users', { params });
|
||
},
|
||
|
||
/**
|
||
* 获取用户详情
|
||
*/
|
||
getById(id: string): Promise<{ data: UserInfo }> {
|
||
return apiClient.get(`/users/${id}`);
|
||
},
|
||
|
||
/**
|
||
* 创建用户
|
||
*/
|
||
create(data: CreateUserData): Promise<{ data: UserInfo }> {
|
||
return apiClient.post('/users', data);
|
||
},
|
||
|
||
/**
|
||
* 更新用户
|
||
*/
|
||
update(id: string, data: UpdateUserData): Promise<{ data: UserInfo }> {
|
||
return apiClient.put(`/users/${id}`, data);
|
||
},
|
||
|
||
/**
|
||
* 删除用户
|
||
*/
|
||
delete(id: string): Promise<void> {
|
||
return apiClient.delete(`/users/${id}`);
|
||
},
|
||
|
||
/**
|
||
* 批量操作用户
|
||
*/
|
||
batchAction(
|
||
action: string,
|
||
userIds: string[],
|
||
data?: any,
|
||
): Promise<{ data: { success: number; failed: number } }> {
|
||
return apiClient.post('/users/batch', {
|
||
action,
|
||
userIds,
|
||
data,
|
||
});
|
||
},
|
||
|
||
/**
|
||
* 上传用户头像
|
||
*/
|
||
uploadAvatar(id: string, file: File): Promise<{ data: { url: string } }> {
|
||
const formData = new FormData();
|
||
formData.append('avatar', file);
|
||
|
||
return apiClient.post(`/users/${id}/avatar`, formData, {
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
},
|
||
});
|
||
},
|
||
};
|
||
```
|
||
|
||
### API类型定义
|
||
|
||
```typescript
|
||
// types/user.ts
|
||
export interface UserInfo {
|
||
id: string;
|
||
username: string;
|
||
email: string;
|
||
profile: UserProfile;
|
||
roles: string[];
|
||
status: UserStatus;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
}
|
||
|
||
export interface UserProfile {
|
||
firstName: string;
|
||
lastName: string;
|
||
avatar?: string;
|
||
phone?: string;
|
||
bio?: string;
|
||
}
|
||
|
||
export type UserStatus = 'active' | 'inactive' | 'suspended';
|
||
|
||
export interface UserListParams {
|
||
page?: number;
|
||
pageSize?: number;
|
||
sortBy?: string;
|
||
sortOrder?: 'asc' | 'desc';
|
||
filters?: {
|
||
status?: UserStatus;
|
||
roles?: string[];
|
||
username?: string;
|
||
email?: string;
|
||
createdAt?: {
|
||
start?: string;
|
||
end?: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
export interface UserListResponse {
|
||
success: boolean;
|
||
data: {
|
||
items: UserInfo[];
|
||
pagination: {
|
||
page: number;
|
||
pageSize: number;
|
||
total: number;
|
||
totalPages: number;
|
||
};
|
||
};
|
||
}
|
||
|
||
export interface CreateUserData {
|
||
username: string;
|
||
email: string;
|
||
password: string;
|
||
profile: Omit<UserProfile, 'avatar'>;
|
||
roles?: string[];
|
||
}
|
||
|
||
export interface UpdateUserData {
|
||
username?: string;
|
||
email?: string;
|
||
profile?: Partial<UserProfile>;
|
||
roles?: string[];
|
||
status?: UserStatus;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 📦 状态管理规范
|
||
|
||
### Pinia Store规范
|
||
|
||
```typescript
|
||
// stores/modules/user.ts
|
||
import { defineStore } from 'pinia';
|
||
import { ref, computed } from 'vue';
|
||
import type { UserInfo, UserListParams } from '@/types/user';
|
||
import { userApi } from '@/api/modules/user';
|
||
|
||
export const useUserStore = defineStore('user', () => {
|
||
// ==================== 状态定义 ====================
|
||
const userList = ref<UserInfo[]>([]);
|
||
const currentUser = ref<UserInfo | null>(null);
|
||
const selectedUsers = ref<UserInfo[]>([]);
|
||
const loading = ref(false);
|
||
const error = ref<string | null>(null);
|
||
|
||
// 分页信息
|
||
const pagination = ref({
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
totalPages: 0,
|
||
});
|
||
|
||
// 筛选条件
|
||
const filters = ref<UserListParams['filters']>({});
|
||
|
||
// ==================== 计算属性 ====================
|
||
const activeUsers = computed(() =>
|
||
userList.value.filter((user) => user.status === 'active'),
|
||
);
|
||
|
||
const userCount = computed(() => userList.value.length);
|
||
|
||
const hasSelectedUsers = computed(() => selectedUsers.value.length > 0);
|
||
|
||
const selectedUserIds = computed(() =>
|
||
selectedUsers.value.map((user) => user.id),
|
||
);
|
||
|
||
// ==================== 私有方法 ====================
|
||
const setError = (message: string) => {
|
||
error.value = message;
|
||
console.error('[UserStore]', message);
|
||
};
|
||
|
||
const clearError = () => {
|
||
error.value = null;
|
||
};
|
||
|
||
// ==================== Actions ====================
|
||
|
||
/**
|
||
* 获取用户列表
|
||
*/
|
||
const fetchUsers = async (params?: UserListParams) => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const mergedParams = {
|
||
...params,
|
||
filters: { ...filters.value, ...params?.filters },
|
||
};
|
||
|
||
const response = await userApi.getList(mergedParams);
|
||
|
||
userList.value = response.data.items;
|
||
pagination.value = response.data.pagination;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to fetch users');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取用户详情
|
||
*/
|
||
const fetchUser = async (id: string): Promise<UserInfo | null> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.getById(id);
|
||
currentUser.value = response.data;
|
||
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to fetch user');
|
||
return null;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 创建用户
|
||
*/
|
||
const createUser = async (data: CreateUserData): Promise<UserInfo> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.create(data);
|
||
|
||
// 如果当前页面能容纳新用户,则添加到列表
|
||
if (userList.value.length < pagination.value.pageSize) {
|
||
userList.value.push(response.data);
|
||
}
|
||
|
||
// 更新总数
|
||
pagination.value.total += 1;
|
||
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to create user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 更新用户
|
||
*/
|
||
const updateUser = async (
|
||
id: string,
|
||
data: UpdateUserData,
|
||
): Promise<UserInfo> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.update(id, data);
|
||
|
||
// 更新列表中的用户
|
||
const index = userList.value.findIndex((user) => user.id === id);
|
||
if (index !== -1) {
|
||
userList.value[index] = response.data;
|
||
}
|
||
|
||
// 更新当前用户
|
||
if (currentUser.value?.id === id) {
|
||
currentUser.value = response.data;
|
||
}
|
||
|
||
return response.data;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to update user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 删除用户
|
||
*/
|
||
const deleteUser = async (id: string): Promise<void> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
await userApi.delete(id);
|
||
|
||
// 从列表中移除
|
||
const index = userList.value.findIndex((user) => user.id === id);
|
||
if (index !== -1) {
|
||
userList.value.splice(index, 1);
|
||
}
|
||
|
||
// 从选中列表中移除
|
||
const selectedIndex = selectedUsers.value.findIndex(
|
||
(user) => user.id === id,
|
||
);
|
||
if (selectedIndex !== -1) {
|
||
selectedUsers.value.splice(selectedIndex, 1);
|
||
}
|
||
|
||
// 清除当前用户
|
||
if (currentUser.value?.id === id) {
|
||
currentUser.value = null;
|
||
}
|
||
|
||
// 更新总数
|
||
pagination.value.total -= 1;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : 'Failed to delete user');
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 批量删除用户
|
||
*/
|
||
const batchDeleteUsers = async (userIds: string[]): Promise<void> => {
|
||
try {
|
||
loading.value = true;
|
||
clearError();
|
||
|
||
const response = await userApi.batchAction('delete', userIds);
|
||
|
||
// 从列表中移除成功删除的用户
|
||
userList.value = userList.value.filter(
|
||
(user) => !userIds.includes(user.id),
|
||
);
|
||
|
||
// 清空选中列表
|
||
selectedUsers.value = [];
|
||
|
||
// 更新总数
|
||
pagination.value.total -= response.data.success;
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof Error ? err.message : 'Failed to batch delete users',
|
||
);
|
||
throw err;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 选择用户
|
||
*/
|
||
const selectUser = (user: UserInfo) => {
|
||
const index = selectedUsers.value.findIndex((u) => u.id === user.id);
|
||
if (index === -1) {
|
||
selectedUsers.value.push(user);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 取消选择用户
|
||
*/
|
||
const unselectUser = (userId: string) => {
|
||
const index = selectedUsers.value.findIndex((user) => user.id === userId);
|
||
if (index !== -1) {
|
||
selectedUsers.value.splice(index, 1);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 切换用户选择状态
|
||
*/
|
||
const toggleUserSelection = (user: UserInfo) => {
|
||
const index = selectedUsers.value.findIndex((u) => u.id === user.id);
|
||
if (index === -1) {
|
||
selectedUsers.value.push(user);
|
||
} else {
|
||
selectedUsers.value.splice(index, 1);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 全选/取消全选
|
||
*/
|
||
const toggleSelectAll = () => {
|
||
if (selectedUsers.value.length === userList.value.length) {
|
||
selectedUsers.value = [];
|
||
} else {
|
||
selectedUsers.value = [...userList.value];
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 清空选择
|
||
*/
|
||
const clearSelection = () => {
|
||
selectedUsers.value = [];
|
||
};
|
||
|
||
/**
|
||
* 更新筛选条件
|
||
*/
|
||
const updateFilters = (newFilters: UserListParams['filters']) => {
|
||
filters.value = { ...filters.value, ...newFilters };
|
||
};
|
||
|
||
/**
|
||
* 重置筛选条件
|
||
*/
|
||
const resetFilters = () => {
|
||
filters.value = {};
|
||
};
|
||
|
||
/**
|
||
* 刷新当前页面
|
||
*/
|
||
const refresh = async () => {
|
||
await fetchUsers({
|
||
page: pagination.value.page,
|
||
pageSize: pagination.value.pageSize,
|
||
filters: filters.value,
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 重置所有状态
|
||
*/
|
||
const resetState = () => {
|
||
userList.value = [];
|
||
currentUser.value = null;
|
||
selectedUsers.value = [];
|
||
loading.value = false;
|
||
error.value = null;
|
||
pagination.value = {
|
||
page: 1,
|
||
pageSize: 20,
|
||
total: 0,
|
||
totalPages: 0,
|
||
};
|
||
filters.value = {};
|
||
};
|
||
|
||
return {
|
||
// 状态
|
||
userList,
|
||
currentUser,
|
||
selectedUsers,
|
||
loading,
|
||
error,
|
||
pagination,
|
||
filters,
|
||
|
||
// 计算属性
|
||
activeUsers,
|
||
userCount,
|
||
hasSelectedUsers,
|
||
selectedUserIds,
|
||
|
||
// Actions
|
||
fetchUsers,
|
||
fetchUser,
|
||
createUser,
|
||
updateUser,
|
||
deleteUser,
|
||
batchDeleteUsers,
|
||
selectUser,
|
||
unselectUser,
|
||
toggleUserSelection,
|
||
toggleSelectAll,
|
||
clearSelection,
|
||
updateFilters,
|
||
resetFilters,
|
||
refresh,
|
||
resetState,
|
||
};
|
||
});
|
||
```
|
||
|
||
### Store组合使用
|
||
|
||
```typescript
|
||
// stores/index.ts
|
||
import type { App } from 'vue';
|
||
import { createPinia } from 'pinia';
|
||
|
||
// 创建pinia实例
|
||
export const pinia = createPinia();
|
||
|
||
// 插件安装
|
||
export function setupStore(app: App) {
|
||
app.use(pinia);
|
||
}
|
||
|
||
// 导出所有store
|
||
export { useAuthStore } from './modules/auth';
|
||
export { useUserStore } from './modules/user';
|
||
export { usePermissionStore } from './modules/permission';
|
||
export { useSettingStore } from './modules/setting';
|
||
```
|
||
|
||
---
|
||
|
||
## 🛣️ 路由规范
|
||
|
||
### 路由配置
|
||
|
||
```typescript
|
||
// router/modules/user.ts
|
||
import type { RouteRecordRaw } from 'vue-router';
|
||
|
||
const userRoutes: RouteRecordRaw[] = [
|
||
{
|
||
path: '/user',
|
||
name: 'User',
|
||
redirect: '/user/list',
|
||
component: () => import('@/layouts/default/index.vue'),
|
||
meta: {
|
||
title: 'route.user.title',
|
||
icon: 'mdi:account-group',
|
||
orderNo: 10,
|
||
requiresAuth: true,
|
||
permissions: ['user:read'],
|
||
},
|
||
children: [
|
||
{
|
||
path: 'list',
|
||
name: 'UserList',
|
||
component: () => import('@/views/user/list/index.vue'),
|
||
meta: {
|
||
title: 'route.user.list',
|
||
icon: 'mdi:account-multiple',
|
||
permissions: ['user:read'],
|
||
keepAlive: true,
|
||
},
|
||
},
|
||
{
|
||
path: 'create',
|
||
name: 'UserCreate',
|
||
component: () => import('@/views/user/create/index.vue'),
|
||
meta: {
|
||
title: 'route.user.create',
|
||
icon: 'mdi:account-plus',
|
||
permissions: ['user:create'],
|
||
hideInMenu: true,
|
||
},
|
||
},
|
||
{
|
||
path: 'edit/:id',
|
||
name: 'UserEdit',
|
||
component: () => import('@/views/user/edit/index.vue'),
|
||
meta: {
|
||
title: 'route.user.edit',
|
||
permissions: ['user:update'],
|
||
hideInMenu: true,
|
||
activeMenu: '/user/list',
|
||
},
|
||
},
|
||
{
|
||
path: 'detail/:id',
|
||
name: 'UserDetail',
|
||
component: () => import('@/views/user/detail/index.vue'),
|
||
meta: {
|
||
title: 'route.user.detail',
|
||
permissions: ['user:read'],
|
||
hideInMenu: true,
|
||
activeMenu: '/user/list',
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
export default userRoutes;
|
||
```
|
||
|
||
### 路由守卫
|
||
|
||
```typescript
|
||
// router/guards/permission.ts
|
||
import type { Router } from 'vue-router';
|
||
import { useAuthStore } from '@/stores/modules/auth';
|
||
import { usePermissionStore } from '@/stores/modules/permission';
|
||
|
||
export function setupPermissionGuard(router: Router) {
|
||
router.beforeEach(async (to, from, next) => {
|
||
const authStore = useAuthStore();
|
||
const permissionStore = usePermissionStore();
|
||
|
||
// 1. 检查登录状态
|
||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||
next({
|
||
path: '/login',
|
||
query: { redirect: to.fullPath },
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 2. 检查权限
|
||
if (to.meta.permissions && to.meta.permissions.length > 0) {
|
||
const hasPermission = to.meta.permissions.some((permission: string) =>
|
||
permissionStore.hasPermission(permission),
|
||
);
|
||
|
||
if (!hasPermission) {
|
||
next({ path: '/403' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 3. 动态加载用户权限(如果还未加载)
|
||
if (authStore.isLoggedIn && !permissionStore.isPermissionsLoaded) {
|
||
try {
|
||
await permissionStore.loadPermissions();
|
||
|
||
// 重新检查权限
|
||
if (to.meta.permissions && to.meta.permissions.length > 0) {
|
||
const hasPermission = to.meta.permissions.some((permission: string) =>
|
||
permissionStore.hasPermission(permission),
|
||
);
|
||
|
||
if (!hasPermission) {
|
||
next({ path: '/403' });
|
||
return;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load permissions:', error);
|
||
next({ path: '/login' });
|
||
return;
|
||
}
|
||
}
|
||
|
||
next();
|
||
});
|
||
}
|
||
```
|
||
|
||
### 动态路由
|
||
|
||
```typescript
|
||
// router/dynamic.ts
|
||
import type { RouteRecordRaw } from 'vue-router';
|
||
import { usePermissionStore } from '@/stores/modules/permission';
|
||
|
||
/**
|
||
* 根据权限过滤路由
|
||
*/
|
||
export function filterRoutesByPermission(
|
||
routes: RouteRecordRaw[],
|
||
permissions: string[],
|
||
): RouteRecordRaw[] {
|
||
return routes.filter((route) => {
|
||
// 检查当前路由权限
|
||
if (route.meta?.permissions) {
|
||
const hasPermission = route.meta.permissions.some((permission: string) =>
|
||
permissions.includes(permission),
|
||
);
|
||
|
||
if (!hasPermission) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 递归处理子路由
|
||
if (route.children) {
|
||
route.children = filterRoutesByPermission(route.children, permissions);
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 生成动态路由
|
||
*/
|
||
export async function generateDynamicRoutes(): Promise<RouteRecordRaw[]> {
|
||
const permissionStore = usePermissionStore();
|
||
|
||
// 获取用户权限
|
||
const permissions = permissionStore.permissions;
|
||
|
||
// 导入所有路由模块
|
||
const routeModules = import.meta.glob('./modules/*.ts', { eager: true });
|
||
|
||
let routes: RouteRecordRaw[] = [];
|
||
|
||
// 合并所有路由
|
||
Object.values(routeModules).forEach((module: any) => {
|
||
const moduleRoutes = module.default || module;
|
||
if (Array.isArray(moduleRoutes)) {
|
||
routes = routes.concat(moduleRoutes);
|
||
}
|
||
});
|
||
|
||
// 根据权限过滤路由
|
||
return filterRoutesByPermission(routes, permissions);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 🎨 样式规范
|
||
|
||
### CSS架构
|
||
|
||
采用 `TailwindCSS + SCSS` 的混合方案:
|
||
|
||
```scss
|
||
// styles/variables.scss
|
||
// 颜色系统
|
||
:root {
|
||
// 主色调
|
||
--color-primary: #1890ff;
|
||
--color-primary-light: #40a9ff;
|
||
--color-primary-dark: #096dd9;
|
||
|
||
// 功能色
|
||
--color-success: #52c41a;
|
||
--color-warning: #faad14;
|
||
--color-error: #f5222d;
|
||
--color-info: #1890ff;
|
||
|
||
// 中性色
|
||
--color-text-primary: #262626;
|
||
--color-text-secondary: #595959;
|
||
--color-text-disabled: #bfbfbf;
|
||
|
||
// 背景色
|
||
--color-bg-primary: #ffffff;
|
||
--color-bg-secondary: #fafafa;
|
||
--color-bg-tertiary: #f5f5f5;
|
||
|
||
// 边框色
|
||
--color-border-primary: #d9d9d9;
|
||
--color-border-secondary: #f0f0f0;
|
||
|
||
// 阴影
|
||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||
|
||
// 圆角
|
||
--radius-sm: 4px;
|
||
--radius-md: 6px;
|
||
--radius-lg: 8px;
|
||
--radius-xl: 12px;
|
||
|
||
// 间距
|
||
--spacing-xs: 4px;
|
||
--spacing-sm: 8px;
|
||
--spacing-md: 16px;
|
||
--spacing-lg: 24px;
|
||
--spacing-xl: 32px;
|
||
--spacing-2xl: 48px;
|
||
}
|
||
|
||
// 暗色主题
|
||
[data-theme='dark'] {
|
||
--color-bg-primary: #141414;
|
||
--color-bg-secondary: #1f1f1f;
|
||
--color-bg-tertiary: #262626;
|
||
|
||
--color-text-primary: #ffffff;
|
||
--color-text-secondary: #a6a6a6;
|
||
|
||
--color-border-primary: #434343;
|
||
--color-border-secondary: #303030;
|
||
}
|
||
```
|
||
|
||
### 组件样式规范
|
||
|
||
```scss
|
||
// 使用BEM命名规范 + TailwindCSS工具类
|
||
.user-profile-card {
|
||
@apply rounded-lg border border-gray-200 bg-white shadow-sm;
|
||
|
||
// 组件状态
|
||
&--loading {
|
||
@apply pointer-events-none opacity-50;
|
||
}
|
||
|
||
&--disabled {
|
||
@apply bg-gray-50 text-gray-400;
|
||
}
|
||
|
||
// 子元素
|
||
&__header {
|
||
@apply flex items-center justify-between border-b border-gray-100 p-4;
|
||
}
|
||
|
||
&__title {
|
||
@apply text-lg font-semibold text-gray-900;
|
||
}
|
||
|
||
&__subtitle {
|
||
@apply text-sm text-gray-500;
|
||
}
|
||
|
||
&__body {
|
||
@apply p-4;
|
||
}
|
||
|
||
&__actions {
|
||
@apply flex justify-end space-x-2 border-t border-gray-100 p-4;
|
||
}
|
||
|
||
// 修饰符
|
||
&--compact {
|
||
.user-profile-card__header,
|
||
.user-profile-card__body,
|
||
.user-profile-card__actions {
|
||
@apply p-2;
|
||
}
|
||
}
|
||
|
||
// 响应式
|
||
@screen md {
|
||
@apply p-6;
|
||
}
|
||
|
||
// 暗色主题
|
||
@apply dark:border-gray-700 dark:bg-gray-800;
|
||
|
||
&__title {
|
||
@apply dark:text-gray-100;
|
||
}
|
||
|
||
&__subtitle {
|
||
@apply dark:text-gray-400;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 全局样式
|
||
|
||
```scss
|
||
// styles/global.scss
|
||
// 重置样式
|
||
*,
|
||
*::before,
|
||
*::after {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html {
|
||
line-height: 1.15;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
|
||
body {
|
||
margin: 0;
|
||
font-family:
|
||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||
Arial, sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--color-text-primary);
|
||
background-color: var(--color-bg-primary);
|
||
}
|
||
|
||
// 工具类
|
||
.text-ellipsis {
|
||
@apply truncate;
|
||
}
|
||
|
||
.text-ellipsis-2 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.text-ellipsis-3 {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
// 滚动条样式
|
||
.scrollbar-thin {
|
||
scrollbar-width: thin;
|
||
scrollbar-color: var(--color-border-primary) transparent;
|
||
|
||
&::-webkit-scrollbar {
|
||
width: 6px;
|
||
height: 6px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb {
|
||
background-color: var(--color-border-primary);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
&::-webkit-scrollbar-thumb:hover {
|
||
background-color: var(--color-text-disabled);
|
||
}
|
||
}
|
||
|
||
// 动画
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
|
||
.slide-up-enter-active,
|
||
.slide-up-leave-active {
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.slide-up-enter-from {
|
||
transform: translateY(20px);
|
||
opacity: 0;
|
||
}
|
||
|
||
.slide-up-leave-to {
|
||
transform: translateY(-20px);
|
||
opacity: 0;
|
||
}
|
||
```
|
||
|
||
### TailwindCSS配置
|
||
|
||
```javascript
|
||
// tailwind.config.js
|
||
module.exports = {
|
||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
||
darkMode: ['class', '[data-theme="dark"]'],
|
||
theme: {
|
||
extend: {
|
||
colors: {
|
||
primary: {
|
||
50: '#e6f7ff',
|
||
100: '#bae7ff',
|
||
200: '#91d5ff',
|
||
300: '#69c0ff',
|
||
400: '#40a9ff',
|
||
500: '#1890ff',
|
||
600: '#096dd9',
|
||
700: '#0050b3',
|
||
800: '#003a8c',
|
||
900: '#002766',
|
||
},
|
||
gray: {
|
||
50: '#fafafa',
|
||
100: '#f5f5f5',
|
||
200: '#f0f0f0',
|
||
300: '#d9d9d9',
|
||
400: '#bfbfbf',
|
||
500: '#8c8c8c',
|
||
600: '#595959',
|
||
700: '#434343',
|
||
800: '#262626',
|
||
900: '#1f1f1f',
|
||
},
|
||
},
|
||
fontFamily: {
|
||
sans: [
|
||
'-apple-system',
|
||
'BlinkMacSystemFont',
|
||
'Segoe UI',
|
||
'Roboto',
|
||
'Helvetica Neue',
|
||
'Arial',
|
||
'sans-serif',
|
||
],
|
||
mono: [
|
||
'SFMono-Regular',
|
||
'Menlo',
|
||
'Monaco',
|
||
'Consolas',
|
||
'Liberation Mono',
|
||
'Courier New',
|
||
'monospace',
|
||
],
|
||
},
|
||
spacing: {
|
||
18: '4.5rem',
|
||
88: '22rem',
|
||
},
|
||
animation: {
|
||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||
'slide-up': 'slideUp 0.3s ease-out',
|
||
'bounce-in': 'bounceIn 0.6s ease-out',
|
||
},
|
||
keyframes: {
|
||
fadeIn: {
|
||
'0%': { opacity: '0' },
|
||
'100%': { opacity: '1' },
|
||
},
|
||
slideUp: {
|
||
'0%': { transform: 'translateY(20px)', opacity: '0' },
|
||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||
},
|
||
bounceIn: {
|
||
'0%': { transform: 'scale(0.3)', opacity: '0' },
|
||
'50%': { transform: 'scale(1.05)' },
|
||
'70%': { transform: 'scale(0.9)' },
|
||
'100%': { transform: 'scale(1)', opacity: '1' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
plugins: [
|
||
require('@tailwindcss/forms'),
|
||
require('@tailwindcss/typography'),
|
||
require('@tailwindcss/aspect-ratio'),
|
||
],
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 🧪 测试规范
|
||
|
||
### 单元测试规范
|
||
|
||
```typescript
|
||
// tests/unit/composables/useUser.test.ts
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
import { useUser } from '@/composables/useUser';
|
||
import { userApi } from '@/api/modules/user';
|
||
|
||
// Mock API
|
||
vi.mock('@/api/modules/user', () => ({
|
||
userApi: {
|
||
getList: vi.fn(),
|
||
getById: vi.fn(),
|
||
create: vi.fn(),
|
||
update: vi.fn(),
|
||
delete: vi.fn(),
|
||
},
|
||
}));
|
||
|
||
describe('useUser', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('should initialize with default values', () => {
|
||
const { userList, loading, error } = useUser();
|
||
|
||
expect(userList.value).toEqual([]);
|
||
expect(loading.value).toBe(false);
|
||
expect(error.value).toBeNull();
|
||
});
|
||
|
||
it('should fetch users successfully', async () => {
|
||
const mockUsers = [
|
||
{ id: '1', username: 'user1', email: 'user1@test.com' },
|
||
{ id: '2', username: 'user2', email: 'user2@test.com' },
|
||
];
|
||
|
||
vi.mocked(userApi.getList).mockResolvedValue({
|
||
success: true,
|
||
data: {
|
||
items: mockUsers,
|
||
pagination: { page: 1, pageSize: 20, total: 2, totalPages: 1 },
|
||
},
|
||
});
|
||
|
||
const { userList, fetchUsers, loading } = useUser();
|
||
|
||
await fetchUsers();
|
||
|
||
expect(loading.value).toBe(false);
|
||
expect(userList.value).toEqual(mockUsers);
|
||
expect(userApi.getList).toHaveBeenCalledOnce();
|
||
});
|
||
|
||
it('should handle fetch users error', async () => {
|
||
const errorMessage = 'Failed to fetch users';
|
||
vi.mocked(userApi.getList).mockRejectedValue(new Error(errorMessage));
|
||
|
||
const { fetchUsers, error } = useUser();
|
||
|
||
await expect(fetchUsers()).rejects.toThrow(errorMessage);
|
||
expect(error.value).toBe(errorMessage);
|
||
});
|
||
|
||
it('should create user successfully', async () => {
|
||
const newUser = { id: '3', username: 'user3', email: 'user3@test.com' };
|
||
const createData = {
|
||
username: 'user3',
|
||
email: 'user3@test.com',
|
||
password: '123456',
|
||
};
|
||
|
||
vi.mocked(userApi.create).mockResolvedValue({
|
||
success: true,
|
||
data: newUser,
|
||
});
|
||
|
||
const { userList, createUser } = useUser();
|
||
|
||
const result = await createUser(createData);
|
||
|
||
expect(result).toEqual(newUser);
|
||
expect(userList.value).toContain(newUser);
|
||
expect(userApi.create).toHaveBeenCalledWith(createData);
|
||
});
|
||
});
|
||
```
|
||
|
||
### 组件测试规范
|
||
|
||
```typescript
|
||
// tests/unit/components/UserCard.test.ts
|
||
import { describe, it, expect, vi } from 'vitest';
|
||
import { mount } from '@vue/test-utils';
|
||
import UserCard from '@/components/UserCard.vue';
|
||
import type { UserInfo } from '@/types/user';
|
||
|
||
const mockUser: UserInfo = {
|
||
id: '1',
|
||
username: 'testuser',
|
||
email: 'test@example.com',
|
||
profile: {
|
||
firstName: 'Test',
|
||
lastName: 'User',
|
||
avatar: 'https://example.com/avatar.jpg',
|
||
},
|
||
roles: ['user'],
|
||
status: 'active',
|
||
createdAt: '2024-01-01T00:00:00Z',
|
||
updatedAt: '2024-01-01T00:00:00Z',
|
||
};
|
||
|
||
describe('UserCard', () => {
|
||
it('should render user information correctly', () => {
|
||
const wrapper = mount(UserCard, {
|
||
props: {
|
||
userInfo: mockUser,
|
||
},
|
||
});
|
||
|
||
expect(wrapper.find('.user-card__username').text()).toBe(mockUser.username);
|
||
expect(wrapper.find('.user-card__email').text()).toBe(mockUser.email);
|
||
expect(wrapper.find('.user-card__status').text()).toContain('active');
|
||
});
|
||
|
||
it('should emit user-click when card is clicked', async () => {
|
||
const wrapper = mount(UserCard, {
|
||
props: {
|
||
userInfo: mockUser,
|
||
},
|
||
});
|
||
|
||
await wrapper.find('.user-card').trigger('click');
|
||
|
||
expect(wrapper.emitted('user-click')).toBeTruthy();
|
||
expect(wrapper.emitted('user-click')?.[0]).toEqual([mockUser]);
|
||
});
|
||
|
||
it('should not show actions when readonly is true', () => {
|
||
const wrapper = mount(UserCard, {
|
||
props: {
|
||
userInfo: mockUser,
|
||
readonly: true,
|
||
},
|
||
});
|
||
|
||
expect(wrapper.find('.user-card__actions').exists()).toBe(false);
|
||
});
|
||
|
||
it('should show actions when readonly is false', () => {
|
||
const wrapper = mount(UserCard, {
|
||
props: {
|
||
userInfo: mockUser,
|
||
readonly: false,
|
||
showActions: true,
|
||
},
|
||
});
|
||
|
||
expect(wrapper.find('.user-card__actions').exists()).toBe(true);
|
||
});
|
||
|
||
it('should handle null user info gracefully', () => {
|
||
const wrapper = mount(UserCard, {
|
||
props: {
|
||
userInfo: null,
|
||
},
|
||
});
|
||
|
||
expect(wrapper.find('.user-card__username').text()).toBe('Unknown');
|
||
});
|
||
});
|
||
```
|
||
|
||
### E2E测试规范
|
||
|
||
```typescript
|
||
// tests/e2e/user-management.test.ts
|
||
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('用户管理功能', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
// 登录
|
||
await page.goto('/login');
|
||
await page.fill('[data-testid="username-input"]', 'admin');
|
||
await page.fill('[data-testid="password-input"]', '111111');
|
||
await page.click('[data-testid="login-button"]');
|
||
await page.waitForURL('/dashboard');
|
||
|
||
// 导航到用户管理页面
|
||
await page.click('[data-testid="menu-user"]');
|
||
await page.click('[data-testid="menu-user-list"]');
|
||
await page.waitForURL('/user/list');
|
||
});
|
||
|
||
test('应该显示用户列表', async ({ page }) => {
|
||
// 等待用户列表加载
|
||
await page.waitForSelector('[data-testid="user-table"]');
|
||
|
||
// 检查表格是否显示
|
||
const table = page.locator('[data-testid="user-table"]');
|
||
await expect(table).toBeVisible();
|
||
|
||
// 检查表格头部
|
||
await expect(page.locator('th')).toContainText([
|
||
'用户名',
|
||
'邮箱',
|
||
'状态',
|
||
'创建时间',
|
||
'操作',
|
||
]);
|
||
|
||
// 检查是否有用户数据
|
||
const rows = page.locator('tbody tr');
|
||
await expect(rows).toHaveCountGreaterThan(0);
|
||
});
|
||
|
||
test('应该能够搜索用户', async ({ page }) => {
|
||
// 输入搜索关键词
|
||
await page.fill('[data-testid="search-input"]', 'admin');
|
||
await page.click('[data-testid="search-button"]');
|
||
|
||
// 等待搜索结果
|
||
await page.waitForTimeout(1000);
|
||
|
||
// 检查搜索结果
|
||
const userRows = page.locator('tbody tr');
|
||
const firstUserName = userRows.first().locator('td').nth(1);
|
||
await expect(firstUserName).toContainText('admin');
|
||
});
|
||
|
||
test('应该能够创建新用户', async ({ page }) => {
|
||
// 点击添加用户按钮
|
||
await page.click('[data-testid="add-user-button"]');
|
||
|
||
// 填写用户信息
|
||
await page.fill('[data-testid="username-input"]', 'newuser');
|
||
await page.fill('[data-testid="email-input"]', 'newuser@example.com');
|
||
await page.fill('[data-testid="password-input"]', '123456');
|
||
await page.fill('[data-testid="firstName-input"]', 'New');
|
||
await page.fill('[data-testid="lastName-input"]', 'User');
|
||
|
||
// 提交表单
|
||
await page.click('[data-testid="submit-button"]');
|
||
|
||
// 等待成功消息
|
||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||
|
||
// 检查用户是否已添加到列表
|
||
await page.waitForTimeout(1000);
|
||
const userRows = page.locator('tbody tr');
|
||
await expect(userRows).toContainText('newuser');
|
||
});
|
||
|
||
test('应该能够编辑用户', async ({ page }) => {
|
||
// 点击第一个用户的编辑按钮
|
||
await page.click('tbody tr:first-child [data-testid="edit-button"]');
|
||
|
||
// 修改用户信息
|
||
await page.fill('[data-testid="firstName-input"]', 'Updated');
|
||
await page.fill('[data-testid="lastName-input"]', 'Name');
|
||
|
||
// 提交更改
|
||
await page.click('[data-testid="submit-button"]');
|
||
|
||
// 等待成功消息
|
||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||
|
||
// 返回列表页面
|
||
await page.click('[data-testid="back-button"]');
|
||
|
||
// 检查更改是否生效
|
||
const firstUserRow = page.locator('tbody tr:first-child');
|
||
await expect(firstUserRow).toContainText('Updated Name');
|
||
});
|
||
|
||
test('应该能够删除用户', async ({ page }) => {
|
||
// 获取删除前的用户数量
|
||
const initialCount = await page.locator('tbody tr').count();
|
||
|
||
// 点击第一个用户的删除按钮
|
||
await page.click('tbody tr:first-child [data-testid="delete-button"]');
|
||
|
||
// 确认删除
|
||
await page.click('[data-testid="confirm-delete-button"]');
|
||
|
||
// 等待成功消息
|
||
await expect(page.locator('.ant-message-success')).toBeVisible();
|
||
|
||
// 检查用户数量是否减少
|
||
await page.waitForTimeout(1000);
|
||
const finalCount = await page.locator('tbody tr').count();
|
||
expect(finalCount).toBe(initialCount - 1);
|
||
});
|
||
});
|
||
```
|
||
|
||
### 测试配置
|
||
|
||
```typescript
|
||
// vitest.config.ts
|
||
import { defineConfig } from 'vitest/config';
|
||
import vue from '@vitejs/plugin-vue';
|
||
import { resolve } from 'path';
|
||
|
||
export default defineConfig({
|
||
plugins: [vue()],
|
||
test: {
|
||
globals: true,
|
||
environment: 'jsdom',
|
||
setupFiles: ['./tests/setup.ts'],
|
||
coverage: {
|
||
provider: 'v8',
|
||
reporter: ['text', 'json', 'html'],
|
||
exclude: [
|
||
'node_modules/',
|
||
'tests/',
|
||
'**/*.d.ts',
|
||
'**/*.config.*',
|
||
'build/',
|
||
'dist/',
|
||
],
|
||
},
|
||
},
|
||
resolve: {
|
||
alias: {
|
||
'@': resolve(__dirname, './src'),
|
||
},
|
||
},
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 🔀 Git工作流
|
||
|
||
### 分支策略
|
||
|
||
采用 Git Flow 分支策略:
|
||
|
||
```bash
|
||
# 主要分支
|
||
main # 生产环境分支
|
||
develop # 开发环境分支
|
||
|
||
# 支持分支
|
||
feature/* # 功能分支
|
||
release/* # 发布分支
|
||
hotfix/* # 热修复分支
|
||
```
|
||
|
||
### 分支命名规范
|
||
|
||
```bash
|
||
# 功能分支
|
||
feature/user-management
|
||
feature/message-template
|
||
feature/api-optimization
|
||
|
||
# 修复分支
|
||
bugfix/login-validation
|
||
bugfix/table-pagination
|
||
|
||
# 热修复分支
|
||
hotfix/security-patch
|
||
hotfix/performance-issue
|
||
|
||
# 发布分支
|
||
release/v2.1.0
|
||
release/v2.2.0
|
||
```
|
||
|
||
### 提交信息规范
|
||
|
||
采用 Conventional Commits 规范:
|
||
|
||
```bash
|
||
# 格式
|
||
<type>[optional scope]: <description>
|
||
|
||
[optional body]
|
||
|
||
[optional footer(s)]
|
||
|
||
# 类型说明
|
||
feat: 新功能
|
||
fix: 修复bug
|
||
docs: 文档更新
|
||
style: 格式调整(不影响代码运行)
|
||
refactor: 重构代码
|
||
perf: 性能优化
|
||
test: 测试相关
|
||
build: 构建系统相关
|
||
ci: CI配置相关
|
||
chore: 其他修改
|
||
|
||
# 示例
|
||
feat(user): add user profile management
|
||
|
||
Add comprehensive user profile management functionality including:
|
||
- User profile editing
|
||
- Avatar upload
|
||
- Profile validation
|
||
- Permission checks
|
||
|
||
Closes #123
|
||
```
|
||
|
||
### Git Hooks配置
|
||
|
||
```javascript
|
||
// .husky/pre-commit
|
||
#!/usr/bin/env sh
|
||
. "$(dirname -- "$0")/_/husky.sh"
|
||
|
||
# 代码检查
|
||
pnpm lint-staged
|
||
|
||
# 类型检查
|
||
pnpm typecheck
|
||
```
|
||
|
||
```javascript
|
||
// .husky/commit-msg
|
||
#!/usr/bin/env sh
|
||
. "$(dirname -- "$0")/_/husky.sh"
|
||
|
||
# 提交信息验证
|
||
pnpm commitlint --edit $1
|
||
```
|
||
|
||
```json
|
||
// lint-staged.config.js
|
||
module.exports = {
|
||
'*.{js,jsx,ts,tsx,vue}': [
|
||
'eslint --fix',
|
||
'prettier --write',
|
||
'git add'
|
||
],
|
||
'*.{css,scss,less}': [
|
||
'stylelint --fix',
|
||
'prettier --write',
|
||
'git add'
|
||
],
|
||
'*.{json,md}': [
|
||
'prettier --write',
|
||
'git add'
|
||
]
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 👀 代码审查
|
||
|
||
### 审查清单
|
||
|
||
#### 功能性审查
|
||
|
||
- [ ] **功能完整性**: 功能是否按需求完整实现
|
||
- [ ] **边界条件**: 是否处理了边界情况和异常情况
|
||
- [ ] **错误处理**: 是否有适当的错误处理和用户提示
|
||
- [ ] **性能影响**: 是否会对系统性能产生负面影响
|
||
- [ ] **安全性**: 是否存在安全漏洞或风险
|
||
|
||
#### 代码质量审查
|
||
|
||
- [ ] **代码规范**: 是否遵循项目的代码规范和最佳实践
|
||
- [ ] **命名规范**: 变量、函数、类名是否清晰且有意义
|
||
- [ ] **代码复用**: 是否有重复的代码可以提取复用
|
||
- [ ] **注释质量**: 复杂逻辑是否有适当的注释说明
|
||
- [ ] **类型安全**: TypeScript类型定义是否完整和准确
|
||
|
||
#### 架构设计审查
|
||
|
||
- [ ] **设计模式**: 是否使用了合适的设计模式
|
||
- [ ] **组件设计**: 组件职责是否单一,是否可复用
|
||
- [ ] **API设计**: API接口设计是否合理和一致
|
||
- [ ] **状态管理**: 状态管理是否合理,是否有不必要的状态
|
||
- [ ] **依赖关系**: 模块间依赖是否合理,是否有循环依赖
|
||
|
||
#### 测试覆盖审查
|
||
|
||
- [ ] **单元测试**: 是否有足够的单元测试覆盖
|
||
- [ ] **集成测试**: 关键功能是否有集成测试
|
||
- [ ] **测试质量**: 测试用例是否覆盖了主要场景和边界情况
|
||
- [ ] **测试维护**: 测试代码是否易于维护和理解
|
||
|
||
### 审查流程
|
||
|
||
1. **自检阶段**
|
||
- 提交前自我审查代码
|
||
- 运行所有测试确保通过
|
||
- 检查代码规范和类型检查
|
||
|
||
2. **同伴审查**
|
||
- 指定至少一名同伴进行代码审查
|
||
- 审查者需要理解业务需求和技术方案
|
||
- 提供建设性的反馈和建议
|
||
|
||
3. **技术负责人审查**
|
||
- 涉及架构变更需要技术负责人审查
|
||
- 检查技术方案的合理性和可维护性
|
||
- 评估对系统整体的影响
|
||
|
||
4. **合并前检查**
|
||
- 确保所有CI检查通过
|
||
- 解决所有审查意见
|
||
- 更新相关文档
|
||
|
||
---
|
||
|
||
## ⚡ 性能优化规范
|
||
|
||
### 前端性能优化
|
||
|
||
#### 1. 构建优化
|
||
|
||
```typescript
|
||
// vite.config.ts
|
||
export default defineConfig({
|
||
build: {
|
||
rollupOptions: {
|
||
output: {
|
||
// 代码分割
|
||
manualChunks: {
|
||
vendor: ['vue', 'vue-router', 'pinia'],
|
||
antd: ['ant-design-vue'],
|
||
utils: ['lodash-es', 'dayjs'],
|
||
},
|
||
},
|
||
},
|
||
// 启用压缩
|
||
minify: 'terser',
|
||
terserOptions: {
|
||
compress: {
|
||
drop_console: true,
|
||
drop_debugger: true,
|
||
pure_funcs: ['console.log'],
|
||
},
|
||
},
|
||
},
|
||
|
||
// 开发服务器优化
|
||
server: {
|
||
hmr: {
|
||
overlay: false,
|
||
},
|
||
},
|
||
|
||
// 预构建优化
|
||
optimizeDeps: {
|
||
include: ['vue', 'vue-router', 'pinia', 'ant-design-vue', '@vueuse/core'],
|
||
},
|
||
});
|
||
```
|
||
|
||
#### 2. 组件优化
|
||
|
||
```vue
|
||
<template>
|
||
<div class="user-list">
|
||
<!-- 虚拟滚动 -->
|
||
<VirtualList
|
||
:items="userList"
|
||
:item-height="60"
|
||
:visible-count="20"
|
||
:buffer-size="5"
|
||
@scroll-end="loadMore"
|
||
>
|
||
<template #default="{ item, index }">
|
||
<UserCard
|
||
:key="item.id"
|
||
:user="item"
|
||
:index="index"
|
||
@click="handleUserClick"
|
||
/>
|
||
</template>
|
||
</VirtualList>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
// 懒加载组件
|
||
const UserCard = defineAsyncComponent(() => import('./UserCard.vue'));
|
||
|
||
// 响应式性能优化
|
||
const userList = shallowRef<UserInfo[]>([]);
|
||
const selectedUsers = shallowRef<Set<string>>(new Set());
|
||
|
||
// 计算属性缓存
|
||
const filteredUsers = computed(() => {
|
||
if (!searchKeyword.value) return userList.value;
|
||
|
||
return userList.value.filter((user) =>
|
||
user.username.toLowerCase().includes(searchKeyword.value.toLowerCase()),
|
||
);
|
||
});
|
||
|
||
// 防抖搜索
|
||
const searchKeyword = ref('');
|
||
const debouncedSearch = useDebounceFn((keyword: string) => {
|
||
// 执行搜索逻辑
|
||
searchUsers(keyword);
|
||
}, 300);
|
||
|
||
watch(searchKeyword, debouncedSearch);
|
||
|
||
// 性能监控
|
||
const startTime = performance.now();
|
||
onMounted(() => {
|
||
const endTime = performance.now();
|
||
console.log(`Component mounted in ${endTime - startTime}ms`);
|
||
});
|
||
</script>
|
||
```
|
||
|
||
#### 3. 图片优化
|
||
|
||
```typescript
|
||
// utils/image.ts
|
||
/**
|
||
* 图片懒加载指令
|
||
*/
|
||
export const vLazyLoad = {
|
||
mounted(el: HTMLImageElement, binding: DirectiveBinding) {
|
||
const observer = new IntersectionObserver((entries) => {
|
||
entries.forEach((entry) => {
|
||
if (entry.isIntersecting) {
|
||
const img = entry.target as HTMLImageElement;
|
||
img.src = binding.value;
|
||
img.onload = () => {
|
||
img.classList.add('loaded');
|
||
};
|
||
observer.unobserve(img);
|
||
}
|
||
});
|
||
});
|
||
|
||
observer.observe(el);
|
||
},
|
||
};
|
||
|
||
/**
|
||
* 图片压缩
|
||
*/
|
||
export function compressImage(
|
||
file: File,
|
||
quality: number = 0.8,
|
||
maxWidth: number = 1920,
|
||
): Promise<Blob> {
|
||
return new Promise((resolve) => {
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d')!;
|
||
const img = new Image();
|
||
|
||
img.onload = () => {
|
||
const { width, height } = img;
|
||
const ratio = Math.min(maxWidth / width, maxWidth / height);
|
||
|
||
canvas.width = width * ratio;
|
||
canvas.height = height * ratio;
|
||
|
||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||
|
||
canvas.toBlob(resolve as BlobCallback, 'image/jpeg', quality);
|
||
};
|
||
|
||
img.src = URL.createObjectURL(file);
|
||
});
|
||
}
|
||
```
|
||
|
||
#### 4. 缓存策略
|
||
|
||
```typescript
|
||
// utils/cache.ts
|
||
class CacheManager {
|
||
private cache = new Map<string, { data: any; expiry: number }>();
|
||
|
||
set(key: string, data: any, ttl: number = 5 * 60 * 1000) {
|
||
const expiry = Date.now() + ttl;
|
||
this.cache.set(key, { data, expiry });
|
||
}
|
||
|
||
get<T = any>(key: string): T | null {
|
||
const item = this.cache.get(key);
|
||
|
||
if (!item) return null;
|
||
|
||
if (Date.now() > item.expiry) {
|
||
this.cache.delete(key);
|
||
return null;
|
||
}
|
||
|
||
return item.data;
|
||
}
|
||
|
||
clear() {
|
||
this.cache.clear();
|
||
}
|
||
|
||
// 清理过期缓存
|
||
cleanup() {
|
||
const now = Date.now();
|
||
for (const [key, value] of this.cache.entries()) {
|
||
if (now > value.expiry) {
|
||
this.cache.delete(key);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const cacheManager = new CacheManager();
|
||
|
||
// 定期清理过期缓存
|
||
setInterval(
|
||
() => {
|
||
cacheManager.cleanup();
|
||
},
|
||
5 * 60 * 1000,
|
||
); // 每5分钟清理一次
|
||
```
|
||
|
||
### 性能监控
|
||
|
||
```typescript
|
||
// utils/performance.ts
|
||
class PerformanceMonitor {
|
||
private metrics: Map<string, number[]> = new Map();
|
||
|
||
// 开始性能监控
|
||
start(name: string): () => void {
|
||
const startTime = performance.now();
|
||
|
||
return () => {
|
||
const endTime = performance.now();
|
||
const duration = endTime - startTime;
|
||
|
||
this.record(name, duration);
|
||
};
|
||
}
|
||
|
||
// 记录性能数据
|
||
record(name: string, value: number) {
|
||
if (!this.metrics.has(name)) {
|
||
this.metrics.set(name, []);
|
||
}
|
||
|
||
const values = this.metrics.get(name)!;
|
||
values.push(value);
|
||
|
||
// 只保留最近100条记录
|
||
if (values.length > 100) {
|
||
values.shift();
|
||
}
|
||
}
|
||
|
||
// 获取性能统计
|
||
getStats(name: string) {
|
||
const values = this.metrics.get(name);
|
||
if (!values || values.length === 0) return null;
|
||
|
||
const sorted = [...values].sort((a, b) => a - b);
|
||
const len = sorted.length;
|
||
|
||
return {
|
||
count: len,
|
||
min: sorted[0],
|
||
max: sorted[len - 1],
|
||
avg: values.reduce((a, b) => a + b, 0) / len,
|
||
p50: sorted[Math.floor(len * 0.5)],
|
||
p90: sorted[Math.floor(len * 0.9)],
|
||
p95: sorted[Math.floor(len * 0.95)],
|
||
};
|
||
}
|
||
|
||
// 获取所有统计数据
|
||
getAllStats() {
|
||
const stats: Record<string, any> = {};
|
||
|
||
for (const name of this.metrics.keys()) {
|
||
stats[name] = this.getStats(name);
|
||
}
|
||
|
||
return stats;
|
||
}
|
||
|
||
// 发送性能数据到服务器
|
||
async report() {
|
||
const stats = this.getAllStats();
|
||
|
||
try {
|
||
await fetch('/api/performance/report', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
timestamp: Date.now(),
|
||
url: window.location.href,
|
||
userAgent: navigator.userAgent,
|
||
stats,
|
||
}),
|
||
});
|
||
} catch (error) {
|
||
console.warn('Failed to report performance metrics:', error);
|
||
}
|
||
}
|
||
}
|
||
|
||
export const performanceMonitor = new PerformanceMonitor();
|
||
|
||
// 定期上报性能数据
|
||
setInterval(() => {
|
||
performanceMonitor.report();
|
||
}, 60 * 1000); // 每分钟上报一次
|
||
```
|
||
|
||
---
|
||
|
||
## 📚 总结
|
||
|
||
本开发规范文档涵盖了Telegram管理系统开发的各个方面,包括:
|
||
|
||
### 🎯 核心规范
|
||
|
||
- **开发环境**: 统一的开发工具和配置
|
||
- **代码规范**: TypeScript、ESLint、Prettier配置
|
||
- **项目结构**: 清晰的目录组织和文件命名
|
||
|
||
### 🛠️ 技术规范
|
||
|
||
- **组件开发**: Vue 3组合式API最佳实践
|
||
- **API开发**: 统一的接口调用和错误处理
|
||
- **状态管理**: Pinia状态管理规范
|
||
- **路由管理**: 动态路由和权限控制
|
||
|
||
### 🎨 质量保证
|
||
|
||
- **样式规范**: TailwindCSS + SCSS混合方案
|
||
- **测试规范**: 单元测试、组件测试、E2E测试
|
||
- **Git工作流**: 分支策略和提交规范
|
||
- **代码审查**: 完整的审查流程和清单
|
||
|
||
### ⚡ 性能优化
|
||
|
||
- **构建优化**: Vite配置和代码分割
|
||
- **运行时优化**: 懒加载、虚拟滚动、缓存策略
|
||
- **性能监控**: 性能指标收集和上报
|
||
|
||
遵循这些规范将确保代码质量、提高开发效率、降低维护成本。所有团队成员都应该熟悉并严格执行这些规范。
|
||
|
||
---
|
||
|
||
**维护说明**: 本规范文档会随着项目发展不断更新和完善,请定期查看最新版本。如有疑问或建议,请联系技术团队。
|