init
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
|
||||
VITE_API_BASE_URL=http://127.0.0.1:3000
|
||||
|
||||
VITE_API_PREFIX=/api/admin
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# 环境变量文件
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 依赖
|
||||
node_modules
|
||||
|
||||
# 构建输出
|
||||
dist
|
||||
*.local
|
||||
|
||||
# 日志
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# npm 默认会安装可选依赖,无需额外配置
|
||||
# 对于 CI/CD 环境(如 Cloudflare Workers),请在构建命令中使用:
|
||||
# npm install --include=optional @rollup/rollup-linux-x64-gnu
|
||||
|
||||
+566
@@ -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)
|
||||
|
||||
@@ -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` 目录。
|
||||
|
||||
## 功能模块
|
||||
|
||||
- 登录认证
|
||||
- 仪表盘
|
||||
- 管理员管理
|
||||
- 角色管理
|
||||
- 权限管理
|
||||
- 菜单管理
|
||||
- 部门管理
|
||||
- 字典管理
|
||||
- 操作日志
|
||||
- 登录日志
|
||||
- 系统日志
|
||||
|
||||
## 多语言支持
|
||||
|
||||
项目支持中文和英文两种语言,可以通过页面右上角的语言切换按钮进行切换。
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Generated
+2901
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(',')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 和 FullJump(FullJump 包含快速跳转功能)
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()` 函数进行国际化处理
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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` 都是可选参数
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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秒防抖窗口
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 注释也能获得类型提示
|
||||
|
||||
Vendored
+227
@@ -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>
|
||||
|
||||
Vendored
+243
@@ -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
|
||||
}
|
||||
|
||||
@@ -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. XSS(XSS 防护)
|
||||
|
||||
提供 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 Factory(API 工厂)
|
||||
|
||||
减少 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()) {
|
||||
// 开发环境代码
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user