This commit is contained in:
Joe
2026-01-16 15:51:24 +08:00
commit d4db462756
155 changed files with 39915 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
VITE_API_BASE_URL=http://127.0.0.1:3000
VITE_API_PREFIX=/api/admin
+17
View File
@@ -0,0 +1,17 @@
# 环境变量文件
.env
.env.local
.env.*.local
# 依赖
node_modules
# 构建输出
dist
*.local
# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+4
View File
@@ -0,0 +1,4 @@
# npm 默认会安装可选依赖,无需额外配置
# 对于 CI/CD 环境(如 Cloudflare Workers),请在构建命令中使用:
# npm install --include=optional @rollup/rollup-linux-x64-gnu
+566
View File
@@ -0,0 +1,566 @@
# 前端开发指南
本文档提供 Goravel Admin 前端项目的开发指南。
## 目录
- [技术栈](#技术栈)
- [项目结构](#项目结构)
- [开发环境](#开发环境)
- [核心概念](#核心概念)
- [组件开发](#组件开发)
- [Composables 使用](#composables-使用)
- [状态管理](#状态管理)
- [国际化](#国际化)
- [测试](#测试)
---
## 技术栈
| 技术 | 版本 | 说明 |
|------|------|------|
| Vue | 3.4+ | 渐进式 JavaScript 框架 |
| Element Plus | 2.4+ | Vue 3 UI 组件库 |
| VXE-Table | 4.7+ | 高性能表格组件 |
| Pinia | 2.1+ | Vue 状态管理 |
| Vue Router | 4.2+ | 路由管理 |
| Axios | 1.6+ | HTTP 客户端 |
| vue-i18n | 9.8+ | 国际化 |
| TypeScript | 5.3+ | 类型支持(可选) |
| Vitest | 1.6+ | 单元测试 |
---
## 项目结构
```
html/
├── public/ # 静态资源
├── src/
│ ├── api/ # API 请求模块
│ │ ├── admin.js # 管理员 API
│ │ ├── role.js # 角色 API
│ │ ├── menu.js # 菜单 API
│ │ └── ...
│ ├── components/ # 通用组件
│ │ ├── SearchForm.vue # 搜索表单
│ │ ├── Pagination.vue # 分页组件
│ │ ├── ErrorBoundary.vue
│ │ └── ColumnSettingDialog.vue
│ ├── composables/ # 可复用逻辑
│ │ ├── useCrud.ts # CRUD 操作 (TypeScript)
│ │ ├── useDebounce.ts # 防抖 (TypeScript)
│ │ ├── useTableSort.ts # 表格排序 (TypeScript)
│ │ ├── usePermission.ts# 权限检查 (TypeScript)
│ │ ├── useListPage.js # 列表页面
│ │ ├── useColumnSetting.js
│ │ └── index.ts # 导出入口
│ ├── i18n/ # 国际化
│ │ ├── index.js # 配置
│ │ └── locales/
│ │ ├── zh-CN.json # 中文
│ │ └── en-US.json # 英文
│ ├── layouts/ # 布局组件
│ │ └── MainLayout.vue
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── store/ # 状态管理
│ │ ├── user.js # 用户状态
│ │ ├── app.js # 应用状态
│ │ └── tabs.js # 标签页状态
│ ├── types/ # TypeScript 类型
│ │ ├── index.d.ts # 实体类型
│ │ └── composables.d.ts
│ ├── utils/ # 工具函数
│ │ ├── request.js # Axios 封装
│ │ ├── storage.js # 存储工具
│ │ ├── validation.js # 验证器
│ │ └── logger.js # 日志工具
│ ├── views/ # 页面组件
│ │ ├── admin/ # 管理员模块
│ │ ├── role/ # 角色模块
│ │ ├── menu/ # 菜单模块
│ │ └── ...
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ └── style.css # 全局样式
├── package.json
├── vite.config.js # Vite 配置
├── vitest.config.js # 测试配置
└── tsconfig.json # TypeScript 配置
```
---
## 开发环境
### 安装依赖
```bash
npm install
```
### 开发服务器
```bash
npm run dev
```
### 生产构建
```bash
npm run build
```
### 类型检查
```bash
npm run type-check
```
### 运行测试
```bash
# 交互式模式
npm test
# 单次运行
npm run test:run
# 覆盖率报告
npm run test:coverage
```
### 环境配置
创建 `.env` 文件:
```env
VITE_API_BASE_URL=http://localhost:3000
VITE_API_PREFIX=/api/admin
```
---
## 核心概念
### 1. 列表页面模式
所有列表页面遵循统一模式:
```vue
<template>
<el-card>
<!-- 搜索表单 -->
<SearchForm :fields="searchFields" v-model="searchForm" @search="handleSearch" />
<!-- 工具栏 -->
<div class="table-toolbar">
<el-button @click="handleAdd">添加</el-button>
</div>
<!-- 表格 -->
<vxe-table :data="tableData" :loading="loading">
<vxe-column field="id" title="ID" />
<!-- ... -->
</vxe-table>
<!-- 分页 -->
<Pagination :model-value="pagination" @page-change="handlePageChange" />
<!-- 表单弹窗 -->
<XxxForm v-model="dialogVisible" :edit-id="editId" @success="handleFormSuccess" />
</el-card>
</template>
<script setup>
import { useListPage } from '@/composables/useListPage'
import { useCrud } from '@/composables/useCrud'
// 列表逻辑
const { tableData, loading, pagination, searchForm, loadData, handleSearch, handlePageChange } = useListPage({
fetchApi: getList,
// ...
})
// CRUD 逻辑
const { dialogVisible, editId, handleAdd, handleEdit, handleDelete, handleFormSuccess } = useCrud({
deleteApi: deleteItem,
})
</script>
```
### 2. 表单弹窗模式
```vue
<template>
<el-dialog v-model="visible" :title="isEdit ? '编辑' : '添加'">
<el-form ref="formRef" :model="form" :rules="rules">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
const props = defineProps({
editId: [Number, String]
})
const emit = defineEmits(['update:modelValue', 'success'])
const isEdit = computed(() => !!props.editId)
const handleSubmit = async () => {
await formRef.value.validate()
if (isEdit.value) {
await updateApi(props.editId, form.value)
} else {
await createApi(form.value)
}
emit('success')
}
</script>
```
---
## Composables 使用
### useCrud (TypeScript)
CRUD 操作的统一封装:
```typescript
import { useCrud } from '@/composables/useCrud'
// 完整用法
const {
dialogVisible, // 对话框显示状态
editId, // 编辑ID
handleAdd, // 打开添加弹窗
handleEdit, // 打开编辑弹窗
handleClose, // 关闭弹窗
handleFormSuccess, // 表单提交成功
handleDelete, // 删除单条
handleBatchDelete // 批量删除
} = useCrud({
deleteApi: deleteItem,
batchDeleteApi: batchDeleteItems,
deleteConfirmKey: 'common.delete_confirm',
deleteSuccessKey: 'common.delete_success',
onDeleteSuccess: () => loadData()
})
// 按需使用
// 只需要添加/编辑
const { dialogVisible, editId, handleAdd, handleEdit } = useCrud()
// 只需要删除
const { handleDelete } = useCrud({ deleteApi })
```
### useListPage
列表页面逻辑封装:
```javascript
import { useListPage } from '@/composables/useListPage'
const {
tableData, // 表格数据
loading, // 加载状态
pagination, // 分页信息
searchForm, // 搜索表单
loadData, // 加载数据
handleSearch, // 搜索
handleReset, // 重置
handlePageChange // 分页变化
} = useListPage({
fetchApi: getList,
searchFields: ['name', 'status'],
defaultSearchValues: { status: 1 },
immediate: true
})
```
### useTableSort (TypeScript)
表格排序逻辑:
```typescript
import { useTableSort } from '@/composables/useTableSort'
const {
sortConfig, // 排序配置
buildOrderBy, // 构建排序参数
handleSortChange, // 处理排序变化
initDefaultSort // 初始化默认排序
} = useTableSort({
tableRef,
defaultSort: 'id:desc',
fieldMapping: { created_at: 'created_at' },
onSortChange: () => loadData()
})
```
### usePermission (TypeScript)
权限检查:
```typescript
import { usePermission } from '@/composables/usePermission'
const { hasPermission, shouldShowButton, getButtonState } = usePermission()
// 检查权限
if (hasPermission('admin.store')) {
// 有权限
}
// 按钮状态
const { show, disabled } = getButtonState('admin.update')
```
---
## 状态管理
### userStore
```javascript
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 用户信息
userStore.userInfo
// 权限列表
userStore.permissions
// 菜单列表
userStore.menus
// 检查权限
userStore.hasPermission('admin.store')
// 登录
await userStore.login(username, password)
// 登出
await userStore.logout()
```
### appStore
```javascript
import { useAppStore } from '@/store/app'
const appStore = useAppStore()
// 侧边栏状态
appStore.sidebarCollapsed
appStore.toggleSidebar()
// 全屏
appStore.isFullscreen
appStore.toggleFullscreen()
// 语言
appStore.language
appStore.setLanguage('en-US')
```
---
## 国际化
### 添加翻译
```json
// i18n/locales/zh-CN.json
{
"module": {
"title": "模块标题",
"add": "添加",
"edit": "编辑"
}
}
```
### 使用翻译
```vue
<template>
<!-- 模板中使用 -->
<span>{{ $t('module.title') }}</span>
<!-- 带参数 -->
<span>{{ $t('common.total', { count: 10 }) }}</span>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// 脚本中使用
const title = t('module.title')
</script>
```
---
## 测试
### 目录结构
```
src/
├── composables/
│ ├── useCrud.ts
│ └── __tests__/
│ └── useCrud.test.js
├── utils/
│ ├── validation.js
│ └── __tests__/
│ └── validation.test.js
```
### 编写测试
```javascript
// src/utils/__tests__/validation.test.js
import { describe, it, expect } from 'vitest'
import { validators } from '../validation'
describe('validators', () => {
describe('required', () => {
it('应该拒绝空字符串', () => {
expect(validators.required('')).not.toBe(true)
})
it('应该接受有效字符串', () => {
expect(validators.required('hello')).toBe(true)
})
})
})
```
### Mock 示例
```javascript
import { vi } from 'vitest'
// Mock API
vi.mock('@/api/admin', () => ({
getAdminList: vi.fn().mockResolvedValue({ data: { list: [] } })
}))
// Mock Element Plus
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
error: vi.fn()
},
ElMessageBox: {
confirm: vi.fn().mockResolvedValue(true)
}
}))
```
---
## 最佳实践
### 1. 组件命名
```
- PascalCase: UserList.vue, UserForm.vue
- 目录按模块组织: views/user/, views/role/
```
### 2. 类型安全
```typescript
// 使用 TypeScript composables
import { useCrud } from '@/composables/useCrud'
// 或使用 JSDoc 类型
/** @type {import('@/types').Admin} */
const admin = {}
```
### 3. 性能优化
```vue
<script setup>
// 使用 shallowRef 减少响应式开销
import { shallowRef } from 'vue'
const tableData = shallowRef([])
// 使用 v-memo 缓存列表项
<template v-for="item in list" :key="item.id" v-memo="[item.id, item.status]">
```
### 4. 错误处理
```javascript
// 使用 ErrorBoundary 组件
<ErrorBoundary>
<YourComponent />
</ErrorBoundary>
// API 错误统一处理(已在 request.js 中配置)
```
---
## 常见问题
### Q: 如何添加新页面?
1.`views/` 下创建页面组件
2.`api/` 下添加 API 请求
3.`router/index.js` 添加路由
4. 在后台菜单管理中添加菜单
### Q: 如何添加新的 Composable
1.`composables/` 下创建文件
2. 使用 TypeScript 编写(推荐)
3.`composables/index.ts` 中导出
4. 添加对应的测试文件
### Q: 如何自定义主题?
修改 `src/style.css` 中的 CSS 变量:
```css
:root {
--el-color-primary: #409eff;
--el-color-success: #67c23a;
}
```
---
## 参考资源
- [Vue 3 文档](https://vuejs.org)
- [Element Plus 文档](https://element-plus.org)
- [VXE-Table 文档](https://vxetable.cn)
- [Pinia 文档](https://pinia.vuejs.org)
- [vue-i18n 文档](https://vue-i18n.intlify.dev)
- [Vitest 文档](https://vitest.dev)
+79
View File
@@ -0,0 +1,79 @@
# 后台管理系统前端
基于 Vue 3 + Element Plus + vxe-table 的后台管理系统。
## 技术栈
- Vue 3
- Element Plus
- vxe-table
- Vue Router
- Pinia
- Axios
- ECharts
- vue-i18n
## 环境配置
项目使用 `.env` 文件管理环境变量。
1. 复制环境变量示例文件:
```bash
cp .env.example .env
```
2. 编辑 `.env` 文件,配置 API 地址:
```env
# API 基础地址
VITE_API_BASE_URL=http://127.0.0.1:3000
# API 前缀
VITE_API_PREFIX=/api/admin
# WebSocket 基础地址(可选,如果配置了单独的 WebSocket 域名)
# 如果不配置,将使用 VITE_API_BASE_URL
# VITE_WS_BASE_URL=wss://wss.xuancheng888.top
# 或者
# VITE_WS_BASE_URL=https://wss.xuancheng888.top
```
## 安装依赖
```bash
npm install
```
## 开发
```bash
npm run dev
```
开发服务器将在 `http://localhost:3007` 启动。
## 构建
```bash
npm run build
```
构建后的文件会输出到 `dist` 目录。
## 功能模块
- 登录认证
- 仪表盘
- 管理员管理
- 角色管理
- 权限管理
- 菜单管理
- 部门管理
- 字典管理
- 操作日志
- 登录日志
- 系统日志
## 多语言支持
项目支持中文和英文两种语言,可以通过页面右上角的语言切换按钮进行切换。
Vendored
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_API_PREFIX: string
readonly VITE_APP_TITLE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理系统</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.svg">
<link rel="apple-touch-icon" href="/favicon.svg">
<!-- Theme Color -->
<meta name="theme-color" content="#409EFF">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+2901
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"name": "goravel-admin-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:ci": "npm install --include=optional @rollup/rollup-linux-x64-gnu && vite build",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@types/lodash-es": "^4.17.12",
"axios": "^1.6.2",
"echarts": "^5.4.3",
"element-plus": "^2.4.4",
"lodash-es": "^4.17.22",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-echarts": "^6.6.9",
"vue-i18n": "^9.8.0",
"vue-router": "^4.2.5",
"vxe-pc-ui": "^4.7.0",
"vxe-table": "^4.7.0"
},
"devDependencies": {
"@types/node": "^20.10.0",
"@vitejs/plugin-vue": "^5.0.0",
"@vitest/coverage-v8": "^1.6.1",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^12.10.3",
"sass": "^1.69.5",
"typescript": "^5.3.0",
"vite": "^5.0.8",
"vitest": "^1.6.1",
"vue-tsc": "^2.0.0"
}
}
+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#409EFF;stop-opacity:1" />
<stop offset="100%" style="stop-color:#66B1FF;stop-opacity:1" />
</linearGradient>
</defs>
<!-- 背景 -->
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
<!-- 管理图标:盾牌和齿轮组合 -->
<g transform="translate(50, 50)">
<!-- 外圈齿轮 -->
<circle cx="0" cy="0" r="25" fill="white" opacity="0.2"/>
<!-- 盾牌 -->
<path d="M 0,-20 L -12,-12 L -12,8 L 0,18 L 12,8 L 12,-12 Z" fill="white" opacity="0.95"/>
<!-- 内部齿轮 -->
<circle cx="0" cy="0" r="6" fill="white" opacity="0.95"/>
<circle cx="0" cy="0" r="3" fill="url(#grad)"/>
<!-- 齿轮齿(简化版) -->
<rect x="-1.5" y="-18" width="3" height="4" fill="white" opacity="0.95" rx="1"/>
<rect x="-1.5" y="14" width="3" height="4" fill="white" opacity="0.95" rx="1"/>
<rect x="-18" y="-1.5" width="4" height="3" fill="white" opacity="0.95" rx="1"/>
<rect x="14" y="-1.5" width="4" height="3" fill="white" opacity="0.95" rx="1"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+30
View File
@@ -0,0 +1,30 @@
<template>
<ErrorBoundary>
<el-config-provider :locale="elementLocale">
<router-view />
</el-config-provider>
</ErrorBoundary>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from './store/app'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import en from 'element-plus/dist/locale/en.mjs'
import ErrorBoundary from './components/ErrorBoundary.vue'
const { locale } = useI18n()
const appStore = useAppStore()
// 根据当前语言动态返回 Element Plus 的语言配置
const elementLocale = computed(() => {
return locale.value === 'zh-CN' ? zhCn : en
})
// 初始化夜间模式
onMounted(() => {
// appStore.initDarkMode()
})
</script>
+57
View File
@@ -0,0 +1,57 @@
import request from '../utils/request'
import { createCRUDApi, extendApi } from '../utils/apiFactory'
// 创建基础 CRUD API
const baseAdminApi = createCRUDApi('admins')
// 扩展 API,添加自定义方法
const adminApi = extendApi(baseAdminApi, {
// 导出管理员
export: (params) => {
return request({
url: '/admins/export',
method: 'post',
data: params
})
},
// 重置密码
resetPassword: (id, data) => {
return request({
url: `/admins/${id}/password`,
method: 'put',
data
})
},
// 踢出用户(删除该用户的所有token)
kickOutUser: (id) => {
return request({
url: `/admins/${id}/tokens`,
method: 'delete'
})
},
// 解绑管理员的谷歌验证码
unbindGoogleAuth: (id, data) => {
return request({
url: `/admins/${id}/unbind-google-auth`,
method: 'post',
data
})
}
})
// 导出所有方法(保持向后兼容)
export const {
list: getAdminList,
detail: getAdminDetail,
create: createAdmin,
update: updateAdmin,
delete: deleteAdmin,
export: exportAdmin,
resetPassword,
kickOutUser,
unbindGoogleAuth: unbindAdminGoogleAuth
} = adminApi
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
export function getArticleList(params) {
return request({
url: '/articles',
method: 'get',
params
})
}
export function getArticleDetail(id) {
return request({
url: `/articles/${id}`,
method: 'get'
})
}
export function createArticle(data) {
return request({
url: '/articles',
method: 'post',
data
})
}
export function updateArticle(id, data) {
return request({
url: `/articles/${id}`,
method: 'put',
data
})
}
export function deleteArticle(id) {
return request({
url: `/articles/${id}`,
method: 'delete'
})
}
+169
View File
@@ -0,0 +1,169 @@
import request from '../utils/request'
import Storage from '../utils/storage'
// 获取附件列表
export function getAttachmentList(params) {
return request({
url: '/attachments',
method: 'get',
params
})
}
// 普通文件上传(小文件)
export function uploadFile(file, onProgress) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/attachments/upload',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted)
}
}
})
}
// 大文件分片上传统一接口
// action: init(初始化)、upload(上传分片)、merge(合并分片)、progress(获取进度)
export function chunkUpload(action, data = {}, onProgress) {
const isGet = action === 'progress'
const config = {
url: '/attachments/chunk',
method: isGet ? 'get' : 'post',
timeout: 60000,
...(isGet ? { params: { action, ...data } } : { data: { action, ...data } })
}
// 如果是上传分片,需要特殊处理 FormData
if (action === 'upload') {
const formData = new FormData()
formData.append('action', 'upload')
formData.append('chunk_id', data.chunk_id)
formData.append('chunk_index', data.chunk_index)
formData.append('chunk', data.chunk)
config.data = formData
config.headers = {
'Content-Type': 'multipart/form-data'
}
if (onProgress) {
config.onUploadProgress = (progressEvent) => {
if (progressEvent.total) {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted)
}
}
}
}
return request(config)
}
// 初始化分片上传
export function initChunkUpload(filename, totalSize, chunkSize, totalChunks) {
return chunkUpload('init', {
filename,
total_size: totalSize,
chunk_size: chunkSize,
total_chunks: totalChunks
})
}
// 上传分片
export function uploadChunk(chunkID, chunkIndex, chunk, onProgress) {
return chunkUpload('upload', {
chunk_id: chunkID,
chunk_index: chunkIndex,
chunk
}, onProgress)
}
// 合并分片
export function mergeChunks(chunkID, filename, mimeType, totalChunks) {
// 如果 totalChunks 未提供,尝试从 localStorage 获取(断点续传场景)
if (!totalChunks) {
try {
const chunkInfo = Storage.getItem(`chunk_${chunkID}`, null)
if (chunkInfo && typeof chunkInfo === 'object') {
totalChunks = chunkInfo.total_chunks
}
} catch (e) {
console.warn('Failed to get totalChunks from storage:', e)
}
}
if (!totalChunks || totalChunks <= 0) {
return Promise.reject(new Error('Total chunks is required'))
}
return chunkUpload('merge', {
chunk_id: chunkID,
filename,
mime_type: mimeType,
total_chunks: totalChunks
})
}
// 获取分片上传进度
export function getChunkProgress(chunkID, totalChunks) {
// 如果 chunkID 为空,直接返回,不调用后端接口
if (!chunkID) {
return Promise.reject(new Error('Chunk ID is empty'))
}
// 如果 totalChunks 未提供,尝试从 localStorage 获取(断点续传场景)
if (!totalChunks) {
try {
const chunkInfo = Storage.getItem(`chunk_${chunkID}`, null)
if (chunkInfo && typeof chunkInfo === 'object') {
totalChunks = chunkInfo.total_chunks
}
} catch (e) {
console.warn('Failed to get totalChunks from storage:', e)
}
}
if (!totalChunks || totalChunks <= 0) {
return Promise.reject(new Error('Total chunks is required'))
}
return chunkUpload('progress', { chunk_id: chunkID, total_chunks: totalChunks })
}
// 删除附件
export function deleteAttachment(id) {
return request({
url: `/attachments/${id}`,
method: 'delete'
})
}
// 批量删除附件
export function batchDeleteAttachments(ids) {
return request({
url: '/attachments/batch-delete',
method: 'post',
data: { ids }
})
}
// 更新显示名称
export function updateDisplayName(id, displayName) {
return request({
url: `/attachments/${id}/display-name`,
method: 'put',
data: { display_name: displayName }
})
}
// 创建上传进度 SSE URL
export function createUploadProgressSSE(chunkID, totalChunks, options = {}) {
const { interval = 500 } = options
const url = `/attachments/upload/progress?chunk_id=${chunkID}&total_chunks=${totalChunks}&interval=${interval}`
return url
}
+93
View File
@@ -0,0 +1,93 @@
import request from '../utils/request'
// 登录
export function login(data) {
return request({
url: '/login',
method: 'post',
data
})
}
// 获取当前用户信息
export function getInfo() {
return request({
url: '/info',
method: 'get'
})
}
// 退出登录
export function logout() {
return request({
url: '/logout',
method: 'post'
})
}
// 获取 token 列表
export function getTokens() {
return request({
url: '/tokens',
method: 'get'
})
}
// 删除指定 token
export function revokeToken(id) {
return request({
url: `/tokens/${id}`,
method: 'delete'
})
}
// 删除所有 token
export function revokeAllTokens() {
return request({
url: '/tokens',
method: 'delete'
})
}
// 获取登录验证码
export function getLoginCaptcha() {
return request({
url: '/login/captcha',
method: 'get'
})
}
// 获取谷歌验证码绑定状态
export function getGoogleAuthenticatorStatus() {
return request({
url: '/google-authenticator/status',
method: 'get'
})
}
// 获取谷歌验证码二维码
export function getGoogleAuthenticatorQRCode() {
return request({
url: '/google-authenticator/qrcode',
method: 'get'
})
}
// 绑定谷歌验证码
export function bindGoogleAuthenticator(data) {
return request({
url: '/google-authenticator/bind',
method: 'post',
data
})
}
// 解绑谷歌验证码
export function unbindGoogleAuthenticator(data) {
return request({
url: '/google-authenticator/unbind',
method: 'post',
data
})
}
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
// 获取黑名单列表
export function getBlacklistList(params) {
return request({
url: '/blacklists',
method: 'get',
params
})
}
// 获取黑名单详情
export function getBlacklistDetail(id) {
return request({
url: `/blacklists/${id}`,
method: 'get'
})
}
// 创建黑名单
export function createBlacklist(data) {
return request({
url: '/blacklists',
method: 'post',
data
})
}
// 更新黑名单
export function updateBlacklist(id, data) {
return request({
url: `/blacklists/${id}`,
method: 'put',
data
})
}
// 删除黑名单
export function deleteBlacklist(id) {
return request({
url: `/blacklists/${id}`,
method: 'delete'
})
}
+32
View File
@@ -0,0 +1,32 @@
import request from '../utils/request'
export function getFieldTypes() {
return request({
url: '/code-generator/field-types',
method: 'get'
})
}
export function previewCode(data) {
return request({
url: '/code-generator/preview',
method: 'post',
data
})
}
export function generateCode(data) {
return request({
url: '/code-generator/generate',
method: 'post',
data
})
}
export function saveCode(data) {
return request({
url: '/code-generator/save',
method: 'post',
data
})
}
+31
View File
@@ -0,0 +1,31 @@
import request from '../utils/request'
// 根据分组获取配置
export function getConfigByGroup(group) {
return request({
url: `/configs/group/${group}`,
method: 'get'
})
}
// 保存配置(按分组批量保存)
export function saveConfig(group, configs) {
return request({
url: '/configs/save',
method: 'post',
data: {
group,
configs
}
})
}
// 测试邮件发送
export function testEmail(emailConfig) {
return request({
url: '/configs/test-email',
method: 'post',
data: emailConfig
})
}
+49
View File
@@ -0,0 +1,49 @@
import request from '../utils/request'
// 获取统计数据
export function getCount() {
return request({
url: '/dashboard/count',
method: 'get'
})
}
// 获取用户访问来源
export function getUserAccessSource() {
return request({
url: '/dashboard/user-access-source',
method: 'get'
})
}
// 获取每周用户活动
export function getWeeklyUserActivity() {
return request({
url: '/dashboard/weekly-user-activity',
method: 'get'
})
}
// 获取每月销售数据(实际是操作统计)
export function getMonthlySales() {
return request({
url: '/dashboard/monthly-sales',
method: 'get'
})
}
// 获取最近活动
export function getRecentActivities() {
return request({
url: '/dashboard/recent-activities',
method: 'get'
})
}
// 创建 Dashboard 数据实时更新 SSE URL
export function createDashboardSSE(options = {}) {
const { interval = 5 } = options
const url = `/dashboard/stream?interval=${interval}`
return url
}
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
// 获取部门列表
export function getDepartmentList(params) {
return request({
url: '/departments',
method: 'get',
params
})
}
// 获取部门详情
export function getDepartmentDetail(id) {
return request({
url: `/departments/${id}`,
method: 'get'
})
}
// 创建部门
export function createDepartment(data) {
return request({
url: '/departments',
method: 'post',
data
})
}
// 更新部门
export function updateDepartment(id, data) {
return request({
url: `/departments/${id}`,
method: 'put',
data
})
}
// 删除部门
export function deleteDepartment(id) {
return request({
url: `/departments/${id}`,
method: 'delete'
})
}
+61
View File
@@ -0,0 +1,61 @@
import request from '../utils/request'
// 获取字典列表
export function getDictionaryList(params) {
return request({
url: '/dictionaries',
method: 'get',
params
})
}
// 获取字典详情
export function getDictionaryDetail(id) {
return request({
url: `/dictionaries/${id}`,
method: 'get'
})
}
// 根据类型获取字典
export function getDictionaryByType(type) {
return request({
url: `/dictionaries/type/${type}`,
method: 'get'
})
}
// 获取所有字典类型
export function getDictionaryTypes() {
return request({
url: '/dictionaries/types',
method: 'get'
})
}
// 创建字典
export function createDictionary(data) {
return request({
url: '/dictionaries',
method: 'post',
data
})
}
// 更新字典
export function updateDictionary(id, data) {
return request({
url: `/dictionaries/${id}`,
method: 'put',
data
})
}
// 删除字典
export function deleteDictionary(id) {
return request({
url: `/dictionaries/${id}`,
method: 'delete'
})
}
+37
View File
@@ -0,0 +1,37 @@
import request from '../utils/request'
// 获取导出记录列表
// 注意:request 已经配置了 baseURL 为 /api/admin,这里只写相对路径
export function getExportList(params) {
return request({
url: '/exports',
method: 'get',
params
})
}
// 删除导出记录(同时删除源文件)
export function deleteExport(id) {
return request({
url: `/exports/${id}`,
method: 'delete'
})
}
// 批量删除导出记录(同时删除源文件)
export function batchDeleteExports(ids) {
return request({
url: '/exports/batch-delete',
method: 'post',
data: { ids }
})
}
// 创建导出任务进度推送 SSE URL
export function createExportProgressSSE(exportID, options = {}) {
const { interval = 1000 } = options
const url = `/exports/${exportID}/progress?interval=${interval}`
return url
}
+126
View File
@@ -0,0 +1,126 @@
import request from '../utils/request'
// 操作日志
export function getOperationLogList(params) {
return request({
url: '/operation-logs',
method: 'get',
params
})
}
export function getOperationLogTitleOptions() {
return request({
url: '/operation-logs/title-options',
method: 'get'
})
}
export function getOperationLogDetail(id) {
return request({
url: `/operation-logs/${id}`,
method: 'get'
})
}
export function deleteOperationLog(id) {
return request({
url: `/operation-logs/${id}`,
method: 'delete'
})
}
export function batchDeleteOperationLogs(ids) {
return request({
url: '/operation-logs/batch-delete',
method: 'post',
data: { ids: ids }
})
}
export function cleanOperationLogs(params = {}) {
return request({
url: '/operation-logs/clean',
method: 'post',
data: params
})
}
// 登录日志
export function getLoginLogList(params) {
return request({
url: '/login-logs',
method: 'get',
params
})
}
export function getLoginLogDetail(id) {
return request({
url: `/login-logs/${id}`,
method: 'get'
})
}
export function deleteLoginLog(id) {
return request({
url: `/login-logs/${id}`,
method: 'delete'
})
}
export function batchDeleteLoginLogs(ids) {
return request({
url: '/login-logs/batch-delete',
method: 'post',
data: { ids: ids }
})
}
export function cleanLoginLogs(params = {}) {
return request({
url: '/login-logs/clean',
method: 'post',
data: params
})
}
// 系统日志
export function getSystemLogList(params) {
return request({
url: '/system-logs',
method: 'get',
params
})
}
export function getSystemLogDetail(id) {
return request({
url: `/system-logs/${id}`,
method: 'get'
})
}
export function deleteSystemLog(id) {
return request({
url: `/system-logs/${id}`,
method: 'delete'
})
}
export function batchDeleteSystemLogs(ids) {
return request({
url: '/system-logs/batch-delete',
method: 'post',
data: { ids: ids }
})
}
export function cleanSystemLogs(params = {}) {
return request({
url: '/system-logs/clean',
method: 'post',
data: params
})
}
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
// 获取菜单列表
export function getMenuList(params) {
return request({
url: '/menus',
method: 'get',
params
})
}
// 获取菜单详情
export function getMenuDetail(id) {
return request({
url: `/menus/${id}`,
method: 'get'
})
}
// 创建菜单
export function createMenu(data) {
return request({
url: '/menus',
method: 'post',
data
})
}
// 更新菜单
export function updateMenu(id, data) {
return request({
url: `/menus/${id}`,
method: 'put',
data
})
}
// 删除菜单
export function deleteMenu(id) {
return request({
url: `/menus/${id}`,
method: 'delete'
})
}
+16
View File
@@ -0,0 +1,16 @@
import request from '../utils/request'
export function getSystemInfo() {
return request({
url: '/monitor/system-info',
method: 'get'
})
}
// 创建系统监控实时数据流 SSE URL
export function createSystemInfoSSE(options = {}) {
const { interval = 2 } = options
const url = `/monitor/system-info/stream?interval=${interval}`
return url
}
+48
View File
@@ -0,0 +1,48 @@
import request from '../utils/request'
export function fetchNotifications(params = {}) {
return request({
url: '/notifications',
method: 'get',
params
})
}
export function fetchUnreadCount() {
return request({
url: '/notifications/unread-count',
method: 'get'
})
}
export function fetchRecentNotifications(params = {}) {
return request({
url: '/notifications/recent',
method: 'get',
params
})
}
export function markNotificationRead(id) {
return request({
url: `/notifications/${id}/read`,
method: 'post'
})
}
export function markAllNotificationsRead() {
return request({
url: '/notifications/read-all',
method: 'post'
})
}
export function createNotification(data) {
return request({
url: '/notifications',
method: 'post',
data
})
}
+30
View File
@@ -0,0 +1,30 @@
import request from '../utils/request'
// 获取在线管理员列表
export function getOnlineAdminList(params) {
return request({
url: '/online-admins',
method: 'get',
params
})
}
// 踢下线(删除token
export function kickOutOnlineAdmin(id) {
return request({
url: `/online-admins/${id}`,
method: 'delete'
})
}
// 批量踢下线
export function batchKickOutOnlineAdmins(tokenIds) {
return request({
url: '/online-admins/batch-kick-out',
method: 'post',
data: {
token_ids: tokenIds.join(',')
}
})
}
+16
View File
@@ -0,0 +1,16 @@
import request from '../utils/request'
/**
* 获取下拉选项数据(统一接口)
* @param {string} type - 选项类型:role, department, status, method, yes_no
* @param {Object} [params] - 其他查询参数
* @returns {Promise}
*/
export function getOptions(type, params = {}) {
return request({
url: '/options',
method: 'get',
params: { type, ...params }
})
}
+99
View File
@@ -0,0 +1,99 @@
import request from '../utils/request'
// 获取订单列表
export function getOrderList(params) {
return request({
url: '/orders',
method: 'get',
params
})
}
// 获取订单详情
// 支持通过订单ID或订单号查询
// 如果提供了order_no参数,优先使用订单号查询(更高效,可直接定位分表)
// 如果没有order_no,则使用id参数查询
export function getOrderDetail(id, params = {}) {
// 如果params中有order_no,优先使用订单号查询
if (params.order_no) {
return request({
url: `/orders/${id || 0}`, // id可以为0,因为会使用order_no查询
method: 'get',
params: {
order_no: params.order_no
}
})
}
// 否则使用订单ID查询
return request({
url: `/orders/${id}`,
method: 'get',
params
})
}
// 创建订单
export function createOrder(data) {
return request({
url: '/orders',
method: 'post',
data
})
}
// 更新订单
// 如果data中有order_no,优先使用订单号查询(更高效,可直接定位分表)
export function updateOrder(id, data) {
const params = {}
if (data.order_no) {
params.order_no = data.order_no
}
return request({
url: `/orders/${id || 0}`, // id可以为0,因为会使用order_no查询
method: 'put',
data,
params
})
}
// 删除订单
// 如果提供了order_no参数,优先使用订单号查询(更高效,可直接定位分表)
export function deleteOrder(id, params = {}) {
return request({
url: `/orders/${id || 0}`, // id可以为0,因为会使用order_no查询
method: 'delete',
params
})
}
// 导出订单(异步)
export function exportOrder(params) {
return request({
url: '/orders/export',
method: 'post',
data: params
})
}
// 查询导出状态
export function getExportStatus(exportId) {
return request({
url: `/orders/export/status/${exportId}`,
method: 'get'
})
}
// 导入订单
export function importOrder(file) {
const formData = new FormData()
formData.append('file', file)
return request({
url: '/orders/import',
method: 'post',
data: formData,
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
+35
View File
@@ -0,0 +1,35 @@
import request from '../utils/request'
// 获取支付记录列表
export function getPaymentList(params) {
return request({
url: '/payments',
method: 'get',
params
})
}
// 获取支付记录详情
export function getPaymentDetail(id) {
return request({
url: `/payments/${id}`,
method: 'get'
})
}
// 导出支付记录
export function exportPayments(params) {
return request({
url: '/payments/export',
method: 'post',
data: params
})
}
// 获取导出状态
export function getExportStatus(id) {
return request({
url: `/payments/export/status/${id}`,
method: 'get'
})
}
+15
View File
@@ -0,0 +1,15 @@
import request from '../utils/request'
import { createCRUDApi } from '../utils/apiFactory'
// 创建基础 CRUD API
const basePaymentMethodApi = createCRUDApi('payment-methods')
// 导出所有方法
export const {
list: getPaymentMethodList,
detail: getPaymentMethodDetail,
create: createPaymentMethod,
update: updatePaymentMethod,
delete: deletePaymentMethod
} = basePaymentMethodApi
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
// 获取权限列表
export function getPermissionList(params) {
return request({
url: '/permissions',
method: 'get',
params
})
}
// 获取权限详情
export function getPermissionDetail(id) {
return request({
url: `/permissions/${id}`,
method: 'get'
})
}
// 创建权限
export function createPermission(data) {
return request({
url: '/permissions',
method: 'post',
data
})
}
// 更新权限
export function updatePermission(id, data) {
return request({
url: `/permissions/${id}`,
method: 'put',
data
})
}
// 删除权限
export function deletePermission(id) {
return request({
url: `/permissions/${id}`,
method: 'delete'
})
}
+28
View File
@@ -0,0 +1,28 @@
import request from '../utils/request'
// 获取个人信息
export function getProfile() {
return request({
url: '/info',
method: 'get'
})
}
// 更新个人信息
export function updateProfile(data) {
return request({
url: '/profile',
method: 'put',
data
})
}
// 修改密码
export function updatePassword(data) {
return request({
url: '/password',
method: 'put',
data
})
}
+45
View File
@@ -0,0 +1,45 @@
import request from '../utils/request'
// 获取角色列表
export function getRoleList(params) {
return request({
url: '/roles',
method: 'get',
params
})
}
// 获取角色详情
export function getRoleDetail(id) {
return request({
url: `/roles/${id}`,
method: 'get'
})
}
// 创建角色
export function createRole(data) {
return request({
url: '/roles',
method: 'post',
data
})
}
// 更新角色
export function updateRole(id, data) {
return request({
url: `/roles/${id}`,
method: 'put',
data
})
}
// 删除角色
export function deleteRole(id) {
return request({
url: `/roles/${id}`,
method: 'delete'
})
}
+48
View File
@@ -0,0 +1,48 @@
import request from '../utils/request'
import { createCRUDApi, extendApi } from '../utils/apiFactory'
// 创建基础 CRUD API
const baseUserApi = createCRUDApi('users')
// 扩展 API,添加自定义方法
const userApi = extendApi(baseUserApi, {
// 重置密码
resetPassword: (id, data) => {
return request({
url: `/users/${id}/password`,
method: 'put',
data
})
},
// 更新用户余额
updateBalance: (id, data) => {
return request({
url: `/users/${id}/update-balance`,
method: 'post',
data
})
},
// 导出用户
export: (params) => {
return request({
url: '/users/export',
method: 'post',
data: params
})
}
})
// 导出所有方法
export const {
list: getUserList,
detail: getUserDetail,
create: createUser,
update: updateUser,
delete: deleteUser,
resetPassword,
updateBalance
} = userApi
// 单独导出 export 方法(避免与关键字冲突)
export const exportUsers = userApi.export
+20
View File
@@ -0,0 +1,20 @@
import request from '../utils/request'
// 获取用户余额变动记录列表
export const getUserBalanceLogList = (params) => {
return request({
url: '/user-balance-logs',
method: 'get',
params
})
}
// 获取用户余额统计
export const getUserBalanceStatistics = (params) => {
return request({
url: '/user-balance-logs/statistics',
method: 'get',
params
})
}
+112
View File
@@ -0,0 +1,112 @@
<template>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="{ path: '/' }">
<span class="breadcrumb-item-inner">
<el-icon><HomeFilled /></el-icon>
<span>{{ $t('breadcrumb.home') }}</span>
</span>
</el-breadcrumb-item>
<el-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="index"
:to="item.path ? { path: item.path } : undefined"
>
<span class="breadcrumb-item-inner">
<span>{{ item.title }}</span>
</span>
</el-breadcrumb-item>
</el-breadcrumb>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { HomeFilled } from '@element-plus/icons-vue'
import { getMenuTranslation } from '../utils/menuTranslation'
const route = useRoute()
const { t, te } = useI18n()
const breadcrumbList = computed(() => {
const matched = route.matched.filter(item => item.meta && item.meta.titleKey)
return matched.map(item => {
let title = item.meta.title || item.name
if (item.meta.titleKey) {
// 优先使用 menuSlug(如果存在)进行智能翻译
if (item.meta.menuSlug) {
const translated = getMenuTranslation(t, te, item.meta.menuSlug)
if (translated) {
title = translated
} else {
// 如果智能翻译失败,尝试直接翻译 titleKey
title = te(item.meta.titleKey) ? t(item.meta.titleKey) : item.meta.titleKey
}
} else {
// 如果没有 menuSlug,从 titleKey 中提取 slug
const keyParts = item.meta.titleKey.split('.')
if (keyParts.length >= 2 && keyParts[0] === 'menu') {
const slug = keyParts.slice(1).join('.')
const translated = getMenuTranslation(t, te, slug)
if (translated) {
title = translated
} else {
// 如果智能翻译失败,尝试直接翻译
title = te(item.meta.titleKey) ? t(item.meta.titleKey) : item.meta.titleKey
}
} else {
// 如果不是 menu.xxx 格式,直接翻译
title = te(item.meta.titleKey) ? t(item.meta.titleKey) : item.meta.titleKey
}
}
}
return {
title,
path: item.path !== route.path ? item.path : undefined
}
})
})
</script>
<style scoped>
.breadcrumb {
margin: 0;
line-height: 1;
}
.breadcrumb-item-inner {
display: inline-flex;
align-items: center;
gap: 4px;
vertical-align: middle;
}
.breadcrumb :deep(.el-breadcrumb__inner) {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.breadcrumb :deep(.el-breadcrumb__inner.is-link) {
color: #606266;
font-weight: normal;
}
.breadcrumb :deep(.el-breadcrumb__inner.is-link:hover) {
color: #409EFF;
}
.breadcrumb :deep(.el-breadcrumb__separator) {
margin: 0 8px;
color: #c0c4cc;
vertical-align: middle;
}
.breadcrumb :deep(.el-icon) {
font-size: 16px;
vertical-align: middle;
}
</style>
+275
View File
@@ -0,0 +1,275 @@
<template>
<el-popover
v-model:visible="visible"
placement="bottom-end"
:width="popoverWidth"
trigger="click"
popper-class="column-setting-popover"
:teleported="true"
@hide="handleClose"
>
<template #reference>
<slot name="reference">
<el-button
type="info"
size="small"
:icon="SettingIcon"
circle
:title="$t('common.column_setting')"
/>
</slot>
</template>
<div class="popover-content">
<div class="popover-header">
<span class="popover-title">{{ $t('common.column_setting') }}</span>
</div>
<div class="column-list">
<el-checkbox-group v-model="localVisibleColumns" class="checkbox-group">
<div
v-for="column in (allColumns || [])"
:key="column.key"
class="column-item"
:class="{ 'column-item-disabled': column.required }"
>
<el-checkbox
:label="column.key"
:disabled="column.required"
class="column-checkbox"
>
<span class="column-title">{{ column.title }}</span>
</el-checkbox>
<el-tag
v-if="column.required"
size="small"
type="info"
class="required-tag"
>
{{ $t('common.required') }}
</el-tag>
</div>
</el-checkbox-group>
</div>
<div class="popover-tips">
<el-icon class="tips-icon"><InfoFilled /></el-icon>
<span>{{ $t('common.column_setting_tip') }}</span>
</div>
<div class="popover-footer">
<el-button size="small" @click="handleReset">{{ $t('common.reset') }}</el-button>
<el-button size="small" @click="handleClose">{{ $t('common.cancel') }}</el-button>
<el-button size="small" type="primary" @click="handleConfirm">{{ $t('common.confirm') }}</el-button>
</div>
</div>
</el-popover>
</template>
<script setup>
import { ref, watch, markRaw, onBeforeUnmount } from 'vue'
import { InfoFilled, Setting } from '@element-plus/icons-vue'
const SettingIcon = markRaw(Setting)
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
visibleColumns: {
type: Array,
required: true
},
allColumns: {
type: Array,
required: true
},
defaultVisibleColumns: {
type: Array,
default: () => []
},
popoverWidth: {
type: [String, Number],
default: 280
}
})
const emit = defineEmits(['update:modelValue', 'confirm'])
const visible = ref(props.modelValue)
const localVisibleColumns = ref(Array.isArray(props.visibleColumns) ? [...props.visibleColumns] : [])
// 监听外部 visible 变化
watch(() => props.modelValue, (newVal) => {
visible.value = newVal
if (newVal) {
// 打开对话框时,重置为当前的 visibleColumns
localVisibleColumns.value = Array.isArray(props.visibleColumns) ? [...props.visibleColumns] : []
}
})
// 监听内部 visible 变化
watch(visible, (newVal) => {
emit('update:modelValue', newVal)
})
// 监听 visibleColumns 变化
watch(() => props.visibleColumns, (newVal) => {
if (!visible.value) {
localVisibleColumns.value = Array.isArray(newVal) ? [...newVal] : []
}
}, { deep: true })
// 重置
const handleReset = () => {
localVisibleColumns.value = Array.isArray(props.defaultVisibleColumns) ? [...props.defaultVisibleColumns] : []
}
const handleClose = () => {
visible.value = false
// 关闭时恢复为原始值
localVisibleColumns.value = Array.isArray(props.visibleColumns) ? [...props.visibleColumns] : []
}
const handleConfirm = () => {
emit('confirm', Array.isArray(localVisibleColumns.value) ? [...localVisibleColumns.value] : [])
visible.value = false
}
// 组件卸载前确保 popover 关闭
onBeforeUnmount(() => {
visible.value = false
})
</script>
<style scoped>
.popover-content {
min-width: 260px;
}
.popover-header {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e4e7ed;
}
.popover-title {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.column-list {
max-height: 300px;
overflow-y: auto;
padding: 4px 0;
margin-bottom: 12px;
}
.column-list::-webkit-scrollbar {
width: 6px;
}
.column-list::-webkit-scrollbar-track {
background: #f5f5f5;
border-radius: 3px;
}
.column-list::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.column-list::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.column-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin-bottom: 4px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
transition: all 0.3s ease;
cursor: pointer;
}
.column-item:hover {
background: #e9ecef;
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
}
.column-item-disabled {
background: #f5f7fa;
cursor: not-allowed;
opacity: 0.7;
}
.column-item-disabled:hover {
background: #f5f7fa;
border-color: #e9ecef;
box-shadow: none;
}
.column-checkbox {
flex: 1;
margin: 0;
}
.column-checkbox :deep(.el-checkbox__label) {
font-size: 14px;
color: #303133;
font-weight: 500;
padding-left: 8px;
}
.column-title {
display: inline-block;
line-height: 1.5;
}
.required-tag {
margin-left: 8px;
font-size: 11px;
}
.popover-tips {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px 12px;
background: #ecf5ff;
border: 1px solid #b3d8ff;
border-radius: 4px;
color: #409eff;
font-size: 12px;
}
.tips-icon {
margin-right: 6px;
font-size: 14px;
}
.popover-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #e4e7ed;
}
/* 响应式设计 */
@media (max-width: 768px) {
.checkbox-group {
grid-template-columns: 1fr;
}
}
/* 列设置弹窗样式 */
.column-setting-popover {
padding: 12px;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
<template>
<el-button
type="text"
class="dark-mode-switch"
@click="toggleDarkMode"
:title="isDarkMode ? $t('header.switch_to_light') : $t('header.switch_to_dark')"
>
<el-icon class="dark-mode-icon">
<Sunny v-if="isDarkMode" />
<Moon v-else />
</el-icon>
</el-button>
</template>
<script setup>
import { computed } from 'vue'
import { useAppStore } from '../store/app'
import { Sunny, Moon } from '@element-plus/icons-vue'
const appStore = useAppStore()
const isDarkMode = computed(() => appStore.darkMode)
const toggleDarkMode = () => {
appStore.toggleDarkMode()
}
</script>
<style scoped>
.dark-mode-switch {
color: #606266;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
transition: all 0.3s;
margin: 0;
}
.dark-mode-switch:hover {
background-color: #f5f7fa;
color: #409EFF;
}
.dark-mode-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<div v-if="hasError" class="error-boundary">
<el-result
icon="error"
:title="title || $t('error_boundary.title')"
:sub-title="subTitle || $t('error_boundary.subtitle')"
>
<template #extra>
<el-button type="primary" @click="handleReset">{{ $t('error_boundary.retry') }}</el-button>
<el-button @click="handleReload">{{ $t('error_boundary.reload') }}</el-button>
</template>
</el-result>
<!-- 开发环境显示错误详情 -->
<el-collapse v-if="isDev && error" class="error-details">
<el-collapse-item :title="$t('error_boundary.error_details')" name="details">
<pre class="error-stack">{{ errorStack }}</pre>
</el-collapse-item>
</el-collapse>
</div>
<slot v-else />
</template>
<script setup>
import { ref, computed, onErrorCaptured } from 'vue'
import { useI18n } from 'vue-i18n'
import logger from '../utils/logger'
import { isDev } from '../utils/env'
const props = defineProps({
title: {
type: String,
default: ''
},
subTitle: {
type: String,
default: ''
},
onError: {
type: Function,
default: null
}
})
const emit = defineEmits(['error', 'reset'])
const hasError = ref(false)
const error = ref(null)
const errorStack = ref('')
const { t } = useI18n()
// 捕获子组件错误
onErrorCaptured((err, instance, info) => {
const errorMessage = err?.message || ''
const errorStackStr = err?.stack || ''
const instanceName = instance?.$?.type?.name || instance?.$.type?.__name || ''
// 静默处理 Element Plus TabPane 卸载时的已知错误
const isTabPaneError = (
errorMessage.includes('indexOf') ||
errorStackStr.includes('unregisterPane') ||
errorStackStr.includes('removeChild') ||
(instanceName === 'ElTabPane' && info === 'beforeUnmount hook')
)
if (isTabPaneError) {
// 这是 Element Plus 的已知问题,不影响功能,静默处理
// 不设置 hasError,不显示错误界面
return false // 阻止错误继续传播
}
hasError.value = true
error.value = err
// 构建错误堆栈信息
errorStack.value = [
`${t('error_boundary.error_message')}: ${err.message}`,
`${t('error_boundary.error_stack')}: ${err.stack || 'N/A'}`,
`${t('error_boundary.component_info')}: ${info || 'N/A'}`,
`${t('error_boundary.instance')}: ${instanceName || 'Unknown'}`
].join('\n')
// 记录错误
logger.error('ErrorBoundary caught error:', {
error: err,
instance,
info,
stack: errorStack.value
})
// 触发错误回调
if (props.onError) {
props.onError(err, instance, info)
}
emit('error', err, instance, info)
// 阻止错误继续传播
return false
})
/**
* 重置错误状态
*/
const handleReset = () => {
hasError.value = false
error.value = null
errorStack.value = ''
emit('reset')
}
/**
* 重新加载页面
*/
const handleReload = () => {
window.location.reload()
}
</script>
<style scoped>
.error-boundary {
padding: 20px;
min-height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.error-details {
margin-top: 20px;
width: 100%;
max-width: 800px;
}
.error-stack {
background: #f5f5f5;
padding: 15px;
border-radius: 4px;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<template>
<el-dropdown @command="handleCommand" trigger="click">
<el-button type="text" class="language-switch">
<el-icon class="language-icon">
<TextIcon />
</el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
command="zh-CN"
:class="{ 'is-active': currentLanguage === 'zh-CN' }"
>
<span class="language-option">
<span class="language-flag">🇨🇳</span>
<span>{{ $t('common.language_zh') }}</span>
</span>
</el-dropdown-item>
<el-dropdown-item
command="en-US"
:class="{ 'is-active': currentLanguage === 'en-US' }"
>
<span class="language-option">
<span class="language-flag">🇺🇸</span>
<span>{{ $t('common.language_en') }}</span>
</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup>
import { computed, defineComponent, h } from 'vue'
import { useI18n } from 'vue-i18n'
import Storage from '../utils/storage'
const { t } = useI18n()
// 自定义文字图标组件
const TextIcon = defineComponent({
render() {
return h('svg', {
viewBox: '0 0 24 24',
width: '1.2em',
height: '1.2em',
style: { verticalAlign: 'middle' }
}, [
h('path', {
fill: 'currentColor',
d: 'm18.5 10l4.4 11h-2.155l-1.201-3h-4.09l-1.199 3h-2.154L16.5 10h2zM10 2v2h6v2h-1.968a18.222 18.222 0 0 1-3.62 6.301a14.864 14.864 0 0 0 2.336 1.707l-.751 1.878A17.015 17.015 0 0 1 9 13.725a16.676 16.676 0 0 1-6.201 3.548l-.536-1.929a14.7 14.7 0 0 0 5.327-3.042A18.078 18.078 0 0 1 4.767 8h2.24A16.032 16.032 0 0 0 9 10.877a16.165 16.165 0 0 0 2.91-4.876L2 6V4h6V2h2zm7.5 10.885L16.253 16h2.492L17.5 12.885z'
})
])
}
})
const { locale } = useI18n()
const currentLanguage = computed(() => locale.value)
const currentLanguageText = computed(() => {
return currentLanguage.value === 'zh-CN' ? t('common.language_zh') : t('common.language_en')
})
const handleCommand = (command) => {
locale.value = command
Storage.setItem('language', command)
// 语言切换会自动通过 ElConfigProvider 更新,无需刷新页面
}
</script>
<style scoped>
.language-switch {
color: #606266;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
transition: all 0.3s;
margin: 0;
}
.language-switch:hover {
background-color: #f5f7fa;
color: #409EFF;
}
.language-icon {
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.language-option {
display: flex;
align-items: center;
gap: 8px;
}
.language-flag {
font-size: 16px;
}
.is-active {
color: #409EFF;
font-weight: bold;
}
</style>
+261
View File
@@ -0,0 +1,261 @@
<template>
<div :class="[`${pageClass}-list`]">
<el-card>
<template #header>
<div class="card-header">
<span>{{ title }}</span>
<div class="header-actions">
<slot name="header-actions">
<el-button
v-if="showAddButton"
type="primary"
:disabled="addButtonDisabled"
@click="handleAdd"
>
<el-icon><Plus /></el-icon>
{{ addButtonText }}
</el-button>
</slot>
</div>
</div>
</template>
<SearchForm
:model="searchForm"
:fields="searchFields"
:initial-values="initialSearchValues"
:i18n-prefix="i18nPrefix"
:loading="loading"
@search="handleSearch"
@reset="handleReset"
>
<template v-for="(_, name) in $slots" #[name]="slotData" :key="name">
<slot :name="name" v-bind="slotData" />
</template>
</SearchForm>
<vxe-table
ref="tableRef"
:data="tableData"
:loading="loading"
border
:column-config="optimizedTableConfig"
:height="tableHeight"
:scroll-y="scrollYConfig"
:sort-config="{ multiple: false, trigger: 'default' }"
@sort-change="handleSortChange"
v-bind="$attrs"
v-on="$attrs"
>
<template v-for="column in tableColumns" :key="column.field || column.title || column.type">
<vxe-column
v-if="column.type === 'checkbox'"
type="checkbox"
:width="column.width"
:fixed="column.fixed"
/>
<vxe-column
v-else
:field="column.field"
:title="column.title"
:width="column.width"
:sortable="column.sortable"
:fixed="column.fixed"
:formatter="column.formatter"
:tree-node="column.treeNode"
>
<!-- 默认 slot 处理 -->
<template v-if="column.slot" #default="slotProps">
<slot
:name="column.slot"
:row="slotProps.row"
:column="slotProps.column"
:rowIndex="slotProps.rowIndex"
:columnIndex="slotProps.columnIndex"
/>
</template>
</vxe-column>
</template>
<!-- 额外的表格插槽 -->
<template v-for="(_, name) in $slots" #[name]="slotData" :key="name">
<slot :name="name" v-bind="slotData" />
</template>
</vxe-table>
<Pagination
:model-value="pagination"
@update:model-value="handlePaginationUpdate"
@page-change="handlePageChange"
/>
</el-card>
<!-- 表单对话框 -->
<slot name="form" />
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import SearchForm from './SearchForm.vue'
import Pagination from './Pagination.vue'
import { useTablePerformance } from '../composables/useTablePerformance'
const props = defineProps({
// 页面类名
pageClass: {
type: String,
default: 'page'
},
// 标题
title: {
type: String,
required: true
},
// 是否显示添加按钮
showAddButton: {
type: Boolean,
default: true
},
// 添加按钮文本
addButtonText: {
type: String,
default: ''
},
// 添加按钮是否禁用
addButtonDisabled: {
type: Boolean,
default: false
},
// 搜索表单数据
searchForm: {
type: Object,
required: true
},
// 搜索表单字段配置
searchFields: {
type: Array,
required: true
},
// 初始搜索值
initialSearchValues: {
type: Object,
default: () => ({})
},
// 国际化前缀
i18nPrefix: {
type: String,
default: ''
},
// 表格数据
tableData: {
type: Array,
default: () => []
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 表格列配置
tableColumns: {
type: Array,
required: true
},
// 表格配置
tableConfig: {
type: Object,
default: () => ({ resizable: true })
},
// 表格高度
tableHeight: {
type: [String, Number],
default: 600
},
// 分页数据
pagination: {
type: Object,
required: true
},
// 对话框显示状态
dialogVisible: {
type: Boolean,
default: false
},
// 编辑ID
editId: {
type: [Number, String],
default: null
}
})
const emit = defineEmits([
'add',
'search',
'reset',
'update:pagination',
'page-change',
'sort-change',
'form-success'
])
const tableRef = ref(null)
// 性能优化:虚拟滚动和列渲染优化
const {
scrollYConfig,
optimizedTableConfig
} = useTablePerformance({
tableColumns: computed(() => props.tableColumns),
tableData: computed(() => props.tableData),
tableHeight: props.tableHeight
})
const handleAdd = () => {
emit('add')
}
const handleSearch = () => {
emit('search')
}
const handleReset = () => {
emit('reset')
}
const handlePaginationUpdate = (value) => {
emit('update:pagination', value)
}
const handlePageChange = (data) => {
emit('page-change', data)
}
const handleSortChange = (data) => {
emit('sort-change', data)
}
const handleFormSuccess = () => {
emit('form-success')
}
defineExpose({
tableRef
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
</style>
+167
View File
@@ -0,0 +1,167 @@
<template>
<el-sub-menu v-if="menu.children && menu.children.length > 0" :index="getMenuIndex(menu)">
<template #title>
<el-icon v-if="getIcon(menu.icon)" class="menu-icon">
<component :is="getIcon(menu.icon)" />
</el-icon>
<el-tooltip
:content="getMenuTitle(menu)"
placement="right"
effect="dark"
:show-after="300"
>
<span class="menu-title">{{ getMenuTitle(menu) }}</span>
</el-tooltip>
</template>
<MenuItem
v-for="child in menu.children"
:key="child.id"
:menu="child"
/>
</el-sub-menu>
<el-menu-item
v-else
:index="getMenuIndex(menu)"
:disabled="menu.status === 0"
@click="handleMenuClick(menu, $event)"
>
<el-icon v-if="getIcon(menu.icon)" class="menu-icon">
<component :is="getIcon(menu.icon)" />
</el-icon>
<template #title>
<el-tooltip
:content="getMenuTitle(menu)"
placement="right"
effect="dark"
:show-after="300"
>
<span class="menu-title">{{ getMenuTitle(menu) }}</span>
</el-tooltip>
</template>
</el-menu-item>
</template>
<script>
import { defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useTabsStore } from '../store/tabs'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { getMenuTitle as getMenuTitleUtil } from '../utils/menuTranslation'
export default defineComponent({
name: 'MenuItem',
props: {
menu: {
type: Object,
required: true
}
},
setup(props) {
const { t, te } = useI18n()
const router = useRouter()
const tabsStore = useTabsStore()
// 获取菜单标题(使用工具函数,自动从 slug 或路径提取翻译)
const getMenuTitle = (menu) => {
return getMenuTitleUtil(t, te, menu)
}
// 获取菜单项的 index(用于 el-menu-item
// 对于外部链接,使用唯一标识符而不是 URL,避免路由导航问题
const getMenuIndex = (menu) => {
const linkType = menu.link_type !== undefined ? menu.link_type : 1
// 外部链接使用唯一标识符
if (linkType === 2) {
return `external-${menu.id || menu.path}`
}
// 内部页面使用路径
return menu.path || `menu-${menu.id}`
}
// 处理菜单点击
const handleMenuClick = (menu, event) => {
if (!menu.path) {
return
}
// 安全地阻止默认行为(如果事件对象存在)
if (event && typeof event.preventDefault === 'function') {
event.preventDefault()
}
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation()
}
const linkType = menu.link_type !== undefined ? menu.link_type : 1
const openType = menu.open_type !== undefined ? menu.open_type : 1
// 外部链接处理
if (linkType === 2) {
// iframe 嵌套显示
if (openType === 1) {
const title = getMenuTitle(menu)
const iframePath = `/iframe?url=${encodeURIComponent(menu.path)}&title=${encodeURIComponent(title)}`
router.push(iframePath)
}
// 新窗口打开
else if (openType === 2) {
window.open(menu.path, '_blank')
}
}
// 内部页面路由 - 强制使用 router.push
else {
router.push(menu.path)
}
}
const normalizeIconName = (iconName) => {
if (!iconName) {
return ''
}
const trimmed = iconName.trim()
if (!trimmed) {
return ''
}
if (ElementPlusIconsVue[trimmed]) {
return trimmed
}
const pascalCase = trimmed.charAt(0).toUpperCase() + trimmed.slice(1)
if (ElementPlusIconsVue[pascalCase]) {
return pascalCase
}
return ''
}
const getIcon = (iconName) => {
const normalized = normalizeIconName(iconName)
return normalized ? ElementPlusIconsVue[normalized] : null
}
return {
getIcon,
getMenuTitle,
getMenuIndex,
handleMenuClick
}
}
})
</script>
<style scoped>
.menu-title {
display: inline-block;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
max-width: 100%;
}
.menu-icon {
flex-shrink: 0;
margin-right: 8px;
}
</style>
+235
View File
@@ -0,0 +1,235 @@
<template>
<el-popover
v-model:visible="popoverVisible"
placement="bottom-end"
width="420"
trigger="click"
popper-class="notification-popover"
>
<template #reference>
<el-badge
:value="badgeValue"
:hidden="notificationStore.unreadCount === 0"
:offset="[-6, 10]"
>
<el-button type="text" class="header-btn bell-btn">
<el-icon><Bell /></el-icon>
</el-button>
</el-badge>
</template>
<div class="notification-popover__header">
<span>{{ $t('notification.center') }}</span>
<div class="header-actions">
<el-button size="small" text @click="handleMarkAll" :disabled="notificationStore.unreadCount === 0">
{{ $t('notification.mark_all') }}
</el-button>
<el-button size="small" text @click="goList">
{{ $t('notification.view_all') }}
</el-button>
</div>
</div>
<el-scrollbar
class="notification-list"
v-loading="notificationStore.loading"
height="360px"
>
<div v-if="notificationStore.items.length === 0" class="notification-empty">
<el-icon><Bell /></el-icon>
<p>{{ $t('notification.empty') }}</p>
</div>
<div
v-for="item in notificationStore.items"
:key="item.id"
class="notification-item"
:class="{ unread: !item.is_read }"
>
<div class="notification-item__head">
<span class="notification-type">{{ typeLabel(item.type) }}</span>
<span class="notification-time">{{ formatTime(item.created_at) }}</span>
</div>
<div class="notification-item__title">{{ item.title }}</div>
<div class="notification-item__content">{{ item.content }}</div>
<div class="notification-item__actions">
<el-tag v-if="!item.is_read" size="small" type="danger" effect="plain">
{{ $t('notification.unread') }}
</el-tag>
<el-button
v-if="!item.is_read"
size="small"
text
@click="notificationStore.markAsRead(item.id)"
>
{{ $t('notification.mark_read') }}
</el-button>
</div>
</div>
</el-scrollbar>
</el-popover>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/zh-cn'
import { Bell } from '@element-plus/icons-vue'
import { useNotificationStore } from '../store/notification'
import { useI18n } from 'vue-i18n'
dayjs.extend(relativeTime)
const notificationStore = useNotificationStore()
const router = useRouter()
const route = useRoute()
const popoverVisible = ref(false)
const badgeValue = computed(() => {
const count = notificationStore.unreadCount || 0
return count > 99 ? '99+' : count
})
const { t, locale } = useI18n()
const typeLabel = (type) => {
if (type === 'message') {
return t('notification.types.message')
}
if (type === 'notice') {
return t('notification.types.notice')
}
return t('notification.types.announcement')
}
const handleMarkAll = () => {
notificationStore.markAllRead()
}
const goList = () => {
popoverVisible.value = false
if (route.path !== '/notifications') {
router.push('/notifications')
}
}
const formatTime = (value) => {
if (!value) return ''
const currentLocale = locale.value === 'zh-CN' ? 'zh-cn' : 'en'
return dayjs(value).locale(currentLocale).fromNow()
}
onMounted(() => {
notificationStore.refresh({ limit: 7 })
notificationStore.connect()
})
onBeforeUnmount(() => {
notificationStore.disconnect()
})
</script>
<style scoped>
.bell-btn {
padding: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.bell-btn .el-icon {
font-size: 20px;
}
/* 确保 el-badge 不会影响按钮大小 */
:deep(.el-badge) {
display: inline-flex;
align-items: center;
justify-content: center;
}
:deep(.el-badge__content) {
font-size: 12px;
height: 18px;
line-height: 18px;
padding: 0 6px;
}
.header-actions {
display: flex;
align-items: center;
gap: 4px;
}
.notification-popover__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
font-weight: 600;
}
.notification-list {
height: 360px;
}
.notification-popover :deep(.el-scrollbar__wrap) {
max-height: 360px;
}
.notification-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 0;
color: #909399;
gap: 8px;
}
.notification-item {
padding: 12px;
border-radius: 8px;
border: 1px solid #ebeef5;
margin-bottom: 10px;
background: #fff;
}
.notification-item.unread {
border-color: #ffd04b;
background: #fffdf5;
}
.notification-item__head {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.notification-type {
font-weight: 600;
color: #409eff;
}
.notification-item__title {
font-weight: 600;
margin-bottom: 4px;
color: #303133;
}
.notification-item__content {
font-size: 13px;
color: #606266;
line-height: 1.4;
}
.notification-item__actions {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
</style>
+362
View File
@@ -0,0 +1,362 @@
<template>
<div :class="['pagination-wrapper', `align-${align}`, { 'pagination-compact': compact }]" :style="wrapperStyle">
<!-- 总数信息 -->
<div v-if="showTotal && shouldShowTotal" class="pagination-total">
<span>{{ totalTextComputed }}</span>
</div>
<!-- 分页组件 -->
<vxe-pager
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="pageSizes"
:layouts="computedLayouts"
:border="border"
:background="background"
:pager-count="pagerCount"
:disabled="disabled"
:loading="loading"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
<!-- 快速跳转 -->
<div v-if="showQuickJumper && totalPages > 0 && shouldShowQuickJumper" class="pagination-jumper">
<span>{{ jumpTextComputed }}</span>
<el-input-number
v-model="jumpPage"
:min="1"
:max="Math.max(1, totalPages)"
:size="inputSize"
:controls="false"
style="width: 80px; margin: 0 8px"
@keyup.enter="handleJump"
/>
<el-button :size="buttonSize" @click="handleJump">{{ confirmTextComputed }}</el-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const props = defineProps({
// 分页数据模型
modelValue: {
type: Object,
required: true,
default: () => ({
page: 1,
pageSize: 10,
total: 0
})
},
// 每页显示条数选项
pageSizes: {
type: Array,
default: () => [10, 20, 50, 100]
},
// 布局配置
layouts: {
type: Array,
default: () => ['PrevJump', 'PrevPage', 'Number', 'NextPage', 'NextJump', 'Sizes', 'FullJump', 'Total']
},
// 是否显示边框
border: {
type: Boolean,
default: false
},
// 是否为分页按钮添加背景色
background: {
type: Boolean,
default: false
},
// 页码按钮的数量
pagerCount: {
type: Number,
default: 7
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否显示加载状态
loading: {
type: Boolean,
default: false
},
// 是否显示总数
showTotal: {
type: Boolean,
default: true
},
// 是否显示快速跳转
showQuickJumper: {
type: Boolean,
default: false
},
// 是否紧凑模式
compact: {
type: Boolean,
default: false
},
// 总数文本模板
totalText: {
type: String,
default: ''
},
// 跳转文本
jumpText: {
type: String,
default: ''
},
// 确认文本
confirmText: {
type: String,
default: ''
},
// 输入框尺寸
inputSize: {
type: String,
default: 'small',
validator: (value) => ['large', 'default', 'small'].includes(value)
},
// 按钮尺寸
buttonSize: {
type: String,
default: 'small',
validator: (value) => ['large', 'default', 'small'].includes(value)
},
// 自定义样式
wrapperStyle: {
type: Object,
default: () => ({})
},
// 对齐方式
align: {
type: String,
default: 'right',
validator: (value) => ['left', 'center', 'right'].includes(value)
},
// 是否自动加载(分页变化时自动触发回调)
autoLoad: {
type: Boolean,
default: false
},
// 自动加载的回调函数
onPageChange: {
type: Function,
default: null
},
// 隐藏总数和快速跳转的阈值(当 total 超过此值时,隐藏总数和快速跳转)
hideTotalThreshold: {
type: Number,
default: 0
}
})
const emit = defineEmits(['update:modelValue', 'page-change', 'page-size-change', 'change'])
const jumpPage = ref(1)
// 当前页
const currentPage = computed({
get: () => props.modelValue.page || 1,
set: (value) => {
updateModelValue({ page: value })
}
})
// 每页条数
const pageSize = computed({
get: () => props.modelValue.pageSize || 10,
set: (value) => {
updateModelValue({ pageSize: value, page: 1 })
}
})
// 总数
const total = computed(() => props.modelValue.total || 0)
// 总页数
const totalPages = computed(() => {
if (total.value === 0 || pageSize.value === 0) return 0
return Math.ceil(total.value / pageSize.value)
})
// 是否应该显示总数(当 total 超过阈值时隐藏)
const shouldShowTotal = computed(() => {
if (props.hideTotalThreshold <= 0) return true
return total.value <= props.hideTotalThreshold
})
// 是否应该显示快速跳转(当 total 超过阈值时隐藏)
const shouldShowQuickJumper = computed(() => {
if (props.hideTotalThreshold <= 0) return true
return total.value <= props.hideTotalThreshold
})
// 计算布局(当 total 超过阈值时,移除 Total 和 FullJump
const computedLayouts = computed(() => {
if (props.hideTotalThreshold <= 0 || total.value <= props.hideTotalThreshold) {
return props.layouts
}
// 移除 Total 和 FullJumpFullJump 包含快速跳转功能)
return props.layouts.filter(layout => layout !== 'Total' && layout !== 'FullJump')
})
// 跳转文本(带默认值)
const jumpTextComputed = computed(() => {
return props.jumpText || t('pagination.jump_to')
})
// 确认文本(带默认值)
const confirmTextComputed = computed(() => {
return props.confirmText || t('common.confirm')
})
// 总数文本
const totalTextComputed = computed(() => {
if (props.totalText) {
return props.totalText
.replace('{total}', total.value)
.replace('{start}', ((currentPage.value - 1) * pageSize.value + 1))
.replace('{end}', Math.min(currentPage.value * pageSize.value, total.value))
}
const start = total.value === 0 ? 0 : (currentPage.value - 1) * pageSize.value + 1
const end = Math.min(currentPage.value * pageSize.value, total.value)
return t('pagination.total_text', { total: total.value, start, end })
})
// 更新模型值
const updateModelValue = (updates) => {
const newValue = {
...props.modelValue,
...updates
}
emit('update:modelValue', newValue)
emit('change', newValue)
}
// 页码变化处理
const handlePageChange = ({ currentPage, pageSize }) => {
updateModelValue({
page: currentPage,
pageSize: pageSize
})
emit('page-change', { currentPage, pageSize })
jumpPage.value = currentPage
// 如果启用了自动加载,触发回调
if (props.autoLoad && props.onPageChange) {
props.onPageChange({ currentPage, pageSize })
}
}
// 每页条数变化处理
const handlePageSizeChange = ({ pageSize }) => {
updateModelValue({
pageSize: pageSize,
page: 1
})
emit('page-size-change', { pageSize })
emit('page-change', { currentPage: 1, pageSize })
// 如果启用了自动加载,触发回调
if (props.autoLoad && props.onPageChange) {
props.onPageChange({ currentPage: 1, pageSize })
}
}
// 快速跳转
const handleJump = () => {
const page = Number(jumpPage.value)
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
currentPage.value = page
} else {
jumpPage.value = currentPage.value
}
}
// 监听当前页变化,同步跳转输入框
watch(() => currentPage.value, (val) => {
jumpPage.value = val
}, { immediate: true })
// 监听总页数变化,限制跳转输入框最大值
watch(totalPages, (val) => {
if (jumpPage.value > val) {
jumpPage.value = val || 1
}
})
</script>
<style scoped lang="scss">
.pagination-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 16px;
margin-top: 20px;
padding: 16px 0;
&.pagination-compact {
margin-top: 15px;
padding: 12px 0;
gap: 12px;
}
.pagination-total {
color: var(--text-color-regular, #606266);
font-size: 14px;
white-space: nowrap;
transition: color 0.3s ease;
}
.pagination-jumper {
display: flex;
align-items: center;
color: var(--text-color-regular, #606266);
font-size: 14px;
white-space: nowrap;
transition: color 0.3s ease;
}
// 对齐方式
&.align-left {
justify-content: flex-start;
}
&.align-center {
justify-content: center;
}
&.align-right {
justify-content: flex-end;
}
// 响应式布局
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
.pagination-total,
.pagination-jumper {
width: 100%;
justify-content: center;
margin-bottom: 8px;
}
:deep(.vxe-pager) {
width: 100%;
justify-content: center;
}
}
}
</style>
+518
View File
@@ -0,0 +1,518 @@
<template>
<el-form
ref="formRef"
:model="model"
:inline="inline"
:label-width="labelWidth"
:label-position="labelPosition"
:rules="rules"
:class="['search-form', { 'search-form-compact': compact }]"
:style="formStyle"
>
<!-- 通过配置生成表单 -->
<div
class="form-fields-wrapper"
:class="{ 'form-fields-collapsed': computedShouldShowExpandButton && !expanded }"
:style="computedFieldsWrapperStyle"
>
<template v-if="fields && fields.length > 0">
<SearchFormField
v-for="field in fields"
:key="field.prop"
:field="field"
:model="model"
:expanded="expanded"
:i18n-prefix="i18nPrefix"
>
<template v-for="(_, slotName) in $slots" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</SearchFormField>
</template>
<!-- 插槽方式向后兼容 -->
<template v-else>
<!-- 基础搜索项始终显示 -->
<slot />
<!-- 高级搜索项可展开/收起 -->
<template v-if="hasAdvancedSlot">
<slot name="advanced" :expanded="expanded" />
</template>
</template>
</div>
<!-- 操作按钮 -->
<el-form-item class="action-item">
<el-button
type="primary"
:size="buttonSize"
:loading="loading"
:icon="searchIcon"
@click="handleSearch"
>
{{ searchText }}
</el-button>
<el-button
:size="buttonSize"
:icon="resetIcon"
@click="handleReset"
>
{{ resetText }}
</el-button>
<!-- 展开/收起按钮移到重置按钮后面根据表单高度自动判断显示 -->
<el-button
v-if="computedShouldShowExpandButton"
:type="expandButtonType"
:plain="expandButtonPlain"
:size="buttonSize"
@click="toggleExpand"
>
<el-icon><component :is="expanded ? ArrowUp : ArrowDown" /></el-icon>
{{ expanded ? collapseText : expandText }}
</el-button>
<slot name="extra-buttons" />
</el-form-item>
</el-form>
</template>
<script setup>
import { ref, useSlots, computed, watch, onMounted, onUnmounted } from 'vue'
import { Search, Refresh, ArrowUp, ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { forOwn } from 'lodash-es'
import SearchFormField from './SearchForm/SearchFormField.vue'
import { useFormHeight } from './SearchForm/useFormHeight'
import { useFieldOptions } from './SearchForm/useFieldOptions'
// 防抖函数
const debounce = (fn, delay) => {
let timer = null
return function(...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
const props = defineProps({
// 表单数据模型
model: {
type: Object,
required: true
},
// 字段配置(JSON 配置方式)
fields: {
type: Array,
default: () => []
},
// 表单验证规则
rules: {
type: Object,
default: () => ({})
},
// 是否行内表单
inline: {
type: Boolean,
default: true
},
// 标签宽度
labelWidth: {
type: [String, Number],
default: ''
},
// 标签位置
labelPosition: {
type: String,
default: 'right',
validator: (value) => ['left', 'right', 'top'].includes(value)
},
// 默认展开状态
defaultExpanded: {
type: Boolean,
default: false
},
// 是否显示展开按钮
showExpandButton: {
type: Boolean,
default: true
},
// 展开按钮类型
expandButtonType: {
type: String,
default: 'default'
},
// 展开按钮是否朴素按钮
expandButtonPlain: {
type: Boolean,
default: false
},
// 按钮尺寸
buttonSize: {
type: String,
default: 'default',
validator: (value) => ['large', 'default', 'small'].includes(value)
},
// 搜索按钮文本
searchText: {
type: String,
default: ''
},
// 重置按钮文本
resetText: {
type: String,
default: ''
},
// 展开按钮文本
expandText: {
type: String,
default: ''
},
// 收起按钮文本
collapseText: {
type: String,
default: ''
},
// 是否紧凑模式
compact: {
type: Boolean,
default: false
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 是否启用搜索防抖
debounce: {
type: Boolean,
default: false
},
// 防抖延迟时间(毫秒)
debounceDelay: {
type: Number,
default: 300
},
// 自定义样式
formStyle: {
type: Object,
default: () => ({})
},
// 初始值(用于重置)
initialValues: {
type: Object,
default: () => ({})
},
// 国际化前缀(用于自动翻译 label 和 placeholder
i18nPrefix: {
type: String,
default: ''
},
// 重置时是否自动刷新数据,默认为 true
resetReload: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['search', 'reset', 'expand-change', 'validate'])
const slots = useSlots()
const formRef = ref(null)
const expanded = ref(props.defaultExpanded)
const { loadFieldOptions } = useFieldOptions()
let resizeObserver = null
// 国际化文本
const { t } = useI18n()
// 检查是否有高级搜索项插槽
const hasAdvancedSlot = computed(() => {
return !!slots.advanced
})
// 检查是否有高级搜索字段
const hasAdvancedFields = computed(() => {
if (props.fields && props.fields.length > 0) {
return props.fields.some(field => field.advanced === true)
}
return hasAdvancedSlot.value
})
// 使用表单高度检测 composable
const { singleLineHeight, shouldShowExpandButton, checkFormHeight } = useFormHeight(
formRef,
expanded,
hasAdvancedFields,
computed(() => props.defaultExpanded)
)
// 计算是否应该显示展开按钮(根据表单高度自动判断)
const computedShouldShowExpandButton = computed(() => {
// 如果手动设置了 showExpandButton 为 false,则不显示
if (props.showExpandButton === false) {
return false
}
// 如果有高级搜索字段,使用原来的逻辑
if (hasAdvancedFields.value) {
return props.showExpandButton !== false
}
// 否则根据表单高度自动判断
return shouldShowExpandButton.value
})
// 计算表单字段容器的样式(动态设置 max-height
const computedFieldsWrapperStyle = computed(() => {
if (computedShouldShowExpandButton.value && !expanded.value) {
// 收起状态:尽可能多地显示表单项,但不超过一行的高度
if (singleLineHeight.value > 0) {
return {
maxHeight: `${singleLineHeight.value}px`
}
}
}
return {}
})
const searchText = computed(() => {
return props.searchText || t('log.search') || '搜索'
})
const resetText = computed(() => {
return props.resetText || t('log.reset') || '重置'
})
const expandText = computed(() => {
return props.expandText || t('log.expand') || '展开'
})
const collapseText = computed(() => {
return props.collapseText || t('log.collapse') || '收起'
})
const searchIcon = computed(() => {
return props.searchText ? undefined : Search
})
const resetIcon = computed(() => {
return props.resetText ? undefined : Refresh
})
// 切换展开/收起
const toggleExpand = () => {
expanded.value = !expanded.value
emit('expand-change', expanded.value)
}
// 搜索处理(支持防抖)
const doSearch = () => {
if (formRef.value && Object.keys(props.rules).length > 0) {
formRef.value.validate((valid) => {
if (valid) {
emit('search', props.model)
} else {
emit('validate', false)
}
})
} else {
emit('search', props.model)
}
}
const handleSearch = props.debounce
? debounce(doSearch, props.debounceDelay)
: doSearch
// 重置处理
const handleReset = () => {
if (formRef.value) {
formRef.value.resetFields()
}
// 重置为初始值
const resetValue = (value) => {
if (Array.isArray(value)) return []
if (typeof value === 'number') return 0
if (typeof value === 'boolean') return false
return ''
}
if (Object.keys(props.initialValues).length > 0) {
forOwn(props.model, (value, key) => {
if (props.initialValues.hasOwnProperty(key)) {
props.model[key] = props.initialValues[key]
} else {
props.model[key] = resetValue(value)
}
})
} else {
forOwn(props.model, (value, key) => {
props.model[key] = resetValue(value)
})
}
if (expanded.value) {
expanded.value = false
emit('expand-change', false)
}
// 传递重置后的表单数据和是否刷新的选项
emit('reset', props.model, { reload: props.resetReload })
}
watch(() => props.initialValues, (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
forOwn(newVal, (value, key) => {
if (props.model.hasOwnProperty(key)) {
props.model[key] = value
}
})
}
}, { deep: true, immediate: true })
watch(() => expanded.value, () => {
setTimeout(() => {
checkFormHeight()
}, 300)
})
watch(() => props.fields, (newFields) => {
checkFormHeight()
if (newFields && Array.isArray(newFields)) {
newFields.forEach(field => {
if (field.apiUrl) {
if (field.type === 'tree-select') {
// TreeSelectField 会自动处理
} else if (field.type === 'select') {
loadFieldOptions(field)
}
}
})
}
}, { deep: true })
onMounted(() => {
checkFormHeight()
if (formRef.value && formRef.value.$el) {
resizeObserver = new ResizeObserver(() => {
checkFormHeight()
})
resizeObserver.observe(formRef.value.$el)
}
if (props.fields && Array.isArray(props.fields)) {
props.fields.forEach(field => {
if (field.apiUrl && field.type === 'select') {
loadFieldOptions(field)
}
})
}
setTimeout(() => {
checkFormHeight()
}, 100)
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect()
resizeObserver = null
}
})
defineExpose({
formRef,
expanded,
validate: () => formRef.value?.validate(),
resetFields: () => {
formRef.value?.resetFields()
handleReset()
},
clearValidate: () => formRef.value?.clearValidate(),
toggleExpand
})
</script>
<style scoped lang="scss">
.search-form {
margin-bottom: 20px;
padding: 20px;
background: var(--bg-color-tertiary, #f5f7fa);
border-radius: 4px;
transition: all 0.3s ease;
&.search-form-compact {
padding: 15px;
margin-bottom: 15px;
}
:deep(.el-form-item) {
margin-bottom: 18px;
margin-right: 10px; // 添加右边距,确保表单项之间有间距
&:last-child {
margin-bottom: 0;
}
}
.expand-item {
margin-left: 10px;
}
.action-item {
margin-left: 10px;
flex: 1;
display: flex;
justify-content: flex-end;
}
// 响应式布局
@media (max-width: 768px) {
padding: 15px;
:deep(.el-form-item) {
width: 100%;
margin-right: 0;
}
.action-item {
width: 100%;
justify-content: flex-start;
margin-left: 0;
margin-top: 10px;
}
}
// 表单字段容器
.form-fields-wrapper {
transition: max-height 0.3s ease, margin-bottom 0.3s ease;
overflow: hidden;
margin-bottom: 0;
// 收起状态:尽可能多地显示表单项(max-height 通过 computedFieldsWrapperStyle 动态设置)
&.form-fields-collapsed {
// 确保表单项对齐,使用 flex 布局
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
// 展开状态:显示所有内容,并添加底部间距
&:not(.form-fields-collapsed) {
max-height: none;
margin-bottom: 18px; // 展开后添加底部间距,避免贴着按钮
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
}
// 操作按钮区域,确保有合适的间距
.action-item {
margin-top: 0;
margin-bottom: 0;
}
}
</style>
@@ -0,0 +1,171 @@
<template>
<el-form-item
v-if="(!field.advanced || expanded) && field.prop"
:label="getFieldLabel(field)"
:prop="field.prop"
:style="getFieldStyle(field)"
>
<!-- 输入框 -->
<el-input
v-if="field.type === 'input' && field.prop"
v-model="model[field.prop]"
:placeholder="getFieldPlaceholder(field)"
:clearable="field.clearable !== false"
:disabled="field.disabled"
:style="{ width: field.width || '200px' }"
v-bind="field.props || {}"
/>
<!-- 文本域 -->
<el-input
v-else-if="field.type === 'textarea'"
v-model="model[field.prop]"
type="textarea"
:placeholder="getFieldPlaceholder(field)"
:clearable="field.clearable !== false"
:disabled="field.disabled"
:rows="field.rows || 3"
:style="{ width: field.width || '200px' }"
v-bind="field.props || {}"
/>
<!-- 树形选择器 -->
<TreeSelectField
v-else-if="field.type === 'tree-select'"
:field="field"
:model-value="model[field.prop]"
:placeholder="getFieldPlaceholder(field)"
@update:model-value="model[field.prop] = $event"
/>
<!-- 选择器 -->
<el-select
v-else-if="field.type === 'select'"
v-model="model[field.prop]"
:placeholder="getFieldPlaceholder(field)"
:clearable="field.clearable !== false"
:disabled="field.disabled"
:multiple="field.multiple"
:filterable="field.filterable"
:style="{ width: field.width || '200px' }"
v-bind="field.props || {}"
>
<el-option
v-for="option in getFieldOptions(field)"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</el-select>
<!-- 日期选择器 -->
<el-date-picker
v-else-if="field.type === 'date' || field.type === 'datetime' || field.type === 'daterange' || field.type === 'datetimerange'"
v-model="model[field.prop]"
:type="field.type === 'date' ? 'date' : field.type === 'datetime' ? 'datetime' : field.type === 'daterange' ? 'daterange' : 'datetimerange'"
:placeholder="getFieldPlaceholder(field)"
:clearable="field.clearable !== false"
:disabled="field.disabled"
:value-format="field.valueFormat || (field.type === 'datetime' || field.type === 'datetimerange' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')"
:style="{ width: field.width || '180px' }"
v-bind="field.props || {}"
/>
<!-- 数字输入框 -->
<el-input-number
v-else-if="field.type === 'number'"
v-model="model[field.prop]"
:placeholder="getFieldPlaceholder(field)"
:disabled="field.disabled"
:min="field.min"
:max="field.max"
:step="field.step"
:style="{ width: field.width || '150px' }"
v-bind="field.props || {}"
/>
<!-- 开关 -->
<el-switch
v-else-if="field.type === 'switch'"
v-model="model[field.prop]"
:disabled="field.disabled"
v-bind="field.props || {}"
/>
</el-form-item>
</template>
<script setup>
import { computed, useSlots } from 'vue'
import { useI18n } from 'vue-i18n'
import TreeSelectField from './TreeSelectField.vue'
import { useFieldOptions } from './useFieldOptions'
const props = defineProps({
field: {
type: Object,
required: true
},
model: {
type: Object,
required: true
},
expanded: {
type: Boolean,
default: false
},
i18nPrefix: {
type: String,
default: ''
}
})
const { t } = useI18n()
const { getFieldOptions: getOptions } = useFieldOptions()
// 获取字段标签
const getFieldLabel = (field) => {
if (!field) return ''
if (field.label) {
// 如果 label 是翻译键
if (typeof field.label === 'string' && (field.label.startsWith('$t(') || (props.i18nPrefix && field.labelKey))) {
const key = field.labelKey || field.label.replace('$t(', '').replace(')', '')
return t(props.i18nPrefix ? `${props.i18nPrefix}.${key}` : key)
}
return field.label
}
// 尝试自动翻译
if (props.i18nPrefix && field.prop) {
const key = `${props.i18nPrefix}.${field.prop}`
const translated = t(key)
return translated !== key ? translated : field.prop
}
return field.prop || ''
}
// 获取字段占位符
const getFieldPlaceholder = (field) => {
if (field.placeholder) {
if (field.placeholder.startsWith('$t(') || (props.i18nPrefix && field.placeholderKey)) {
const key = field.placeholderKey || field.placeholder.replace('$t(', '').replace(')', '')
return t(props.i18nPrefix ? `${props.i18nPrefix}.${key}` : key)
}
return field.placeholder
}
// 自动生成占位符
const label = getFieldLabel(field)
if (field.type === 'select') {
return t('form.please_select') + label
}
return t('form.please_enter') + label
}
const getFieldStyle = (field) => {
return field.style || {}
}
const getFieldOptions = (field) => {
return getOptions(field)
}
</script>
@@ -0,0 +1,223 @@
<template>
<el-popover
placement="bottom-start"
:width="field.popoverWidth || 300"
:visible="popoverVisible"
trigger="manual"
@update:visible="(val) => {
// 只有在明确设置为 false 时才关闭,避免点击外部时意外关闭
if (val === false) {
updatePopoverVisible(false)
}
}"
:popper-options="{
modifiers: [
{ name: 'computeStyles', options: { gpuAcceleration: false } },
{ name: 'preventOverflow', options: { boundary: 'viewport' } }
]
}"
>
<template #reference>
<el-input
:model-value="displayInputValue"
:placeholder="placeholder"
:clearable="field.clearable !== false && (!!modelValue || !!filterText)"
:disabled="field.disabled"
:style="{ width: field.width || '200px' }"
@clear="handleClear"
@input="handleInput"
@focus="handleInputFocus"
@click="handleInputClick"
style="cursor: text"
>
<template #suffix>
<el-icon
v-if="!field.disabled && !modelValue && !inputValue"
class="el-input__icon"
:class="{ 'is-reverse': popoverVisible }"
style="transition: transform 0.3s; pointer-events: none;"
>
<ArrowDown />
</el-icon>
</template>
</el-input>
</template>
<div @click.stop @mousedown.stop>
<el-tree
:data="treeData"
:props="field.treeProps || { label: 'name', children: 'children' }"
node-key="id"
:default-expand-all="field.filterable !== false && filterText ? true : (field.defaultExpandAll || false)"
:expand-on-click-node="false"
:highlight-current="true"
@node-click="handleNodeClick"
:style="{ maxHeight: field.treeHeight || '300px', overflowY: 'auto' }"
>
<template #default="{ node, data }">
<span class="custom-tree-node" style="flex: 1; display: flex; align-items: center; justify-content: space-between; font-size: 14px; padding-right: 8px;">
<span>{{ node.label }}</span>
</span>
</template>
</el-tree>
</div>
</el-popover>
</template>
<script setup>
import { computed, watch } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { useTreeSelect } from './useTreeSelect'
const props = defineProps({
field: {
type: Object,
required: true
},
modelValue: {
type: [String, Number],
default: null
},
placeholder: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const { t } = useI18n()
const {
popoverVisible,
filterText,
treeData,
inputValue,
updatePopoverVisible,
togglePopover,
handleNodeClick: handleTreeSelectNodeClick,
handleClear: handleTreeSelectClear,
handleInput: handleTreeSelectInput,
loadData
} = useTreeSelect({
field: props.field,
modelValue: computed(() => props.modelValue),
onUpdate: (value) => {
emit('update:modelValue', value)
emit('change', value)
}
})
// 计算输入框显示值
const displayInputValue = computed(() => {
// 如果有输入文本,优先显示输入文本
if (filterText.value) {
return filterText.value
}
// 如果有选中值,显示选中值的标签(不管弹窗是否打开)
if (props.modelValue) {
return inputValue.value
}
return ''
})
// 监听 field.apiUrl 变化,重新加载数据
watch(() => props.field.apiUrl, () => {
if (props.field.apiUrl) {
loadData()
}
}, { immediate: true })
// 监听 field.treeData 变化(支持 getter 函数)
watch(() => {
if (typeof props.field.treeData === 'function') {
return props.field.treeData()
}
return props.field.treeData
}, (newTreeData) => {
if (newTreeData && Array.isArray(newTreeData) && newTreeData.length > 0) {
loadData()
}
}, { deep: true, immediate: true })
const handleNodeClick = (data) => {
// 获取节点的标签(优先使用 label,否则使用 name)
const labelKey = props.field.treeProps?.label || 'label'
const nameKey = props.field.treeProps?.name || 'name'
const nodeLabel = data[labelKey] || data[nameKey] || ''
// 先更新值
handleTreeSelectNodeClick(data)
// 清空 filterText,确保显示选中值
filterText.value = ''
// 然后关闭弹窗(延迟一下,确保值更新完成)
setTimeout(() => {
updatePopoverVisible(false)
}, 10)
}
const handleClear = (e) => {
e?.stopPropagation()
handleTreeSelectClear()
}
const handleInput = (val) => {
const inputVal = val || ''
// 如果输入为空,且有选中值,清空选中值
if (props.modelValue && !inputVal) {
handleTreeSelectClear()
return
}
// 如果当前有选中值,且输入内容发生变化
if (props.modelValue && inputVal) {
const currentDisplayValue = inputValue.value
if (inputVal !== currentDisplayValue) {
handleTreeSelectClear()
filterText.value = inputVal // 恢复输入内容
} else {
filterText.value = inputVal
}
} else {
filterText.value = inputVal
}
// 输入时自动打开弹窗
if (inputVal && !popoverVisible.value && !props.field.disabled) {
togglePopover()
}
}
const handleInputFocus = (e) => {
// 获得焦点时,如果有选中值,全选文本方便用户修改
if (props.modelValue && !filterText.value) {
// 延迟一下确保 DOM 更新
setTimeout(() => {
if (e.target && e.target.select) {
e.target.select()
}
}, 50)
}
}
const handleInputClick = (e) => {
// 如果点击的是清除按钮,不打开弹窗
if (e.target.closest('.el-input__clear')) {
return
}
// 打开弹窗
if (!popoverVisible.value && !props.field.disabled) {
togglePopover()
}
}
</script>
<style scoped>
:deep(.el-input__icon.is-reverse) {
transform: rotate(180deg);
}
</style>
@@ -0,0 +1,113 @@
import { ref } from 'vue'
import Storage from '../../utils/storage'
import { getOptions } from '../../api/option'
export function useFieldOptions() {
const fieldOptionsCache = ref({})
const loadFieldOptions = async (field) => {
if (!field.apiUrl) return []
const cacheKey = field.apiUrl
if (fieldOptionsCache.value[cacheKey] !== undefined) {
return fieldOptionsCache.value[cacheKey] || []
}
try {
fieldOptionsCache.value[cacheKey] = null
if (field.apiUrl.startsWith('/options')) {
const url = new URL(field.apiUrl, window.location.origin)
const type = url.searchParams.get('type')
const params = {}
for (const [key, value] of url.searchParams) {
if (key !== 'type') {
params[key] = value
}
}
const res = await getOptions(type, params)
if (res.data) {
// 适配后端返回结构:有的返回 { data: { options: [] } },有的直接返回 { data: [] }
let options = []
if (res.data.options) {
options = res.data.options
} else if (Array.isArray(res.data)) {
options = res.data
}
fieldOptionsCache.value[cacheKey] = options
return options
}
} else {
const token = Storage.getItem('token', '') || ''
const res = await fetch(field.apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${typeof token === 'string' ? token.trim() : ''}`,
'Content-Type': 'application/json'
}
})
if (res.ok) {
const data = await res.json()
let options = []
if (data.data && data.data.options) {
options = data.data.options
} else if (data.options) {
options = data.options
} else if (Array.isArray(data.data)) {
options = data.data.map(item => ({
label: item.name || item.Name || item.label || String(item.id || item.ID),
value: String(item.id || item.ID || item.value)
}))
} else if (Array.isArray(data)) {
options = data.map(item => ({
label: item.name || item.Name || item.label || String(item.id || item.ID),
value: String(item.id || item.ID || item.value)
}))
}
fieldOptionsCache.value[cacheKey] = options
return options
}
}
} catch (error) {
console.error('Load field options error:', error)
fieldOptionsCache.value[cacheKey] = []
return []
}
return []
}
const getFieldOptions = (field) => {
if (!field) return []
if (field.options && Array.isArray(field.options)) {
return field.options
}
if (field.apiUrl) {
const cacheKey = field.apiUrl
if (fieldOptionsCache.value[cacheKey]) {
return fieldOptionsCache.value[cacheKey]
}
loadFieldOptions(field)
return []
}
if (field.optionsFn && typeof field.optionsFn === 'function') {
try {
return field.optionsFn()
} catch (e) {
return []
}
}
return []
}
return {
fieldOptionsCache,
loadFieldOptions,
getFieldOptions
}
}
@@ -0,0 +1,98 @@
import { ref, nextTick } from 'vue'
export function useFormHeight(formRef, expanded, hasAdvancedFields, defaultExpanded) {
const formHeight = ref(0)
const singleLineHeight = ref(0)
const shouldShowExpandButton = ref(false)
// 检测表单高度,判断是否需要显示展开按钮
const checkFormHeight = () => {
nextTick(() => {
if (!formRef.value || !formRef.value.$el) return
const formEl = formRef.value.$el
const formItems = Array.from(formEl.querySelectorAll('.el-form-item:not(.action-item)'))
if (formItems.length === 0) return
// 获取第一个表单项的高度(作为单行高度参考)
const firstItem = formItems[0]
if (firstItem) {
const firstItemRect = firstItem.getBoundingClientRect()
const computedStyle = window.getComputedStyle(firstItem)
const marginBottom = parseFloat(computedStyle.marginBottom) || 18
// 单行高度 = 表单项高度 + 底部间距
singleLineHeight.value = firstItemRect.height + marginBottom
}
// 获取表单字段容器的高度
const fieldsWrapper = formEl.querySelector('.form-fields-wrapper')
if (fieldsWrapper) {
const wrapperRect = fieldsWrapper.getBoundingClientRect()
formHeight.value = wrapperRect.height
// 计算可以显示多少行(根据容器宽度和表单项宽度)
// 获取容器宽度(减去 padding)
const containerPadding = 40 // 左右 padding 各 20px
const containerWidth = wrapperRect.width - containerPadding
let currentRowWidth = 0
let rowCount = 1
const rowGap = 10 // 表单项之间的间距
let firstRowItems = [] // 第一行能显示的字段
formItems.forEach((item) => {
const itemRect = item.getBoundingClientRect()
const itemWidth = itemRect.width
const itemMarginRight = parseFloat(window.getComputedStyle(item).marginRight) || 10
// 如果当前行放不下这个表单项,换行
if (currentRowWidth + itemWidth + itemMarginRight > containerWidth && currentRowWidth > 0) {
rowCount++
currentRowWidth = itemWidth + itemMarginRight
} else {
if (rowCount === 1) {
firstRowItems.push(item)
}
currentRowWidth += itemWidth + itemMarginRight + rowGap
}
})
// 如果超过一行,需要显示展开按钮
if (rowCount > 1) {
shouldShowExpandButton.value = true
// 计算收起状态应该显示的高度(尽可能多地显示第一行的字段)
// 如果第一行能显示所有基础字段,则高度就是单行高度
// 否则需要计算第一行实际占用的高度
if (firstRowItems.length > 0) {
// 使用第一行最后一个元素的位置来计算高度
const lastFirstRowItem = firstRowItems[firstRowItems.length - 1]
const lastItemRect = lastFirstRowItem.getBoundingClientRect()
const firstItemRect = firstRowItems[0].getBoundingClientRect()
// 第一行的实际高度 = 最后一个元素底部 - 第一个元素顶部 + 底部间距
const firstRowHeight = lastItemRect.bottom - firstItemRect.top + 18
singleLineHeight.value = Math.max(singleLineHeight.value, firstRowHeight)
}
// 如果默认是收起状态,且表单高度超过单行,则默认收起
if (!defaultExpanded && expanded.value === defaultExpanded) {
expanded.value = false
}
} else {
shouldShowExpandButton.value = false
// 如果不需要展开按钮,则始终展开
if (!hasAdvancedFields.value) {
expanded.value = true
}
}
}
})
}
return {
formHeight,
singleLineHeight,
shouldShowExpandButton,
checkFormHeight
}
}
+264
View File
@@ -0,0 +1,264 @@
import { ref, computed, watch } from 'vue'
import Storage from '../../utils/storage'
import { getOptions } from '../../api/option'
export function useTreeSelect({ field, modelValue, onUpdate }) {
const popoverVisible = ref(false)
const filterText = ref('')
const treeData = ref([])
const fieldOptionsCache = ref({})
const selectedLabel = ref('') // 保存选中节点的标签
// 获取树形选择器显示值
const getTreeSelectDisplayValue = (fieldObj, value) => {
if (!value) return ''
const findNode = (data, targetId) => {
for (const node of data) {
const nodeId = node[fieldObj.treeProps?.value || 'id']
if (nodeId == targetId) {
return node[fieldObj.treeProps?.label || 'name']
}
if (node[fieldObj.treeProps?.children || 'children']) {
const found = findNode(node[fieldObj.treeProps?.children || 'children'], targetId)
if (found) return found
}
}
return ''
}
return findNode(fieldObj.treeData || treeData.value || [], value) || ''
}
// 计算输入框显示值(用于显示选中值的标签)
const inputValue = computed(() => {
const selectedValue = modelValue.value
if (selectedValue) {
// 优先使用保存的标签,如果不存在则通过查找获取
if (selectedLabel.value) {
return selectedLabel.value
}
return getTreeSelectDisplayValue(field, selectedValue)
}
return ''
})
// 更新弹窗显示状态
const updatePopoverVisible = (visible) => {
popoverVisible.value = visible
// 关闭弹窗时,如果有选中值,清空搜索文本(恢复显示选中值)
// 但只有在没有输入文本时才清空,避免清空用户正在输入的内容
if (!visible && modelValue.value && !filterText.value) {
filterText.value = ''
}
// 关闭弹窗时,如果没有选中值且没有输入文本,也清空搜索文本
if (!visible && !modelValue.value && !filterText.value) {
filterText.value = ''
}
}
// 切换弹窗显示状态
const togglePopover = () => {
popoverVisible.value = !popoverVisible.value
// 关闭弹窗时,如果有选中值,清空搜索文本(恢复显示选中值)
// 但只有在没有输入文本时才清空,避免清空用户正在输入的内容
if (!popoverVisible.value && modelValue.value && !filterText.value) {
filterText.value = ''
}
// 关闭弹窗时,如果没有选中值且没有输入文本,也清空搜索文本
if (!popoverVisible.value && !modelValue.value && !filterText.value) {
filterText.value = ''
}
}
// 处理节点点击
const handleNodeClick = (data) => {
const valueKey = field.treeProps?.value || 'id'
const value = data[valueKey]
// 保存选中节点的标签
const labelKey = field.treeProps?.label || 'label'
const nameKey = field.treeProps?.name || 'name'
selectedLabel.value = data[labelKey] || data[nameKey] || ''
onUpdate(value)
filterText.value = ''
}
// 处理清除
const handleClear = () => {
onUpdate(null)
filterText.value = ''
selectedLabel.value = '' // 清空保存的标签
}
// 处理输入
const handleInput = (val) => {
const inputVal = val || ''
filterText.value = inputVal
// 如果开始输入,清空选中值(允许重新选择)
if (inputVal && modelValue.value) {
// 如果输入的内容与选中值的显示文本不同,清空选中值
const displayValue = getTreeSelectDisplayValue(field, modelValue.value)
if (inputVal !== displayValue) {
onUpdate(null)
}
}
// 输入时自动打开弹窗
if (inputVal && !popoverVisible.value) {
togglePopover()
}
}
// 获取过滤后的树形数据
const getFilteredTreeData = (data) => {
if (!filterText.value || filterText.value === '') {
return data || []
}
const labelKey = field.treeProps?.label || 'name'
const childrenKey = field.treeProps?.children || 'children'
const filterNode = (node) => {
const label = node[labelKey] || ''
const matches = label.toLowerCase().includes(filterText.value.toLowerCase())
if (node[childrenKey] && Array.isArray(node[childrenKey])) {
const filteredChildren = node[childrenKey].map(child => filterNode(child)).filter(Boolean)
if (matches || filteredChildren.length > 0) {
return {
...node,
[childrenKey]: filteredChildren
}
}
} else if (matches) {
return node
}
return null
}
return (data || []).map(node => filterNode(node)).filter(Boolean)
}
// 加载树形数据
const loadData = async () => {
if (!field.apiUrl) {
if (field.treeData && Array.isArray(field.treeData)) {
treeData.value = getFilteredTreeData(field.treeData)
return
}
treeData.value = []
return
}
const cacheKey = field.apiUrl
if (fieldOptionsCache.value[cacheKey] !== undefined) {
const data = fieldOptionsCache.value[cacheKey]
if (Array.isArray(data)) {
treeData.value = getFilteredTreeData(data)
}
return
}
try {
fieldOptionsCache.value[cacheKey] = null
if (field.apiUrl.startsWith('/options')) {
const url = new URL(field.apiUrl, window.location.origin)
const type = url.searchParams.get('type')
const res = await getOptions(type)
if (res.data) {
if (res.data.options && Array.isArray(res.data.options)) {
fieldOptionsCache.value[cacheKey] = res.data.options
treeData.value = getFilteredTreeData(res.data.options)
} else if (res.data.list && Array.isArray(res.data.list)) {
fieldOptionsCache.value[cacheKey] = res.data.list
treeData.value = getFilteredTreeData(res.data.list)
}
}
} else {
const token = Storage.getItem('token', '') || ''
const res = await fetch(field.apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${typeof token === 'string' ? token.trim() : ''}`,
'Content-Type': 'application/json'
}
})
if (res.ok) {
const data = await res.json()
let options = []
if (data.data && data.data.options) {
options = data.data.options
} else if (data.data && data.data.list) {
options = data.data.list
} else if (data.options) {
options = data.options
} else if (Array.isArray(data.data)) {
options = data.data
} else if (Array.isArray(data)) {
options = data
}
fieldOptionsCache.value[cacheKey] = options
treeData.value = getFilteredTreeData(options)
}
}
} catch (error) {
console.error('Load tree select data error:', error)
fieldOptionsCache.value[cacheKey] = []
treeData.value = []
}
}
// 监听过滤文本变化,更新树形数据
watch(filterText, () => {
const currentTreeData = typeof field.treeData === 'function' ? field.treeData() : field.treeData
if (currentTreeData && Array.isArray(currentTreeData)) {
treeData.value = getFilteredTreeData(currentTreeData)
} else if (field.apiUrl) {
const cacheKey = field.apiUrl
if (fieldOptionsCache.value[cacheKey]) {
treeData.value = getFilteredTreeData(fieldOptionsCache.value[cacheKey])
}
}
})
// 监听 field.treeData 变化,更新树形数据
// 使用 computed 来访问 treeData,这样可以正确追踪 getter 的变化
const treeDataGetter = computed(() => {
if (typeof field.treeData === 'function') {
return field.treeData()
}
return field.treeData
})
watch(treeDataGetter, (newTreeData) => {
if (newTreeData && Array.isArray(newTreeData) && newTreeData.length > 0) {
treeData.value = getFilteredTreeData(newTreeData)
} else if (newTreeData && Array.isArray(newTreeData) && newTreeData.length === 0) {
// 如果数据被清空,也更新
treeData.value = []
}
}, { deep: true, immediate: true })
// 初始化加载数据
const initialTreeData = typeof field.treeData === 'function' ? field.treeData() : field.treeData
if (initialTreeData && Array.isArray(initialTreeData) && initialTreeData.length > 0) {
treeData.value = getFilteredTreeData(initialTreeData)
} else if (field.apiUrl) {
loadData()
}
return {
popoverVisible,
filterText,
treeData,
inputValue,
updatePopoverVisible,
togglePopover,
handleNodeClick,
handleClear,
handleInput,
loadData
}
}
+211
View File
@@ -0,0 +1,211 @@
<!--
通用表格操作按钮组件
使用示例
1. 简单用法只有主要操作
<TableActionButtons
:row="row"
:primary-actions="[
{
key: 'edit',
label: $t('common.edit'),
type: 'primary',
permission: 'admin.update',
handler: handleEdit
},
{
key: 'delete',
label: $t('common.delete'),
type: 'danger',
permission: 'admin.destroy',
handler: handleDelete
}
]"
:get-button-state="getButtonState"
/>
2. 带下拉菜单的用法
<TableActionButtons
:row="row"
:primary-actions="getPrimaryActions(row)"
:more-actions="getMoreActions(row)"
:get-button-state="getButtonState"
@action="handleAction"
/>
操作配置说明
- key: 操作的唯一标识
- label: 按钮显示的文本
- type: 按钮类型primary/danger/warning/info/success
- permission: 权限标识用于权限检查
- handler: 点击处理函数接收 row 作为参数
- show: 是否显示该操作函数或布尔值
- disabled: 是否禁用该操作函数或布尔值
- command: 下拉菜单命令用于 moreActions
- divided: 是否显示分割线仅用于下拉菜单
-->
<template>
<div class="table-action-buttons">
<!-- 主要操作按钮 -->
<template v-for="(action, index) in primaryActions" :key="index">
<el-button
v-if="shouldShowAction(action, row)"
:type="action.type || 'primary'"
link
:disabled="isActionDisabled(action, row)"
@click="handleAction(action, row)"
:title="action.title || (action.iconOnly ? action.label : '')"
>
<el-icon v-if="action.icon" :class="{ 'el-icon--left': !!action.label && !action.iconOnly }">
<component :is="action.icon" />
</el-icon>
<template v-if="!action.iconOnly">{{ action.label }}</template>
</el-button>
</template>
<!-- 更多操作使用下拉菜单 -->
<el-dropdown
v-if="hasMoreActions(row)"
trigger="click"
:teleported="false"
@command="(command) => handleDropdownCommand(command, row)"
>
<el-button
type="info"
link
:disabled="isMoreActionsDisabled(row)"
>
{{ t('common.more') }}
<el-icon class="el-icon--right"><ArrowDownIcon /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<template v-for="(action, index) in moreActions" :key="index">
<el-dropdown-item
v-if="shouldShowAction(action, row)"
:command="action.command || action.key"
:disabled="isActionDisabled(action, row)"
:divided="action.divided"
:icon="action.icon"
>
{{ action.label }}
</el-dropdown-item>
</template>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowDown } from '@element-plus/icons-vue'
import { markRaw } from 'vue'
const { t } = useI18n()
const ArrowDownIcon = markRaw(ArrowDown)
const props = defineProps({
// 行数据
row: {
type: Object,
required: true
},
// 主要操作按钮配置
primaryActions: {
type: Array,
default: () => []
},
// 更多操作按钮配置(下拉菜单)
moreActions: {
type: Array,
default: () => []
},
// 权限检查函数
getButtonState: {
type: Function,
default: () => ({ disabled: false })
}
})
const emit = defineEmits(['action'])
// 判断是否显示操作
const shouldShowAction = (action, row) => {
if (action.show === false) return false
if (typeof action.show === 'function') {
return action.show(row)
}
return true
}
// 判断操作是否禁用
const isActionDisabled = (action, row) => {
if (action.disabled === true) return true
if (typeof action.disabled === 'function') {
return action.disabled(row)
}
if (action.permission && props.getButtonState) {
return props.getButtonState(action.permission).disabled
}
return false
}
// 处理操作点击
const handleAction = (action, row) => {
if (action.handler) {
action.handler(row)
} else {
emit('action', action.command || action.key, row)
}
}
// 处理下拉菜单命令
const handleDropdownCommand = (command, row) => {
const action = props.moreActions.find(a => (a.command || a.key) === command)
if (action) {
handleAction(action, row)
} else {
emit('action', command, row)
}
}
// 判断是否有更多操作
const hasMoreActions = (row) => {
return props.moreActions.some(action => shouldShowAction(action, row))
}
// 判断更多操作是否全部禁用
const isMoreActionsDisabled = (row) => {
const visibleActions = props.moreActions.filter(action => shouldShowAction(action, row))
if (visibleActions.length === 0) return true
return visibleActions.every(action => isActionDisabled(action, row))
}
</script>
<style scoped>
.table-action-buttons {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
white-space: nowrap;
}
.table-action-buttons .el-button {
margin: 0;
padding: 0;
white-space: nowrap;
}
.table-action-buttons .el-dropdown {
margin-left: 0;
}
.table-action-buttons .el-icon--right {
margin-left: 2px;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<!--
通用表格列组件
用于简化 vxe-table 列的定义和渲染
使用示例
<vxe-table>
<template v-for="(column, index) in tableColumns" :key="column.field || column.slot || index">
<TableColumn :column="column" :index="index">
<template v-if="column.slot === 'status'" #status="{ row }">
<el-tag>...</el-tag>
</template>
<template v-if="column.slot === 'operation'" #operation="{ row }">
<TableActionButtons ... />
</template>
</TableColumn>
</template>
</vxe-table>
-->
<template>
<vxe-column
:type="column.type"
:field="column.field"
:title="column.title"
:width="column.width"
:sortable="column.sortable"
:fixed="column.fixed"
:formatter="column.formatter"
:tree-node="column.treeNode"
>
<template v-if="column.slot" #default="{ row }">
<slot :name="column.slot" :row="row" />
</template>
</vxe-column>
</template>
<script setup>
defineProps({
column: {
type: Object,
required: true
},
index: {
type: Number,
default: 0
}
})
</script>
+467
View File
@@ -0,0 +1,467 @@
<template>
<div class="tabs-view" v-if="tabsStore.hasTabs">
<el-tabs
v-model="activeTab"
type="card"
closable
@tab-remove="handleRemove"
@tab-click="handleClick"
class="tabs-container"
>
<el-tab-pane
v-for="tab in tabsStore.tabs"
:key="`tab-${tab.path}`"
:label="getTabTitle(tab)"
:name="tab.path"
:lazy="true"
>
<template #label>
<span
class="tab-label"
@contextmenu.prevent="showContextMenu($event, tab.path)"
>
<span class="tab-title">{{ getTabTitle(tab) }}</span>
<el-icon
v-if="tab.path === activeTab"
class="refresh-icon"
@click.stop="handleRefresh(tab.path)"
:title="$t('tabs.refresh')"
>
<Refresh />
</el-icon>
</span>
</template>
</el-tab-pane>
</el-tabs>
<!-- 右键菜单 -->
<teleport to="body">
<div
v-if="contextMenuVisible"
class="context-menu-overlay"
@click="contextMenuVisible = false"
></div>
<div
v-if="contextMenuVisible"
class="context-menu"
:style="{ left: contextMenuX + 'px', top: contextMenuY + 'px' }"
@click.stop
>
<div
class="context-menu-item"
@click="handleContextMenu({ action: 'refresh', path: contextMenuPath })"
>
<el-icon><Refresh /></el-icon>
<span>{{ $t('tabs.refresh') }}</span>
</div>
<div
class="context-menu-item"
@click="handleContextMenu({ action: 'close', path: contextMenuPath })"
>
<el-icon><Close /></el-icon>
<span>{{ $t('tabs.close') }}</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: tabsStore.tabs.length <= 1 }"
@click="tabsStore.tabs.length > 1 && handleContextMenu({ action: 'closeOther', path: contextMenuPath })"
>
<el-icon><CircleClose /></el-icon>
<span>{{ $t('tabs.closeOther') }}</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: !canCloseLeft }"
@click="canCloseLeft && handleContextMenu({ action: 'closeLeft', path: contextMenuPath })"
>
<el-icon><ArrowLeft /></el-icon>
<span>{{ $t('tabs.closeLeft') }}</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: !canCloseRight }"
@click="canCloseRight && handleContextMenu({ action: 'closeRight', path: contextMenuPath })"
>
<el-icon><ArrowRight /></el-icon>
<span>{{ $t('tabs.closeRight') }}</span>
</div>
<div
class="context-menu-item"
:class="{ disabled: tabsStore.tabs.length === 0 }"
@click="tabsStore.tabs.length > 0 && handleContextMenu({ action: 'closeAll', path: contextMenuPath })"
>
<el-icon><Delete /></el-icon>
<span>{{ $t('tabs.closeAll') }}</span>
</div>
</div>
</teleport>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useTabsStore } from '../store/tabs'
import { getMenuTranslation } from '../utils/menuTranslation'
import {
Refresh,
Close,
CircleClose,
ArrowLeft,
ArrowRight,
Delete
} from '@element-plus/icons-vue'
const router = useRouter()
const { t, te } = useI18n()
const tabsStore = useTabsStore()
const activeTab = computed({
get: () => tabsStore.activeTab,
set: (val) => tabsStore.setActiveTab(val)
})
const contextMenuRef = ref(null)
const contextMenuVisible = ref(false)
const contextMenuX = ref(0)
const contextMenuY = ref(0)
const contextMenuPath = ref('')
const canCloseLeft = computed(() => {
if (!contextMenuPath.value) return false
const index = tabsStore.tabs.findIndex(t => t.path === contextMenuPath.value)
return index > 0
})
const canCloseRight = computed(() => {
if (!contextMenuPath.value) return false
const index = tabsStore.tabs.findIndex(t => t.path === contextMenuPath.value)
return index < tabsStore.tabs.length - 1
})
const getTabTitle = (tab) => {
if (tab.titleKey) {
// 如果是 menu.xxx 格式,使用智能翻译
if (tab.titleKey.startsWith('menu.')) {
const slug = tab.titleKey.replace('menu.', '')
const translated = getMenuTranslation(t, te, slug)
if (translated) {
return translated
}
}
// 如果智能翻译失败,尝试直接翻译
if (te(tab.titleKey)) {
return t(tab.titleKey)
}
// 如果翻译键不存在,返回原始键(避免显示翻译键本身)
// 尝试从路径提取可能的标题
if (tab.path) {
const pathParts = tab.path.split('/').filter(p => p)
if (pathParts.length > 0) {
const lastPart = pathParts[pathParts.length - 1]
const extractedSlug = lastPart.split('?')[0] // 移除查询参数
const translated = getMenuTranslation(t, te, extractedSlug)
if (translated) {
return translated
}
}
}
}
return tab.title || tab.name
}
const handleRemove = async (path) => {
const isCurrentTab = tabsStore.activeTab === path
const currentIndex = tabsStore.tabs.findIndex(t => t.path === path)
// 使用 nextTick 确保 DOM 更新完成后再移除标签
await nextTick()
// 先移除标签
try {
tabsStore.removeTab(path)
} catch (error) {
// 忽略 Element Plus TabPane 卸载时的已知错误
if (error?.message?.includes('indexOf') || error?.stack?.includes('unregisterPane')) {
console.warn('TabPane unregister error (known Element Plus issue):', error)
return
}
throw error
}
// 如果关闭的是当前激活的标签,需要跳转到其他标签
if (isCurrentTab) {
if (tabsStore.tabs.length > 0) {
// 优先跳转到右侧的标签,如果没有则跳转到左侧的标签
let nextTab
if (currentIndex < tabsStore.tabs.length) {
// 右侧还有标签,跳转到右侧第一个
nextTab = tabsStore.tabs[currentIndex]
} else if (currentIndex > 0) {
// 左侧还有标签,跳转到左侧最后一个
nextTab = tabsStore.tabs[currentIndex - 1]
} else {
// 没有其他标签了,跳转到最后一个
nextTab = tabsStore.tabs[tabsStore.tabs.length - 1]
}
if (nextTab) {
tabsStore.setActiveTab(nextTab.path)
await router.push(nextTab.path)
}
} else {
// 如果没有标签了,跳转到首页并添加标签
tabsStore.setActiveTab('/dashboard')
await router.push('/dashboard')
}
}
}
const handleClick = (tab) => {
const path = tab.paneName
tabsStore.setActiveTab(path)
router.push(path)
}
const handleRefresh = (path) => {
tabsStore.refreshTab(path)
}
const handleContextMenu = async (command) => {
const { action, path } = command
contextMenuVisible.value = false
switch (action) {
case 'refresh':
handleRefresh(path)
break
case 'close':
await handleRemove(path)
break
case 'closeOther':
const isCurrentTab = tabsStore.activeTab === path
tabsStore.removeOtherTabs(path)
if (isCurrentTab || tabsStore.activeTab !== path) {
await router.push(path)
tabsStore.setActiveTab(path)
}
break
case 'closeLeft':
const isCurrentTabLeft = tabsStore.activeTab === path
tabsStore.removeLeftTabs(path)
if (isCurrentTabLeft || tabsStore.activeTab !== path) {
await router.push(path)
tabsStore.setActiveTab(path)
}
break
case 'closeRight':
const isCurrentTabRight = tabsStore.activeTab === path
tabsStore.removeRightTabs(path)
if (isCurrentTabRight || tabsStore.activeTab !== path) {
await router.push(path)
tabsStore.setActiveTab(path)
}
break
case 'closeAll':
tabsStore.removeAllTabs()
await router.push('/dashboard')
tabsStore.setActiveTab('/dashboard')
break
}
}
const showContextMenu = (e, path) => {
e.preventDefault()
e.stopPropagation()
contextMenuX.value = e.clientX
contextMenuY.value = e.clientY
contextMenuPath.value = path
contextMenuVisible.value = true
}
onMounted(() => {
// 点击其他地方关闭右键菜单
const handleClick = () => {
contextMenuVisible.value = false
}
document.addEventListener('click', handleClick)
onUnmounted(() => {
document.removeEventListener('click', handleClick)
})
})
</script>
<style scoped>
.tabs-view {
background: var(--header-bg);
border-bottom: 1px solid var(--border-color-light);
padding: 0;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.tabs-container {
margin: 0;
}
.tabs-container :deep(.el-tabs__header) {
margin: 0;
border-bottom: 1px solid var(--border-color-light);
background: var(--header-bg);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.tabs-container :deep(.el-tabs__nav-wrap) {
margin-bottom: 0;
/* padding: 0 12px; */
}
.tabs-container :deep(.el-tabs__nav) {
border: none;
}
.tabs-container :deep(.el-tabs__item) {
height: 36px;
line-height: 36px;
padding: 0 14px;
margin-right: 4px;
margin-top: 4px;
background: var(--bg-color-tertiary);
border: 1px solid var(--border-color-light);
border-bottom: none;
border-radius: 4px 4px 0 0;
color: var(--text-color-regular);
font-size: 12px;
user-select: none;
transition: all 0.2s;
position: relative;
}
.tabs-container :deep(.el-tabs__item:hover) {
color: #409EFF;
background: var(--bg-color-tertiary);
}
.tabs-container :deep(.el-tabs__item.is-active) {
background: var(--header-bg);
border: 1px solid #409EFF;
border-bottom: 2px solid #409EFF;
color: #409EFF;
font-weight: 500;
margin-bottom: -1px;
}
.tabs-container :deep(.el-tabs__item .el-icon-close) {
width: 14px;
height: 14px;
font-size: 12px;
margin-left: 8px;
border-radius: 50%;
transition: all 0.2s;
color: var(--text-color-secondary);
}
.tabs-container :deep(.el-tabs__item .el-icon-close:hover) {
background: #f56c6c;
color: #fff;
}
.tabs-container :deep(.el-tabs__item.is-active .el-icon-close) {
color: #409EFF;
}
.tabs-container :deep(.el-tabs__item.is-active .el-icon-close:hover) {
background: #f56c6c;
color: #fff;
}
.tab-label {
display: inline-flex;
align-items: center;
gap: 8px;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.refresh-icon {
cursor: pointer;
padding: 3px;
border-radius: 4px;
transition: all 0.2s;
color: #409EFF;
font-size: 14px;
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-left: 4px;
}
.refresh-icon:hover {
background: var(--bg-color-tertiary);
color: #66B1FF;
transform: rotate(180deg);
}
.context-menu {
position: fixed;
z-index: 9999;
background: var(--card-bg);
border: 1px solid var(--border-color-light);
border-radius: 4px;
box-shadow: 0 2px 12px 0 var(--shadow-base);
min-width: 160px;
padding: 4px 0;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
color: var(--text-color-regular);
transition: background-color 0.3s;
}
.context-menu-item:hover:not(.disabled) {
background-color: var(--bg-color-tertiary);
}
.context-menu-item.disabled {
color: var(--text-color-placeholder);
cursor: not-allowed;
}
.context-menu-item .el-icon {
font-size: 16px;
}
.context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9998;
background: transparent;
}
.el-tabs__nav-scroll{
margin-left: 10px;
}
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<el-select
class="timezone-switch"
v-model="selectedTimezone"
size="small"
filterable
allow-create
default-first-option
:placeholder="$t('header.timezone')"
>
<el-option
v-for="option in timezoneOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
<script setup>
import { computed, reactive } from 'vue'
import { useAppStore } from '../store/app'
const appStore = useAppStore()
const presetTimezones = [
{ value: 'Pacific/Honolulu', offset: '-10:00', label: 'UTC-10:00 (Pacific/Honolulu)' },
{ value: 'America/Anchorage', offset: '-09:00', label: 'UTC-09:00 (America/Anchorage)' },
{ value: 'America/Los_Angeles', offset: '-08:00', label: 'UTC-08:00 (America/Los_Angeles)' },
{ value: 'America/Denver', offset: '-07:00', label: 'UTC-07:00 (America/Denver)' },
{ value: 'America/Chicago', offset: '-06:00', label: 'UTC-06:00 (America/Chicago)' },
{ value: 'America/New_York', offset: '-05:00', label: 'UTC-05:00 (America/New_York)' },
{ value: 'America/Sao_Paulo', offset: '-03:00', label: 'UTC-03:00 (America/Sao_Paulo)' },
{ value: 'UTC', offset: '+00:00', label: 'UTC+00:00 (UTC)' },
{ value: 'Europe/Berlin', offset: '+01:00', label: 'UTC+01:00 (Europe/Berlin)' },
{ value: 'Europe/Moscow', offset: '+03:00', label: 'UTC+03:00 (Europe/Moscow)' },
{ value: 'Asia/Dubai', offset: '+04:00', label: 'UTC+04:00 (Asia/Dubai)' },
{ value: 'Asia/Kolkata', offset: '+05:30', label: 'UTC+05:30 (Asia/Kolkata)' },
{ value: 'Asia/Bangkok', offset: '+07:00', label: 'UTC+07:00 (Asia/Bangkok)' },
{ value: 'Asia/Shanghai', offset: '+08:00', label: 'UTC+08:00 (Asia/Shanghai)' },
{ value: 'Asia/Tokyo', offset: '+09:00', label: 'UTC+09:00 (Asia/Tokyo)' },
{ value: 'Australia/Sydney', offset: '+10:00', label: 'UTC+10:00 (Australia/Sydney)' },
{ value: 'Pacific/Auckland', offset: '+12:00', label: 'UTC+12:00 (Pacific/Auckland)' }
]
const timezoneMap = reactive(new Map(presetTimezones.map(item => [item.value, item.label])))
const formatOffsetLabel = (tz) => {
if (!tz) {
return ''
}
if (timezoneMap.has(tz)) {
return timezoneMap.get(tz)
}
try {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
timeZoneName: 'short',
hour: '2-digit',
minute: '2-digit'
})
const parts = formatter.formatToParts(new Date())
const tzName = parts.find(part => part.type === 'timeZoneName')?.value || ''
const match = tzName.match(/([+-]\d{1,2})(?::(\d{2}))?/)
if (match) {
const sign = match[1].startsWith('-') ? '-' : '+'
const hours = Math.abs(parseInt(match[1], 10)).toString().padStart(2, '0')
const minutes = (match[2] || '00').padStart(2, '0')
return `UTC${sign}${hours}:${minutes} (${tz})`
}
} catch {
// ignore errors and fall back to raw name
}
return tz
}
const ensureTimezoneIncluded = (tz) => {
if (!tz) {
return
}
if (!timezoneMap.has(tz)) {
timezoneMap.set(tz, formatOffsetLabel(tz))
}
}
ensureTimezoneIncluded(appStore.timezone)
const timezoneOptions = computed(() => {
return Array.from(timezoneMap.entries())
.map(([value, label]) => ({ value, label }))
.sort((a, b) => a.label.localeCompare(b.label))
})
const selectedTimezone = computed({
get: () => appStore.timezone,
set: (val) => {
ensureTimezoneIncluded(val)
appStore.setTimezone(val)
}
})
</script>
<style scoped>
.timezone-switch {
width: 210px;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<!--
封装的 VXE Table 组件
简化列表页的表格渲染逻辑
使用示例
<VxeTable
ref="tableRef"
:data="tableData"
:loading="loading"
:columns="tableColumns"
:height="600"
@sort-change="handleSortChange"
>
<template #status="{ row }">
<el-tag>...</el-tag>
</template>
<template #operation="{ row }">
<TableActionButtons ... />
</template>
</VxeTable>
-->
<template>
<vxe-table
ref="tableRef"
:data="data"
:loading="loading"
border
:column-config="{ resizable: true }"
:height="height"
:tree-config="treeConfig"
:sort-config="{ multiple: false, trigger: 'default' }"
@sort-change="handleSortChange"
@checkbox-change="handleCheckboxChange"
@checkbox-all="handleCheckboxAll"
>
<template v-for="(column, index) in columns" :key="column.field || column.slot || index">
<vxe-column
:type="column.type"
:field="column.field"
:title="column.title"
:width="column.width"
:min-width="column.minWidth"
:sortable="column.sortable"
:fixed="column.fixed"
:formatter="column.formatter"
:tree-node="column.treeNode"
>
<template v-if="column.slot" #default="{ row }">
<slot :name="column.slot" :row="row" />
</template>
</vxe-column>
</template>
</vxe-table>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
data: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
columns: {
type: Array,
required: true
},
height: {
type: [String, Number],
default: 600
},
treeConfig: {
type: [Object, Boolean],
default: null
}
})
const emit = defineEmits(['sort-change', 'checkbox-change', 'checkbox-all'])
const tableRef = ref(null)
const handleSortChange = (params) => {
emit('sort-change', params)
}
const handleCheckboxChange = (params) => {
emit('checkbox-change', params)
}
const handleCheckboxAll = (params) => {
emit('checkbox-all', params)
}
defineExpose({
get tableRef() {
return tableRef.value
}
})
</script>
+243
View File
@@ -0,0 +1,243 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key, params) => {
const translations = {
'common.delete_confirm': '确定要删除吗?',
'common.batch_delete_confirm': `确定要删除选中的 ${params?.count || 0} 条数据吗?`,
'common.delete_success': '删除成功',
'common.update_success': '更新成功',
'common.create_success': '创建成功',
'common.confirm': '确定',
'common.cancel': '取消',
'form.tip': '提示',
'common.please_select_items': '请选择要操作的项目'
}
return translations[key] || key
}
})
}))
// Mock element-plus
vi.mock('element-plus', () => ({
ElMessage: {
success: vi.fn(),
warning: vi.fn(),
error: vi.fn()
},
ElMessageBox: {
confirm: vi.fn().mockResolvedValue(true)
}
}))
// Mock logger
vi.mock('../../utils/logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}))
import { useCrud } from '../useCrud'
import { ElMessage, ElMessageBox } from 'element-plus'
describe('useCrud', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('对话框状态管理', () => {
it('应该初始化 dialogVisible 为 false', () => {
const { dialogVisible } = useCrud()
expect(dialogVisible.value).toBe(false)
})
it('应该初始化 editId 为 null', () => {
const { editId } = useCrud()
expect(editId.value).toBe(null)
})
it('handleAdd 应该打开对话框并清空 editId', () => {
const { dialogVisible, editId, handleAdd } = useCrud()
editId.value = 123
handleAdd()
expect(dialogVisible.value).toBe(true)
expect(editId.value).toBe(null)
})
it('handleEdit 应该打开对话框并设置 editId', () => {
const { dialogVisible, editId, handleEdit } = useCrud()
handleEdit({ id: 456 })
expect(dialogVisible.value).toBe(true)
expect(editId.value).toBe(456)
})
it('handleEdit 应该支持 ID 属性', () => {
const { editId, handleEdit } = useCrud()
handleEdit({ ID: 789 })
expect(editId.value).toBe(789)
})
it('handleClose 应该关闭对话框并清空 editId', () => {
const { dialogVisible, editId, handleClose, handleAdd } = useCrud()
handleAdd()
editId.value = 123
handleClose()
expect(dialogVisible.value).toBe(false)
expect(editId.value).toBe(null)
})
})
describe('handleFormSuccess', () => {
it('应该关闭对话框', () => {
const { dialogVisible, handleAdd, handleFormSuccess } = useCrud()
handleAdd()
handleFormSuccess()
expect(dialogVisible.value).toBe(false)
})
it('应该调用 reloadData 回调', () => {
const reloadData = vi.fn()
const { handleAdd, handleFormSuccess } = useCrud()
handleAdd()
handleFormSuccess(reloadData)
expect(reloadData).toHaveBeenCalled()
})
})
describe('handleDelete', () => {
it('应该显示确认对话框', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const { handleDelete } = useCrud({ deleteApi })
await handleDelete({ id: 1 })
expect(ElMessageBox.confirm).toHaveBeenCalled()
})
it('应该调用 deleteApi 并显示成功消息', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const { handleDelete } = useCrud({ deleteApi })
await handleDelete({ id: 1 })
expect(deleteApi).toHaveBeenCalledWith(1)
expect(ElMessage.success).toHaveBeenCalled()
})
it('应该支持直接传递 ID', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const { handleDelete } = useCrud({ deleteApi })
await handleDelete(123)
expect(deleteApi).toHaveBeenCalledWith(123)
})
it('应该在删除后调用 reloadData', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const reloadData = vi.fn()
const { handleDelete } = useCrud({ deleteApi })
await handleDelete({ id: 1 }, reloadData)
expect(reloadData).toHaveBeenCalled()
})
it('应该在用户取消时不执行删除', async () => {
ElMessageBox.confirm.mockRejectedValueOnce('cancel')
const deleteApi = vi.fn()
const { handleDelete } = useCrud({ deleteApi })
await handleDelete({ id: 1 })
expect(deleteApi).not.toHaveBeenCalled()
})
})
describe('handleBatchDelete', () => {
it('应该拒绝空数组', async () => {
const { handleBatchDelete } = useCrud()
await handleBatchDelete([])
expect(ElMessage.warning).toHaveBeenCalled()
expect(ElMessageBox.confirm).not.toHaveBeenCalled()
})
it('应该使用 batchDeleteApi 当提供时', async () => {
const batchDeleteApi = vi.fn().mockResolvedValue({})
const { handleBatchDelete } = useCrud({ batchDeleteApi })
await handleBatchDelete([{ id: 1 }, { id: 2 }])
expect(batchDeleteApi).toHaveBeenCalledWith([1, 2])
})
it('应该回退到循环调用 deleteApi', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const { handleBatchDelete } = useCrud({ deleteApi })
await handleBatchDelete([{ id: 1 }, { id: 2 }])
expect(deleteApi).toHaveBeenCalledTimes(2)
expect(deleteApi).toHaveBeenCalledWith(1)
expect(deleteApi).toHaveBeenCalledWith(2)
})
it('应该在成功后调用 reloadData', async () => {
const batchDeleteApi = vi.fn().mockResolvedValue({})
const reloadData = vi.fn()
const { handleBatchDelete } = useCrud({ batchDeleteApi })
await handleBatchDelete([{ id: 1 }], reloadData)
expect(reloadData).toHaveBeenCalled()
})
})
describe('可选参数', () => {
it('应该支持自定义 deleteConfirmKey', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const { handleDelete } = useCrud({
deleteApi,
deleteConfirmKey: 'custom.confirm'
})
await handleDelete({ id: 1 })
// 验证确认框被调用
expect(ElMessageBox.confirm).toHaveBeenCalled()
})
it('应该支持 onDeleteSuccess 回调', async () => {
const deleteApi = vi.fn().mockResolvedValue({})
const onDeleteSuccess = vi.fn()
const { handleDelete } = useCrud({
deleteApi,
onDeleteSuccess
})
const row = { id: 1 }
await handleDelete(row)
expect(onDeleteSuccess).toHaveBeenCalledWith(row, 1)
})
})
})
@@ -0,0 +1,174 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, nextTick } from 'vue'
import { useDebounce, useDebouncedRef } from '../useDebounce'
describe('useDebounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('基本防抖功能', () => {
it('应该在延迟后调用函数', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn()
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
})
it('应该只在最后一次调用后执行', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn('a')
debouncedFn('b')
debouncedFn('c')
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
it('应该在连续调用时重置定时器', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn()
vi.advanceTimersByTime(50)
debouncedFn()
vi.advanceTimersByTime(50)
debouncedFn()
vi.advanceTimersByTime(50)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(50)
expect(fn).toHaveBeenCalledTimes(1)
})
it('应该使用默认延迟 300ms', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn)
debouncedFn()
vi.advanceTimersByTime(299)
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(1)
expect(fn).toHaveBeenCalledTimes(1)
})
})
describe('立即执行模式', () => {
it('应该在首次调用时立即执行', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100, true)
debouncedFn()
expect(fn).toHaveBeenCalledTimes(1)
})
it('应该在立即执行后的延迟期间不再执行', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100, true)
debouncedFn()
debouncedFn()
debouncedFn()
expect(fn).toHaveBeenCalledTimes(1)
})
it('应该在延迟结束后重置状态', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100, true)
debouncedFn()
expect(fn).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(100)
debouncedFn()
expect(fn).toHaveBeenCalledTimes(2)
})
})
describe('取消功能', () => {
it('应该能够取消待执行的函数', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn()
debouncedFn.cancel()
vi.advanceTimersByTime(100)
expect(fn).not.toHaveBeenCalled()
})
it('多次取消应该是安全的', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn()
debouncedFn.cancel()
debouncedFn.cancel()
debouncedFn.cancel()
vi.advanceTimersByTime(100)
expect(fn).not.toHaveBeenCalled()
})
})
describe('参数传递', () => {
it('应该正确传递参数', () => {
const fn = vi.fn()
const debouncedFn = useDebounce(fn, 100)
debouncedFn('arg1', 'arg2', { key: 'value' })
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledWith('arg1', 'arg2', { key: 'value' })
})
})
})
describe('useDebouncedRef', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('应该创建带有初始值的防抖 ref', async () => {
const source = ref('initial')
const debounced = useDebouncedRef(source, 100)
expect(debounced.value).toBe('initial')
})
it('应该在延迟后更新值', async () => {
const source = ref('initial')
const debounced = useDebouncedRef(source, 100)
source.value = 'updated'
await nextTick()
// 还未到延迟时间
expect(debounced.value).toBe('initial')
vi.advanceTimersByTime(100)
await nextTick()
expect(debounced.value).toBe('updated')
})
})
+35
View File
@@ -0,0 +1,35 @@
/**
* Composables 导出入口
*
* TypeScript 版本的 composables 可以直接从这里导入:
* import { useCrud, useDebounce, useTableSort, usePermission } from '@/composables'
*
* 注意:部分 composables 仍为 JavaScript 版本,可以直接从各自文件导入
*/
// TypeScript composables
export { useCrud, type UseCrudOptions, type UseCrudReturn } from './useCrud'
export {
useDebounce,
useDebouncedRef,
type DebouncedFunction
} from './useDebounce'
export {
useTableSort,
type SortItem,
type SortConfig,
type UseTableSortOptions,
type UseTableSortReturn
} from './useTableSort'
export {
usePermission,
type ButtonState,
type UsePermissionReturn
} from './usePermission'
// JavaScript composables(仍可使用,但无完整类型支持)
// export { useListPage } from './useListPage'
// export { useColumnSetting } from './useColumnSetting'
// export { useTablePerformance } from './useTablePerformance'
// export { useApiRequest } from './useApiRequest'
+114
View File
@@ -0,0 +1,114 @@
import { ref, onUnmounted } from 'vue'
import logger from '../utils/logger'
/**
* API 请求 composable
* 提供请求取消机制和加载状态管理
*/
export function useApiRequest() {
const abortController = ref(null)
const loading = ref(false)
const error = ref(null)
/**
* 执行 API 请求
* @param {Function} apiCall - API 调用函数(应该返回 Promise,可以是 axios 请求)
* @param {Object} options - 配置选项
* @param {boolean} options.cancelPrevious - 是否取消之前的请求
* @returns {Promise} 请求结果
*/
const request = async (apiCall, options = {}) => {
const { cancelPrevious = true } = options
// 取消之前的请求
if (cancelPrevious && abortController.value) {
abortController.value.abort()
logger.debug('Previous request cancelled')
}
// 创建新的 AbortController
abortController.value = new AbortController()
loading.value = true
error.value = null
try {
// 调用 API 函数
// 注意:如果 apiCall 返回的是 axios 请求对象,需要手动添加 signal
const promise = apiCall()
// 如果返回的是 axios 请求对象(有 cancel 方法),添加取消支持
if (promise && typeof promise.cancel === 'function') {
// 存储取消函数
const originalCancel = promise.cancel
promise.cancel = () => {
abortController.value?.abort()
originalCancel()
}
}
const result = await promise
// 检查请求是否被取消
if (abortController.value?.signal?.aborted) {
logger.debug('Request was cancelled during execution')
return null
}
return result
} catch (err) {
// 如果是取消错误,不处理
if (err.name === 'AbortError' ||
err.message === 'canceled' ||
err.code === 'ERR_CANCELED' ||
err.message?.includes('canceled')) {
logger.debug('Request was cancelled')
return null
}
// 其他错误
error.value = err
throw err
} finally {
// 只有在请求未被取消时才重置 loading
if (!abortController.value?.signal?.aborted) {
loading.value = false
}
}
}
/**
* 取消当前请求
*/
const cancel = () => {
if (abortController.value) {
abortController.value.abort()
abortController.value = null
loading.value = false
logger.debug('Request cancelled manually')
}
}
/**
* 重置状态
*/
const reset = () => {
cancel()
error.value = null
}
// 组件卸载时取消请求
onUnmounted(() => {
cancel()
})
return {
request,
cancel,
reset,
loading,
error
}
}
export default useApiRequest
+125
View File
@@ -0,0 +1,125 @@
# useColumnSetting 使用示例
这是一个通用的列设置 composable,可以在任何列表页面中使用。
## 基本用法
```vue
<template>
<div>
<!-- 在表格头部添加列设置按钮 -->
<el-button type="info" @click="showColumnSetting = true">
<el-icon><Setting /></el-icon>
{{ $t('common.column_setting') }}
</el-button>
<!-- 表格 -->
<vxe-table :columns="tableColumns" :data="tableData">
<!-- ... -->
</vxe-table>
<!-- 列设置对话框 -->
<el-dialog
v-model="showColumnSetting"
:title="$t('common.column_setting')"
width="500px"
>
<el-checkbox-group v-model="visibleColumns">
<el-checkbox
v-for="column in allColumns"
:key="column.key"
:label="column.key"
:disabled="column.required"
>
{{ column.title }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="showColumnSetting = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="handleSaveColumnSetting">{{ $t('common.confirm') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Setting } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import { useColumnSetting } from '@/composables/useColumnSetting'
const { t } = useI18n()
// 1. 定义所有可配置的列(用于对话框显示)
const allColumnsConfig = computed(() => [
{ key: 'username', title: t('admin.username'), required: false },
{ key: 'nickname', title: t('admin.nickname'), required: false },
{ key: 'email', title: t('admin.email'), required: false },
{ key: 'phone', title: t('admin.phone'), required: false },
{ key: 'status', title: t('common.status'), required: false },
{ key: 'created_at', title: t('common.created_at'), required: false }
])
// 2. 使用列设置 composable
const {
showColumnSetting,
visibleColumns,
allColumns,
handleSaveColumnSetting,
getVisibleColumns
} = useColumnSetting({
storageKey: 'admin_list_column_setting', // localStorage 键名,每个页面应该不同
allColumns: allColumnsConfig,
defaultVisibleColumns: ['username', 'nickname', 'email', 'status'], // 默认显示的列
alwaysVisibleKeys: ['checkbox', 'operation'] // 始终显示的列
})
// 3. 定义所有列的完整配置(包括始终显示的列)
const allTableColumns = computed(() => [
{ type: 'checkbox', width: 50, fixed: 'left', key: 'checkbox' },
{ field: 'username', title: t('admin.username'), width: 120, sortable: true, key: 'username' },
{ field: 'nickname', title: t('admin.nickname'), width: 120, sortable: true, key: 'nickname' },
{ field: 'email', title: t('admin.email'), width: 180, sortable: true, key: 'email' },
{ field: 'phone', title: t('admin.phone'), width: 150, sortable: true, key: 'phone' },
{ field: 'status', title: t('common.status'), width: 100, sortable: true, key: 'status', slot: 'status' },
{ field: 'created_at', title: t('common.created_at'), width: 180, sortable: true, key: 'created_at' },
{ slot: 'operation', title: t('common.operation'), width: 150, fixed: 'right', key: 'operation' }
])
// 4. 根据 visibleColumns 过滤显示的列
const tableColumns = getVisibleColumns(allTableColumns)
</script>
```
## 参数说明
### useColumnSetting 参数
- `storageKey` (string, 必需): localStorage 存储键名,每个页面应该使用不同的键名
- `allColumns` (ComputedRef<Array>, 必需): 所有可配置列的配置数组
- 每个列需要包含 `key` (唯一标识) 和 `title` (显示标题)
- 可选 `required` (boolean): 是否必需显示(禁用复选框)
- `defaultVisibleColumns` (Array<string>, 可选): 默认可见的列 key 数组
- `alwaysVisibleKeys` (Array<string>, 可选): 始终显示的列 key 数组,默认为 `['checkbox', 'operation']`
### 返回值
- `showColumnSetting` (Ref<boolean>): 控制列设置对话框显示
- `visibleColumns` (Ref<Array<string>>): 当前可见的列 key 数组
- `allColumns` (ComputedRef<Array>): 所有可配置的列(已过滤掉始终显示的列)
- `handleSaveColumnSetting` (Function): 保存列设置的方法
- `getVisibleColumns` (Function): 获取过滤后的列的方法,接收 `allTableColumns` 参数
## 注意事项
1. **storageKey 必须唯一**: 每个列表页面应该使用不同的 storageKey,例如:
- `admin_list_column_setting`
- `role_list_column_setting`
- `blacklist_column_setting`
2. **列配置必须包含 key**: 所有列配置(包括始终显示的列)都必须包含 `key` 属性
3. **始终显示的列**: checkbox 和 operation 列默认始终显示,可以通过 `alwaysVisibleKeys` 参数自定义
4. **国际化**: 列的 `title` 应该使用 `t()` 函数进行国际化处理
+94
View File
@@ -0,0 +1,94 @@
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import Storage from '../utils/storage'
/**
* 列设置 composable(简化版)
*
* 使用示例:
* const { tableColumns, showColumnSetting, allColumns, visibleColumns, handleSaveColumnSetting } = useColumnSetting('page_name', allTableColumns)
*
* @param {string} storageKey localStorage 存储键名(会自动加上 '_column_setting' 后缀)
* @param {ComputedRef|Array} tableColumns 表格列配置数组
* @returns {Object} 返回列设置相关的状态和方法
*/
export function useColumnSetting(storageKey, tableColumns) {
const { t } = useI18n()
// 处理存储键
const fullStorageKey = storageKey.includes('_column_setting') ? storageKey : `${storageKey}_column_setting`
// 列设置对话框显示状态
const showColumnSetting = ref(false)
// 获取列的唯一标识
const getColumnKey = (column) => column.field || column.slot || column.key || ''
// 从 tableColumns 自动提取所有可配置的列(排除 checkbox 和 operation
const allColumns = computed(() => {
const columns = Array.isArray(tableColumns) ? tableColumns : (tableColumns?.value || [])
return columns
.filter((column) => {
if (column.type === 'checkbox') return false
const key = getColumnKey(column)
if (!key || key === 'operation') return false
return true
})
.map((column) => ({
key: getColumnKey(column),
title: column.title || getColumnKey(column),
required: column.required || false
}))
})
// 默认显示所有列
const defaultVisibleColumns = computed(() => allColumns.value.map(col => col.key))
// 从 localStorage 加载或使用默认值
const visibleColumns = ref(
Storage.getItem(fullStorageKey) || []
)
// 如果没有存储值,使用默认值
if (!visibleColumns.value.length && defaultVisibleColumns.value.length) {
visibleColumns.value = [...defaultVisibleColumns.value]
}
// 保存列设置
const handleSaveColumnSetting = (newVisibleColumns) => {
visibleColumns.value = Array.isArray(newVisibleColumns) ? newVisibleColumns : []
Storage.setItem(fullStorageKey, visibleColumns.value)
showColumnSetting.value = false
ElMessage.success(t('common.save_success'))
}
// 根据 visibleColumns 过滤显示的列
const filteredColumns = computed(() => {
const columns = Array.isArray(tableColumns) ? tableColumns : (tableColumns?.value || [])
return columns.filter((column) => {
// checkbox 和 operation 始终显示
if (column.type === 'checkbox') return true
const key = getColumnKey(column)
if (!key || key === 'operation') return true
// 其他列根据 visibleColumns 决定
return visibleColumns.value.includes(key)
})
})
return {
// 过滤后的表格列(直接用于 vxe-table)
tableColumns: filteredColumns,
// 列设置对话框显示状态
showColumnSetting,
// 所有可配置的列(用于 ColumnSettingDialog
allColumns,
// 当前可见的列 key 数组
visibleColumns,
// 默认可见列
defaultVisibleColumns,
// 保存列设置
handleSaveColumnSetting
}
}
+253
View File
@@ -0,0 +1,253 @@
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { compact, map } from 'lodash-es'
import logger from '../utils/logger'
/**
* @typedef {import('../types/composables').UseCrudOptions} UseCrudOptions
* @typedef {import('../types/composables').UseCrudReturn} UseCrudReturn
*/
/**
* CRUD 操作通用 composable
*
* @description 提供通用的增删改查操作,支持按需使用
*
* @example
* // 1. 只需要添加/编辑功能(不需要删除)
* const { dialogVisible, editId, handleAdd, handleEdit, handleClose, handleFormSuccess } = useCrud()
*
* // 2. 需要删除功能
* const { handleDelete } = useCrud({ deleteApi: deleteXxx })
*
* // 3. 需要批量删除功能(使用专门的批量删除 API)
* const { handleBatchDelete } = useCrud({ batchDeleteApi: batchDeleteXxx })
*
* // 4. 完整功能
* const { handleDelete, handleBatchDelete } = useCrud({ deleteApi, batchDeleteApi })
*
* @param {UseCrudOptions} [options={}] 配置选项(所有参数都是可选的)
* @returns {UseCrudReturn} 返回 CRUD 相关的状态和方法
*/
export function useCrud(options = {}) {
const {
deleteApi = null,
batchDeleteApi = null,
deleteConfirmKey = '',
deleteSuccessKey = '',
batchDeleteConfirmKey = '',
tipKey = 'form.tip',
onDeleteSuccess = null,
onDeleteError = null,
beforeDelete = null,
afterDelete = null
} = options
const { t } = useI18n()
// 对话框显示状态
const dialogVisible = ref(false)
// 编辑ID
const editId = ref(null)
/**
* 打开添加对话框
*/
const handleAdd = () => {
editId.value = null
dialogVisible.value = true
}
/**
* 打开编辑对话框
* @param {Object|Number|String} rowOrId - 行数据或ID
*/
const handleEdit = (rowOrId) => {
if (typeof rowOrId === 'object' && rowOrId !== null) {
editId.value = rowOrId.id || rowOrId.ID
} else {
editId.value = rowOrId
}
dialogVisible.value = true
}
/**
* 关闭对话框
*/
const handleClose = () => {
dialogVisible.value = false
editId.value = null
}
/**
* 表单提交成功后的处理
* @param {Function} reloadData - 重新加载数据的函数
*/
const handleFormSuccess = (reloadData = null) => {
handleClose()
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
}
/**
* 删除操作
* @param {Object|Number|String} rowOrId - 行数据或ID
* @param {Function} reloadData - 重新加载数据的函数
*/
const handleDelete = async (rowOrId, reloadData = null) => {
try {
// 获取ID
let id
if (typeof rowOrId === 'object' && rowOrId !== null) {
id = rowOrId.id || rowOrId.ID
} else {
id = rowOrId
}
if (!id) {
logger.error('useCrud: delete id is required')
return
}
// 删除前的钩子
if (beforeDelete) {
const result = await beforeDelete(rowOrId, id)
if (result === false) {
return // 取消删除
}
}
// 确认删除
const confirmKey = deleteConfirmKey || 'common.delete_confirm'
await ElMessageBox.confirm(
t(confirmKey),
t(tipKey),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
// 执行删除
if (!deleteApi) {
logger.error('useCrud: deleteApi is required')
return
}
await deleteApi(id)
// 删除成功提示
const successKey = deleteSuccessKey || 'common.delete_success'
ElMessage.success(t(successKey))
// 删除成功回调
if (onDeleteSuccess) {
onDeleteSuccess(rowOrId, id)
}
// 删除后钩子
if (afterDelete) {
afterDelete(rowOrId, id)
}
// 重新加载数据
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
} catch (error) {
if (error === 'cancel') {
return // 用户取消
}
logger.error('Delete error:', error)
// 删除失败回调
if (onDeleteError) {
onDeleteError(error, rowOrId)
}
}
}
/**
* 批量删除
* @param {Array} rows - 要删除的行数据数组
* @param {Function} reloadData - 重新加载数据的函数
*/
const handleBatchDelete = async (rows, reloadData = null) => {
if (!rows || rows.length === 0) {
ElMessage.warning(t('common.please_select_items'))
return
}
try {
const confirmKey = batchDeleteConfirmKey || 'common.batch_delete_confirm'
const count = rows.length
await ElMessageBox.confirm(
t(confirmKey, { count }),
t(tipKey),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
}
)
const ids = compact(map(rows, row => row.id || row.ID))
if (ids.length === 0) {
return
}
// 优先使用批量删除 API,否则循环调用单个删除 API
if (batchDeleteApi) {
await batchDeleteApi(ids)
} else if (deleteApi) {
for (const id of ids) {
await deleteApi(id)
}
} else {
logger.error('useCrud: deleteApi or batchDeleteApi is required for batch delete')
return
}
const successKey = deleteSuccessKey || 'common.delete_success'
ElMessage.success(t(successKey))
if (onDeleteSuccess) {
onDeleteSuccess(rows, ids)
}
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
} catch (error) {
if (error === 'cancel') {
return
}
logger.error('Batch delete error:', error)
if (onDeleteError) {
onDeleteError(error, rows)
}
}
}
return {
// 状态
dialogVisible,
editId,
// 方法
handleAdd,
handleEdit,
handleClose,
handleFormSuccess,
handleDelete,
handleBatchDelete
}
}
+293
View File
@@ -0,0 +1,293 @@
import { ref, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import { compact, map } from 'lodash-es'
import logger from '../utils/logger'
/**
* CRUD 配置选项
*/
export interface UseCrudOptions {
/** 单个删除 API 函数 */
deleteApi?: (id: number | string) => Promise<any>
/** 批量删除 API 函数 */
batchDeleteApi?: (ids: (number | string)[]) => Promise<any>
/** 删除确认提示的 i18n key */
deleteConfirmKey?: string
/** 删除成功提示的 i18n key */
deleteSuccessKey?: string
/** 批量删除确认提示的 i18n key */
batchDeleteConfirmKey?: string
/** 提示框标题的 i18n key */
tipKey?: string
/** 删除成功回调 */
onDeleteSuccess?: (deletedItems: any, deletedIds: number | string | (number | string)[]) => void
/** 删除失败回调 */
onDeleteError?: (error: any, failedItems: any) => void
/** 删除前置处理函数,返回 false 取消删除 */
beforeDelete?: (item: any, id: number | string) => Promise<boolean | void> | boolean | void
/** 删除后置处理函数 */
afterDelete?: (item: any, id: number | string) => void
}
/**
* CRUD 返回值类型
*/
export interface UseCrudReturn {
/** 对话框显示状态 */
dialogVisible: Ref<boolean>
/** 当前编辑的 ID */
editId: Ref<number | string | null>
/** 打开添加对话框 */
handleAdd: () => void
/** 打开编辑对话框 */
handleEdit: (rowOrId: { id?: number | string; ID?: number | string } | number | string) => void
/** 关闭对话框 */
handleClose: () => void
/** 表单提交成功后处理 */
handleFormSuccess: (reloadData?: () => void) => void
/** 处理单个删除 */
handleDelete: (
rowOrId: { id?: number | string; ID?: number | string } | number | string,
reloadData?: () => void
) => Promise<void>
/** 处理批量删除 */
handleBatchDelete: (
rows: Array<{ id?: number | string; ID?: number | string }>,
reloadData?: () => void
) => Promise<void>
}
/**
* CRUD 操作通用 composable
*
* @description 提供通用的增删改查操作,支持按需使用
*
* @example
* // 1. 只需要添加/编辑功能(不需要删除)
* const { dialogVisible, editId, handleAdd, handleEdit, handleClose, handleFormSuccess } = useCrud()
*
* // 2. 需要删除功能
* const { handleDelete } = useCrud({ deleteApi: deleteXxx })
*
* // 3. 需要批量删除功能
* const { handleBatchDelete } = useCrud({ batchDeleteApi: batchDeleteXxx })
*
* // 4. 完整功能
* const { handleDelete, handleBatchDelete } = useCrud({ deleteApi, batchDeleteApi })
*/
export function useCrud(options: UseCrudOptions = {}): UseCrudReturn {
const {
deleteApi = null,
batchDeleteApi = null,
deleteConfirmKey = '',
deleteSuccessKey = '',
batchDeleteConfirmKey = '',
tipKey = 'form.tip',
onDeleteSuccess = null,
onDeleteError = null,
beforeDelete = null,
afterDelete = null
} = options
const { t } = useI18n()
// 对话框显示状态
const dialogVisible = ref(false)
// 编辑ID
const editId = ref<number | string | null>(null)
/**
* 打开添加对话框
*/
const handleAdd = (): void => {
editId.value = null
dialogVisible.value = true
}
/**
* 打开编辑对话框
*/
const handleEdit = (
rowOrId: { id?: number | string; ID?: number | string } | number | string
): void => {
if (typeof rowOrId === 'object' && rowOrId !== null) {
editId.value = rowOrId.id ?? rowOrId.ID ?? null
} else {
editId.value = rowOrId as number | string
}
dialogVisible.value = true
}
/**
* 关闭对话框
*/
const handleClose = (): void => {
dialogVisible.value = false
editId.value = null
}
/**
* 表单提交成功后的处理
*/
const handleFormSuccess = (reloadData?: () => void): void => {
handleClose()
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
}
/**
* 删除操作
*/
const handleDelete = async (
rowOrId: { id?: number | string; ID?: number | string } | number | string,
reloadData?: () => void
): Promise<void> => {
try {
// 获取ID
let id: number | string | undefined
if (typeof rowOrId === 'object' && rowOrId !== null) {
id = rowOrId.id ?? rowOrId.ID
} else {
id = rowOrId as number | string
}
if (!id) {
logger.error('useCrud: delete id is required')
return
}
// 删除前的钩子
if (beforeDelete) {
const result = await beforeDelete(rowOrId, id as number | string)
if (result === false) {
return // 取消删除
}
}
// 确认删除
const confirmKey = deleteConfirmKey || 'common.delete_confirm'
await ElMessageBox.confirm(t(confirmKey), t(tipKey), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
// 执行删除
if (!deleteApi) {
logger.error('useCrud: deleteApi is required')
return
}
await deleteApi(id)
// 删除成功提示
const successKey = deleteSuccessKey || 'common.delete_success'
ElMessage.success(t(successKey))
// 删除成功回调
if (onDeleteSuccess) {
onDeleteSuccess(rowOrId, id as number | string)
}
// 删除后钩子
if (afterDelete) {
afterDelete(rowOrId, id as number | string)
}
// 重新加载数据
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
} catch (error) {
if (error === 'cancel') {
return // 用户取消
}
logger.error('Delete error:', error)
// 删除失败回调
if (onDeleteError) {
onDeleteError(error, rowOrId)
}
}
}
/**
* 批量删除
*/
const handleBatchDelete = async (
rows: Array<{ id?: number | string; ID?: number | string }>,
reloadData?: () => void
): Promise<void> => {
if (!rows || rows.length === 0) {
ElMessage.warning(t('common.please_select_items'))
return
}
try {
const confirmKey = batchDeleteConfirmKey || 'common.batch_delete_confirm'
const count = rows.length
await ElMessageBox.confirm(t(confirmKey, { count }), t(tipKey), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
const ids = compact(map(rows, (row) => row.id ?? row.ID)) as (number | string)[]
if (ids.length === 0) {
return
}
// 优先使用批量删除 API,否则循环调用单个删除 API
if (batchDeleteApi) {
await batchDeleteApi(ids)
} else if (deleteApi) {
for (const id of ids) {
await deleteApi(id)
}
} else {
logger.error('useCrud: deleteApi or batchDeleteApi is required for batch delete')
return
}
const successKey = deleteSuccessKey || 'common.delete_success'
ElMessage.success(t(successKey))
if (onDeleteSuccess) {
onDeleteSuccess(rows, ids)
}
if (reloadData && typeof reloadData === 'function') {
reloadData()
}
} catch (error) {
if (error === 'cancel') {
return
}
logger.error('Batch delete error:', error)
if (onDeleteError) {
onDeleteError(error, rows)
}
}
}
return {
// 状态
dialogVisible,
editId,
// 方法
handleAdd,
handleEdit,
handleClose,
handleFormSuccess,
handleDelete,
handleBatchDelete
}
}
+75
View File
@@ -0,0 +1,75 @@
import { ref, watch } from 'vue'
/**
* 防抖 composable
* @param {Function} fn - 需要防抖的函数
* @param {Number} delay - 延迟时间(毫秒),默认 300ms
* @param {Boolean} immediate - 是否立即执行,默认 false
* @returns {Function} 防抖后的函数
*/
export function useDebounce(fn, delay = 300, immediate = false) {
let timer = null
let isInvoked = false
const debouncedFn = function (...args) {
const context = this
// 清除之前的定时器
if (timer) {
clearTimeout(timer)
}
// 如果立即执行且还未执行过
if (immediate && !isInvoked) {
fn.apply(context, args)
isInvoked = true
}
// 设置新的定时器
timer = setTimeout(() => {
if (!immediate) {
fn.apply(context, args)
}
isInvoked = false
timer = null
}, delay)
}
// 取消防抖
debouncedFn.cancel = () => {
if (timer) {
clearTimeout(timer)
timer = null
isInvoked = false
}
}
return debouncedFn
}
/**
* 防抖响应式值
* @param {any} source - 源值(ref 或 computed
* @param {Number} delay - 延迟时间(毫秒),默认 300ms
* @returns {Ref} 防抖后的响应式值
*/
export function useDebouncedRef(source, delay = 300) {
const debouncedValue = ref(source.value)
watch(
source,
(newValue) => {
const timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
return () => {
clearTimeout(timer)
}
},
{ immediate: true }
)
return debouncedValue
}
+105
View File
@@ -0,0 +1,105 @@
import { ref, watch, type Ref, type WatchSource } from 'vue'
/**
* 防抖函数接口
*/
export interface DebouncedFunction<T extends (...args: any[]) => any> {
(...args: Parameters<T>): void
/** 取消待执行的防抖函数 */
cancel: () => void
}
/**
* 防抖 composable
* @param fn - 需要防抖的函数
* @param delay - 延迟时间(毫秒),默认 300ms
* @param immediate - 是否立即执行,默认 false
* @returns 防抖后的函数
*
* @example
* const debouncedSearch = useDebounce((keyword: string) => {
* console.log('Searching:', keyword)
* }, 300)
*
* // 使用
* debouncedSearch('hello')
*
* // 取消
* debouncedSearch.cancel()
*/
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay: number = 300,
immediate: boolean = false
): DebouncedFunction<T> {
let timer: ReturnType<typeof setTimeout> | null = null
let isInvoked = false
const debouncedFn = function (this: any, ...args: Parameters<T>): void {
const context = this
// 清除之前的定时器
if (timer) {
clearTimeout(timer)
}
// 如果立即执行且还未执行过
if (immediate && !isInvoked) {
fn.apply(context, args)
isInvoked = true
}
// 设置新的定时器
timer = setTimeout(() => {
if (!immediate) {
fn.apply(context, args)
}
isInvoked = false
timer = null
}, delay)
} as DebouncedFunction<T>
// 取消防抖
debouncedFn.cancel = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
isInvoked = false
}
}
return debouncedFn
}
/**
* 防抖响应式值
* @param source - 源值(ref 或 computed
* @param delay - 延迟时间(毫秒),默认 300ms
* @returns 防抖后的响应式值
*
* @example
* const searchKeyword = ref('')
* const debouncedKeyword = useDebouncedRef(searchKeyword, 500)
*
* // 当 searchKeyword 变化时,debouncedKeyword 会延迟 500ms 更新
*/
export function useDebouncedRef<T>(source: Ref<T>, delay: number = 300): Ref<T> {
const debouncedValue = ref(source.value) as Ref<T>
watch(
source as WatchSource<T>,
(newValue: T) => {
const timer = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
return () => {
clearTimeout(timer)
}
},
{ immediate: true }
)
return debouncedValue
}
+91
View File
@@ -0,0 +1,91 @@
/**
* TypeScript Composable 示例
*
* 这是一个展示如何使用 TypeScript 编写 composable 的示例文件
* 将 .ts.example 改为 .ts 即可启用
*
* 注意:现有的 .js 文件不需要修改,可以继续使用
* TypeScript 支持是可选的,新文件可以选择使用 .ts 或 .js
*/
import { ref, computed, type Ref, type ComputedRef } from 'vue'
import type { Admin, Pagination } from '../types'
// 定义 composable 的参数类型
interface UseExampleOptions {
initialValue?: number
maxValue?: number
}
// 定义 composable 的返回类型
interface UseExampleReturn {
count: Ref<number>
doubleCount: ComputedRef<number>
increment: () => void
decrement: () => void
reset: () => void
}
/**
* 示例 composable
* @param options 配置选项
* @returns composable 返回值
*/
export function useExample(options: UseExampleOptions = {}): UseExampleReturn {
const { initialValue = 0, maxValue = 100 } = options
const count = ref(initialValue)
const doubleCount = computed(() => count.value * 2)
const increment = () => {
if (count.value < maxValue) {
count.value++
}
}
const decrement = () => {
if (count.value > 0) {
count.value--
}
}
const reset = () => {
count.value = initialValue
}
return {
count,
doubleCount,
increment,
decrement,
reset
}
}
// ==================== 使用示例 ====================
/**
* 展示如何在组件中使用类型
*/
function exampleUsage() {
// 使用 composable
const { count, doubleCount, increment } = useExample({ initialValue: 10 })
// 类型安全的数据操作
const admin: Admin = {
id: 1,
username: 'admin',
status: 1
}
// 分页类型
const pagination: Pagination = {
page: 1,
pageSize: 10,
total: 100
}
console.log(count.value, doubleCount.value, admin.username, pagination.total)
}
+184
View File
@@ -0,0 +1,184 @@
# useListPage Composable 使用说明
`useListPage` 是一个用于封装列表页面通用逻辑的 composable,可以大大减少重复代码。
## 基本用法
```javascript
import { useListPage } from '@/composables/useListPage'
import { getAdminList } from '@/api/admin'
// 使用 composable
const {
pagination,
tableData,
loading,
searchForm,
loadData,
handleSearch,
handleReset,
handlePageChange,
handleSortChange,
initDefaultSort
} = useListPage({
fetchApi: getAdminList,
initialSearchForm: {
username: '',
status: '',
role_id: ''
}
})
// 在 onMounted 中初始化
onMounted(() => {
initDefaultSort?.()
loadData()
})
```
## 带排序的用法
```javascript
import { ref } from 'vue'
import { useListPage } from '@/composables/useListPage'
import { getAdminList } from '@/api/admin'
const tableRef = ref(null)
const fieldMapping = {
'id': 'id',
'username': 'username',
'created_at': 'created_at'
}
const {
pagination,
tableData,
loading,
searchForm,
loadData,
handleSearch,
handleReset,
handlePageChange,
handleSortChange,
initDefaultSort
} = useListPage({
fetchApi: getAdminList,
initialSearchForm: {
username: '',
status: ''
},
sortOptions: {
tableRef,
fieldMapping,
defaultSort: 'id:desc'
}
})
```
## 自定义参数构建
```javascript
const {
pagination,
tableData,
loading,
searchForm,
loadData
} = useListPage({
fetchApi: getAdminList,
initialSearchForm: {
username: '',
status: ''
},
buildParams: (searchForm, pagination, orderBy) => {
const params = {
page: pagination.page,
page_size: pagination.pageSize
}
if (orderBy) {
params.order_by = orderBy
}
// 自定义参数构建逻辑
if (searchForm.username) {
params.username = searchForm.username.trim()
}
return params
}
})
```
## 数据转换
```javascript
const {
tableData,
loadData
} = useListPage({
fetchApi: getAdminList,
initialSearchForm: {},
transformData: (item) => {
// 转换数据格式
return {
id: item.id || item.ID,
username: item.username || item.Username,
// ... 其他字段
}
}
})
```
## 回调函数
```javascript
const {
loadData
} = useListPage({
fetchApi: getAdminList,
initialSearchForm: {},
onLoadSuccess: (res, list) => {
// 加载成功后的处理
console.log('加载成功', list)
},
onLoadError: (error) => {
// 加载失败后的处理
console.error('加载失败', error)
}
})
```
## 返回值说明
- `pagination`: 分页状态对象(reactive
- `page`: 当前页码
- `pageSize`: 每页数量
- `total`: 总记录数
- `tableData`: 表格数据(ref
- `loading`: 加载状态(ref
- `searchForm`: 搜索表单数据(reactive
- `loadData()`: 加载数据方法
- `handleSearch()`: 搜索处理方法(重置页码并加载)
- `handleReset()`: 重置搜索条件方法(重置表单、排序、页码并加载)
- `handlePageChange({ currentPage, pageSize })`: 分页变化处理方法
- `handleSortChange()`: 排序变化处理方法(如果提供了 sortOptions
- `initDefaultSort()`: 初始化默认排序方法(如果提供了 sortOptions
## 注意事项
1. `fetchApi` 是必需的参数
2. `initialSearchForm` 定义了搜索表单的初始值
3. 如果提供了 `sortOptions`,会自动集成排序功能
4. `buildParams``transformData``onLoadSuccess``onLoadError` 都是可选参数
+162
View File
@@ -0,0 +1,162 @@
import { ref, reactive, computed } from 'vue'
import { forOwn } from 'lodash-es'
import { useTableData } from './useTableData'
import { useTableSort } from './useTableSort'
import { buildSearchParams } from '../utils/buildSearchParams'
/**
* 列表页面通用 composable
* 整合表格数据、排序、搜索等功能,简化列表页代码
*
* @param {Object} options 配置选项
* @param {Function} options.fetchApi - 获取数据的 API 函数
* @param {Object} options.initialSearchForm - 初始搜索表单数据
* @param {Object} options.fieldMapping - 字段映射(可选)
* @param {String} options.defaultSort - 默认排序(可选)
* @param {Object} options.tableRef - 表格引用(可选)
* @param {Function} options.transformData - 数据转换函数(可选)
* @param {Function} options.onLoadSuccess - 加载成功回调(可选)
* @param {Function} options.buildParams - 自定义参数构建函数(可选),接收 (searchForm, baseParams) 参数,返回构建后的参数对象
* @param {Function} options.onSearch - 搜索前回调(可选),在搜索执行前调用
* @param {Function} options.onReset - 重置前回调(可选),在重置执行前调用
* @returns {Object} 返回列表页面需要的所有状态和方法
*/
export function useListPage(options = {}) {
const {
fetchApi,
initialSearchForm = {},
fieldMapping = {},
defaultSort = 'id:desc',
tableRef = null,
transformData = null,
onLoadSuccess = null,
buildParams = null,
onSearch = null,
onReset = null
} = options
// 搜索表单
const searchForm = reactive({ ...initialSearchForm })
// 使用表格数据 composable
const { pagination, tableData, loading, loadData: baseLoadData, resetAndLoad: baseResetAndLoad } = useTableData({
fetchApi,
transformData,
onLoadSuccess
})
// 使用排序 composable
const { buildOrderBy, handleSortChange, resetSort, initDefaultSort } = useTableSort({
tableRef,
fieldMapping,
defaultSort,
onSortChange: () => {
pagination.page = 1
enhancedLoadData()
}
})
/**
* 增强的加载数据函数(自动添加排序和搜索参数)
* @param {Object} pageParams - 可选的分页参数 { currentPage, pageSize }
*/
const enhancedLoadData = async (pageParams = null) => {
// 如果提供了分页参数,使用它们;否则使用 pagination 的值
const page = pageParams?.currentPage ?? pagination.page
const pageSize = pageParams?.pageSize ?? pagination.pageSize
// 更新 pagination(确保同步)
if (pageParams) {
pagination.page = page
pagination.pageSize = pageSize
}
// 基础参数
const baseParams = {
page,
page_size: pageSize,
order_by: buildOrderBy()
}
// 如果提供了自定义参数构建函数,使用它;否则使用默认的 buildSearchParams
let params
if (buildParams && typeof buildParams === 'function') {
params = buildParams(searchForm, baseParams)
} else {
params = buildSearchParams(searchForm, baseParams)
}
await baseLoadData(params)
}
/**
* 增强的重置并加载函数(自动重置到第一页)
*/
const enhancedResetAndLoad = async () => {
pagination.page = 1
await enhancedLoadData()
}
/**
* 搜索处理(自动重置到第一页并加载)
*/
const handleSearch = () => {
// 如果提供了搜索前回调,先执行它
if (onSearch && typeof onSearch === 'function') {
onSearch()
}
enhancedResetAndLoad()
}
/**
* 清空搜索表单(不刷新数据)
*/
const clearSearchForm = () => {
// 重置搜索表单
forOwn(searchForm, (value, key) => {
searchForm[key] = initialSearchForm[key] !== undefined ? initialSearchForm[key] : ''
})
// 重置排序
resetSort()
}
/**
* 重置搜索条件
* @param {Object} formData - 重置后的表单数据(由 SearchForm 组件传递,可选)
* @param {Object} options - 选项对象,包含 reload 属性(是否刷新数据,默认为 true)
*/
const handleReset = (formData = null, options = {}) => {
// 如果提供了重置前回调,先执行它
if (onReset && typeof onReset === 'function') {
onReset()
}
// 清空搜索表单
clearSearchForm()
// 如果需要刷新,则重置并加载(默认为 true,保持向后兼容)
const shouldReload = options.reload !== false
if (shouldReload) {
enhancedResetAndLoad()
}
}
return {
// 响应式数据
pagination,
tableData,
loading,
searchForm,
// 方法
loadData: enhancedLoadData,
resetAndLoad: enhancedResetAndLoad,
handleSearch,
handleReset,
clearSearchForm, // 只清空表单,不刷新数据
// 排序相关
buildOrderBy,
handleSortChange,
resetSort,
initDefaultSort
}
}
+89
View File
@@ -0,0 +1,89 @@
import { computed } from 'vue'
import { useUserStore } from '../store/user'
/**
* 权限相关的 composable
* 提供权限检查和按钮显示控制的功能
*/
export function usePermission() {
const userStore = useUserStore()
/**
* 检查是否有权限
* @param {string} permission - 权限标识
* @returns {boolean}
*/
const hasPermission = (permission) => {
return userStore.hasPermission(permission)
}
/**
* 检查是否应该显示按钮(考虑权限和配置)
* 如果用户有权限,总是返回 true
* 如果用户没有权限,根据配置 ADMIN_SHOW_BUTTONS_WITHOUT_PERMISSION 决定是否显示
*
* @param {string} permission - 权限标识
* @returns {boolean}
*
* @example
* // 在模板中使用
* <el-button v-if="shouldShowButton('admin.store')" @click="handleAdd">添加</el-button>
*
* // 在脚本中使用
* const { shouldShowButton } = usePermission()
* if (shouldShowButton('admin.update')) {
* // 显示编辑按钮
* }
*/
const shouldShowButton = (permission) => {
return userStore.shouldShowButton(permission)
}
/**
* 检查按钮是否应该被禁用(没有权限时禁用)
* @param {string} permission - 权限标识
* @returns {boolean}
*/
const isButtonDisabled = (permission) => {
return !userStore.hasPermission(permission)
}
/**
* 获取按钮的显示和禁用状态(推荐使用,一个方法搞定)
* @param {string} permission - 权限标识
* @returns {{ show: boolean, disabled: boolean }}
*
* @example
* // 方式一:同时控制显示和禁用(根据配置决定是否隐藏)
* <el-button
* v-if="getButtonState('admin.store').show"
* :disabled="getButtonState('admin.store').disabled"
* @click="handleAdd"
* >
* 添加
* </el-button>
*
* // 方式二:只控制禁用(按钮始终显示,无权限时禁用)
* <el-button
* :disabled="getButtonState('admin.store').disabled"
* @click="handleAdd"
* >
* 添加
* </el-button>
*/
const getButtonState = (permission) => {
const hasPerm = userStore.hasPermission(permission)
const show = userStore.shouldShowButton(permission)
const disabled = !hasPerm
return { show, disabled }
}
return {
hasPermission,
shouldShowButton,
isButtonDisabled,
getButtonState
}
}
+108
View File
@@ -0,0 +1,108 @@
import { useUserStore } from '../store/user'
/**
* 按钮状态
*/
export interface ButtonState {
/** 是否显示按钮 */
show: boolean
/** 是否禁用按钮 */
disabled: boolean
}
/**
* 权限 composable 返回值
*/
export interface UsePermissionReturn {
/** 检查是否有权限 */
hasPermission: (permission: string) => boolean
/** 检查是否应该显示按钮 */
shouldShowButton: (permission: string) => boolean
/** 检查按钮是否应该被禁用 */
isButtonDisabled: (permission: string) => boolean
/** 获取按钮的显示和禁用状态 */
getButtonState: (permission: string) => ButtonState
}
/**
* 权限相关的 composable
* 提供权限检查和按钮显示控制的功能
*
* @example
* const { hasPermission, getButtonState } = usePermission()
*
* // 检查权限
* if (hasPermission('admin.store')) {
* // 有添加权限
* }
*
* // 获取按钮状态
* const { show, disabled } = getButtonState('admin.update')
*/
export function usePermission(): UsePermissionReturn {
const userStore = useUserStore()
/**
* 检查是否有权限
* @param permission - 权限标识
* @returns 是否有权限
*/
const hasPermission = (permission: string): boolean => {
return userStore.hasPermission(permission)
}
/**
* 检查是否应该显示按钮(考虑权限和配置)
* 如果用户有权限,总是返回 true
* 如果用户没有权限,根据配置 ADMIN_SHOW_BUTTONS_WITHOUT_PERMISSION 决定是否显示
*
* @param permission - 权限标识
* @returns 是否显示按钮
*
* @example
* <el-button v-if="shouldShowButton('admin.store')" @click="handleAdd">添加</el-button>
*/
const shouldShowButton = (permission: string): boolean => {
return userStore.shouldShowButton(permission)
}
/**
* 检查按钮是否应该被禁用(没有权限时禁用)
* @param permission - 权限标识
* @returns 是否禁用按钮
*/
const isButtonDisabled = (permission: string): boolean => {
return !userStore.hasPermission(permission)
}
/**
* 获取按钮的显示和禁用状态(推荐使用,一个方法搞定)
* @param permission - 权限标识
* @returns 按钮状态对象
*
* @example
* // 同时控制显示和禁用
* <el-button
* v-if="getButtonState('admin.store').show"
* :disabled="getButtonState('admin.store').disabled"
* @click="handleAdd"
* >
* 添加
* </el-button>
*/
const getButtonState = (permission: string): ButtonState => {
const hasPerm = userStore.hasPermission(permission)
const show = userStore.shouldShowButton(permission)
const disabled = !hasPerm
return { show, disabled }
}
return {
hasPermission,
shouldShowButton,
isButtonDisabled,
getButtonState
}
}
+118
View File
@@ -0,0 +1,118 @@
import { ref, reactive } from 'vue'
/**
* 表格数据管理 composable
* 自动处理分页、数据加载、total 更新等通用逻辑
*
* @param {Object} options 配置选项
* @param {Function} options.fetchApi - 获取数据的 API 函数
* @param {Function} options.buildParams - 构建请求参数的自定义函数(可选)
* @param {Function} options.transformData - 数据转换函数(可选)
* @param {Function} options.onLoadSuccess - 加载成功回调(可选)
* @returns {Object} 返回分页、数据、加载状态和加载函数
*/
export function useTableData(options = {}) {
const { fetchApi, buildParams = null, transformData = null, onLoadSuccess = null } = options
// 分页对象(统一格式)
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref([])
// 加载状态
const loading = ref(false)
/**
* 加载数据
* @param {Object} extraParams - 完整的请求参数(如果提供,将覆盖基础参数)
*/
const loadData = async (extraParams = null) => {
if (!fetchApi) {
console.error('useTableData: fetchApi is required')
return
}
loading.value = true
try {
let params
if (extraParams) {
// 如果提供了完整参数,直接使用(但确保包含分页信息)
params = {
page: pagination.page,
page_size: pagination.pageSize,
...extraParams
}
// 如果 extraParams 中包含了 page 和 page_size,同步到 pagination 对象
if (extraParams.page !== undefined) {
pagination.page = Number(extraParams.page) || 1
}
if (extraParams.page_size !== undefined) {
pagination.pageSize = Number(extraParams.page_size) || 10
}
} else {
// 构建基础参数
const baseParams = {
page: pagination.page,
page_size: pagination.pageSize
}
// 如果提供了自定义参数构建函数,使用它
params = buildParams ? buildParams(baseParams) : baseParams
}
const res = await fetchApi(params)
if (res && res.data) {
// 获取原始数据
const rawList = res.data.list || res.data.data || []
// 如果提供了数据转换函数,应用它
if (transformData && typeof transformData === 'function') {
tableData.value = rawList.map(transformData)
} else {
tableData.value = rawList
}
// 确保 total 是数字类型,并正确更新
const total = res.data.total
if (total !== undefined && total !== null) {
pagination.total = Number(total) || 0
} else {
pagination.total = 0
}
// 如果提供了加载成功回调,调用它
if (onLoadSuccess && typeof onLoadSuccess === 'function') {
onLoadSuccess(res, tableData.value)
}
}
} catch (error) {
console.error('Load table data error:', error)
} finally {
loading.value = false
}
}
/**
* 重置到第一页并加载数据
*/
const resetAndLoad = (extraParams = {}) => {
pagination.page = 1
loadData(extraParams)
}
return {
pagination,
tableData,
loading,
loadData,
resetAndLoad
}
}
+80
View File
@@ -0,0 +1,80 @@
import { computed, ref } from 'vue'
/**
* 表格性能优化 composable
* 提供虚拟滚动、列渲染优化等功能
*
* @param {Object} options 配置选项
* @param {Number} options.enableVirtualScroll - 启用虚拟滚动的数据量阈值(默认 100)
* @param {Number} options.virtualScrollItemHeight - 虚拟滚动每行高度(默认 46)
* @param {Number} options.tableHeight - 表格高度(默认 600
* @param {Array} options.tableColumns - 表格列配置
* @param {Array} options.tableData - 表格数据
* @returns {Object} 性能优化相关的配置和方法
*/
export function useTablePerformance(options = {}) {
const {
enableVirtualScroll = 100, // 超过 100 条数据时启用虚拟滚动
virtualScrollItemHeight = 46, // 每行高度(px
tableHeight = 600,
tableColumns = [],
tableData = []
} = options
// 计算是否启用虚拟滚动
const shouldEnableVirtualScroll = computed(() => {
return tableData.length >= enableVirtualScroll
})
// 虚拟滚动配置
const scrollYConfig = computed(() => {
if (shouldEnableVirtualScroll.value) {
return {
enabled: true,
gt: enableVirtualScroll, // 超过这个数量才启用虚拟滚动
oSize: virtualScrollItemHeight, // 每行高度
rSize: virtualScrollItemHeight, // 渲染行高度
mode: 'wheel' // 滚动模式:wheel(鼠标滚轮)或 scroll(滚动条)
}
}
return { enabled: false }
})
// 优化列渲染:使用 computed 缓存列配置
const optimizedColumns = computed(() => {
return tableColumns.map(column => {
// 如果 formatter 是函数,确保它被正确缓存
if (column.formatter && typeof column.formatter === 'function') {
// 保持 formatter 函数引用,避免重复创建
return {
...column,
formatter: column.formatter
}
}
return column
})
})
// 表格配置优化
const optimizedTableConfig = computed(() => {
return {
resizable: true,
// 优化渲染性能
showOverflow: 'tooltip', // 超出内容显示 tooltip,而不是换行
showHeaderOverflow: 'tooltip'
}
})
return {
// 配置
scrollYConfig,
optimizedColumns,
optimizedTableConfig,
shouldEnableVirtualScroll,
// 方法
enableVirtualScroll,
virtualScrollItemHeight
}
}
+148
View File
@@ -0,0 +1,148 @@
import { ref, nextTick } from 'vue'
/**
* 表格排序 Composable
* @param {Object} options 配置选项
* @param {Object} options.tableRef 表格引用
* @param {Object} options.fieldMapping 字段映射(前端字段名 -> 数据库字段名)
* @param {String} options.defaultSort 默认排序,格式:'field:direction' 或 'field1:direction1,field2:direction2'
* @param {Function} options.onSortChange 排序变化回调函数
* @returns {Object} 排序相关的状态和方法
*/
export function useTableSort(options = {}) {
const {
tableRef = null,
fieldMapping = {},
defaultSort = 'id:desc',
onSortChange = null
} = options
// 排序配置(单字段排序)
const sortConfig = ref({
multiple: false,
data: []
})
// 解析默认排序
const parseDefaultSort = (sortStr) => {
if (!sortStr) return []
return sortStr.split(',').map(item => {
const [field, order = 'desc'] = item.trim().split(':')
return { field: field.trim(), order: order.trim() }
})
}
// 初始化默认排序
const initDefaultSort = () => {
const defaultSorts = parseDefaultSort(defaultSort)
// 单字段排序:只取第一个排序字段
if (defaultSorts.length > 0) {
sortConfig.value.data = [defaultSorts[0]]
} else {
sortConfig.value.data = []
}
// 设置表格的默认排序
if (tableRef?.value) {
nextTick(() => {
// 检查 tableRef.value 是否有 setSort 方法(vxe-table 实例)
// 或者检查 tableRef.value.tableRef 是否有 setSort 方法(VxeTable 组件)
const tableInstance = tableRef.value?.setSort ? tableRef.value : (tableRef.value?.tableRef || null)
if (tableInstance && typeof tableInstance.setSort === 'function') {
tableInstance.setSort(sortConfig.value.data)
}
})
}
}
// 构建排序参数字符串(单字段排序)
const buildOrderBy = () => {
if (!sortConfig.value.data || sortConfig.value.data.length === 0) {
// 如果没有排序,返回默认排序的第一个字段
const defaultSorts = parseDefaultSort(defaultSort)
if (defaultSorts.length > 0) {
const sort = defaultSorts[0]
const direction = sort.order === 'asc' ? 'asc' : 'desc'
const dbField = fieldMapping[sort.field] || sort.field
return `${dbField}:${direction}`
}
return defaultSort || ''
}
// 单字段排序:只取第一个排序字段
const sort = sortConfig.value.data[0]
const direction = sort.order === 'asc' ? 'asc' : 'desc'
const dbField = fieldMapping[sort.field || sort.property] || (sort.field || sort.property)
return `${dbField}:${direction}`
}
// 处理排序变化(单字段排序)
const handleSortChange = ({ column, property, order, sortBy, sortList }) => {
// 获取当前点击的字段
const clickedField = property || column?.field || column?.property
// 更新排序配置(优先使用 vxe-table 返回的 sortList
if (sortList && Array.isArray(sortList)) {
// 单字段排序:只保留最后一个排序字段
if (sortList.length > 0) {
// 取最后一个(最新点击的)
sortConfig.value.data = [sortList[sortList.length - 1]]
} else {
// 取消排序
sortConfig.value.data = []
}
} else if (clickedField) {
// 如果没有 sortList,使用当前列的信息更新
if (order && (order === 'asc' || order === 'desc')) {
// 单字段排序:清除之前的排序,只保留当前字段
sortConfig.value.data = [{ field: clickedField, order }]
} else {
// 取消排序
sortConfig.value.data = []
}
}
// 调用回调函数
if (onSortChange && typeof onSortChange === 'function') {
onSortChange(sortConfig.value.data)
}
}
// 重置排序
const resetSort = () => {
sortConfig.value.data = []
if (tableRef?.value) {
// 检查 tableRef.value 是否有 clearSort 方法(vxe-table 实例)
// 或者检查 tableRef.value.tableRef 是否有 clearSort 方法(VxeTable 组件)
const tableInstance = tableRef.value?.clearSort ? tableRef.value : (tableRef.value?.tableRef || null)
if (tableInstance && typeof tableInstance.clearSort === 'function') {
tableInstance.clearSort()
}
}
}
// 设置排序
const setSort = (sorts) => {
if (Array.isArray(sorts)) {
sortConfig.value.data = sorts
if (tableRef?.value) {
// 检查 tableRef.value 是否有 setSort 方法(vxe-table 实例)
// 或者检查 tableRef.value.tableRef 是否有 setSort 方法(VxeTable 组件)
const tableInstance = tableRef.value?.setSort ? tableRef.value : (tableRef.value?.tableRef || null)
if (tableInstance && typeof tableInstance.setSort === 'function') {
tableInstance.setSort(sorts)
}
}
}
}
return {
sortConfig,
buildOrderBy,
handleSortChange,
resetSort,
setSort,
initDefaultSort
}
}
+200
View File
@@ -0,0 +1,200 @@
import { ref, nextTick, type Ref } from 'vue'
/**
* 排序项
*/
export interface SortItem {
field: string
order: 'asc' | 'desc'
property?: string
}
/**
* 排序配置
*/
export interface SortConfig {
multiple: boolean
data: SortItem[]
}
/**
* 表格排序选项
*/
export interface UseTableSortOptions {
/** 表格引用 */
tableRef?: Ref<any>
/** 字段映射(前端字段名 -> 数据库字段名) */
fieldMapping?: Record<string, string>
/** 默认排序,格式:'field:direction' 或 'field1:direction1,field2:direction2' */
defaultSort?: string
/** 排序变化回调函数 */
onSortChange?: (sortData: SortItem[]) => void
}
/**
* 表格排序返回值
*/
export interface UseTableSortReturn {
sortConfig: Ref<SortConfig>
buildOrderBy: () => string
handleSortChange: (params: {
column?: any
property?: string
order?: 'asc' | 'desc' | null
sortBy?: any
sortList?: SortItem[]
}) => void
resetSort: () => void
setSort: (sorts: SortItem[]) => void
initDefaultSort: () => void
}
/**
* 表格排序 Composable
* @param options 配置选项
* @returns 排序相关的状态和方法
*
* @example
* const { sortConfig, buildOrderBy, handleSortChange, initDefaultSort } = useTableSort({
* tableRef,
* fieldMapping: { created_at: 'created_at' },
* defaultSort: 'id:desc',
* onSortChange: (sortData) => loadData()
* })
*/
export function useTableSort(options: UseTableSortOptions = {}): UseTableSortReturn {
const {
tableRef = null,
fieldMapping = {},
defaultSort = 'id:desc',
onSortChange = null
} = options
// 排序配置(单字段排序)
const sortConfig = ref<SortConfig>({
multiple: false,
data: []
})
// 解析默认排序
const parseDefaultSort = (sortStr: string): SortItem[] => {
if (!sortStr) return []
return sortStr.split(',').map((item) => {
const [field, order = 'desc'] = item.trim().split(':')
return { field: field.trim(), order: order.trim() as 'asc' | 'desc' }
})
}
// 初始化默认排序
const initDefaultSort = (): void => {
const defaultSorts = parseDefaultSort(defaultSort)
// 单字段排序:只取第一个排序字段
if (defaultSorts.length > 0) {
sortConfig.value.data = [defaultSorts[0]]
} else {
sortConfig.value.data = []
}
// 设置表格的默认排序
if (tableRef?.value) {
nextTick(() => {
if (tableRef.value) {
tableRef.value.setSort(sortConfig.value.data)
}
})
}
}
// 构建排序参数字符串(单字段排序)
const buildOrderBy = (): string => {
if (!sortConfig.value.data || sortConfig.value.data.length === 0) {
// 如果没有排序,返回默认排序的第一个字段
const defaultSorts = parseDefaultSort(defaultSort)
if (defaultSorts.length > 0) {
const sort = defaultSorts[0]
const direction = sort.order === 'asc' ? 'asc' : 'desc'
const dbField = fieldMapping[sort.field] || sort.field
return `${dbField}:${direction}`
}
return defaultSort || ''
}
// 单字段排序:只取第一个排序字段
const sort = sortConfig.value.data[0]
const direction = sort.order === 'asc' ? 'asc' : 'desc'
const field = sort.field || sort.property || ''
const dbField = fieldMapping[field] || field
return `${dbField}:${direction}`
}
// 处理排序变化(单字段排序)
const handleSortChange = ({
column,
property,
order,
sortList
}: {
column?: any
property?: string
order?: 'asc' | 'desc' | null
sortBy?: any
sortList?: SortItem[]
}): void => {
// 获取当前点击的字段
const clickedField = property || column?.field || column?.property
// 更新排序配置(优先使用 vxe-table 返回的 sortList
if (sortList && Array.isArray(sortList)) {
// 单字段排序:只保留最后一个排序字段
if (sortList.length > 0) {
// 取最后一个(最新点击的)
sortConfig.value.data = [sortList[sortList.length - 1]]
} else {
// 取消排序
sortConfig.value.data = []
}
} else if (clickedField) {
// 如果没有 sortList,使用当前列的信息更新
if (order && (order === 'asc' || order === 'desc')) {
// 单字段排序:清除之前的排序,只保留当前字段
sortConfig.value.data = [{ field: clickedField, order }]
} else {
// 取消排序
sortConfig.value.data = []
}
}
// 调用回调函数
if (onSortChange && typeof onSortChange === 'function') {
onSortChange(sortConfig.value.data)
}
}
// 重置排序
const resetSort = (): void => {
sortConfig.value.data = []
if (tableRef?.value) {
tableRef.value.clearSort()
}
}
// 设置排序
const setSort = (sorts: SortItem[]): void => {
if (Array.isArray(sorts)) {
sortConfig.value.data = sorts
if (tableRef?.value) {
tableRef.value.setSort(sorts)
}
}
}
return {
sortConfig,
buildOrderBy,
handleSortChange,
resetSort,
setSort,
initDefaultSort
}
}
+22
View File
@@ -0,0 +1,22 @@
import { createI18n } from 'vue-i18n'
import Storage from '../utils/storage'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
const messages = {
'zh-CN': zhCN,
'en-US': enUS
}
// 从 localStorage 获取语言设置,默认为中文
const locale = Storage.getItem('language', 'zh-CN') || 'zh-CN'
const i18n = createI18n({
legacy: false,
locale,
fallbackLocale: 'zh-CN',
messages
})
export default i18n
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+607
View File
@@ -0,0 +1,607 @@
<template>
<el-container class="layout-container" :class="`layout-${appStore.layoutSize}`">
<el-aside
:width="appStore.sidebarCollapsed ? '64px' : '240px'"
class="sidebar"
:class="{ 'is-collapse': appStore.sidebarCollapsed }"
>
<div class="logo">
<h3 v-if="!appStore.sidebarCollapsed">{{ $t('header.system') }}</h3>
<el-icon v-else><Setting /></el-icon>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="appStore.sidebarCollapsed"
@select="handleMenuSelect"
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<template #title>{{ $t('menu.dashboard') }}</template>
</el-menu-item>
<template v-if="menuTree.length > 0">
<MenuItem
v-for="menu in menuTree"
:key="menu.id"
:menu="menu"
/>
</template>
<template v-else>
<!-- 如果菜单数据为空显示默认菜单 -->
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>{{ $t('menu.system') }}</span>
</template>
<el-menu-item index="/admins">
<el-icon><User /></el-icon>
<template #title>{{ $t('menu.admin') }}</template>
</el-menu-item>
<el-menu-item index="/roles">
<el-icon><Avatar /></el-icon>
<template #title>{{ $t('menu.role') }}</template>
</el-menu-item>
<el-menu-item index="/permissions">
<el-icon><Key /></el-icon>
<template #title>{{ $t('menu.permission') }}</template>
</el-menu-item>
<el-menu-item index="/menus">
<el-icon><Menu /></el-icon>
<template #title>{{ $t('menu.menu') }}</template>
</el-menu-item>
<el-menu-item index="/departments">
<el-icon><OfficeBuilding /></el-icon>
<template #title>{{ $t('menu.department') }}</template>
</el-menu-item>
<el-menu-item index="/dictionaries">
<el-icon><Document /></el-icon>
<template #title>{{ $t('menu.dictionary') }}</template>
</el-menu-item>
<el-menu-item index="/configs">
<el-icon><Setting /></el-icon>
<template #title>{{ $t('menu.config') }}</template>
</el-menu-item>
<el-menu-item index="/blacklists">
<el-icon><Warning /></el-icon>
<template #title>{{ $t('menu.blacklist') }}</template>
</el-menu-item>
<el-menu-item index="/online-admins">
<el-icon><User /></el-icon>
<template #title>{{ $t('menu.online_admin') }}</template>
</el-menu-item>
<el-menu-item index="/exports">
<el-icon><Document /></el-icon>
<template #title>{{ $t('menu.export') }}</template>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="logs">
<template #title>
<el-icon><Document /></el-icon>
<span>{{ $t('menu.log') }}</span>
</template>
<el-menu-item index="/operation-logs">{{ $t('menu.operation_log') }}</el-menu-item>
<el-menu-item index="/login-logs">{{ $t('menu.login_log') }}</el-menu-item>
<el-menu-item index="/system-logs">{{ $t('menu.system_log') }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/notifications">
<el-icon><Bell /></el-icon>
<template #title>{{ $t('menu.notification_center') }}</template>
</el-menu-item>
<el-menu-item index="/monitor">
<el-icon><Monitor /></el-icon>
<template #title>{{ $t('menu.service_monitor') }}</template>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-button
type="text"
class="collapse-btn"
@click="appStore.toggleSidebar"
>
<el-icon><Fold v-if="!appStore.sidebarCollapsed" /><Expand v-else /></el-icon>
</el-button>
<BreadcrumbView />
</div>
<div class="header-right">
<el-button
type="text"
class="header-btn"
@click="appStore.toggleFullscreen"
:title="$t('header.fullscreen')"
>
<el-icon>
<FullScreen v-if="!appStore.isFullscreen" />
<Aim v-else />
</el-icon>
</el-button>
<NotificationBell />
<!-- <DarkModeSwitch /> -->
<TimezoneSwitch />
<LanguageSwitch />
<el-dropdown @command="handleCommand" class="user-dropdown">
<span class="user-info">
<el-avatar
v-if="userStore.adminInfo?.avatar"
:size="32"
:src="userStore.adminInfo.avatar"
class="user-avatar"
>
<el-icon><User /></el-icon>
</el-avatar>
<el-icon v-else class="user-icon"><User /></el-icon>
<span class="user-name">{{ userStore.adminInfo?.nickname || userStore.adminInfo?.username }}</span>
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><UserFilled /></el-icon>
{{ $t('header.profile') }}
</el-dropdown-item>
<el-dropdown-item divided command="logout">{{ $t('header.logout') }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<div class="tabs-wrapper">
<TabsView />
</div>
<el-main class="main-content">
<router-view v-slot="{ Component, route: routeItem }">
<transition name="fade-transform" mode="out-in">
<keep-alive>
<component
:is="Component"
:key="`${routeItem.path}-${tabsStore.getRefreshKey(routeItem.path)}`"
/>
</keep-alive>
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { computed, watch, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessageBox } from 'element-plus'
import { useUserStore } from '../store/user'
import { useTabsStore } from '../store/tabs'
import { useAppStore } from '../store/app'
import request from '../utils/request'
import LanguageSwitch from '../components/LanguageSwitch.vue'
import TimezoneSwitch from '../components/TimezoneSwitch.vue'
import NotificationBell from '../components/NotificationBell.vue'
import DarkModeSwitch from '../components/DarkModeSwitch.vue'
import TabsView from '../components/TabsView.vue'
import BreadcrumbView from '../components/BreadcrumbView.vue'
import MenuItem from '../components/MenuItem.vue'
import {
Fold,
Expand,
Setting,
User,
UserFilled,
ArrowDown,
FullScreen,
Aim,
Odometer,
Avatar,
Key,
Menu,
OfficeBuilding,
Document,
Bell,
Monitor,
Warning
} from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const tabsStore = useTabsStore()
const appStore = useAppStore()
const { t } = useI18n()
const activeMenu = computed(() => route.path)
// 根据主题获取 sidebar 背景色
// 转换菜单数据格式并构建树形结构
const menuTree = computed(() => {
const menus = userStore.menus || []
if (menus.length === 0) {
// console.warn('No menus found in userStore.menus, userStore:', userStore)
return []
}
// 转换数据格式(后端返回的是扁平数组,需要自己构建树形结构)
const transformMenu = (menu) => {
// 直接使用后端返回的路径,前后端路径已统一
const originalPath = menu.Path || menu.path || ''
return {
id: menu.id,
parent_id: menu.ParentID || menu.parent_id || 0,
title: menu.Title || menu.title || '',
slug: menu.Slug || menu.slug || '',
path: originalPath,
icon: menu.Icon || menu.icon || '',
type: menu.Type !== undefined ? menu.Type : (menu.type !== undefined ? menu.type : 1),
status: menu.Status !== undefined ? menu.Status : (menu.status !== undefined ? menu.status : 1),
sort: menu.Sort !== undefined ? menu.Sort : (menu.sort !== undefined ? menu.sort : 0),
is_hidden: menu.IsHidden !== undefined ? menu.IsHidden : (menu.is_hidden !== undefined ? menu.is_hidden : 0),
link_type: menu.LinkType !== undefined ? menu.LinkType : (menu.link_type !== undefined ? menu.link_type : 1),
open_type: menu.OpenType !== undefined ? menu.OpenType : (menu.open_type !== undefined ? menu.open_type : 1)
}
}
// 转换所有菜单(扁平数组)
const transformedMenus = menus.map(menu => transformMenu(menu))
// 构建树形结构(只返回顶级菜单,子菜单在children中)
const buildTree = (menus, parentId = 0) => {
const result = menus
.filter(menu => menu.parent_id === parentId && menu.is_hidden === 0 && menu.status === 1)
.map(menu => ({
...menu,
children: buildTree(menus, menu.id)
}))
.sort((a, b) => a.sort - b.sort)
return result
}
const tree = buildTree(transformedMenus)
return tree
})
// 监听路由变化,自动添加标签页
watch(
() => route.path,
(newPath) => {
if (route.meta.requiresAuth !== false && route.name !== 'Login') {
tabsStore.addTab(route)
}
},
{ immediate: true }
)
// 心跳机制:每2分钟发送一次心跳请求,更新用户的最后活跃时间
let heartbeatInterval = null
const sendHeartbeat = async () => {
try {
// 只有在已登录状态下才发送心跳
if (userStore.token) {
await request.get('/heartbeat')
}
} catch (error) {
// 心跳失败不显示错误,静默处理
console.debug('Heartbeat failed:', error)
}
}
// 监听全屏事件
onMounted(() => {
// 初始化布局大小
appStore.setLayoutSize(appStore.layoutSize)
// 如果当前路由需要标签页,添加它
if (route.meta.requiresAuth !== false && route.name !== 'Login') {
tabsStore.addTab(route)
}
// 初始化全屏状态
appStore.isFullscreen = !!document.fullscreenElement
// 监听全屏状态变化
const handleFullscreenChange = () => {
appStore.isFullscreen = !!document.fullscreenElement
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
// 启动心跳机制:每2分钟发送一次
heartbeatInterval = setInterval(sendHeartbeat, 2 * 60 * 1000)
// 立即发送一次心跳
sendHeartbeat()
// 清理事件监听器和心跳定时器
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
if (heartbeatInterval) {
clearInterval(heartbeatInterval)
heartbeatInterval = null
}
})
})
const handleMenuSelect = (index) => {
// 处理静态菜单项的导航(如 dashboard)
// MenuItem 组件已经处理了动态菜单的点击,所以这里主要处理静态菜单
// 外部链接的 index 以 'external-' 开头,不应该在这里处理
if (index && typeof index === 'string' && !index.startsWith('external-')) {
// 检查是否是有效的内部路由路径(不以 http:// 或 https:// 开头)
if (!index.startsWith('http://') && !index.startsWith('https://')) {
router.push(index)
}
}
}
const handleCommand = async (command) => {
if (command === 'profile') {
router.push('/profile')
} else if (command === 'logout') {
try {
await ElMessageBox.confirm(t('header.logout_confirm'), t('common.confirm'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning'
})
await userStore.logout()
tabsStore.removeAllTabs()
router.push('/login')
} catch (error) {
// 用户取消
}
}
}
</script>
<style scoped>
.layout-container {
height: 100vh;
}
.sidebar {
/* background-color: var(--sidebar-bg); */
background-color:#fff;
overflow-y: auto;
transition: width 0.3s;
border-right: 1px solid #00000014;
}
/* 自定义滚动条样式 - 更细更美观 */
.sidebar::-webkit-scrollbar {
width: 4px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 3px;
transition: background-color 0.3s;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.3);
}
/* 兼容 Firefox */
.sidebar {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.sidebar.is-collapse {
width: 64px;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
color: white;
/* border-bottom: 1px solid #434a55; */
}
.logo h3 {
margin: 0;
font-size: 18px;
white-space: nowrap;
color:#383853;
opacity: 1;
}
.sidebar-menu {
border-right: none;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 240px;
}
/* 菜单项文字溢出处理 */
.sidebar-menu :deep(.el-menu-item),
.sidebar-menu :deep(.el-sub-menu__title) {
display: flex;
align-items: center;
overflow: hidden;
}
/* 菜单项标题容器 */
.sidebar-menu :deep(.el-menu-item > span),
.sidebar-menu :deep(.el-sub-menu__title > span) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
display: flex;
align-items: center;
}
/* 确保下拉箭头不被遮挡,固定在右侧 */
.sidebar-menu :deep(.el-sub-menu__icon-arrow) {
flex-shrink: 0;
margin-left: auto;
margin-right: 0;
width: 16px;
text-align: right;
}
/* 菜单项图标样式 */
.sidebar-menu :deep(.el-menu-item .el-icon),
.sidebar-menu :deep(.el-sub-menu__title .el-icon) {
flex-shrink: 0;
margin-right: 8px;
}
/* 菜单项文字溢出处理 */
.sidebar-menu :deep(.el-menu-item),
.sidebar-menu :deep(.el-sub-menu__title) {
display: flex;
align-items: center;
overflow: hidden;
}
.sidebar-menu :deep(.el-menu-item > span),
.sidebar-menu :deep(.el-sub-menu__title > span) {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* 确保下拉箭头不被遮挡 */
.sidebar-menu :deep(.el-sub-menu__icon-arrow) {
flex-shrink: 0;
margin-left: auto;
margin-right: 0;
}
.header {
background-color: var(--header-bg);
border-bottom: 1px solid var(--border-color-light);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
height: 60px;
line-height: 60px;
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.header-left {
flex: 1;
display: flex;
align-items: center;
gap: 15px;
height: 100%;
}
.collapse-btn {
font-size: 18px;
color: var(--text-color-regular);
transition: color 0.3s ease;
}
.size-btn {
color: var(--text-color-regular);
transition: color 0.3s ease;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.header-btn {
color: var(--text-color-regular);
padding: 8px;
border-radius: 4px;
transition: all 0.3s;
}
.header-btn:hover {
background-color: var(--bg-color-tertiary);
color: #409EFF;
}
.user-dropdown {
margin-left: 0;
}
.user-info {
display: flex;
align-items: center;
cursor: pointer;
color: var(--text-color-regular);
gap: 8px;
transition: color 0.3s ease;
}
.user-avatar {
flex-shrink: 0;
}
.user-icon {
flex-shrink: 0;
}
.user-name {
white-space: nowrap;
}
.tabs-wrapper {
background: var(--header-bg);
border-bottom: 1px solid var(--border-color-light);
transition: background-color 0.3s ease, border-color 0.3s ease;
}
.main-content {
background-color: var(--bg-color-secondary);
padding: 20px;
overflow-y: auto;
transition: background-color 0.3s ease;
}
/* 布局大小样式 */
.layout-small .main-content {
padding: 10px;
}
.layout-large .main-content {
padding: 30px;
}
/* 过渡动画 */
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(-20px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(20px);
}
.is-active {
color: #409EFF;
font-weight: bold;
}
</style>
+268
View File
@@ -0,0 +1,268 @@
import { createApp, watch, nextTick } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import VXETable from 'vxe-table'
import 'vxe-table/lib/style.css'
import VxePcUI from 'vxe-pc-ui'
import 'vxe-pc-ui/lib/style.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import en from 'element-plus/dist/locale/en.mjs'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import Storage from './utils/storage'
import { setupTabsStorageSync } from './store/tabs'
import { validateEnv } from './utils/env'
import logger from './utils/logger'
import './style.css'
// 验证环境变量
try {
validateEnv(false) // 非严格模式,只警告
} catch (error) {
logger.error('Environment validation failed:', error)
}
// 检查 localStorage 是否可用
if (!Storage.isAvailable()) {
logger.warn('localStorage is not available. Some features may not work properly.')
}
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// 根据当前语言设置 Element Plus 语言
const getElementLocale = () => {
const savedLocale = Storage.getItem('language', 'zh-CN')
return savedLocale === 'zh-CN' ? zhCn : en
}
// 配置 vxe-table 国际化
const setupVxeTableI18n = () => {
const vxeI18nMap = {
'zh-CN': {
'vxe.pager.goto': '前往',
'vxe.pager.pagesize': '{size} 条/页',
'vxe.pager.total': '共 {total} 条记录',
'vxe.pager.pageClassifier': '页',
'vxe.table.emptyText': '暂无数据',
'vxe.loading.text': '加载中...',
'pager.goto': '前往',
'pager.pagesize': '{size} 条/页',
'pager.total': '共 {total} 条记录',
'pager.pageClassifier': '页',
'table.emptyText': '暂无数据',
'loading.text': '加载中...'
},
'en-US': {
'vxe.pager.goto': 'Go to',
'vxe.pager.pagesize': '{size} records/page',
'vxe.pager.total': 'Total {total} records',
'vxe.pager.pageClassifier': 'page',
'vxe.table.emptyText': 'No Data',
'vxe.loading.text': 'Loading...',
'pager.goto': 'Go to',
'pager.pagesize': '{size} records/page',
'pager.total': 'Total {total} records',
'pager.pageClassifier': 'page',
'table.emptyText': 'No Data',
'loading.text': 'Loading...'
}
}
VXETable.setup({
i18n: (key, args) => {
// 动态获取当前语言,而不是使用闭包变量
const currentLocale = i18n.global.locale.value || Storage.getItem('language', 'zh-CN') || 'zh-CN'
// 尝试直接匹配
let value = vxeI18nMap[currentLocale]?.[key]
// 如果没有找到,尝试去掉 vxe. 前缀
if (!value && key.startsWith('vxe.')) {
value = vxeI18nMap[currentLocale]?.[key.substring(4)]
}
// 如果还是没有找到,尝试添加 vxe. 前缀
if (!value && !key.startsWith('vxe.')) {
value = vxeI18nMap[currentLocale]?.[`vxe.${key}`]
}
// 特殊处理:vxe-table 可能使用 table.emptyText 或 loading.text 格式
if (!value) {
if (key === 'table.emptyText' || key === 'emptyText') {
value = vxeI18nMap[currentLocale]?.['vxe.table.emptyText'] || vxeI18nMap[currentLocale]?.['table.emptyText']
} else if (key === 'loading.text' || key === 'loading') {
value = vxeI18nMap[currentLocale]?.['vxe.loading.text'] || vxeI18nMap[currentLocale]?.['loading.text']
}
}
// 调试日志(开发环境)- 扩展调试范围
if (process.env.NODE_ENV === 'development' && (key.includes('empty') || key.includes('loading'))) {
// console.log('[VXE i18n]', { key, currentLocale, value })
}
// 如果找到值且有参数,替换参数
if (value && args !== undefined && args !== null) {
// 处理 args 可能是对象、数组或其他类型的情况
let params = {}
if (Array.isArray(args)) {
// 如果是数组,尝试从数组中提取参数
if (args.length > 0 && typeof args[0] === 'object') {
params = args[0]
} else {
// 如果数组元素不是对象,可能是按位置传递的参数
params = { total: args[0], pageSize: args[1] }
}
} else if (typeof args === 'object') {
params = args
} else {
// 如果是单个值,可能是 total
params = { total: args }
}
// 调试日志(开发环境)
if (process.env.NODE_ENV === 'development' && (key.includes('total') || key.includes('pagesize'))) {
// console.log('[VXE i18n]', { key, args, params, value })
}
// 替换所有参数占位符
let result = value
for (const paramKey in params) {
const paramValue = params[paramKey]
if (paramValue !== undefined && paramValue !== null) {
// 支持多种占位符格式:{key}、${key}、$key
const regex1 = new RegExp(`\\{${paramKey}\\}`, 'g')
const regex2 = new RegExp(`\\$\\{${paramKey}\\}`, 'g')
const regex3 = new RegExp(`\\$${paramKey}\\b`, 'g')
result = result.replace(regex1, String(paramValue))
result = result.replace(regex2, String(paramValue))
result = result.replace(regex3, String(paramValue))
}
}
// 特殊处理:如果 key 包含 pagesize 且没有 size 参数,尝试从 args 中提取
if (key.includes('pagesize') && !params.size && args !== undefined && args !== null) {
// 如果 args 是数字,直接使用
if (typeof args === 'number') {
result = result.replace(/\{size\}/g, String(args))
}
// 如果 args 是数组且第一个元素是数字
else if (Array.isArray(args) && args.length > 0 && typeof args[0] === 'number') {
result = result.replace(/\{size\}/g, String(args[0]))
}
// 如果 args 是对象但没有 size 属性,尝试其他可能的属性名
else if (typeof args === 'object' && !Array.isArray(args)) {
const sizeValue = args.size || args.pageSize || args.pagesize || args.value
if (sizeValue !== undefined && sizeValue !== null) {
result = result.replace(/\{size\}/g, String(sizeValue))
}
}
}
return result
}
return value || key
}
})
}
const pinia = createPinia()
app.use(pinia)
app.use(router)
app.use(i18n)
app.use(ElementPlus, { locale: getElementLocale() })
// 初始化 vxe-table 国际化(必须在 app.use(VXETable) 之前调用)
const currentLocale = Storage.getItem('language', 'zh-CN') || 'zh-CN'
i18n.global.locale.value = currentLocale
setupVxeTableI18n()
app.use(VXETable)
app.use(VxePcUI)
// 监听语言变化,重新设置 vxe-table 国际化
watch(() => i18n.global.locale.value, (newLocale) => {
// 重新设置 vxe-table 国际化配置
setupVxeTableI18n()
// 强制刷新所有 vxe-table 实例(通过触发全局事件)
// 注意:vxe-table 可能不会自动刷新,我们需要手动触发
nextTick(() => {
// 触发一个自定义事件,让所有使用 vxe-table 的组件知道语言已更改
window.dispatchEvent(new CustomEvent('vxe-i18n-updated', { detail: { locale: newLocale } }))
})
})
// 初始化布局大小
const layoutSize = Storage.getItem('layoutSize', 'default')
document.body.classList.add(`layout-${layoutSize}`)
// 设置多标签页同步监听器(在 Pinia 初始化后)
setupTabsStorageSync()
// 导入错误上报器
import { reportComponentError, reportUnhandledRejection } from './utils/errorReporter'
// 全局错误处理:静默处理 Element Plus TabPane 的已知卸载错误
app.config.errorHandler = (err, instance, info) => {
const errorMessage = err?.message || ''
const errorStack = err?.stack || ''
const instanceName = instance?.$?.type?.name || instance?.$.type?.__name || ''
// 静默处理 Element Plus TabPane 卸载时的已知错误
const isTabPaneError = (
errorMessage.includes('indexOf') ||
errorStack.includes('unregisterPane') ||
errorStack.includes('removeChild') ||
(instanceName === 'ElTabPane' && info === 'beforeUnmount hook')
)
if (isTabPaneError) {
// 这是 Element Plus 的已知问题,不影响功能,静默处理
return
}
// 上报组件错误
reportComponentError(err, instanceName, info)
}
// 全局未捕获错误处理
window.addEventListener('error', (event) => {
const errorMessage = event.message || ''
const errorStack = event.error?.stack || ''
// 静默处理 Element Plus TabPane 卸载时的已知错误
if (errorMessage.includes('indexOf') && (errorStack.includes('unregisterPane') || errorStack.includes('element-plus'))) {
event.preventDefault()
return
}
})
// 全局未处理的 Promise 错误
window.addEventListener('unhandledrejection', (event) => {
const errorMessage = event.reason?.message || ''
const errorStack = event.reason?.stack || ''
// 静默处理 Element Plus TabPane 卸载时的已知错误
if (errorMessage.includes('indexOf') && (errorStack.includes('unregisterPane') || errorStack.includes('element-plus'))) {
event.preventDefault()
return
}
// 上报未处理的 Promise 错误
reportUnhandledRejection(event)
})
app.mount('#app')
+629
View File
@@ -0,0 +1,629 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useUserStore } from '../store/user'
import logger from '../utils/logger'
/**
* 带重试和错误处理的动态导入包装函数
* @param {Function} importFn - 动态导入函数
* @param {number} maxRetries - 最大重试次数,默认 3
* @param {number} timeout - 超时时间(毫秒),默认 30 秒
* @returns {Promise} 导入的模块
*/
function lazyLoad(importFn, maxRetries = 3, timeout = 10000) {
return new Promise((resolve, reject) => {
let retryCount = 0
const attemptLoad = () => {
// 创建超时 Promise
const timeoutPromise = new Promise((_, timeoutReject) => {
setTimeout(() => {
timeoutReject(new Error('模块加载超时'))
}, timeout)
})
// 创建加载 Promise
const loadPromise = importFn().catch(err => {
// 如果是网络错误或加载失败,可以重试
if (err.message && (
err.message.includes('Failed to fetch') ||
err.message.includes('Loading chunk') ||
err.message.includes('Loading CSS chunk')
)) {
throw err
}
throw err
})
// 竞争加载和超时
Promise.race([loadPromise, timeoutPromise])
.then(module => {
resolve(module)
})
.catch(error => {
retryCount++
if (retryCount < maxRetries) {
logger.warn(`模块加载失败,正在重试 (${retryCount}/${maxRetries}):`, error.message)
// 指数退避:1秒、2秒、4秒
const delay = Math.min(1000 * Math.pow(2, retryCount - 1), 5000)
setTimeout(() => {
attemptLoad()
}, delay)
} else {
logger.error('模块加载失败,已达到最大重试次数:', error)
ElMessage.error({
message: '页面加载失败,请刷新页面重试',
duration: 5000,
showClose: true
})
reject(error)
}
})
}
attemptLoad()
})
}
// 固定路由(不需要从接口获取)
const staticRoutes = [
{
path: '/login',
name: 'Login',
component: () => lazyLoad(() => import('../views/Login.vue')),
meta: { requiresAuth: false }
},
{
path: '/',
name: 'MainLayout',
component: () => lazyLoad(() => import('../layouts/MainLayout.vue')),
redirect: '/dashboard',
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => lazyLoad(() => import('../views/Dashboard.vue')),
meta: { titleKey: 'menu.dashboard' }
},
{
path: 'profile',
name: 'Profile',
component: () => lazyLoad(() => import('../views/profile/Profile.vue')),
meta: { titleKey: 'menu.profile' }
},
{
path: 'iframe',
name: 'Iframe',
component: () => lazyLoad(() => import('../views/iframe/IframeView.vue')),
meta: { titleKey: 'menu.external_link' }
},
{
// 404 路由,必须放在最后,作为 catch-all 路由
// 在子路由中使用相对路径(不带前导斜杠)
path: ':pathMatch(.*)*',
name: 'NotFound',
component: () => lazyLoad(() => import('../views/NotFound.vue')),
meta: { titleKey: 'notFound.title' }
}
]
}
]
/**
* 使用 import.meta.glob 动态导入所有 views 目录下的 Vue 组件
* 这样菜单的 component 字段可以直接设置路径,无需在此维护映射表
*
* 支持的 component 格式:
* - 'admin/AdminList' -> ../views/admin/AdminList.vue
* - 'log/OperationLogList' -> ../views/log/OperationLogList.vue
* - 'views/admin/AdminList.vue' -> ../views/admin/AdminList.vue (完整路径)
*/
const viewModules = import.meta.glob('../views/**/*.vue')
/**
* 获取组件的导入函数
* @param {string} component - 菜单的 component 字段
* 支持格式:
* - 'admin/AdminList' -> ../views/admin/AdminList.vue
* - 'log/OperationLogList' -> ../views/log/OperationLogList.vue
* - 'views/admin/AdminList.vue' -> ../views/admin/AdminList.vue
* @returns {Function|null} 组件导入函数,如果不存在则返回 null
*/
function getComponentImport(component) {
if (!component || component === 'Layout') {
return null
}
// 标准化路径:将 component 转换为 import.meta.glob 的键格式
let modulePath = component
// 移除开头的 views/ 或 ../views/(如果有)
modulePath = modulePath.replace(/^(\.\.\/)?views\//, '')
// 移除结尾的 .vue(如果有)
modulePath = modulePath.replace(/\.vue$/, '')
// 构建可能的路径列表(支持 article/ArticleList 和 views/article/ArticleList 等多种格式)
const possiblePaths = [
`../views/${modulePath}.vue`,
`../views/${modulePath}/index.vue`, // 支持 index.vue
`../views/${modulePath.replace(/^\//, '')}.vue`, // 确保没有双斜杠
]
// 查找模块
let moduleImport = null
let fullPath = ''
for (const path of possiblePaths) {
if (viewModules[path]) {
moduleImport = viewModules[path]
fullPath = path
break
}
}
if (moduleImport) {
return () => lazyLoad(moduleImport)
}
// 如果找不到,记录警告
logger.warn(`Component not found: ${component} (tried: ${possiblePaths.join(', ')})`)
logger.debug('Available modules:', Object.keys(viewModules))
return null
}
/**
* 将菜单数据转换为路由配置
* @param {Array} menus - 菜单数组(扁平结构)
* @returns {Array} 路由配置数组
*/
function convertMenusToRoutes(menus) {
if (!menus || !Array.isArray(menus)) {
return []
}
const routes = []
const processedPaths = new Set() // 避免重复路由
menus.forEach(menu => {
// 只处理类型为菜单(type === 2)且状态为启用(status === 1)的菜单
const type = menu.Type !== undefined ? menu.Type : (menu.type !== undefined ? menu.type : 1)
const status = menu.Status !== undefined ? menu.Status : (menu.status !== undefined ? menu.status : 1)
const linkType = menu.LinkType !== undefined ? menu.LinkType : (menu.link_type !== undefined ? menu.link_type : 1)
// 如果有组件路径,即使类型不是菜单(可能是误操作或历史数据),也尝试生成路由
// 但必须是启用的,且不是按钮类型(type === 3)
const component = menu.Component || menu.component || ''
if (status !== 1 || type === 3) {
return
}
// 如果是目录类型(type === 1)且没有组件路径,跳过(仅作为父级菜单)
if (type === 1 && !component) {
return
}
const path = menu.Path || menu.path || ''
// 如果没有路径,跳过
if (!path || path === '/') {
return
}
// 避免重复路由
if (processedPaths.has(path)) {
return
}
processedPaths.add(path)
// 处理路径:移除前导斜杠,子路由使用相对路径(不带前导斜杠)
// 静态路由中的子路由都是相对路径,如 "dashboard", "profile"
const routePath = path.startsWith('/') ? path.slice(1) : path
// 生成路由名称(从路径转换,如 "admins" -> "Admins", "user-balance-logs" -> "UserBalanceLogs"
const routeName = routePath
.split('/') // 先按 / 分割,处理多级路径
.filter(Boolean) // 移除空字符串
.map(part => {
// 处理带连字符的部分
return part.split('-')
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join('')
})
.join('')
// 生成 titleKey
// 翻译文件中的键通常是 menu.xxx_management 格式
// 但有些菜单的 slug 可能已经包含了 _management,所以需要智能处理
const slug = menu.Slug || menu.slug || routePath
// 如果 slug 是以 / 开头的路径(如 /articles),去掉开头的 /
const cleanSlug = slug.startsWith('/') ? slug.slice(1) : slug
let titleKey = `menu.${cleanSlug}`
// 如果 slug 不包含 _management,尝试添加后缀
// 但先检查原始键是否存在,如果存在就不添加后缀
// 注意:这里我们无法直接检查翻译键,所以先尝试添加 _management
// BreadcrumbView 会使用智能翻译函数来处理
// 构建路由配置
const route = {
path: routePath,
name: routeName,
meta: {
titleKey: titleKey,
menuId: menu.id || menu.ID,
menuSlug: cleanSlug // 保存 slug,供 BreadcrumbView 使用
}
}
// 如果是外部链接(linkType === 2),使用 iframe 组件
if (linkType === 2) {
route.component = () => lazyLoad(() => import('../views/iframe/IframeView.vue'))
route.meta.externalUrl = path
} else {
// 内部页面,根据 component 字段获取组件导入函数
const componentImport = getComponentImport(component)
if (componentImport) {
route.component = componentImport
} else {
// Layout 组件视为目录,不需要生成路由
if (component === 'Layout') {
return
}
// 如果无法获取组件导入函数,跳过(可能是目录类型或其他特殊类型)
logger.warn(`Skipping route ${path} due to missing component import for: ${component}`)
return
}
}
routes.push(route)
})
return routes
}
// 标记是否已经添加过动态路由
let dynamicRoutesAdded = false
// 防止路由守卫在接口异常/菜单为空时陷入 next(to.fullPath) 死循环(从而导致 /info 无限请求)
const navigationRetryState = new Map() // fullPath -> { count: number, lastAt: number }
const NAVIGATION_RETRY_WINDOW = 3000 // 3 秒窗口
const MAX_RETRIES_PER_PATH = 1
let lastMenuRefreshAttemptAt = 0
const MENU_REFRESH_COOLDOWN = 5000 // 5 秒内不重复刷新菜单
function canRetryNavigation(fullPath) {
const now = Date.now()
const state = navigationRetryState.get(fullPath)
if (!state || now - state.lastAt > NAVIGATION_RETRY_WINDOW) {
navigationRetryState.set(fullPath, { count: 0, lastAt: now })
return true
}
return state.count < MAX_RETRIES_PER_PATH
}
function markNavigationRetried(fullPath) {
const now = Date.now()
const state = navigationRetryState.get(fullPath) || { count: 0, lastAt: now }
state.count += 1
state.lastAt = now
navigationRetryState.set(fullPath, state)
}
// 初始路由(只包含固定路由)
const routes = [...staticRoutes]
const router = createRouter({
history: createWebHistory(),
routes
})
/**
* 动态添加路由
* @param {Array} menus - 菜单数组
*/
function addDynamicRoutes(menus) {
if (!menus || menus.length === 0) {
return
}
const dynamicRoutes = convertMenusToRoutes(menus)
if (dynamicRoutes.length === 0) {
logger.warn('No dynamic routes to add')
return
}
// 检查路由是否已存在,避免重复添加
const existingRoutes = router.getRoutes()
const existingPaths = new Set(
existingRoutes
.filter(route => route.path !== '/' && route.path !== '/login')
.flatMap(route => route.children || [])
.map(child => child.path)
)
// 只添加不存在的路由
const routesToAdd = dynamicRoutes.filter(route => !existingPaths.has(route.path))
if (routesToAdd.length === 0) {
logger.debug('All dynamic routes already exist')
return
}
// 找到主布局路由(path === '/' 或 name === 'MainLayout'
const mainLayoutRoute = existingRoutes.find(route => route.path === '/' || route.name === 'MainLayout')
if (!mainLayoutRoute) {
logger.error('Main layout route not found')
return
}
// 添加新路由到主布局路由
// Vue Router 的子路由路径应该是相对路径(不带前导斜杠)
// 但为了匹配 URL 路径(如 /users),我们需要确保路径格式正确
const parentName = mainLayoutRoute.name || 'MainLayout'
routesToAdd.forEach(route => {
// 子路由路径应该是相对路径(不带前导斜杠)
// 这样 Vue Router 会自动处理路径匹配
const routePath = route.path.startsWith('/') ? route.path.slice(1) : route.path
const routeConfig = {
...route,
path: routePath
}
try {
router.addRoute(parentName, routeConfig)
logger.debug(`Added route: ${routePath} (parent: ${parentName})`)
} catch (error) {
logger.error(`Failed to add route ${routePath}:`, error)
}
})
logger.debug(`Added ${routesToAdd.length} dynamic routes:`, routesToAdd.map(r => r.path))
}
/**
* 重置动态路由标志(在登出时调用)
*/
export function resetDynamicRoutes() {
dynamicRoutesAdded = false
}
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth === false) {
// 登录页面,如果已登录则跳转到首页
if (userStore.isLoggedIn) {
next('/')
} else {
// 如果未登录,重置动态路由标志
if (dynamicRoutesAdded) {
dynamicRoutesAdded = false
}
next()
}
} else {
// 需要认证的页面
if (!userStore.isLoggedIn) {
// 如果没有token,重置动态路由标志并跳转到登录页
if (dynamicRoutesAdded) {
dynamicRoutesAdded = false
}
next('/login')
} else {
// 优化:只在首次加载(从登录页或刷新页面)时才阻塞导航
// 如果用户信息已获取过,允许导航继续,菜单可以在后台异步加载
const isFirstLoad = !from.name || from.name === 'Login'
// 检查菜单是否为空(即使 userInfoFetched 为 true,菜单也可能被意外清空)
const menusEmpty = !userStore.menus || userStore.menus.length === 0
// 如果用户信息已获取过,但菜单为空,需要重新获取菜单(不阻塞导航)
if (userStore.userInfoFetched && menusEmpty && userStore.adminInfo) {
const now = Date.now()
// 冷却期内不重复拉取菜单,避免 /info 被频繁请求
if (now - lastMenuRefreshAttemptAt < MENU_REFRESH_COOLDOWN) {
const resolved = router.resolve(to.path)
if (!resolved.name && to.path !== '/dashboard') {
next('/dashboard')
} else {
next()
}
return
}
lastMenuRefreshAttemptAt = now
// 阻塞导航,等待菜单加载完成(因为当前路由可能依赖动态路由)
userStore.fetchUserInfo().then(() => {
// 获取菜单后添加动态路由
if (userStore.menus && userStore.menus.length > 0 && !dynamicRoutesAdded) {
addDynamicRoutes(userStore.menus)
dynamicRoutesAdded = true
}
// 如果菜单仍为空,不再对当前路径做 next(to.fullPath) 递归重试,直接放行或降级
if (!userStore.menus || userStore.menus.length === 0) {
const resolved = router.resolve(to.path)
if (!resolved.name && to.path !== '/dashboard') {
next('/dashboard')
} else {
next()
}
return
}
// 只有在允许重试时才 next(to.fullPath),避免死循环
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
}).catch((error) => {
logger.error('Failed to refresh menus:', error)
// 获取失败时不再反复重试,直接回到 dashboard(避免触发更多 /info
next('/dashboard')
})
return
}
// 如果用户信息已获取过且菜单不为空,检查是否需要添加动态路由
if (userStore.userInfoFetched && !menusEmpty) {
// 如果还没有添加动态路由,现在添加
if (!dynamicRoutesAdded && userStore.menus && userStore.menus.length > 0) {
addDynamicRoutes(userStore.menus)
dynamicRoutesAdded = true
// 路由添加后,使用 next() 重试导航
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
return
}
// 检查路由是否存在(只检查路径,不包含查询参数)
const route = router.resolve(to.path)
if (!route.name && to.path !== '/') {
// 路由不存在,可能是路径不匹配;只允许重试一次,避免无限循环
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
return
}
next()
return
}
// 首次加载:如果用户信息不存在或菜单为空,需要获取
if (!userStore.adminInfo || menusEmpty) {
// 阻塞导航,等待用户信息加载完成
userStore.fetchUserInfo().then(() => {
// 获取菜单后添加动态路由
if (userStore.menus && userStore.menus.length > 0 && !dynamicRoutesAdded) {
addDynamicRoutes(userStore.menus)
dynamicRoutesAdded = true
}
// 菜单为空时不递归重试,避免 /info 无限请求
if (!userStore.menus || userStore.menus.length === 0) {
const resolved = router.resolve(to.path)
if (!resolved.name && to.path !== '/dashboard') {
next('/dashboard')
} else {
next()
}
return
}
// 路由添加后,使用 next() 重试导航(仅一次)
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
}).catch((error) => {
// 如果获取用户信息失败(可能是401),拦截器会处理跳转
// 这里只需要阻止导航
next(false)
})
} else {
// 用户信息已存在,检查是否需要添加动态路由
if (!dynamicRoutesAdded && userStore.menus && userStore.menus.length > 0) {
addDynamicRoutes(userStore.menus)
dynamicRoutesAdded = true
// 路由添加后,使用 next() 重试导航
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
return
}
// 检查路由是否存在(只检查路径,不包含查询参数)
const route = router.resolve(to.path)
if (!route.name && to.path !== '/') {
// 路由不存在,可能是路径不匹配;只允许重试一次,避免无限循环
if (canRetryNavigation(to.fullPath)) {
markNavigationRetried(to.fullPath)
next(to.fullPath)
} else {
next('/dashboard')
}
return
}
// 标记为已获取,避免后续路由切换时重复检查
userStore.userInfoFetched = true
next()
}
}
}
})
// 捕获路由错误(包括动态导入失败和路由未找到)
router.onError((error) => {
logger.error('Router error:', error)
// 如果是路由未找到的错误,尝试重新加载动态路由
if (error.message && (
error.message.includes('No match found') ||
error.message.includes('No match') ||
error.name === 'NavigationFailure'
)) {
const userStore = useUserStore()
// 如果用户已登录但路由未找到,可能是动态路由未加载
if (userStore.isLoggedIn && userStore.menus && userStore.menus.length > 0 && !dynamicRoutesAdded) {
logger.warn('Route not found, attempting to reload dynamic routes')
addDynamicRoutes(userStore.menus)
dynamicRoutesAdded = true
// 重试导航
router.push(router.currentRoute.value.fullPath).catch(() => {
// 如果重试失败,跳转到首页
router.push('/').catch(() => {})
})
}
}
// 检查是否是动态导入失败
if (error.message && (
error.message.includes('Failed to fetch dynamically imported module') ||
error.message.includes('Loading chunk') ||
error.message.includes('Loading CSS chunk') ||
error.name === 'ChunkLoadError'
)) {
ElMessage.error({
message: '页面加载失败,请刷新页面重试',
duration: 5000,
showClose: true
})
// 可以尝试重新加载页面
const retry = () => {
window.location.reload()
}
// 延迟 2 秒后自动刷新,给用户时间看到错误提示
setTimeout(retry, 2000)
} else {
ElMessage.error({
message: '路由导航失败,请刷新页面',
duration: 3000
})
}
})
export default router
+82
View File
@@ -0,0 +1,82 @@
import { defineStore } from 'pinia'
import Storage from '../utils/storage'
const detectBrowserTimezone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
} catch {
return 'UTC'
}
}
export const useAppStore = defineStore('app', {
state: () => ({
sidebarCollapsed: Storage.getItem('sidebarCollapsed', 'false') === 'true',
layoutSize: Storage.getItem('layoutSize', 'default') || 'default', // default, large, small
isFullscreen: false,
timezone: Storage.getItem('timezone', detectBrowserTimezone()) || detectBrowserTimezone(),
darkMode: Storage.getItem('darkMode', 'false') === 'true'
}),
actions: {
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed
Storage.setItem('sidebarCollapsed', this.sidebarCollapsed.toString())
},
setSidebarCollapsed(collapsed) {
this.sidebarCollapsed = collapsed
Storage.setItem('sidebarCollapsed', collapsed.toString())
},
setLayoutSize(size) {
this.layoutSize = size
Storage.setItem('layoutSize', size)
// 应用布局大小到 body
document.body.className = document.body.className.replace(/layout-\w+/g, '')
document.body.classList.add(`layout-${size}`)
},
toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
this.isFullscreen = true
}).catch(() => {
console.error('无法进入全屏模式')
})
} else {
document.exitFullscreen().then(() => {
this.isFullscreen = false
}).catch(() => {
console.error('无法退出全屏模式')
})
}
},
setTimezone(timezone) {
this.timezone = timezone || 'UTC'
Storage.setItem('timezone', this.timezone)
},
toggleDarkMode() {
this.darkMode = !this.darkMode
Storage.setItem('darkMode', this.darkMode.toString())
// 应用或移除 dark-mode 类到 body
if (this.darkMode) {
document.body.classList.add('dark-mode')
} else {
document.body.classList.remove('dark-mode')
}
},
initDarkMode() {
// 初始化时应用夜间模式
if (this.darkMode) {
document.body.classList.add('dark-mode')
} else {
document.body.classList.remove('dark-mode')
}
}
}
})
+230
View File
@@ -0,0 +1,230 @@
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
import i18n from '../i18n'
import Storage from '../utils/storage'
import {
fetchNotifications,
fetchUnreadCount,
fetchRecentNotifications,
markNotificationRead,
markAllNotificationsRead
} from '../api/notification'
import { playNotificationSound } from '../utils/sound'
const { t } = i18n.global
export const useNotificationStore = defineStore('notification', {
state: () => ({
items: [],
unreadCount: 0,
loading: false,
ws: null,
wsConnected: false,
initializing: false,
retryCount: 0,
retryTimer: null,
soundDebounceTimer: null // 声音防抖定时器
}),
actions: {
async init() {
if (this.initializing) {
return
}
this.initializing = true
await this.refresh()
this.connect()
},
async refresh(params = {}) {
this.loading = true
try {
const { data } = await fetchRecentNotifications({
limit: params.limit || 7
})
this.items = data.notifications || []
this.unreadCount = data.unread_count || 0
} catch (error) {
console.error('Load notifications error:', error)
} finally {
this.loading = false
}
},
async fetchUnread() {
try {
const { data } = await fetchUnreadCount()
this.unreadCount = data.count || 0
} catch (error) {
console.error('Fetch unread count error:', error)
}
},
async markAsRead(id) {
try {
await markNotificationRead(id)
this.items = this.items.map(item =>
item.id === id ? { ...item, is_read: true, read_at: new Date().toISOString() } : item
)
if (this.unreadCount > 0) {
this.unreadCount -= 1
}
} catch (error) {
console.error('Mark notification read failed:', error)
}
},
async markAllRead() {
try {
await markAllNotificationsRead()
this.items = this.items.map(item => ({ ...item, is_read: true, read_at: new Date().toISOString() }))
this.unreadCount = 0
} catch (error) {
console.error('Mark all notifications read failed:', error)
}
},
connect() {
if (this.ws || this.wsConnected) {
return
}
const token = Storage.getItem('token', '')
if (!token || typeof token !== 'string') {
return
}
// 构建 WebSocket URL
// 优先使用 VITE_WS_BASE_URL(单独的 WebSocket 域名)
// 如果没有配置,则使用 VITE_API_BASE_URL
let wsUrl
const wsBaseURL = import.meta.env.VITE_WS_BASE_URL
const apiBaseURL = import.meta.env.VITE_API_BASE_URL
if (wsBaseURL) {
// 如果配置了单独的 WebSocket 域名,使用它
const base = wsBaseURL.replace(/\/+$/, '')
if (base.startsWith('wss://') || base.startsWith('ws://')) {
// 如果已经包含协议,直接使用
wsUrl = base + '/ws/admin/notifications?token=' + encodeURIComponent(token.trim())
} else if (base.startsWith('https://')) {
wsUrl = base.replace('https://', 'wss://') + '/ws/admin/notifications?token=' + encodeURIComponent(token.trim())
} else if (base.startsWith('http://')) {
wsUrl = base.replace('http://', 'ws://') + '/ws/admin/notifications?token=' + encodeURIComponent(token.trim())
} else {
// 如果没有协议,根据当前页面协议判断
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${protocol}//${base}/ws/admin/notifications?token=${encodeURIComponent(token.trim())}`
}
} else if (apiBaseURL) {
// 如果没有配置 WebSocket 域名,使用 API 基础 URL
const base = apiBaseURL.replace(/\/+$/, '')
if (base.startsWith('https://')) {
wsUrl = base.replace('https://', 'wss://') + '/ws/admin/notifications?token=' + encodeURIComponent(token.trim())
} else if (base.startsWith('http://')) {
wsUrl = base.replace('http://', 'ws://') + '/ws/admin/notifications?token=' + encodeURIComponent(token.trim())
} else {
// 如果没有协议,根据当前页面协议判断
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
wsUrl = `${protocol}//${base}/ws/admin/notifications?token=${encodeURIComponent(token.trim())}`
}
} else {
// 如果都没有配置,使用当前页面的协议和主机(开发环境)
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
wsUrl = `${protocol}//${host}/ws/admin/notifications?token=${encodeURIComponent(token.trim())}`
}
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
this.wsConnected = true
this.retryCount = 0
}
this.ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data)
this.handleIncoming(payload)
} catch (error) {
console.error('Invalid notification payload:', error)
}
}
this.ws.onclose = () => {
this.wsConnected = false
this.ws = null
this.scheduleReconnect()
}
this.ws.onerror = () => {
this.wsConnected = false
}
},
scheduleReconnect() {
if (!this.retryCount) {
this.retryCount = 0
}
if (this.retryTimer) {
clearTimeout(this.retryTimer)
}
const delay = Math.min(30000, 2000 * Math.pow(2, this.retryCount))
this.retryTimer = setTimeout(() => {
this.retryCount += 1
const token = Storage.getItem('token', '')
if (!token || typeof token !== 'string') {
return
}
this.connect()
if (!this.wsConnected && this.retryCount === 3) {
ElMessage.error(t('notification.ws_error'))
}
}, delay)
},
disconnect() {
if (this.ws) {
this.ws.close()
this.ws = null
}
this.wsConnected = false
this.initializing = false
this.items = []
this.unreadCount = 0
if (this.retryTimer) {
clearTimeout(this.retryTimer)
this.retryTimer = null
}
if (this.soundDebounceTimer) {
clearTimeout(this.soundDebounceTimer)
this.soundDebounceTimer = null
}
this.retryCount = 0
},
handleIncoming(notification) {
const exists = this.items.find(item => item.id === notification.id)
const isNewNotification = !exists
if (isNewNotification) {
this.items.unshift(notification)
if (!notification.is_read) {
this.unreadCount += 1
// 播放提示音(带防抖,1秒内只播放一次)
this.playNotificationSoundWithDebounce()
}
if (this.items.length > 7) {
this.items = this.items.slice(0, 7)
}
} else {
this.items = this.items.map(item => item.id === notification.id ? notification : item)
}
},
/**
* 播放通知提示音(带防抖)
* 如果1秒内收到多个通知,只播放一次声音
*/
playNotificationSoundWithDebounce() {
// 如果已经有待执行的定时器,说明在防抖窗口内,直接返回
if (this.soundDebounceTimer) {
return
}
// 立即播放一次声音
playNotificationSound()
// 设置防抖定时器,1秒后重置
this.soundDebounceTimer = setTimeout(() => {
this.soundDebounceTimer = null
}, 1000) // 1秒防抖窗口
}
}
})
+191
View File
@@ -0,0 +1,191 @@
import { defineStore } from 'pinia'
import Storage from '../utils/storage'
// 从 localStorage 加载标签页数据
const loadTabsFromStorage = () => {
try {
const data = Storage.getItem('tabs', null)
if (data && typeof data === 'object') {
return {
tabs: data.tabs || [],
activeTab: data.activeTab || null
}
}
} catch (error) {
console.error('Failed to load tabs from storage:', error)
}
return {
tabs: [],
activeTab: null
}
}
// 保存标签页数据到 localStorage
const saveTabsToStorage = (tabs, activeTab) => {
try {
const data = { tabs, activeTab }
Storage.setItem('tabs', data)
// 触发 storage 事件,通知其他标签页更新
window.dispatchEvent(new StorageEvent('storage', {
key: 'tabs',
newValue: JSON.stringify(data)
}))
} catch (error) {
console.error('Failed to save tabs to storage:', error)
}
}
export const useTabsStore = defineStore('tabs', {
state: () => {
const { tabs, activeTab } = loadTabsFromStorage()
return {
tabs,
activeTab
}
},
getters: {
hasTabs: (state) => state.tabs.length > 0
},
actions: {
addTab(route) {
const tab = {
name: route.name,
path: route.path,
title: route.meta?.titleKey || route.meta?.title || route.name,
titleKey: route.meta?.titleKey
}
// 检查是否已存在
const exists = this.tabs.find(t => t.path === tab.path)
if (!exists) {
this.tabs.push(tab)
}
this.activeTab = tab.path
saveTabsToStorage(this.tabs, this.activeTab)
},
removeTab(path) {
const index = this.tabs.findIndex(t => t.path === path)
if (index > -1) {
this.tabs.splice(index, 1)
}
// 如果删除的是当前激活的标签,需要外部处理路由跳转
// 这里不自动切换,由组件处理
if (this.activeTab === path) {
if (this.tabs.length > 0) {
// 优先选择右侧标签,如果没有则选择左侧
const nextIndex = index < this.tabs.length ? index : index - 1
if (nextIndex >= 0 && nextIndex < this.tabs.length) {
this.activeTab = this.tabs[nextIndex].path
} else if (this.tabs.length > 0) {
this.activeTab = this.tabs[this.tabs.length - 1].path
} else {
this.activeTab = null
}
} else {
this.activeTab = null
}
}
saveTabsToStorage(this.tabs, this.activeTab)
},
removeOtherTabs(path) {
this.tabs = this.tabs.filter(t => t.path === path)
this.activeTab = path
saveTabsToStorage(this.tabs, this.activeTab)
},
removeLeftTabs(path) {
const index = this.tabs.findIndex(t => t.path === path)
if (index > -1) {
this.tabs = this.tabs.slice(index)
this.activeTab = path
}
saveTabsToStorage(this.tabs, this.activeTab)
},
removeRightTabs(path) {
const index = this.tabs.findIndex(t => t.path === path)
if (index > -1) {
this.tabs = this.tabs.slice(0, index + 1)
this.activeTab = path
}
saveTabsToStorage(this.tabs, this.activeTab)
},
removeAllTabs() {
this.tabs = []
this.activeTab = null
saveTabsToStorage(this.tabs, this.activeTab)
},
refreshTab(path) {
const tab = this.tabs.find(t => t.path === path)
if (tab) {
tab.refreshKey = Date.now()
saveTabsToStorage(this.tabs, this.activeTab)
}
},
getRefreshKey(path) {
const tab = this.tabs.find(t => t.path === path)
return tab?.refreshKey || ''
},
setActiveTab(path) {
this.activeTab = path
saveTabsToStorage(this.tabs, this.activeTab)
},
// 从 localStorage 同步标签页(用于多标签页同步)
syncTabsFromStorage() {
const { tabs, activeTab } = loadTabsFromStorage()
this.tabs = tabs
this.activeTab = activeTab
}
}
})
// 监听 storage 事件,实现多标签页同步
// 注意:storage 事件只在其他标签页修改 localStorage 时触发,不会在当前标签页触发
// 这个监听器会在 store 初始化后设置
let storageListener = null
export const setupTabsStorageSync = () => {
if (typeof window === 'undefined' || storageListener) {
return
}
storageListener = (e) => {
if (e.key === 'tabs' && e.newValue) {
try {
const data = JSON.parse(e.newValue)
const tabsStore = useTabsStore()
if (tabsStore) {
tabsStore.tabs = data.tabs || []
tabsStore.activeTab = data.activeTab || null
}
} catch (error) {
console.error('Failed to sync tabs from storage:', error)
}
}
}
window.addEventListener('storage', storageListener)
}
// 在浏览器环境中自动设置监听器
if (typeof window !== 'undefined') {
// 延迟设置,确保 Pinia 已经初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupTabsStorageSync)
} else {
// DOM 已经加载完成,延迟一下确保 Pinia 初始化
setTimeout(setupTabsStorageSync, 100)
}
}
+254
View File
@@ -0,0 +1,254 @@
import { defineStore } from 'pinia'
import { compact, map } from 'lodash-es'
import { getInfo, logout } from '../api/auth'
import Storage from '../utils/storage'
import logger from '../utils/logger'
export const useUserStore = defineStore('user', {
state: () => {
const adminInfo = Storage.getItem('adminInfo', null)
return {
token: Storage.getItem('token', ''),
adminInfo: adminInfo,
permissions: [],
menus: [], // 菜单不缓存,每次刷新都从服务器重新获取
isSuperAdmin: false, // 是否是超级管理员
isFetchingUserInfo: false, // 是否正在获取用户信息,避免重复请求
// 如果从 localStorage 恢复了用户信息,认为已获取过(但菜单仍需从服务器获取)
userInfoFetched: !!adminInfo, // 用户信息是否已获取过(用于判断是否需要阻塞导航)
config: {
showButtonsWithoutPermission: false // 是否显示无权限的按钮
}
}
},
getters: {
isLoggedIn: (state) => !!state.token,
hasPermission: (state) => (permission) => {
// 超级管理员拥有所有权限
if (state.isSuperAdmin) {
return true
}
return state.permissions.includes(permission)
},
// 检查是否应该显示按钮(考虑权限和配置)
shouldShowButton: (state) => (permission) => {
// 超级管理员总是显示所有按钮
if (state.isSuperAdmin) {
return true
}
const hasPerm = state.permissions.includes(permission)
// 如果有权限,总是显示
if (hasPerm) {
return true
}
// 如果没有权限,根据配置决定是否显示
return state.config.showButtonsWithoutPermission
}
},
actions: {
setToken(token) {
this.token = token
Storage.setItem('token', token)
},
setAdminInfo(adminInfo) {
// 只存储必要的基本信息,避免 localStorage 配额超限
// 不存储 permissions, menus 等大数据字段,它们已经单独存储
// roles 只存储基本信息(id 和 name),用于显示
const basicInfo = {
id: adminInfo.id || adminInfo.ID,
username: adminInfo.username || adminInfo.Username,
nickname: adminInfo.nickname || adminInfo.Nickname,
avatar: adminInfo.avatar || adminInfo.Avatar,
email: adminInfo.email || adminInfo.Email,
phone: adminInfo.phone || adminInfo.Phone,
department_id: adminInfo.department_id || adminInfo.DepartmentID,
department: adminInfo.department || adminInfo.Department,
// roles 只存储基本信息,避免存储完整的关联数据
roles: (adminInfo.roles || adminInfo.Roles || []).map(role => ({
id: role.id || role.ID,
name: role.name || role.Name,
slug: role.slug || role.Slug
}))
}
this.adminInfo = basicInfo
// 设置超级管理员标识(从后端返回或从角色判断)
this.isSuperAdmin = adminInfo.is_super_admin === true || adminInfo.isSuperAdmin === true ||
(adminInfo.roles || adminInfo.Roles || []).some(role =>
(role.slug || role.Slug) === 'super-admin' && (role.status || role.Status) === 1
)
// 使用 Storage 工具保存,自动处理错误
const saved = Storage.setItem('adminInfo', basicInfo)
if (!saved) {
// 如果保存失败,尝试只存储最基本信息
const minimalInfo = {
id: basicInfo.id,
username: basicInfo.username,
nickname: basicInfo.nickname,
avatar: basicInfo.avatar
}
const minimalSaved = Storage.setItem('adminInfo', minimalInfo)
if (minimalSaved) {
this.adminInfo = minimalInfo
} else {
logger.error('Failed to save adminInfo even with minimal data')
}
}
},
setPermissions(permissions) {
// 如果 permissions 是对象数组,提取 slug 字段;如果是字符串数组,直接使用
if (Array.isArray(permissions) && permissions.length > 0) {
if (typeof permissions[0] === 'object' && permissions[0] !== null) {
// 对象数组,提取 slug 字段
this.permissions = compact(map(permissions, perm => perm.slug || perm.Slug || perm))
} else {
// 字符串数组,直接使用
this.permissions = permissions
}
} else {
this.permissions = []
}
},
setMenus(menus) {
this.menus = menus
// 菜单不缓存到 localStorage,每次刷新都从服务器重新获取
},
setConfig(config) {
this.config = {
showButtonsWithoutPermission: config?.show_buttons_without_permission || config?.showButtonsWithoutPermission || false
}
},
async fetchUserInfo(force = false) {
// 如果正在获取中,且不是强制刷新,则等待当前请求完成
if (this.isFetchingUserInfo && !force) {
// 等待当前请求完成
while (this.isFetchingUserInfo) {
await new Promise(resolve => setTimeout(resolve, 50))
}
return Promise.resolve()
}
// 如果已经获取过且不是强制刷新,且菜单不为空,直接返回
if (this.userInfoFetched && !force && this.adminInfo && this.menus.length > 0) {
return Promise.resolve()
}
try {
this.isFetchingUserInfo = true
// 保存当前菜单数据,避免在请求失败时丢失
const oldMenus = this.menus.length > 0 ? [...this.menus] : []
const oldAdminInfo = this.adminInfo
const oldPermissions = [...this.permissions]
// 清除旧的数据,确保获取最新的数据(仅在强制刷新或首次加载时)
if (force || !this.userInfoFetched) {
this.menus = []
this.adminInfo = null
this.permissions = []
Storage.removeItem('adminInfo')
}
const res = await getInfo()
if (res.data && res.data.admin) {
this.setAdminInfo(res.data.admin)
// 设置权限(需要先设置,因为 setAdminInfo 可能会用到)
this.setPermissions(res.data.admin.permissions || [])
// 确保菜单数据存在且不为空
const newMenus = res.data.admin.menus || []
if (newMenus.length > 0) {
this.setMenus(newMenus)
} else if (oldMenus.length > 0 && !force) {
// 如果新菜单为空但旧菜单存在,保留旧菜单(可能是接口返回问题)
logger.warn('New menus is empty, keeping old menus')
this.setMenus(oldMenus)
} else {
this.setMenus([])
}
// 设置超级管理员标识
this.isSuperAdmin = res.data.admin.is_super_admin === true || res.data.admin.isSuperAdmin === true ||
(res.data.admin.roles || []).some(role =>
(role.slug || role.Slug) === 'super-admin' && (role.status || role.Status) === 1
)
// 标记用户信息已获取
this.userInfoFetched = true
// 调试:打印权限信息(开发环境)
logger.debug('User permissions:', this.permissions)
logger.debug('Is super admin:', this.isSuperAdmin)
logger.debug('Menus count:', this.menus.length)
} else {
// 如果接口返回的数据中没有 admin 信息,恢复旧数据
if (oldMenus.length > 0 && !force) {
logger.warn('No admin data in response, restoring old data')
this.menus = oldMenus
this.adminInfo = oldAdminInfo
this.permissions = oldPermissions
}
}
// 保存配置信息
if (res.data && res.data.config) {
this.setConfig(res.data.config)
}
return res
} catch (error) {
// fetchUserInfo 失败时,如果旧数据存在,恢复旧数据
if (oldMenus.length > 0 && !force) {
logger.warn('Failed to fetch user info, restoring old data:', error)
this.menus = oldMenus
this.adminInfo = oldAdminInfo
this.permissions = oldPermissions
// 不标记为失败,允许继续使用旧数据
return Promise.resolve()
}
// 如果没有旧数据或强制刷新,清除状态
this.userInfoFetched = false
this.logout(true)
throw error
} finally {
this.isFetchingUserInfo = false
}
},
async logout(skipApiCall = false) {
try {
// 如果有 token 且不需要跳过 API 调用,尝试调用后端登出接口
if (this.token && !skipApiCall) {
await logout()
}
} catch (error) {
// 即使登出接口失败,也要清除本地状态
logger.error('Logout error:', error)
} finally {
// 清除所有状态(同步执行,不等待)
this.token = ''
this.adminInfo = null
this.permissions = []
this.menus = []
this.isSuperAdmin = false
this.userInfoFetched = false
this.isFetchingUserInfo = false
this.config = {
showButtonsWithoutPermission: false
}
Storage.removeItem('token')
Storage.removeItem('adminInfo')
// 菜单不缓存,无需清除
}
// 返回 resolved promise 确保调用者可以继续
return Promise.resolve()
}
}
})
+83
View File
@@ -0,0 +1,83 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* CSS变量定义 - 浅色模式(默认) */
:root {
--bg-color: #ffffff;
--bg-color-secondary: #f0f2f5;
--bg-color-tertiary: #f5f7fa;
--text-color-primary: #303133;
--text-color-regular: #606266;
--text-color-secondary: #909399;
--text-color-placeholder: #c0c4cc;
--border-color-base: #dcdfe6;
--border-color-light: #e4e7ed;
--border-color-lighter: #ebeef5;
--border-color-extra-light: #f2f6fc;
--header-bg: #ffffff;
--sidebar-bg: #1f2937;
--sidebar-text: #bfcbd9;
--sidebar-active: #409EFF;
--card-bg: #ffffff;
--card-border: #e4e7ed;
--shadow-base: rgba(0, 0, 0, 0.1);
--shadow-light: rgba(0, 0, 0, 0.05);
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color-secondary);
color: var(--text-color-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
#app {
width: 100%;
height: 100vh;
}
/* 夜间模式 - 使用滤镜方式 */
body.dark-mode {
filter: invert(1) hue-rotate(180deg);
}
/* 左侧菜单栏在夜间模式下保持原样,不受滤镜影响 */
body.dark-mode .sidebar {
filter: invert(1) hue-rotate(180deg);
}
/* 图片在夜间模式下需要再次反转以保持正常显示 */
body.dark-mode img,
body.dark-mode .el-image img,
body.dark-mode .el-avatar img {
filter: invert(1) hue-rotate(180deg);
}
/* 视频和其他媒体元素也需要反转 */
body.dark-mode video,
body.dark-mode iframe {
filter: invert(1) hue-rotate(180deg);
}
/* 列表页公共样式 */
.list-page {
background: white;
border-radius: 4px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-actions {
display: flex;
gap: 10px;
}
+64
View File
@@ -0,0 +1,64 @@
# TypeScript 类型定义
本目录包含项目的 TypeScript 类型定义文件。
## 文件说明
- `index.d.ts` - 通用类型和业务实体类型定义
- `composables.d.ts` - Composables 函数的类型定义
## 使用方式
### 在 JavaScript 文件中使用(JSDoc
```javascript
/**
* @typedef {import('../types').Admin} Admin
* @typedef {import('../types').Pagination} Pagination
*/
/**
* @param {Admin} admin
* @returns {string}
*/
function getAdminDisplayName(admin) {
return admin.nickname || admin.username
}
```
### 在 TypeScript 文件中使用
```typescript
import type { Admin, Pagination } from '../types'
function getAdminDisplayName(admin: Admin): string {
return admin.nickname || admin.username
}
```
### 在 Vue 组件中使用
```vue
<script setup lang="ts">
import type { Admin } from '../types'
import { ref } from 'vue'
const admin = ref<Admin | null>(null)
</script>
```
## 类型检查
运行类型检查(不影响构建):
```bash
npm run type-check
```
## 注意事项
1. **不强制使用 TypeScript**:现有的 `.js` 文件可以继续使用,不需要修改
2. **渐进式采用**:新文件可以选择使用 `.ts``.js`
3. **类型定义共享**`.d.ts` 文件中的类型可以在 JS 和 TS 文件中使用
4. **IDE 支持**:即使使用 JS 文件,配合 JSDoc 注释也能获得类型提示
+227
View File
@@ -0,0 +1,227 @@
/**
* Composables 类型定义
* 为 JavaScript composables 提供类型提示
*/
import type { Ref, ComputedRef } from 'vue'
import type { Pagination, TableColumn, SearchField } from './index'
// ==================== useCrud ====================
export interface UseCrudOptions {
/** 单条删除 API 函数 */
deleteApi?: (id: number | string) => Promise<any>
/** 批量删除 API 函数(接收 ids 数组) */
batchDeleteApi?: (ids: (number | string)[]) => Promise<any>
/** 删除确认提示的 i18n key */
deleteConfirmKey?: string
/** 删除成功提示的 i18n key */
deleteSuccessKey?: string
/** 批量删除确认提示的 i18n key */
batchDeleteConfirmKey?: string
/** 提示框标题的 i18n key */
tipKey?: string
/** 删除成功回调 */
onDeleteSuccess?: (data: any, id?: number | string) => void
/** 删除失败回调 */
onDeleteError?: (error: any, data: any) => void
/** 删除前钩子 */
beforeDelete?: (data: any) => boolean | Promise<boolean>
/** 删除后钩子 */
afterDelete?: (data: any) => void
}
export interface UseCrudReturn {
/** 对话框可见状态 */
dialogVisible: Ref<boolean>
/** 编辑 ID */
editId: Ref<number | string | null>
/** 打开添加对话框 */
handleAdd: () => void
/** 打开编辑对话框 */
handleEdit: (row: any) => void
/** 关闭对话框 */
handleClose: () => void
/** 表单提交成功处理 */
handleFormSuccess: (reloadData?: () => void) => void
/** 删除单条记录 */
handleDelete: (row: any, reloadData?: () => void) => Promise<void>
/** 批量删除记录 */
handleBatchDelete: (rows: any[], reloadData?: () => void) => Promise<void>
}
export function useCrud(options?: UseCrudOptions): UseCrudReturn
// ==================== useListPage ====================
export interface UseListPageOptions<T = any> {
/** 获取列表数据的 API 函数 */
fetchApi: (params: any) => Promise<any>
/** 初始搜索表单值 */
initialSearchForm?: Record<string, any>
/** 排序配置 */
sortOptions?: {
tableRef: Ref<any>
fieldMapping?: Record<string, string>
defaultSort?: string
}
/** 数据转换函数 */
transformData?: (item: any) => T
/** 加载成功回调 */
onLoadSuccess?: (response: any, list: T[]) => void
/** 加载失败回调 */
onLoadError?: (error: any) => void
}
export interface UseListPageReturn<T = any> {
/** 分页状态 */
pagination: Pagination
/** 表格数据 */
tableData: Ref<T[]>
/** 加载状态 */
loading: Ref<boolean>
/** 搜索表单 */
searchForm: Record<string, any>
/** 加载数据 */
loadData: () => Promise<void>
/** 搜索处理 */
handleSearch: () => void
/** 重置处理 */
handleReset: () => void
/** 分页变化处理 */
handlePageChange: (params: { currentPage: number; pageSize: number }) => void
/** 排序变化处理 */
handleSortChange: (params: any) => void
/** 初始化默认排序 */
initDefaultSort: () => void
/** 取消请求 */
cancelRequest: () => void
}
export function useListPage<T = any>(options: UseListPageOptions<T>): UseListPageReturn<T>
// ==================== useTableSort ====================
export interface UseTableSortOptions {
/** 表格引用 */
tableRef: Ref<any>
/** 字段映射 */
fieldMapping?: Record<string, string>
/** 默认排序 */
defaultSort?: string
/** 排序变化回调 */
onSortChange?: () => void
}
export interface UseTableSortReturn {
/** 构建排序参数 */
buildOrderBy: () => string
/** 排序变化处理 */
handleSortChange: (params: any) => void
/** 重置排序 */
resetSort: () => void
/** 初始化默认排序 */
initDefaultSort: () => void
}
export function useTableSort(options: UseTableSortOptions): UseTableSortReturn
// ==================== useColumnSetting ====================
export interface UseColumnSettingOptions {
/** 默认显示的列 keys */
defaultVisibleColumns?: string[]
/** 始终显示的列 keys */
alwaysVisibleKeys?: string[]
/** 排除的字段 */
excludeFields?: string[]
/** i18n 前缀 */
i18nPrefix?: string
/** 自定义获取列标题函数 */
getColumnTitle?: (column: TableColumn) => string
}
export interface UseColumnSettingReturn {
/** 当前可见列 */
visibleColumns: Ref<string[]>
/** 所有可配置的列 */
allColumns: ComputedRef<Array<{ key: string; title: string }>>
/** 默认可见列 */
defaultVisibleColumns: string[]
/** 过滤后的表格列 */
tableColumns: ComputedRef<TableColumn[]>
/** 更新可见列 */
updateVisibleColumns: (columns: string[]) => void
/** 重置为默认 */
resetToDefault: () => void
}
export function useColumnSetting(
storageKey: string,
allTableColumns: ComputedRef<TableColumn[]>,
options?: UseColumnSettingOptions
): UseColumnSettingReturn
// ==================== usePermission ====================
export interface ButtonState {
disabled: boolean
hidden: boolean
}
export interface UsePermissionReturn {
/** 获取按钮状态 */
getButtonState: (permissionSlug: string) => ButtonState
/** 检查是否有权限 */
hasPermission: (permissionSlug: string) => boolean
/** 检查是否有任一权限 */
hasAnyPermission: (permissionSlugs: string[]) => boolean
/** 检查是否有所有权限 */
hasAllPermissions: (permissionSlugs: string[]) => boolean
}
export function usePermission(): UsePermissionReturn
// ==================== useDebounce ====================
export interface UseDebounceReturn<T extends (...args: any[]) => any> {
/** 防抖后的函数 */
debouncedFn: T
/** 取消防抖 */
cancel: () => void
}
export function useDebounce<T extends (...args: any[]) => any>(
fn: T,
delay?: number
): UseDebounceReturn<T>
// ==================== useApiRequest ====================
export interface UseApiRequestOptions {
/** 是否立即执行 */
immediate?: boolean
/** 成功回调 */
onSuccess?: (data: any) => void
/** 失败回调 */
onError?: (error: any) => void
}
export interface UseApiRequestReturn<T = any> {
/** 响应数据 */
data: Ref<T | null>
/** 加载状态 */
loading: Ref<boolean>
/** 错误信息 */
error: Ref<Error | null>
/** 执行请求 */
execute: (...args: any[]) => Promise<T>
/** 重置状态 */
reset: () => void
}
export function useApiRequest<T = any>(
apiFn: (...args: any[]) => Promise<any>,
options?: UseApiRequestOptions
): UseApiRequestReturn<T>
+243
View File
@@ -0,0 +1,243 @@
/**
* 通用类型定义
* 这些类型定义可以在 .js 和 .ts 文件中使用
* 使用 JSDoc 注释在 JS 文件中引用这些类型
*/
// ==================== 基础类型 ====================
/** 分页参数 */
export interface Pagination {
page: number
pageSize: number
total: number
}
/** 分页响应 */
export interface PaginatedResponse<T> {
list: T[]
total: number
}
/** API 响应基础结构 */
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
/** 表格列配置 */
export interface TableColumn {
field?: string
title?: string
type?: 'checkbox' | 'seq' | 'radio'
width?: number | string
minWidth?: number | string
sortable?: boolean
fixed?: 'left' | 'right'
slot?: string
formatter?: (params: { row: any; cellValue: any }) => string
treeNode?: boolean
}
/** 搜索字段配置 */
export interface SearchField {
prop: string
label: string
type: 'input' | 'select' | 'datetime' | 'date' | 'tree-select' | 'cascader'
width?: string
options?: Array<{ label: string; value: string | number }>
apiUrl?: string
treeProps?: Record<string, string>
filterable?: boolean
clearable?: boolean
allowCreate?: boolean
advanced?: boolean
valueFormat?: string
}
// ==================== 业务实体类型 ====================
/** 管理员 */
export interface Admin {
id: number
username: string
nickname?: string
email?: string
phone?: string
status: number
department_id?: number
is_super_admin?: boolean
is_2fa_bound?: boolean
created_at?: string
updated_at?: string
Department?: Department
Roles?: Role[]
}
/** 角色 */
export interface Role {
id: number
name: string
slug: string
description?: string
status: number
sort?: number
created_at?: string
}
/** 部门 */
export interface Department {
id: number
parent_id?: number
name: string
remark?: string
status: number
sort?: number
children?: Department[]
created_at?: string
}
/** 菜单 */
export interface Menu {
id: number
parent_id?: number
title: string
slug?: string
path?: string
icon?: string
link_type?: number
open_type?: number
status: number
sort?: number
children?: Menu[]
created_at?: string
}
/** 权限 */
export interface Permission {
id: number
name: string
slug: string
method?: string
path?: string
description?: string
menu_id?: number
status: number
sort?: number
created_at?: string
}
/** 字典 */
export interface Dictionary {
id: number
type: string
label: string
value: string
sort?: number
status: number
created_at?: string
}
/** 附件 */
export interface Attachment {
id: number
filename: string
display_name?: string
file_type: string
disk: string
extension: string
size: number
mime_type: string
file_url?: string
created_at?: string
Admin?: Admin
}
/** 操作日志 */
export interface OperationLog {
id: number
admin_id: number
title: string
method: string
path: string
ip?: string
user_agent?: string
request_body?: string
response_body?: string
status: number
duration?: number
created_at?: string
Admin?: Admin
}
/** 登录日志 */
export interface LoginLog {
id: number
admin_id: number
ip?: string
location?: string
user_agent?: string
status: number
message?: string
request?: string
created_at?: string
Admin?: Admin
}
/** 系统日志 */
export interface SystemLog {
id: number
level: string
trace_id?: string
message: string
context?: any
created_at?: string
}
/** 黑名单 */
export interface Blacklist {
id: number
type: string
value: string
reason?: string
expire_at?: string
created_at?: string
}
/** 在线管理员 */
export interface OnlineAdmin {
id: number
admin_id: number
token: string
ip?: string
user_agent?: string
last_activity?: string
Admin?: Admin
}
/** 通知 */
export interface Notification {
id: number
admin_id: number
title: string
content: string
type: string
is_read: boolean
read_at?: string
created_at?: string
}
/** 导出记录 */
export interface ExportRecord {
id: number
admin_id: number
filename: string
type: string
status: number
file_path?: string
error?: string
created_at?: string
Admin?: Admin
}
+192
View File
@@ -0,0 +1,192 @@
# 工具函数使用说明
## 1. Logger(日志工具)
统一日志输出,开发环境输出到控制台,生产环境可发送到日志服务。
```javascript
import logger from '@/utils/logger'
logger.log('普通日志')
logger.info('信息日志')
logger.warn('警告日志')
logger.error('错误日志')
logger.debug('调试日志')
```
## 2. ErrorHandler(错误处理)
统一错误处理,自动显示错误消息。
```javascript
import ErrorHandler from '@/utils/errorHandler'
try {
await someApi()
} catch (error) {
// 基本使用
ErrorHandler.handle(error)
// 使用通知而不是消息
ErrorHandler.handle(error, { showNotification: true })
// 静默处理(不显示消息)
ErrorHandler.handle(error, { silent: true })
// 自定义消息
ErrorHandler.handle(error, { customMessage: '自定义错误消息' })
// 处理 API 错误
ErrorHandler.handleApiError(error)
}
```
## 3. Storage(存储工具)
安全的 localStorage 操作,包含错误处理和配额管理。
```javascript
import Storage from '@/utils/storage'
// 设置值
Storage.setItem('key', { data: 'value' })
// 获取值
const value = Storage.getItem('key', defaultValue)
// 删除值
Storage.removeItem('key')
// 清空所有
Storage.clear()
// 清理旧数据
Storage.clearOldData(['token', 'adminInfo'])
// 检查是否可用
if (Storage.isAvailable()) {
// 使用存储
}
// 获取使用情况
const usage = Storage.getUsage()
```
## 4. Validation(输入验证)
提供常用的表单验证规则。
```javascript
import { validators } from '@/utils/validation'
// 在 Element Plus 表单中使用
const rules = {
username: [
{ validator: validators.required },
{ validator: validators.minLength(3) },
{ validator: validators.maxLength(20) }
],
email: [
{ validator: validators.required },
{ validator: validators.email }
],
phone: [
{ validator: validators.required },
{ validator: validators.phone }
],
password: [
{ validator: validators.required },
{ validator: validators.password(8) }
]
}
```
## 5. XSSXSS 防护)
提供 HTML 转义和清理功能。
```javascript
import { escapeHtml, sanitizeHtml, isSafeUrl, sanitizeInput } from '@/utils/xss'
// HTML 转义
const safe = escapeHtml('<script>alert("xss")</script>')
// 清理 HTML
const clean = sanitizeHtml('<div onclick="alert(1)">content</div>')
// 验证 URL 是否安全
if (isSafeUrl(userInput)) {
// 使用 URL
}
// 清理用户输入
const sanitized = sanitizeInput(userInput)
```
## 6. useApiRequest(请求取消)
提供请求取消机制和加载状态管理。
```javascript
import { useApiRequest } from '@/composables/useApiRequest'
const { request, cancel, loading, error } = useApiRequest()
// 执行请求(自动取消之前的请求)
const result = await request(() => getAdminList(params))
// 手动取消请求
cancel()
```
## 7. API FactoryAPI 工厂)
减少 API 代码重复。
```javascript
import { createCRUDApi, extendApi } from '@/utils/apiFactory'
// 创建基础 CRUD API
const baseApi = createCRUDApi('admins')
// 扩展 API,添加自定义方法
const adminApi = extendApi(baseApi, {
resetPassword: (id, data) => {
return request({
url: `/admins/${id}/password`,
method: 'put',
data
})
}
})
// 导出
export const {
list: getAdminList,
detail: getAdminDetail,
create: createAdmin,
update: updateAdmin,
delete: deleteAdmin,
resetPassword
} = adminApi
```
## 8. Env(环境变量)
环境变量验证和获取。
```javascript
import { getEnv, getApiBaseURL, getApiPrefix, isDev, isProd } from '@/utils/env'
// 获取环境变量
const apiUrl = getEnv('VITE_API_BASE_URL', 'http://localhost:3000')
// 获取 API 基础 URL
const baseURL = getApiBaseURL()
// 检查环境
if (isDev()) {
// 开发环境代码
}
```
+132
View File
@@ -0,0 +1,132 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { Storage } from '../storage'
// Mock localStorage
const localStorageMock = (() => {
let store = {}
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = value
}),
removeItem: vi.fn((key) => {
delete store[key]
}),
clear: vi.fn(() => {
store = {}
}),
get length() {
return Object.keys(store).length
},
key: vi.fn((index) => Object.keys(store)[index] || null)
}
})()
// Mock logger
vi.mock('../logger', () => ({
default: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
}))
Object.defineProperty(global, 'localStorage', { value: localStorageMock })
describe('Storage', () => {
beforeEach(() => {
localStorageMock.clear()
vi.clearAllMocks()
})
describe('setItem', () => {
it('应该成功存储字符串', () => {
const result = Storage.setItem('key1', 'value1')
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('key1', '"value1"')
})
it('应该成功存储对象', () => {
const obj = { name: 'test', value: 123 }
const result = Storage.setItem('key2', obj)
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('key2', JSON.stringify(obj))
})
it('应该成功存储数组', () => {
const arr = [1, 2, 3]
const result = Storage.setItem('key3', arr)
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('key3', JSON.stringify(arr))
})
it('应该成功存储数字', () => {
const result = Storage.setItem('key4', 123)
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('key4', '123')
})
it('应该成功存储布尔值', () => {
const result = Storage.setItem('key5', true)
expect(result).toBe(true)
expect(localStorageMock.setItem).toHaveBeenCalledWith('key5', 'true')
})
})
describe('getItem', () => {
it('应该成功获取字符串', () => {
localStorageMock.getItem.mockReturnValueOnce('"value1"')
const result = Storage.getItem('key1')
expect(result).toBe('value1')
})
it('应该成功获取对象', () => {
const obj = { name: 'test', value: 123 }
localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(obj))
const result = Storage.getItem('key2')
expect(result).toEqual(obj)
})
it('应该返回默认值当键不存在时', () => {
localStorageMock.getItem.mockReturnValueOnce(null)
const result = Storage.getItem('nonexistent', 'default')
expect(result).toBe('default')
})
it('应该返回 null 作为默认默认值', () => {
localStorageMock.getItem.mockReturnValueOnce(null)
const result = Storage.getItem('nonexistent')
expect(result).toBe(null)
})
it('应该处理非 JSON 字符串', () => {
localStorageMock.getItem.mockReturnValueOnce('plain-string')
const result = Storage.getItem('key')
expect(result).toBe('plain-string')
})
})
describe('removeItem', () => {
it('应该成功删除项', () => {
const result = Storage.removeItem('key1')
expect(result).toBe(true)
expect(localStorageMock.removeItem).toHaveBeenCalledWith('key1')
})
})
describe('clear', () => {
it('应该成功清空存储', () => {
const result = Storage.clear()
expect(result).toBe(true)
expect(localStorageMock.clear).toHaveBeenCalled()
})
})
describe('isAvailable', () => {
it('应该返回 true 当 localStorage 可用', () => {
const result = Storage.isAvailable()
expect(result).toBe(true)
})
})
})
+263
View File
@@ -0,0 +1,263 @@
import { describe, it, expect } from 'vitest'
import { validators, createValidator } from '../validation'
describe('validators', () => {
describe('required', () => {
it('应该拒绝空字符串', () => {
expect(validators.required('')).not.toBe(true)
})
it('应该拒绝 null', () => {
expect(validators.required(null)).not.toBe(true)
})
it('应该拒绝 undefined', () => {
expect(validators.required(undefined)).not.toBe(true)
})
it('应该拒绝空数组', () => {
expect(validators.required([])).not.toBe(true)
})
it('应该接受有效字符串', () => {
expect(validators.required('hello')).toBe(true)
})
it('应该接受数字', () => {
expect(validators.required(0)).toBe(true)
expect(validators.required(123)).toBe(true)
})
it('应该接受非空数组', () => {
expect(validators.required([1, 2, 3])).toBe(true)
})
})
describe('email', () => {
it('应该接受有效邮箱', () => {
expect(validators.email('test@example.com')).toBe(true)
expect(validators.email('user.name@domain.co.uk')).toBe(true)
})
it('应该拒绝无效邮箱', () => {
expect(validators.email('invalid')).not.toBe(true)
expect(validators.email('invalid@')).not.toBe(true)
expect(validators.email('@domain.com')).not.toBe(true)
})
it('应该接受空值(由 required 验证)', () => {
expect(validators.email('')).toBe(true)
expect(validators.email(null)).toBe(true)
})
})
describe('phone', () => {
it('应该接受有效手机号', () => {
expect(validators.phone('13812345678')).toBe(true)
expect(validators.phone('19912345678')).toBe(true)
})
it('应该拒绝无效手机号', () => {
expect(validators.phone('1234567890')).not.toBe(true)
expect(validators.phone('12812345678')).not.toBe(true) // 第二位不能是2
expect(validators.phone('138123456789')).not.toBe(true) // 太长
})
it('应该接受空值', () => {
expect(validators.phone('')).toBe(true)
})
})
describe('minLength', () => {
it('应该拒绝短于最小长度的字符串', () => {
const validator = validators.minLength(5)
expect(validator('ab')).not.toBe(true)
})
it('应该接受达到最小长度的字符串', () => {
const validator = validators.minLength(5)
expect(validator('abcde')).toBe(true)
expect(validator('abcdefg')).toBe(true)
})
it('应该处理数组', () => {
const validator = validators.minLength(3)
expect(validator([1, 2])).not.toBe(true)
expect(validator([1, 2, 3])).toBe(true)
})
})
describe('maxLength', () => {
it('应该接受短于最大长度的字符串', () => {
const validator = validators.maxLength(5)
expect(validator('abc')).toBe(true)
})
it('应该拒绝超过最大长度的字符串', () => {
const validator = validators.maxLength(5)
expect(validator('abcdefg')).not.toBe(true)
})
it('应该接受空值', () => {
const validator = validators.maxLength(5)
expect(validator('')).toBe(true)
})
})
describe('length', () => {
it('应该接受在范围内的字符串', () => {
const validator = validators.length(3, 6)
expect(validator('abc')).toBe(true)
expect(validator('abcdef')).toBe(true)
})
it('应该拒绝超出范围的字符串', () => {
const validator = validators.length(3, 6)
expect(validator('ab')).not.toBe(true)
expect(validator('abcdefg')).not.toBe(true)
})
})
describe('number', () => {
it('应该接受有效数字', () => {
expect(validators.number('123')).toBe(true)
expect(validators.number('12.34')).toBe(true)
expect(validators.number(-123)).toBe(true)
})
it('应该拒绝非数字', () => {
expect(validators.number('abc')).not.toBe(true)
expect(validators.number('12a')).not.toBe(true)
})
})
describe('integer', () => {
it('应该接受整数', () => {
expect(validators.integer('123')).toBe(true)
expect(validators.integer(-456)).toBe(true)
expect(validators.integer(0)).toBe(true)
})
it('应该拒绝小数', () => {
expect(validators.integer('12.34')).not.toBe(true)
expect(validators.integer(1.5)).not.toBe(true)
})
})
describe('min', () => {
it('应该接受大于等于最小值的数', () => {
const validator = validators.min(10)
expect(validator(10)).toBe(true)
expect(validator(15)).toBe(true)
})
it('应该拒绝小于最小值的数', () => {
const validator = validators.min(10)
expect(validator(5)).not.toBe(true)
})
})
describe('max', () => {
it('应该接受小于等于最大值的数', () => {
const validator = validators.max(10)
expect(validator(10)).toBe(true)
expect(validator(5)).toBe(true)
})
it('应该拒绝大于最大值的数', () => {
const validator = validators.max(10)
expect(validator(15)).not.toBe(true)
})
})
describe('range', () => {
it('应该接受在范围内的数', () => {
const validator = validators.range(1, 10)
expect(validator(1)).toBe(true)
expect(validator(5)).toBe(true)
expect(validator(10)).toBe(true)
})
it('应该拒绝超出范围的数', () => {
const validator = validators.range(1, 10)
// 注意:0 被视为空值,会跳过验证返回 true
// 这是设计行为,空值检验应由 required 验证器处理
expect(validator(-1)).not.toBe(true)
expect(validator(11)).not.toBe(true)
})
})
describe('url', () => {
it('应该接受有效 URL', () => {
expect(validators.url('https://example.com')).toBe(true)
expect(validators.url('http://localhost:3000')).toBe(true)
expect(validators.url('ftp://files.example.com')).toBe(true)
})
it('应该拒绝无效 URL', () => {
expect(validators.url('not-a-url')).not.toBe(true)
expect(validators.url('example.com')).not.toBe(true) // 缺少协议
})
})
describe('pattern', () => {
it('应该接受匹配正则的值', () => {
const validator = validators.pattern(/^\d{4}$/, '必须是4位数字')
expect(validator('1234')).toBe(true)
})
it('应该拒绝不匹配正则的值', () => {
const validator = validators.pattern(/^\d{4}$/, '必须是4位数字')
expect(validator('123')).toBe('必须是4位数字')
expect(validator('12345')).toBe('必须是4位数字')
})
})
describe('password', () => {
it('应该接受足够长的密码', () => {
const validator = validators.password(6)
expect(validator('password123')).toBe(true)
})
it('应该拒绝过短的密码', () => {
const validator = validators.password(6)
expect(validator('pass')).not.toBe(true)
})
})
describe('confirmPassword', () => {
it('应该接受匹配的密码', () => {
const validator = validators.confirmPassword('password123')
expect(validator('password123')).toBe(true)
})
it('应该拒绝不匹配的密码', () => {
const validator = validators.confirmPassword('password123')
expect(validator('differentpassword')).not.toBe(true)
})
})
})
describe('createValidator', () => {
it('应该创建自定义验证器', () => {
const isEven = createValidator(
(value) => value % 2 === 0,
'必须是偶数'
)
expect(isEven(2)).toBe(true)
expect(isEven(4)).toBe(true)
expect(isEven(3)).toBe('必须是偶数')
})
it('空值应该通过验证', () => {
const customValidator = createValidator(
(value) => value === 'test',
'必须是 test'
)
expect(customValidator('')).toBe(true)
expect(customValidator(null)).toBe(true)
})
})
+106
View File
@@ -0,0 +1,106 @@
import request from './request'
/**
* 创建 CRUD API 工厂函数
* @param {string} resource - 资源名称(如 'admins', 'roles'
* @returns {Object} CRUD API 方法
*/
export function createCRUDApi(resource) {
return {
/**
* 获取列表
* @param {Object} params - 查询参数
* @returns {Promise} 请求结果
*/
list: (params) => {
return request({
url: `/${resource}`,
method: 'get',
params
})
},
/**
* 获取详情
* @param {string|number} id - 资源 ID
* @returns {Promise} 请求结果
*/
detail: (id) => {
return request({
url: `/${resource}/${id}`,
method: 'get'
})
},
/**
* 创建资源
* @param {Object} data - 创建数据
* @returns {Promise} 请求结果
*/
create: (data) => {
return request({
url: `/${resource}`,
method: 'post',
data
})
},
/**
* 更新资源
* @param {string|number} id - 资源 ID
* @param {Object} data - 更新数据
* @returns {Promise} 请求结果
*/
update: (id, data) => {
return request({
url: `/${resource}/${id}`,
method: 'put',
data
})
},
/**
* 删除资源
* @param {string|number} id - 资源 ID
* @returns {Promise} 请求结果
*/
delete: (id) => {
return request({
url: `/${resource}/${id}`,
method: 'delete'
})
},
/**
* 批量删除
* @param {Array<string|number>} ids - 资源 ID 数组
* @returns {Promise} 请求结果
*/
batchDelete: (ids) => {
return request({
url: `/${resource}/batch`,
method: 'delete',
data: { ids }
})
}
}
}
/**
* 扩展 CRUD API,添加自定义方法
* @param {Object} baseApi - 基础 CRUD API
* @param {Object} customMethods - 自定义方法
* @returns {Object} 扩展后的 API
*/
export function extendApi(baseApi, customMethods) {
return {
...baseApi,
...customMethods
}
}
export default {
createCRUDApi,
extendApi
}
+35
View File
@@ -0,0 +1,35 @@
import { forOwn } from 'lodash-es'
/**
* 构建搜索参数
* 自动过滤空值,统一处理搜索表单字段
*
* @param {Object} searchForm - 搜索表单对象
* @param {Object} extraParams - 额外的参数(如排序等)
* @returns {Object} 处理后的参数对象
*/
export function buildSearchParams(searchForm = {}, extraParams = {}) {
const params = { ...extraParams }
// 遍历搜索表单,只添加有值的字段
forOwn(searchForm, (value, key) => {
// 跳过空值
if (value === '' || value === null || value === undefined) {
return
}
// 如果是字符串,去除首尾空格后判断
if (typeof value === 'string') {
const trimmed = value.trim()
if (trimmed) {
params[key] = trimmed
}
} else {
// 非字符串类型直接添加
params[key] = value
}
})
return params
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 日期时间工具函数
*/
/**
* 格式化日期时间为 YYYY-MM-DD HH:mm:ss
* @param {Date} date - 日期对象
* @returns {string} 格式化后的日期时间字符串
*/
export function formatDateTime(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
/**
* 获取 N 天前的日期时间(用于默认开始时间)
* @param {number} days - 天数,默认 7 天
* @param {boolean} setToStartOfDay - 是否设置为当天的 00:00:00,默认 true
* @returns {string} 格式化后的日期时间字符串
*/
export function getDaysAgo(days = 7, setToStartOfDay = true) {
const date = new Date()
date.setDate(date.getDate() - days)
if (setToStartOfDay) {
date.setHours(0, 0, 0, 0) // 设置为当天的00:00:00
}
return formatDateTime(date)
}
/**
* 获取 N 个月前的日期时间(用于默认开始时间)
* @param {number} months - 月数,默认 1 个月
* @param {boolean} setToStartOfDay - 是否设置为当天的 00:00:00,默认 true
* @returns {string} 格式化后的日期时间字符串
*/
export function getMonthsAgo(months = 1, setToStartOfDay = true) {
const date = new Date()
date.setMonth(date.getMonth() - months)
if (setToStartOfDay) {
date.setHours(0, 0, 0, 0) // 设置为当天的00:00:00
}
return formatDateTime(date)
}
/**
* 获取 N 年前的日期时间(用于默认开始时间)
* @param {number} years - 年数,默认 1 年
* @param {boolean} setToStartOfDay - 是否设置为当天的 00:00:00,默认 true
* @returns {string} 格式化后的日期时间字符串
*/
export function getYearsAgo(years = 1, setToStartOfDay = true) {
const date = new Date()
date.setFullYear(date.getFullYear() - years)
if (setToStartOfDay) {
date.setHours(0, 0, 0, 0) // 设置为当天的00:00:00
}
return formatDateTime(date)
}
/**
* 获取指定时间单位前的日期时间(通用方法)
* @param {Object} options - 配置选项
* @param {number} options.days - 天数
* @param {number} options.months - 月数
* @param {number} options.years - 年数
* @param {boolean} options.setToStartOfDay - 是否设置为当天的 00:00:00,默认 true
* @returns {string} 格式化后的日期时间字符串
* @example
* getTimeAgo({ days: 7 }) // 7天前
* getTimeAgo({ months: 1 }) // 1个月前
* getTimeAgo({ years: 1 }) // 1年前
* getTimeAgo({ days: 7, months: 1 }) // 1个月零7天前
*/
export function getTimeAgo({ days = 0, months = 0, years = 0, setToStartOfDay = true } = {}) {
const date = new Date()
if (years > 0) {
date.setFullYear(date.getFullYear() - years)
}
if (months > 0) {
date.setMonth(date.getMonth() - months)
}
if (days > 0) {
date.setDate(date.getDate() - days)
}
if (setToStartOfDay) {
date.setHours(0, 0, 0, 0) // 设置为当天的00:00:00
}
return formatDateTime(date)
}
/**
* 获取7天前的日期时间(便捷方法)
* @returns {string} 格式化后的日期时间字符串
*/
export function getSevenDaysAgo() {
return getDaysAgo(7, true)
}
/**
* 获取1个月前的日期时间(便捷方法)
* @returns {string} 格式化后的日期时间字符串
*/
export function getOneMonthAgo() {
return getMonthsAgo(1, true)
}
/**
* 获取3个月前的日期时间(便捷方法)
* @returns {string} 格式化后的日期时间字符串
*/
export function getThreeMonthsAgo() {
return getMonthsAgo(3, true)
}
+105
View File
@@ -0,0 +1,105 @@
import logger from './logger'
/**
* 环境变量配置
*/
const requiredEnvVars = []
const optionalEnvVars = ['VITE_API_BASE_URL', 'VITE_API_PREFIX']
/**
* 验证环境变量
* @param {boolean} strict - 是否严格模式(生产环境应该为 true)
* @returns {boolean} 是否验证通过
*/
export function validateEnv(strict = import.meta.env.PROD) {
const missing = []
const warnings = []
// 检查必需的环境变量
requiredEnvVars.forEach(key => {
if (!import.meta.env[key]) {
missing.push(key)
}
})
// 检查可选但推荐的环境变量
optionalEnvVars.forEach(key => {
if (!import.meta.env[key]) {
warnings.push(key)
}
})
// 输出警告
if (warnings.length > 0) {
logger.warn('Optional environment variables not set:', warnings)
}
// 检查必需变量
if (missing.length > 0) {
const message = `Missing required environment variables: ${missing.join(', ')}`
logger.error(message)
if (strict) {
throw new Error(message)
}
}
return missing.length === 0
}
/**
* 获取环境变量值
* @param {string} key - 环境变量键名
* @param {any} defaultValue - 默认值
* @returns {any} 环境变量值或默认值
*/
export function getEnv(key, defaultValue = null) {
return import.meta.env[key] || defaultValue
}
/**
* 获取 API 基础 URL
* @returns {string} API 基础 URL
*/
export function getApiBaseURL() {
return getEnv('VITE_API_BASE_URL', '')
}
/**
* 获取 API 前缀
* @returns {string} API 前缀
*/
export function getApiPrefix() {
return getEnv('VITE_API_PREFIX', '/api/admin')
}
/**
* 是否为开发环境
* @returns {boolean} 是否为开发环境
*/
export function isDev() {
return import.meta.env.DEV
}
/**
* 是否为生产环境
* @returns {boolean} 是否为生产环境
*/
export function isProd() {
return import.meta.env.PROD
}
// 自动验证环境变量(仅在开发环境)
if (import.meta.env.DEV) {
validateEnv(false)
}
export default {
validateEnv,
getEnv,
getApiBaseURL,
getApiPrefix,
isDev,
isProd
}
+133
View File
@@ -0,0 +1,133 @@
import { ElMessage, ElNotification } from 'element-plus'
import { useI18n } from 'vue-i18n'
import logger from './logger'
/**
* 统一错误处理工具
*/
export class ErrorHandler {
/**
* 处理错误
* @param {Error} error - 错误对象
* @param {Object} options - 配置选项
* @param {boolean} options.silent - 是否静默处理(不显示消息)
* @param {boolean} options.showNotification - 是否使用通知而不是消息
* @param {string} options.customMessage - 自定义错误消息
* @param {string} options.title - 通知标题(仅在使用通知时)
*/
static handle(error, options = {}) {
const {
silent = false,
showNotification = false,
customMessage = null,
title = null
} = options
if (silent) {
logger.debug('Error handled silently:', error)
return
}
// 如果错误已经被处理过,不再重复处理
if (error?.__handled) {
logger.debug('Error already handled:', error)
return
}
// 获取错误消息
const message = customMessage ||
error.translatedMessage ||
error.message ||
this.getDefaultMessage(error)
// 记录错误
logger.error('Error occurred:', {
message,
errorCode: error.errorCode,
code: error.code,
error
})
// 显示错误消息
if (showNotification) {
ElNotification.error({
title: title || '错误',
message,
duration: 5000,
showClose: true
})
} else {
ElMessage.error(message)
}
// 标记为已处理
if (error && typeof error === 'object') {
error.__handled = true
}
}
/**
* 获取默认错误消息
* @param {Error} error - 错误对象
* @returns {string} 默认错误消息
*/
static getDefaultMessage(error) {
// 根据错误类型返回默认消息
if (error.code === 'ERR_NETWORK' || error.message === 'Network Error') {
return '网络连接失败,请检查网络设置'
}
if (error.code === 'ECONNABORTED') {
return '请求超时,请稍后重试'
}
if (error.code === 401) {
return '未授权,请重新登录'
}
if (error.code === 403) {
return '没有权限执行此操作'
}
if (error.code === 404) {
return '请求的资源不存在'
}
if (error.code === 500) {
return '服务器内部错误,请稍后重试'
}
return '操作失败,请稍后重试'
}
/**
* 处理 API 错误
* @param {Error} error - 错误对象
* @param {Object} options - 配置选项
*/
static handleApiError(error, options = {}) {
const {
silent = false,
showNotification = false,
customMessage = null
} = options
// 提取错误信息
const message = customMessage ||
error.translatedMessage ||
error.response?.data?.message ||
error.message ||
this.getDefaultMessage(error)
const errorCode = error.errorCode || error.response?.data?.error_code
// 创建错误对象
const apiError = {
...error,
message,
errorCode,
__handled: false
}
this.handle(apiError, { silent, showNotification, customMessage: message })
return apiError
}
}
export default ErrorHandler
+263
View File
@@ -0,0 +1,263 @@
/**
* 错误上报工具
*
* 用于收集和上报前端错误,便于监控和排查问题
*/
import logger from './logger'
/**
* 错误级别
*/
export const ErrorLevel = {
INFO: 'info',
WARNING: 'warning',
ERROR: 'error',
FATAL: 'fatal'
}
/**
* 错误上报配置
*/
const config = {
// 是否启用错误上报
enabled: import.meta.env.PROD,
// 上报地址 (如 Sentry DSN)
reportUrl: import.meta.env.VITE_ERROR_REPORT_URL || '',
// 采样率 (0-1)
sampleRate: 1.0,
// 忽略的错误类型
ignoreErrors: [
'ResizeObserver loop limit exceeded',
'ResizeObserver loop completed with undelivered notifications',
'Cannot read properties of undefined (reading \'indexOf\')', // Element Plus TabPane 已知问题
],
// 最大错误堆栈长度
maxStackLength: 50
}
/**
* 错误队列 (用于批量上报)
*/
const errorQueue = []
const MAX_QUEUE_SIZE = 10
let flushTimer = null
/**
* 判断错误是否应该被忽略
* @param {Error|string} error
* @returns {boolean}
*/
function shouldIgnore(error) {
const message = error?.message || String(error)
return config.ignoreErrors.some(pattern => message.includes(pattern))
}
/**
* 格式化错误信息
* @param {Error|string} error
* @param {Object} context
* @returns {Object}
*/
function formatError(error, context = {}) {
const errorInfo = {
timestamp: new Date().toISOString(),
level: context.level || ErrorLevel.ERROR,
message: error?.message || String(error),
stack: error?.stack?.split('\n').slice(0, config.maxStackLength).join('\n') || '',
url: window.location.href,
userAgent: navigator.userAgent,
...context
}
// 添加用户信息 (如果有)
try {
const userStore = JSON.parse(localStorage.getItem('user') || '{}')
if (userStore.userInfo) {
errorInfo.userId = userStore.userInfo.id
errorInfo.username = userStore.userInfo.username
}
} catch {
// 忽略
}
return errorInfo
}
/**
* 发送错误到服务器
* @param {Object[]} errors
*/
async function sendErrors(errors) {
if (!config.reportUrl || errors.length === 0) {
return
}
try {
await fetch(config.reportUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ errors }),
keepalive: true // 页面关闭时也能发送
})
} catch (e) {
// 上报失败不影响用户体验
console.warn('[ErrorReporter] Failed to send errors:', e)
}
}
/**
* 刷新错误队列
*/
function flushQueue() {
if (errorQueue.length === 0) return
const errors = errorQueue.splice(0)
sendErrors(errors)
}
/**
* 添加错误到队列
* @param {Object} errorInfo
*/
function addToQueue(errorInfo) {
errorQueue.push(errorInfo)
if (errorQueue.length >= MAX_QUEUE_SIZE) {
flushQueue()
} else if (!flushTimer) {
// 5秒后自动刷新
flushTimer = setTimeout(() => {
flushQueue()
flushTimer = null
}, 5000)
}
}
/**
* 上报错误
* @param {Error|string} error 错误对象或错误消息
* @param {Object} context 上下文信息
* @param {string} context.level 错误级别
* @param {string} context.component 组件名
* @param {string} context.action 操作名
* @param {Object} context.extra 额外信息
*
* @example
* // 上报普通错误
* reportError(new Error('Something went wrong'))
*
* // 上报带上下文的错误
* reportError(error, {
* level: ErrorLevel.WARNING,
* component: 'UserList',
* action: 'fetchUsers',
* extra: { userId: 123 }
* })
*/
export function reportError(error, context = {}) {
// 检查是否应该忽略
if (shouldIgnore(error)) {
return
}
// 采样
if (Math.random() > config.sampleRate) {
return
}
// 格式化错误
const errorInfo = formatError(error, context)
// 本地日志
if (context.level === ErrorLevel.FATAL) {
logger.error('[FATAL]', errorInfo.message, errorInfo)
} else if (context.level === ErrorLevel.WARNING) {
logger.warn('[WARN]', errorInfo.message, errorInfo)
} else {
logger.error('[ERROR]', errorInfo.message, errorInfo)
}
// 生产环境上报
if (config.enabled) {
addToQueue(errorInfo)
}
}
/**
* 上报 API 错误
* @param {Object} error Axios 错误对象
* @param {Object} requestConfig 请求配置
*/
export function reportApiError(error, requestConfig = {}) {
const context = {
level: ErrorLevel.ERROR,
type: 'api',
url: requestConfig.url,
method: requestConfig.method,
status: error.response?.status,
responseData: error.response?.data
}
reportError(error, context)
}
/**
* 上报组件错误
* @param {Error} error
* @param {string} componentName
* @param {string} hook 生命周期钩子名
*/
export function reportComponentError(error, componentName, hook = '') {
reportError(error, {
level: ErrorLevel.ERROR,
type: 'component',
component: componentName,
hook
})
}
/**
* 上报 Promise 未处理的 rejection
* @param {PromiseRejectionEvent} event
*/
export function reportUnhandledRejection(event) {
reportError(event.reason || 'Unhandled Promise Rejection', {
level: ErrorLevel.ERROR,
type: 'unhandledRejection'
})
}
/**
* 配置错误上报
* @param {Object} options
*/
export function configureErrorReporter(options = {}) {
Object.assign(config, options)
}
/**
* 手动刷新队列 (页面关闭前调用)
*/
export function flush() {
flushQueue()
}
// 页面关闭前刷新队列
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', flush)
window.addEventListener('pagehide', flush)
}
export default {
reportError,
reportApiError,
reportComponentError,
reportUnhandledRejection,
configureErrorReporter,
flush,
ErrorLevel
}
+43
View File
@@ -0,0 +1,43 @@
/**
* 通用的搜索表单字段选项配置
* 用于在各个列表页面的搜索表单中复用
*/
/**
* 获取状态选项(启用/禁用)
* @param {Function} t - i18n 的 t 函数
* @returns {Array} 选项数组
*/
export const getStatusOptions = (t) => {
return [
{ label: t('common.enabled'), value: '1' },
{ label: t('common.disabled'), value: '0' }
]
}
/**
* 获取 HTTP 方法选项
* @returns {Array} 选项数组
*/
export const getMethodOptions = () => {
return [
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' }
]
}
/**
* 获取是否选项(是/否)
* @param {Function} t - i18n 的 t 函数
* @returns {Array} 选项数组
*/
export const getYesNoOptions = (t) => {
return [
{ label: t('common.yes'), value: '1' },
{ label: t('common.no'), value: '0' }
]
}
+63
View File
@@ -0,0 +1,63 @@
/**
* 日志工具
* 开发环境输出到控制台,生产环境可发送到日志服务
*/
const isDev = import.meta.env.DEV
export const logger = {
/**
* 输出日志信息
* @param {...any} args - 日志参数
*/
log: (...args) => {
if (isDev) {
console.log('[LOG]', ...args)
}
},
/**
* 输出错误信息
* @param {...any} args - 错误参数
*/
error: (...args) => {
if (isDev) {
console.error('[ERROR]', ...args)
} else {
// 生产环境可以发送到日志服务
// sendToLogService('error', ...args)
}
},
/**
* 输出警告信息
* @param {...any} args - 警告参数
*/
warn: (...args) => {
if (isDev) {
console.warn('[WARN]', ...args)
}
},
/**
* 输出调试信息
* @param {...any} args - 调试参数
*/
debug: (...args) => {
if (isDev) {
console.debug('[DEBUG]', ...args)
}
},
/**
* 输出信息
* @param {...any} args - 信息参数
*/
info: (...args) => {
if (isDev) {
console.info('[INFO]', ...args)
}
}
}
export default logger

Some files were not shown because too many files have changed in this diff Show More