Merge remote-tracking branch 'origin/master'

合并远程前端源代码与本地后端修复

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-25 23:51:35 +08:00
135 changed files with 33086 additions and 20 deletions

31
.editorconfig Normal file
View File

@@ -0,0 +1,31 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Matches multiple files with brace expansion notation
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
# Matches multiple files with brace expansion notation
[*.{json,md,yml,yaml}]
indent_style = space
indent_size = 2
# Matches multiple files with brace expansion notation
[*.scss]
indent_style = space
indent_size = 2
# The above files are for code, but for config files we want no indentation
[{package.json,.eslintrc.cjs,.prettierrc}]
indent_style = space
indent_size = 2

3
.env.development Normal file
View File

@@ -0,0 +1,3 @@
# 开发环境配置
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_APP_TITLE=资产管理系统

3
.env.production Normal file
View File

@@ -0,0 +1,3 @@
# 生产环境配置
VITE_API_BASE_URL=https://zc.workyai.cn/api/v1
VITE_APP_TITLE=资产管理系统

312
.eslintrc-auto-import.json Normal file
View File

@@ -0,0 +1,312 @@
{
"globals": {
"Component": true,
"ComponentPublicInstance": true,
"ComputedRef": true,
"DirectiveBinding": true,
"EffectScope": true,
"ExtractDefaultPropTypes": true,
"ExtractPropTypes": true,
"ExtractPublicPropTypes": true,
"InjectionKey": true,
"MaybeRef": true,
"MaybeRefOrGetter": true,
"PropType": true,
"Ref": true,
"VNode": true,
"WritableComputedRef": true,
"acceptHMRUpdate": true,
"asyncComputed": true,
"autoResetRef": true,
"computed": true,
"computedAsync": true,
"computedEager": true,
"computedInject": true,
"computedWithControl": true,
"controlledComputed": true,
"controlledRef": true,
"createApp": true,
"createEventHook": true,
"createGlobalState": true,
"createInjectionState": true,
"createPinia": true,
"createReactiveFn": true,
"createReusableTemplate": true,
"createSharedComposable": true,
"createTemplatePromise": true,
"createUnrefFn": true,
"customRef": true,
"debouncedRef": true,
"debouncedWatch": true,
"defineAsyncComponent": true,
"defineComponent": true,
"defineStore": true,
"eagerComputed": true,
"effectScope": true,
"extendRef": true,
"getActivePinia": true,
"getCurrentInstance": true,
"getCurrentScope": true,
"h": true,
"ignorableWatch": true,
"inject": true,
"injectLocal": true,
"isDefined": true,
"isProxy": true,
"isReactive": true,
"isReadonly": true,
"isRef": true,
"makeDestructurable": true,
"mapActions": true,
"mapGetters": true,
"mapState": true,
"mapStores": true,
"mapWritableState": true,
"markRaw": true,
"nextTick": true,
"onActivated": true,
"onBeforeMount": true,
"onBeforeRouteLeave": true,
"onBeforeRouteUpdate": true,
"onBeforeUnmount": true,
"onBeforeUpdate": true,
"onClickOutside": true,
"onDeactivated": true,
"onErrorCaptured": true,
"onKeyStroke": true,
"onLongPress": true,
"onMounted": true,
"onRenderTracked": true,
"onRenderTriggered": true,
"onScopeDispose": true,
"onServerPrefetch": true,
"onStartTyping": true,
"onUnmounted": true,
"onUpdated": true,
"onWatcherCleanup": true,
"pausableWatch": true,
"provide": true,
"provideLocal": true,
"reactify": true,
"reactifyObject": true,
"reactive": true,
"reactiveComputed": true,
"reactiveOmit": true,
"reactivePick": true,
"readonly": true,
"ref": true,
"refAutoReset": true,
"refDebounced": true,
"refDefault": true,
"refThrottled": true,
"refWithControl": true,
"resolveComponent": true,
"resolveRef": true,
"resolveUnref": true,
"setActivePinia": true,
"setMapStoreSuffix": true,
"shallowReactive": true,
"shallowReadonly": true,
"shallowRef": true,
"storeToRefs": true,
"syncRef": true,
"syncRefs": true,
"templateRef": true,
"throttledRef": true,
"throttledWatch": true,
"toRaw": true,
"toReactive": true,
"toRef": true,
"toRefs": true,
"toValue": true,
"triggerRef": true,
"tryOnBeforeMount": true,
"tryOnBeforeUnmount": true,
"tryOnMounted": true,
"tryOnScopeDispose": true,
"tryOnUnmounted": true,
"unref": true,
"unrefElement": true,
"until": true,
"useActiveElement": true,
"useAnimate": true,
"useArrayDifference": true,
"useArrayEvery": true,
"useArrayFilter": true,
"useArrayFind": true,
"useArrayFindIndex": true,
"useArrayFindLast": true,
"useArrayIncludes": true,
"useArrayJoin": true,
"useArrayMap": true,
"useArrayReduce": true,
"useArraySome": true,
"useArrayUnique": true,
"useAsyncQueue": true,
"useAsyncState": true,
"useAttrs": true,
"useBase64": true,
"useBattery": true,
"useBluetooth": true,
"useBreakpoints": true,
"useBroadcastChannel": true,
"useBrowserLocation": true,
"useCached": true,
"useClipboard": true,
"useClipboardItems": true,
"useCloned": true,
"useColorMode": true,
"useConfirmDialog": true,
"useCounter": true,
"useCssModule": true,
"useCssVar": true,
"useCssVars": true,
"useCurrentElement": true,
"useCycleList": true,
"useDark": true,
"useDateFormat": true,
"useDebounce": true,
"useDebounceFn": true,
"useDebouncedRefHistory": true,
"useDeviceMotion": true,
"useDeviceOrientation": true,
"useDevicePixelRatio": true,
"useDevicesList": true,
"useDisplayMedia": true,
"useDocumentVisibility": true,
"useDraggable": true,
"useDropZone": true,
"useElementBounding": true,
"useElementByPoint": true,
"useElementHover": true,
"useElementSize": true,
"useElementVisibility": true,
"useEventBus": true,
"useEventListener": true,
"useEventSource": true,
"useEyeDropper": true,
"useFavicon": true,
"useFetch": true,
"useFileDialog": true,
"useFileSystemAccess": true,
"useFocus": true,
"useFocusWithin": true,
"useFps": true,
"useFullscreen": true,
"useGamepad": true,
"useGeolocation": true,
"useId": true,
"useIdle": true,
"useImage": true,
"useInfiniteScroll": true,
"useIntersectionObserver": true,
"useInterval": true,
"useIntervalFn": true,
"useKeyModifier": true,
"useLastChanged": true,
"useLink": true,
"useLocalStorage": true,
"useMagicKeys": true,
"useManualRefHistory": true,
"useMediaControls": true,
"useMediaQuery": true,
"useMemoize": true,
"useMemory": true,
"useModel": true,
"useMounted": true,
"useMouse": true,
"useMouseInElement": true,
"useMousePressed": true,
"useMutationObserver": true,
"useNavigatorLanguage": true,
"useNetwork": true,
"useNow": true,
"useObjectUrl": true,
"useOffsetPagination": true,
"useOnline": true,
"usePageLeave": true,
"useParallax": true,
"useParentElement": true,
"usePerformanceObserver": true,
"usePermission": true,
"usePointer": true,
"usePointerLock": true,
"usePointerSwipe": true,
"usePreferredColorScheme": true,
"usePreferredContrast": true,
"usePreferredDark": true,
"usePreferredLanguages": true,
"usePreferredReducedMotion": true,
"usePrevious": true,
"useRafFn": true,
"useRefHistory": true,
"useResizeObserver": true,
"useRoute": true,
"useRouter": true,
"useScreenOrientation": true,
"useScreenSafeArea": true,
"useScriptTag": true,
"useScroll": true,
"useScrollLock": true,
"useSessionStorage": true,
"useShare": true,
"useSlots": true,
"useSorted": true,
"useSpeechRecognition": true,
"useSpeechSynthesis": true,
"useStepper": true,
"useStorage": true,
"useStorageAsync": true,
"useStyleTag": true,
"useSupported": true,
"useSwipe": true,
"useTemplateRef": true,
"useTemplateRefsList": true,
"useTextDirection": true,
"useTextSelection": true,
"useTextareaAutosize": true,
"useThrottle": true,
"useThrottleFn": true,
"useThrottledRefHistory": true,
"useTimeAgo": true,
"useTimeout": true,
"useTimeoutFn": true,
"useTimeoutPoll": true,
"useTimestamp": true,
"useTitle": true,
"useToNumber": true,
"useToString": true,
"useToggle": true,
"useTransition": true,
"useUrlSearchParams": true,
"useUserMedia": true,
"useVModel": true,
"useVModels": true,
"useVibrate": true,
"useVirtualList": true,
"useWakeLock": true,
"useWebNotification": true,
"useWebSocket": true,
"useWebWorker": true,
"useWebWorkerFn": true,
"useWindowFocus": true,
"useWindowScroll": true,
"useWindowSize": true,
"watch": true,
"watchArray": true,
"watchAtMost": true,
"watchDebounced": true,
"watchDeep": true,
"watchEffect": true,
"watchIgnorable": true,
"watchImmediate": true,
"watchOnce": true,
"watchPausable": true,
"watchPostEffect": true,
"watchSyncEffect": true,
"watchThrottled": true,
"watchTriggerable": true,
"watchWithFilter": true,
"whenever": true
}
}

41
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,41 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true
},
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:vue/vue3-recommended'
],
parser: 'vue-eslint-parser',
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module'
},
plugins: ['@typescript-eslint', 'vue'],
rules: {
// Vue 规则
'vue/multi-word-component-names': 'off',
'vue/no-v-html': 'off',
// TypeScript 规则
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'prefer-const': 'error',
'no-var': 'error'
}
}

72
.gitignore vendored
View File

@@ -1,23 +1,55 @@
# Secrets
.db_password
.redis_password
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
postgres/
redis/
backend/uploads/
backend/logs/
node_modules
dist
dist-ssr
*.local
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.mypy_cache/
# Node
node_modules/
# OS
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
.nyc_output/
# Documentation
*.md
docs/
PHASE*.md
DELIVERY*.md
SUMMARY*.md
!README.md
!src/components/charts/README.md
# Temporary files
*.tmp
*.temp
*.bak
*.backup
# Lock files
package-lock.json
yarn.lock
pnpm-lock.yaml

9
.prettierrc Normal file
View File

@@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"trailingComma": "none",
"arrowParens": "avoid",
"endOfLine": "auto"
}

64
CHARTS_FILES.txt Normal file
View File

@@ -0,0 +1,64 @@
图表组件库文件清单
===================
核心组件
--------
src/components/charts/BaseChart.vue
src/components/charts/PieChart.vue
src/components/charts/BarChart.vue
src/components/charts/LineChart.vue
src/components/charts/GaugeChart.vue
src/components/charts/FunnelChart.vue
业务图表组件
------------
src/components/charts/business/AssetStatusChart.vue
src/components/charts/business/AssetDistributionChart.vue
src/components/charts/business/AssetValueTrendChart.vue
src/components/charts/business/AssetUtilizationChart.vue
统计卡片组件
------------
src/components/statistics/StatCard.vue
src/components/statistics/StatCardGroup.vue
Composables
-----------
src/composables/useECharts.ts
src/composables/useChartData.ts
工具函数
--------
src/utils/echarts.ts
src/utils/echarts/performance.ts
类型定义
--------
src/types/charts.ts
src/components/charts/charts.d.ts
组件导出
--------
src/components/charts/index.ts
src/components/charts/README.md
src/components/statistics/index.ts
文档
----
CHARTS_README.md - 完整使用文档
CHARTS_DELIVERY.md - 交付文档
CHARTS_QUICKSTART.md - 快速开始指南
CHARTS_SUMMARY.md - 项目总结
CHARTS_FILES.txt - 本文件
示例和测试
----------
src/views/examples/ChartsExample.vue
tests/unit/components/PieChart.test.ts
tests/unit/composables/useECharts.test.ts
路由配置
--------
src/router/index.ts (已添加示例页面路由)
总计25 个文件

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM nginx:alpine
# 复制构建产物
COPY dist /usr/share/nginx/html
# 复制Nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 暴露端口
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

217
README.md Normal file
View File

@@ -0,0 +1,217 @@
# 资产管理系统前端
> 基于 Vue 3 + TypeScript + Element Plus 构建的现代化资产管理系统前端应用
## 技术栈
- **框架**: Vue 3.4+ (Composition API + `<script setup>`)
- **语言**: TypeScript 5.0+
- **构建工具**: Vite 5
- **UI组件库**: Element Plus
- **状态管理**: Pinia
- **路由**: Vue Router 4
- **HTTP客户端**: Axios
- **表单验证**: VeeValidate + Yup
- **图表**: ECharts
- **工具库**: dayjs, lodash-es, qrcode
- **组合式函数**: @vueuse/core
- **代码规范**: ESLint + Prettier
- **测试**: Vitest
## 功能特性
### Phase 1: 项目基础搭建 ✅
- [x] Vite + Vue 3项目初始化
- [x] TypeScript配置
- [x] ESLint + Prettier配置
- [x] 路由配置Vue Router 4
- [x] 状态管理Pinia
- [x] Axios请求封装
- [x] UI主题配置青灰配色 #475569
### Phase 2: 认证与布局 ✅
- [x] 登录页面Login.vue
- [x] 主应用布局MainLayout.vue
- [x] 侧边菜单组件
- [x] 顶部导航栏
- [x] 认证状态管理
- [x] 路由守卫
### Phase 3: 后台管理模块(开发中)
- [ ] 用户管理页面
- [ ] 角色权限页面
- [ ] 设备类型管理页面
- [ ] 动态表单设计器
- [ ] 机构网点管理页面
### Phase 4: 资产管理模块(部分完成)
- [x] 资产列表页面
- [x] 资产详情对话框
- [x] 资产入库页面
- [x] 资产编辑对话框
- [ ] 批量导入组件
- [ ] 批量导出组件
- [ ] 扫码查询页面
- [ ] 二维码对话框
- [ ] 资产状态追踪组件
### Phase 5: 资产分配模块(待开发)
- [ ] 分配单列表页面
- [ ] 创建分配单对话框
- [ ] 审批对话框
- [ ] 资产选择器
### Phase 6: 维修与统计(待开发)
- [ ] 维修管理页面
- [ ] 统计报表页面
- [ ] ECharts图表集成
### Phase 7: 系统管理(待开发)
- [ ] 系统配置页面
- [ ] 操作日志页面
- [ ] 消息通知组件
- [ ] 个人中心页面
## 目录结构
```
src/
├── api/ # API接口
│ ├── request.ts # Axios封装
│ ├── auth.ts # 认证接口
│ ├── assets.ts # 资产接口
│ ├── users.ts # 用户接口
│ └── index.ts # 统一导出
├── assets/ # 静态资源
│ └── styles/ # 全局样式
│ ├── variables.scss # 主题变量
│ └── index.scss # 全局样式
├── components/ # 通用组件
├── composables/ # 组合式函数
│ ├── usePagination.ts # 分页
│ └── useTable.ts # 表格
├── layouts/ # 布局组件
│ └── MainLayout.vue # 主布局
├── router/ # 路由配置
│ └── index.ts
├── stores/ # Pinia状态管理
│ ├── modules/
│ │ ├── user.ts # 用户状态
│ │ └── app.ts # 应用状态
│ └── index.ts
├── types/ # TypeScript类型定义
│ └── index.ts
├── utils/ # 工具函数
│ ├── auth.ts # 认证工具
│ ├── format.ts # 格式化工具
│ ├── validate.ts # 验证工具
│ └── constants.ts # 常量定义
├── views/ # 页面组件
│ ├── auth/ # 认证相关
│ │ └── Login.vue
│ ├── admin/ # 后台管理
│ │ ├── UserManagement.vue
│ │ ├── RoleManagement.vue
│ │ ├── DeviceTypeManagement.vue
│ │ └── OrganizationManagement.vue
│ ├── assets/ # 资产管理
│ │ ├── AssetList.vue
│ │ ├── AssetCreate.vue
│ │ ├── AssetAllocation.vue
│ │ ├── AssetScan.vue
│ │ ├── MaintenanceManagement.vue
│ │ ├── StatisticsDashboard.vue
│ │ └── components/ # 资产相关组件
│ │ ├── AssetDetailDialog.vue
│ │ ├── AssetEditDialog.vue
│ │ └── QrcodeDialog.vue
│ └── error/ # 错误页面
│ └── 404.vue
├── App.vue # 根组件
└── main.ts # 应用入口
```
## 开发指南
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
访问 `http://localhost:3000`
### 构建生产版本
```bash
npm run build
```
### 代码检查
```bash
npm run lint
```
### 代码格式化
```bash
npm run format
```
### 运行测试
```bash
npm run test
```
## 环境配置
### 开发环境 (.env.development)
```
VITE_API_BASE_URL=http://localhost:8000/api/v1
VITE_APP_TITLE=资产管理系统
```
### 生产环境 (.env.production)
```
VITE_API_BASE_URL=https://api.yourcompany.com/api/v1
VITE_APP_TITLE=资产管理系统
```
## 主题定制
主题色采用青灰配色方案 (#475569),在 `src/assets/styles/variables.scss` 中可以自定义主题变量。
## API接口规范
详见 `complete_api_reference.md` 文档。
## 开发规范
详见 `development_standards_guide.md` 文档。
## 页面原型
详见 `frontend_page_prototypes.md` 文档。
## 浏览器支持
- Chrome >= 90
- Firefox >= 88
- Safari >= 14
- Edge >= 90
## 作者
老王
## 许可证
MIT

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>资产管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# 处理前端路由
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

6381
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "asset-management-frontend",
"version": "1.0.0",
"private": true,
"description": "资产管理系统前端",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
"format": "prettier --write src/",
"test": "vitest"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@vueuse/core": "^10.7.2",
"axios": "^1.6.5",
"dayjs": "^1.11.10",
"echarts": "^5.4.3",
"element-plus": "^2.5.2",
"lodash-es": "^4.17.21",
"nprogress": "^0.2.0",
"pinia": "^2.1.7",
"qrcode": "^1.5.3",
"vee-validate": "^4.12.5",
"vue": "^3.4.15",
"vue-echarts": "^8.0.1",
"vue-router": "^4.2.5",
"yup": "^1.3.3"
},
"devDependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.5",
"@types/nprogress": "^0.2.3",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/eslint-plugin": "^6.19.0",
"@typescript-eslint/parser": "^6.19.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/test-utils": "^2.4.3",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"prettier": "^3.2.4",
"sass": "^1.70.0",
"typescript": "^5.3.3",
"unplugin-auto-import": "^0.17.3",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.11",
"vitest": "^1.2.1",
"vue-tsc": "^1.8.27"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
}
}

107
playwright.config.ts Normal file
View File

@@ -0,0 +1,107 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Playwright配置文件
* E2E测试配置
*/
export default defineConfig({
// 测试目录
testDir: './tests/e2e',
// 测试文件匹配模式
testMatch: '**/*.spec.ts',
// 完全并行运行测试文件
fullyParallel: true,
// 在CI中失败时不重试
// 在本地开发中重试失败的测试
retries: process.env.CI ? 0 : 2,
// 在CI中限制并行工作线程数
// 在本地使用所有可用线程
workers: process.env.CI ? 2 : undefined,
// 测试报告
reporter: [
['html', { outputFolder: 'test_reports/playwright-report' }],
['json', { outputFile: 'test_reports/playwright-results.json' }],
['junit', { outputFile: 'test_reports/playwright-junit.xml' }],
['list']
],
// 全局设置
use: {
// 基础URL
baseURL: 'http://localhost:5173',
// 收集失败测试的追踪信息
trace: 'retain-on-failure',
// 截图配置
screenshot: 'only-on-failure',
// 视频配置
video: 'retain-on-failure',
// 操作超时时间
actionTimeout: 10 * 1000, // 10秒
navigationTimeout: 30 * 1000, // 30秒
// 浏览器视口大小
viewport: { width: 1280, height: 720 },
// 忽略HTTPS错误
ignoreHTTPSErrors: true,
// 等待超时
waitUntil: 'networkidle'
},
// 项目配置(不同浏览器)
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
/* 测试移动端视图 */
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// 开发服务器配置(启动测试前自动启动)
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, // 2分钟
},
// 全局setup
globalSetup: './tests/e2e/global-setup.ts',
// 全局teardown
globalTeardown: './tests/e2e/global-teardown.ts',
// 期望超时
expect: {
timeout: 5000
}
})

14
src/App.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<router-view />
</template>
<script setup lang="ts">
// App 根组件
</script>
<style>
#app {
height: 100vh;
overflow: hidden;
}
</style>

102
src/api/assets.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* 资产管理 API
*/
import { request } from './request'
import type {
Asset,
PaginationParams,
PaginationResponse,
DynamicField
} from '@/types'
/** 资产列表参数 */
export interface AssetListParams extends PaginationParams {
keyword?: string
device_type_id?: number
organization_id?: number
status?: string
purchase_date_start?: string
purchase_date_end?: string
}
/** 创建资产参数 */
export interface AssetCreateParams {
assetName: string
deviceTypeId: number
brandId?: number
model?: string
serialNumber?: string
supplierId?: number
purchaseDate?: string
purchasePrice?: number
warrantyPeriod?: number
organizationId: number
location?: string
dynamicAttributes: Record<string, any>
}
/** 更新资产参数 */
export type AssetUpdateParams = Partial<AssetCreateParams>
/**
* 获取资产列表
*/
export const getAssetList = (params: AssetListParams) => {
return request.get<PaginationResponse<Asset>>('/assets', { params })
}
/**
* 获取资产详情
*/
export const getAssetById = (id: number) => {
return request.get<Asset>(`/assets/${id}`)
}
/**
* 根据编码查询资产
*/
export const getAssetByCode = (code: string) => {
return request.get<Asset>(`/assets/scan/${code}`)
}
/**
* 创建资产
*/
export const createAsset = (data: AssetCreateParams) => {
return request.post<Asset>('/assets', data)
}
/**
* 更新资产
*/
export const updateAsset = (id: number, data: AssetUpdateParams) => {
return request.put(`/assets/${id}`, data)
}
/**
* 删除资产
*/
export const deleteAsset = (id: number) => {
return request.delete(`/assets/${id}`)
}
/**
* 批量导入资产
*/
export const importAssets = (file: File, onProgress?: (percent: number) => void) => {
return request.upload('/assets/import', file, onProgress)
}
/**
* 批量导出资产
*/
export const exportAssets = (params: AssetListParams) => {
return request.download('/assets/export', `资产列表_${Date.now()}.xlsx`)
}
/**
* 获取设备类型的动态字段
*/
export const getDeviceTypeFields = (typeId: number) => {
return request.get<DynamicField[]>(`/device-types/${typeId}/fields`)
}

78
src/api/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* 认证相关 API
*/
import { request } from './request'
import type { UserInfo } from '@/types'
/** 登录请求参数 */
export interface LoginParams {
username: string
password: string
captcha: string
captcha_key: string
}
/** 登录响应 */
export interface LoginResponse {
access_token: string
refresh_token: string
token_type: string
expires_in: number
user: UserInfo
}
/** 刷新 Token 参数 */
export interface RefreshTokenParams {
refresh_token: string
}
/** 刷新 Token 响应 */
export interface RefreshTokenResponse {
access_token: string
expires_in: number
}
/** 修改密码参数 */
export interface ChangePasswordParams {
old_password: string
new_password: string
confirm_password: string
}
/**
* 用户登录
*/
export const login = (data: LoginParams) => {
return request.post<LoginResponse>('/auth/login', data)
}
/**
* 刷新 Token
*/
export const refreshToken = (data: RefreshTokenParams) => {
return request.post<RefreshTokenResponse>('/auth/refresh', data)
}
/**
* 用户登出
*/
export const logout = () => {
return request.post('/auth/logout')
}
/**
* 修改密码
*/
export const changePassword = (data: ChangePasswordParams) => {
return request.put('/auth/change-password', data)
}
/**
* 获取验证码
*/
export const getCaptcha = () => {
return request.get<{
captcha_key: string
captcha_image: string
}>('/auth/captcha')
}

103
src/api/device-types.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* 设备类型管理 API
*/
import { request } from './request'
import type { DeviceType, DynamicField, PaginationResponse } from '@/types'
/** 设备类型列表参数 */
export interface DeviceTypeListParams {
category?: string
status?: string
}
/** 创建设备类型参数 */
export interface DeviceTypeCreateParams {
typeCode: string
typeName: string
category: string
description?: string
icon?: string
sortOrder?: number
}
/** 更新设备类型参数 */
export type DeviceTypeUpdateParams = Partial<DeviceTypeCreateParams>
/** 创建字段参数 */
export interface FieldCreateParams {
fieldCode: string
fieldName: string
fieldType: DynamicField['fieldType']
isRequired: boolean
placeholder?: string
options?: Array<{ label: string; value: any }>
validationRules?: Record<string, any>
defaultValue?: any
sortOrder: number
}
/** 更新字段参数 */
export type FieldUpdateParams = Partial<FieldCreateParams>
/**
* 获取设备类型列表
*/
export const getDeviceTypeList = (params?: DeviceTypeListParams) => {
return request.get<DeviceType[]>('/device-types', { params })
}
/**
* 获取设备类型详情
*/
export const getDeviceTypeById = (id: number) => {
return request.get<DeviceType>(`/device-types/${id}`)
}
/**
* 创建设备类型
*/
export const createDeviceType = (data: DeviceTypeCreateParams) => {
return request.post<DeviceType>('/device-types', data)
}
/**
* 更新设备类型
*/
export const updateDeviceType = (id: number, data: DeviceTypeUpdateParams) => {
return request.put(`/device-types/${id}`, data)
}
/**
* 删除设备类型
*/
export const deleteDeviceType = (id: number) => {
return request.delete(`/device-types/${id}`)
}
/**
* 获取设备类型的字段配置
*/
export const getDeviceTypeFields = (typeId: number) => {
return request.get<DynamicField[]>(`/device-types/${typeId}/fields`)
}
/**
* 添加字段
*/
export const addDeviceTypeField = (typeId: number, data: FieldCreateParams) => {
return request.post<DynamicField>(`/device-types/${typeId}/fields`, data)
}
/**
* 更新字段
*/
export const updateDeviceTypeField = (typeId: number, fieldId: number, data: FieldUpdateParams) => {
return request.put(`/device-types/${typeId}/fields/${fieldId}`, data)
}
/**
* 删除字段
*/
export const deleteDeviceTypeField = (typeId: number, fieldId: number) => {
return request.delete(`/device-types/${typeId}/fields/${fieldId}`)
}

204
src/api/file.ts Normal file
View File

@@ -0,0 +1,204 @@
/**
* 文件管理 API
*/
import { request } from './request'
export interface FileItem {
id: number
file_name: string
original_name: string
file_path: string
file_size: number
file_type: string
file_ext: string
uploader_id: number
uploader_name?: string
upload_time: string
thumbnail_path?: string
share_code?: string
share_expire_time?: string
download_count: number
remark?: string
download_url?: string
preview_url?: string
share_url?: string
}
export interface FileUploadResponse {
id: number
file_name: string
original_name: string
file_size: number
file_type: string
file_path: string
download_url: string
preview_url?: string
message: string
}
export interface FileShareResponse {
share_code: string
share_url: string
expire_time: string
}
export interface FileStatistics {
total_files: number
total_size: number
total_size_human: string
type_distribution: Record<string, number>
upload_today: number
upload_this_week: number
upload_this_month: number
top_uploaders: Array<{
uploader_id: number
count: number
}>
}
export interface FileQueryParams {
keyword?: string
file_type?: string
uploader_id?: number
start_date?: string
end_date?: string
page?: number
page_size?: number
}
/**
* 上传文件
*/
export function uploadFile(file: File, data?: { remark?: string }) {
const formData = new FormData()
formData.append('file', file)
if (data?.remark) {
formData.append('remark', data.remark)
}
return request.post<FileUploadResponse>('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 获取文件列表
*/
export function getFileList(params?: FileQueryParams) {
return request.get<FileItem[]>('/files', { params })
}
/**
* 获取文件详情
*/
export function getFileDetail(id: number) {
return request.get<FileItem>(`/files/${id}`)
}
/**
* 下载文件
*/
export function downloadFile(id: number) {
return request.get(`/files/${id}/download`, {
responseType: 'blob'
})
}
/**
* 预览文件
*/
export function previewFile(id: number) {
return request.get(`/files/${id}/preview`, {
responseType: 'blob'
})
}
/**
* 更新文件信息
*/
export function updateFile(id: number, data: { remark?: string }) {
return request.put<FileItem>(`/files/${id}`, data)
}
/**
* 删除文件
*/
export function deleteFile(id: number) {
return request.delete(`/files/${id}`)
}
/**
* 批量删除文件
*/
export function deleteFilesBatch(fileIds: number[]) {
return request.delete('/files/batch', { data: { file_ids: fileIds } })
}
/**
* 生成分享链接
*/
export function createShareLink(id: number, expireDays: number = 7) {
return request.post<FileShareResponse>(`/files/${id}/share`, {
expire_days: expireDays
})
}
/**
* 访问分享文件
*/
export function accessSharedFile(shareCode: string) {
return request.get(`/files/share/${shareCode}`, {
responseType: 'blob'
})
}
/**
* 获取文件统计
*/
export function getFileStatistics(uploaderId?: number) {
return request.get<FileStatistics>('/files/statistics', {
params: uploaderId ? { uploader_id: uploaderId } : undefined
})
}
/**
* 初始化分片上传
*/
export function initChunkUpload(data: {
file_name: string
file_size: number
file_type: string
total_chunks: number
file_hash?: string
}) {
return request.post<{ upload_id: string; message: string }>('/files/chunks/init', data)
}
/**
* 上传分片
*/
export function uploadChunk(uploadId: string, chunkIndex: number, chunk: Blob) {
const formData = new FormData()
formData.append('upload_id', uploadId)
formData.append('chunk_index', chunkIndex.toString())
formData.append('chunk', chunk)
return request.post('/files/chunks/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
* 完成分片上传
*/
export function completeChunkUpload(data: {
upload_id: string
file_name: string
file_hash?: string
}) {
return request.post<FileUploadResponse>('/files/chunks/complete', data)
}

244
src/api/index.ts Normal file
View File

@@ -0,0 +1,244 @@
/**
* API 统一导出
*/
export { request } from './request'
export * from './auth'
export * from './assets'
export * from './users'
/**
* 设备类型管理 API
*/
export const getDeviceTypes = (params?: { category?: string; status?: string }) => {
return request.get('/device-types', { params })
}
export const createDeviceType = (data: any) => {
return request.post('/device-types', data)
}
export const updateDeviceType = (id: number, data: any) => {
return request.put(`/device-types/${id}`, data)
}
export const deleteDeviceType = (id: number) => {
return request.delete(`/device-types/${id}`)
}
/**
* 角色权限 API
*/
export const getRoles = (params?: { status?: string }) => {
return request.get('/roles', { params })
}
export const createRole = (data: any) => {
return request.post('/roles', data)
}
export const updateRole = (id: number, data: any) => {
return request.put(`/roles/${id}`, data)
}
export const deleteRole = (id: number) => {
return request.delete(`/roles/${id}`)
}
export const getPermissionTree = () => {
return request.get('/permissions/tree')
}
/**
* 机构网点 API
*/
export const getOrganizationTree = () => {
return request.get('/organizations/tree')
}
export const createOrganization = (data: any) => {
return request.post('/organizations', data)
}
export const updateOrganization = (id: number, data: any) => {
return request.put(`/organizations/${id}`, data)
}
export const deleteOrganization = (id: number) => {
return request.delete(`/organizations/${id}`)
}
/**
* 资产分配 API
*/
export const getAllocationOrders = (params?: any) => {
return request.get('/allocation-orders', { params })
}
export const createAllocationOrder = (data: any) => {
return request.post('/allocation-orders', data)
}
export const approveAllocationOrder = (id: number, data: any) => {
return request.post(`/allocation-orders/${id}/approve`, data)
}
export const getAllocationOrderDetail = (id: number) => {
return request.get(`/allocation-orders/${id}`)
}
/**
* 维修管理 API
*/
export const getMaintenanceRecords = (params?: any) => {
return request.get('/maintenance-records', { params })
}
export const createMaintenanceRecord = (data: any) => {
return request.post('/maintenance-records', data)
}
export const updateMaintenanceRecord = (id: number, data: any) => {
return request.put(`/maintenance-records/${id}`, data)
}
/**
* 统计分析 API
*/
export const getStatisticsOverview = () => {
return request.get('/statistics/overview')
}
export const getStatisticsTrend = (params: { period: string }) => {
return request.get('/statistics/trend', { params })
}
/**
* 调拨管理 API
*/
export const getTransferList = (params?: any) => {
return request.get('/transfers', { params })
}
export const createTransfer = (data: any) => {
return request.post('/transfers', data)
}
export const getTransferDetail = (id: number) => {
return request.get(`/transfers/${id}`)
}
export const startTransfer = (id: number, data: any) => {
return request.post(`/transfers/${id}/start`, data)
}
export const completeTransfer = (id: number) => {
return request.post(`/transfers/${id}/complete`)
}
export const cancelTransfer = (id: number) => {
return request.post(`/transfers/${id}/cancel`)
}
export const approveTransfer = (id: number, data: any) => {
return request.post(`/transfers/${id}/approve`, data)
}
export const executeTransfer = (id: number) => {
return request.post(`/transfers/${id}/execute`)
}
/**
* 回收管理 API
*/
export const getRecoveryList = (params?: any) => {
return request.get('/recoveries', { params })
}
export const createRecovery = (data: any) => {
return request.post('/recoveries', data)
}
export const getRecoveryDetail = (id: number) => {
return request.get(`/recoveries/${id}`)
}
export const startRecovery = (id: number, data: any) => {
return request.post(`/recoveries/${id}/start`, data)
}
export const completeRecovery = (id: number) => {
return request.post(`/recoveries/${id}/complete`)
}
export const cancelRecovery = (id: number) => {
return request.post(`/recoveries/${id}/cancel`)
}
export const approveRecovery = (id: number, data: any) => {
return request.post(`/recoveries/${id}/approve`, data)
}
export const executeRecovery = (id: number) => {
return request.post(`/recoveries/${id}/execute`)
}
/**
* 系统配置 API
*/
export const getConfigList = () => {
return request.get('/system-config')
}
export const createConfig = (data: any) => {
return request.post('/system-config', data)
}
export const updateConfig = (id: number, data: any) => {
return request.put(`/system-config/${id}`, data)
}
export const deleteConfig = (id: number) => {
return request.delete(`/system-config/${id}`)
}
export const refreshConfigCache = () => {
return request.post('/system-config/refresh-cache')
}
/**
* 操作日志 API
*/
export const getOperationLogs = (params?: any) => {
return request.get('/operation-logs', { params })
}
export const getOperationLogDetail = (id: number) => {
return request.get(`/operation-logs/${id}`)
}
/**
* 消息通知 API
*/
export const getNotifications = (params?: any) => {
return request.get('/notifications', { params })
}
export const getNotificationDetail = (id: number) => {
return request.get(`/notifications/${id}`)
}
export const markAsRead = (ids: number[]) => {
return request.put('/notifications/read', { ids })
}
export const markAsUnread = (ids: number[]) => {
return request.post('/notifications/mark-unread', { ids })
}
export const markAllAsRead = () => {
return request.post('/notifications/mark-all-read')
}
export const deleteNotification = (ids: number[]) => {
return request.delete('/notifications', { data: { ids } })
}

61
src/api/organizations.ts Normal file
View File

@@ -0,0 +1,61 @@
/**
* 机构网点管理 API
*/
import { request } from './request'
import type { Organization } from '@/types'
/** 创建机构参数 */
export interface OrganizationCreateParams {
orgCode: string
orgName: string
orgType: Organization['orgType']
parentId?: number
address?: string
contactPerson?: string
contactPhone?: string
}
/** 更新机构参数 */
export type OrganizationUpdateParams = Partial<Omit<OrganizationCreateParams, 'orgCode'>>
/**
* 获取机构树
*/
export const getOrganizationTree = () => {
return request.get<Organization[]>('/organizations/tree')
}
/**
* 获取机构详情
*/
export const getOrganizationById = (id: number) => {
return request.get<Organization>(`/organizations/${id}`)
}
/**
* 创建机构
*/
export const createOrganization = (data: OrganizationCreateParams) => {
return request.post<Organization>('/organizations', data)
}
/**
* 更新机构
*/
export const updateOrganization = (id: number, data: OrganizationUpdateParams) => {
return request.put(`/organizations/${id}`, data)
}
/**
* 删除机构
*/
export const deleteOrganization = (id: number) => {
return request.delete(`/organizations/${id}`)
}
/**
* 移动机构(调整层级)
*/
export const moveOrganization = (id: number, targetParentId: number | null) => {
return request.put(`/organizations/${id}/move`, { target_parent_id: targetParentId })
}

196
src/api/request.ts Normal file
View File

@@ -0,0 +1,196 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/modules/user'
import type { ApiResponse } from '@/types'
// 创建 axios 实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: {
'Content-Type': 'application/json;charset=UTF-8'
}
})
// 请求拦截器
service.interceptors.request.use(
(config: AxiosRequestConfig) => {
const userStore = useUserStore()
// 添加 Token
if (userStore.token) {
config.headers = config.headers || {}
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 添加时间戳防止缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
(error: AxiosError) => {
console.error('Request error:', error)
return Promise.reject(error)
}
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data as ApiResponse
// 如果响应的是文件流,直接返回
if (response.config.responseType === 'blob') {
return response
}
// 业务成功
if (res.code === 200) {
return res.data
}
// 业务失败
// 特殊错误码处理
if (res.code === 401) {
handleTokenExpired()
return Promise.reject(new Error('未授权'))
}
// 其他错误才显示提示
ElMessage.error(res.message || '请求失败')
return Promise.reject(new Error(res.message || '请求失败'))
},
(error: AxiosError) => {
console.error('Response error:', error)
if (error.response) {
const { status } = error.response
switch (status) {
case 400:
ElMessage.error('请求参数错误')
break
case 401:
// 401错误不显示提示,直接跳转登录
handleTokenExpired()
break
case 403:
ElMessage.error('拒绝访问')
break
case 404:
ElMessage.error('请求资源不存在')
break
case 405:
ElMessage.error('请求方法不允许')
break
case 408:
ElMessage.error('请求超时')
break
case 500:
ElMessage.error('服务器内部错误')
break
case 501:
ElMessage.error('服务未实现')
break
case 502:
ElMessage.error('网关错误')
break
case 503:
ElMessage.error('服务不可用')
break
case 504:
ElMessage.error('网关超时')
break
case 505:
ElMessage.error('HTTP版本不受支持')
break
default:
ElMessage.error(`连接错误${status}`)
}
} else if (error.request) {
ElMessage.error('网络连接失败,请检查网络')
} else {
ElMessage.error(error.message || '请求失败')
}
return Promise.reject(error)
}
)
/**
* 处理 Token 过期
* 直接清理Token并跳转到登录页,不显示任何提示
*/
function handleTokenExpired() {
const userStore = useUserStore()
// 清理Token和用户信息
userStore.logout()
// 直接跳转到登录页,不显示任何提示
window.location.href = '/login'
}
/**
* 通用请求方法
*/
export const request = {
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.get(url, config)
},
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.post(url, data, config)
},
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.put(url, data, config)
},
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return service.delete(url, config)
},
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return service.patch(url, data, config)
},
// 文件上传
upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
const formData = new FormData()
formData.append('file', file)
return service.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percent)
}
}
})
},
// 文件下载
download(url: string, filename?: string): Promise<void> {
return service.get(url, {
responseType: 'blob'
}).then((response: AxiosResponse) => {
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = filename || 'download'
link.click()
URL.revokeObjectURL(link.href)
})
}
}
export default service

65
src/api/roles.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* 角色权限 API
*/
import { request } from './request'
import type { Role, Permission, PaginationResponse } from '@/types'
/** 角色列表参数 */
export interface RoleListParams {
status?: string
}
/** 创建角色参数 */
export interface RoleCreateParams {
roleCode: string
roleName: string
description?: string
permissionIds: number[]
}
/** 更新角色参数 */
export type RoleUpdateParams = Partial<Pick<RoleCreateParams, 'roleName' | 'description'>> & {
permissionIds?: number[]
}
/**
* 获取角色列表
*/
export const getRoleList = (params?: RoleListParams) => {
return request.get<Role[]>('/roles', { params })
}
/**
* 获取角色详情
*/
export const getRoleById = (id: number) => {
return request.get<Role>(`/roles/${id}`)
}
/**
* 创建角色
*/
export const createRole = (data: RoleCreateParams) => {
return request.post<Role>('/roles', data)
}
/**
* 更新角色
*/
export const updateRole = (id: number, data: RoleUpdateParams) => {
return request.put(`/roles/${id}`, data)
}
/**
* 删除角色
*/
export const deleteRole = (id: number) => {
return request.delete(`/roles/${id}`)
}
/**
* 获取权限树
*/
export const getPermissionTree = () => {
return request.get<Permission[]>('/permissions/tree')
}

80
src/api/users.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* 用户管理 API
*/
import { request } from './request'
import type {
UserInfo,
PaginationParams,
PaginationResponse
} from '@/types'
/** 用户列表参数 */
export interface UserListParams extends PaginationParams {
keyword?: string
status?: string
role_id?: number
}
/** 创建用户参数 */
export interface UserCreateParams {
username: string
password: string
realName: string
email?: string
phone?: string
roleIds: number[]
}
/** 更新用户参数 */
export type UserUpdateParams = Partial<Pick<UserCreateParams, 'realName' | 'email' | 'phone'>> & {
status?: string
}
/**
* 获取用户列表
*/
export const getUserList = (params: UserListParams) => {
return request.get<PaginationResponse<UserInfo>>('/users', { params })
}
/**
* 获取用户详情
*/
export const getUserById = (id: number) => {
return request.get<UserInfo>(`/users/${id}`)
}
/**
* 获取当前用户信息
*/
export const getCurrentUser = () => {
return request.get<UserInfo>('/users/me')
}
/**
* 创建用户
*/
export const createUser = (data: UserCreateParams) => {
return request.post<UserInfo>('/users', data)
}
/**
* 更新用户
*/
export const updateUser = (id: number, data: UserUpdateParams) => {
return request.put(`/users/${id}`, data)
}
/**
* 删除用户
*/
export const deleteUser = (id: number) => {
return request.delete(`/users/${id}`)
}
/**
* 重置用户密码
*/
export const resetUserPassword = (id: number, newPassword: string) => {
return request.post(`/users/${id}/reset-password`, { new_password: newPassword })
}

View File

@@ -0,0 +1,256 @@
/**
* 全局样式
*/
@import './variables.scss';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
font-family: $font-family;
font-size: $font-size-base;
color: $text-primary;
background-color: $bg-color;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
width: 100%;
height: 100%;
}
a {
color: $primary-color;
text-decoration: none;
transition: $transition-base;
&:hover {
color: darken($primary-color, 10%);
}
}
// 滚动条样式
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background-color: $text-placeholder;
border-radius: $border-radius-base;
&:hover {
background-color: $text-secondary;
}
}
::-webkit-scrollbar-track {
background-color: $border-light;
}
// 卡片样式
.el-card {
border-radius: $border-radius-large;
box-shadow: $box-shadow-base;
border: 1px solid $border-base;
.el-card__header {
border-bottom: 1px solid $border-base;
padding: $spacing-medium $spacing-large;
font-weight: 600;
}
.el-card__body {
padding: $spacing-large;
}
}
// 表格样式
.el-table {
.el-table__header-wrapper {
th {
background-color: $bg-color-page;
color: $text-regular;
font-weight: 600;
}
}
.el-table__body-wrapper {
.el-table__row {
&:hover > td {
background-color: $border-lighter;
}
}
}
}
// 按钮样式
.el-button {
&.el-button--primary {
background-color: $primary-color;
border-color: $primary-color;
&:hover {
background-color: darken($primary-color, 10%);
border-color: darken($primary-color, 10%);
}
}
}
// 对话框样式
.el-dialog {
border-radius: $border-radius-large;
.el-dialog__header {
border-bottom: 1px solid $border-base;
padding: $spacing-medium $spacing-large;
}
.el-dialog__body {
padding: $spacing-large;
}
.el-dialog__footer {
border-top: 1px solid $border-base;
padding: $spacing-medium $spacing-large;
}
}
// 表单样式
.el-form {
.el-form-item__label {
color: $text-regular;
font-weight: 500;
}
.el-input__inner,
.el-textarea__inner {
border-color: $border-base;
&:focus {
border-color: $primary-color;
}
}
}
// 消息提示样式
.el-message {
border-radius: $border-radius-base;
box-shadow: $box-shadow-dark;
}
// 标签样式
.el-tag {
border-radius: $border-radius-small;
}
// 分页样式
.el-pagination {
.el-pager li {
&.active {
background-color: $primary-color;
}
}
.btn-prev,
.btn-next {
&:disabled {
background-color: $bg-color-page;
}
}
}
// 工具类
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.mb-10 {
margin-bottom: 10px;
}
.mb-20 {
margin-bottom: 20px;
}
.ml-10 {
margin-left: 10px;
}
.mr-10 {
margin-right: 10px;
}
.p-10 {
padding: 10px;
}
.p-20 {
padding: 20px;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.flex-1 {
flex: 1;
}
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
// 加载动画
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading {
display: inline-block;
animation: rotate 1s linear infinite;
}

View File

@@ -0,0 +1,86 @@
/**
* 主题变量 - 青灰配色方案
*/
// 主题色
$primary-color: #475569;
$success-color: #10b981;
$warning-color: #f59e0b;
$danger-color: #ef4444;
$info-color: #3b82f6;
// 中性色
$text-primary: #1e293b;
$text-regular: #475569;
$text-secondary: #64748b;
$text-placeholder: #94a3b8;
$text-disabled: #cbd5e1;
// 边框色
$border-base: #e2e8f0;
$border-light: #f1f5f9;
$border-lighter: #f8fafc;
$border-extra-light: #f8fafc;
// 背景色
$bg-color: #f8fafc;
$bg-color-page: #f1f5f9;
$bg-color-overlay: #ffffff;
// 字体
$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
$font-size-base: 14px;
$font-size-small: 12px;
$font-size-medium: 14px;
$font-size-large: 16px;
$font-size-extra-large: 20px;
// 圆角
$border-radius-small: 2px;
$border-radius-base: 4px;
$border-radius-large: 8px;
$border-radius-circle: 50%;
// 阴影
$box-shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
$box-shadow-dark: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
$box-shadow-light: 0 0 2px 0 rgba(0, 0, 0, 0.05);
// 间距
$spacing-small: 4px;
$spacing-base: 8px;
$spacing-medium: 16px;
$spacing-large: 24px;
$spacing-extra-large: 32px;
// 高度
$height-small: 24px;
$height-base: 32px;
$height-medium: 36px;
$height-large: 40px;
$height-extra-large: 48px;
// 侧边栏
$sidebar-width: 200px;
$sidebar-collapsed-width: 64px;
$sidebar-bg: #1e293b;
$sidebar-text-color: #94a3b8;
$sidebar-active-bg: rgba(71, 85, 105, 0.2);
$sidebar-active-text: #ffffff;
// 头部
$header-height: 60px;
$header-bg: #ffffff;
$header-border: #e2e8f0;
// 过渡
$transition-base: all 0.3s ease;
$transition-fade: opacity 0.3s ease;
$transition-slide: transform 0.3s ease;
// z-index
$z-index-normal: 1;
$z-index-top: 1000;
$z-index-popper: 2000;

308
src/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,308 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
const asyncComputed: typeof import('@vueuse/core')['asyncComputed']
const autoResetRef: typeof import('@vueuse/core')['autoResetRef']
const computed: typeof import('vue')['computed']
const computedAsync: typeof import('@vueuse/core')['computedAsync']
const computedEager: typeof import('@vueuse/core')['computedEager']
const computedInject: typeof import('@vueuse/core')['computedInject']
const computedWithControl: typeof import('@vueuse/core')['computedWithControl']
const controlledComputed: typeof import('@vueuse/core')['controlledComputed']
const controlledRef: typeof import('@vueuse/core')['controlledRef']
const createApp: typeof import('vue')['createApp']
const createEventHook: typeof import('@vueuse/core')['createEventHook']
const createGlobalState: typeof import('@vueuse/core')['createGlobalState']
const createInjectionState: typeof import('@vueuse/core')['createInjectionState']
const createPinia: typeof import('pinia')['createPinia']
const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn']
const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate']
const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']
const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise']
const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn']
const customRef: typeof import('vue')['customRef']
const debouncedRef: typeof import('@vueuse/core')['debouncedRef']
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const defineStore: typeof import('pinia')['defineStore']
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
const effectScope: typeof import('vue')['effectScope']
const extendRef: typeof import('@vueuse/core')['extendRef']
const getActivePinia: typeof import('pinia')['getActivePinia']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch']
const inject: typeof import('vue')['inject']
const injectLocal: typeof import('@vueuse/core')['injectLocal']
const isDefined: typeof import('@vueuse/core')['isDefined']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable']
const mapActions: typeof import('pinia')['mapActions']
const mapGetters: typeof import('pinia')['mapGetters']
const mapState: typeof import('pinia')['mapState']
const mapStores: typeof import('pinia')['mapStores']
const mapWritableState: typeof import('pinia')['mapWritableState']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onClickOutside: typeof import('@vueuse/core')['onClickOutside']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke']
const onLongPress: typeof import('@vueuse/core')['onLongPress']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onStartTyping: typeof import('@vueuse/core')['onStartTyping']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const pausableWatch: typeof import('@vueuse/core')['pausableWatch']
const provide: typeof import('vue')['provide']
const provideLocal: typeof import('@vueuse/core')['provideLocal']
const reactify: typeof import('@vueuse/core')['reactify']
const reactifyObject: typeof import('@vueuse/core')['reactifyObject']
const reactive: typeof import('vue')['reactive']
const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed']
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
const reactivePick: typeof import('@vueuse/core')['reactivePick']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
const refDebounced: typeof import('@vueuse/core')['refDebounced']
const refDefault: typeof import('@vueuse/core')['refDefault']
const refThrottled: typeof import('@vueuse/core')['refThrottled']
const refWithControl: typeof import('@vueuse/core')['refWithControl']
const resolveComponent: typeof import('vue')['resolveComponent']
const resolveRef: typeof import('@vueuse/core')['resolveRef']
const resolveUnref: typeof import('@vueuse/core')['resolveUnref']
const setActivePinia: typeof import('pinia')['setActivePinia']
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs']
const syncRef: typeof import('@vueuse/core')['syncRef']
const syncRefs: typeof import('@vueuse/core')['syncRefs']
const templateRef: typeof import('@vueuse/core')['templateRef']
const throttledRef: typeof import('@vueuse/core')['throttledRef']
const throttledWatch: typeof import('@vueuse/core')['throttledWatch']
const toRaw: typeof import('vue')['toRaw']
const toReactive: typeof import('@vueuse/core')['toReactive']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount']
const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount']
const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted']
const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose']
const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted']
const unref: typeof import('vue')['unref']
const unrefElement: typeof import('@vueuse/core')['unrefElement']
const until: typeof import('@vueuse/core')['until']
const useActiveElement: typeof import('@vueuse/core')['useActiveElement']
const useAnimate: typeof import('@vueuse/core')['useAnimate']
const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference']
const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery']
const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter']
const useArrayFind: typeof import('@vueuse/core')['useArrayFind']
const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex']
const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast']
const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes']
const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin']
const useArrayMap: typeof import('@vueuse/core')['useArrayMap']
const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce']
const useArraySome: typeof import('@vueuse/core')['useArraySome']
const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique']
const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue']
const useAsyncState: typeof import('@vueuse/core')['useAsyncState']
const useAttrs: typeof import('vue')['useAttrs']
const useBase64: typeof import('@vueuse/core')['useBase64']
const useBattery: typeof import('@vueuse/core')['useBattery']
const useBluetooth: typeof import('@vueuse/core')['useBluetooth']
const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']
const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel']
const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation']
const useCached: typeof import('@vueuse/core')['useCached']
const useClipboard: typeof import('@vueuse/core')['useClipboard']
const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems']
const useCloned: typeof import('@vueuse/core')['useCloned']
const useColorMode: typeof import('@vueuse/core')['useColorMode']
const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog']
const useCounter: typeof import('@vueuse/core')['useCounter']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVar: typeof import('@vueuse/core')['useCssVar']
const useCssVars: typeof import('vue')['useCssVars']
const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement']
const useCycleList: typeof import('@vueuse/core')['useCycleList']
const useDark: typeof import('@vueuse/core')['useDark']
const useDateFormat: typeof import('@vueuse/core')['useDateFormat']
const useDebounce: typeof import('@vueuse/core')['useDebounce']
const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn']
const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory']
const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion']
const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation']
const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio']
const useDevicesList: typeof import('@vueuse/core')['useDevicesList']
const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia']
const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility']
const useDraggable: typeof import('@vueuse/core')['useDraggable']
const useDropZone: typeof import('@vueuse/core')['useDropZone']
const useElementBounding: typeof import('@vueuse/core')['useElementBounding']
const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint']
const useElementHover: typeof import('@vueuse/core')['useElementHover']
const useElementSize: typeof import('@vueuse/core')['useElementSize']
const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility']
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useFileDialog: typeof import('@vueuse/core')['useFileDialog']
const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess']
const useFocus: typeof import('@vueuse/core')['useFocus']
const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin']
const useFps: typeof import('@vueuse/core')['useFps']
const useFullscreen: typeof import('@vueuse/core')['useFullscreen']
const useGamepad: typeof import('@vueuse/core')['useGamepad']
const useGeolocation: typeof import('@vueuse/core')['useGeolocation']
const useId: typeof import('vue')['useId']
const useIdle: typeof import('@vueuse/core')['useIdle']
const useImage: typeof import('@vueuse/core')['useImage']
const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll']
const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver']
const useInterval: typeof import('@vueuse/core')['useInterval']
const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']
const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier']
const useLastChanged: typeof import('@vueuse/core')['useLastChanged']
const useLink: typeof import('vue-router')['useLink']
const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']
const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys']
const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory']
const useMediaControls: typeof import('@vueuse/core')['useMediaControls']
const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery']
const useMemoize: typeof import('@vueuse/core')['useMemoize']
const useMemory: typeof import('@vueuse/core')['useMemory']
const useModel: typeof import('vue')['useModel']
const useMounted: typeof import('@vueuse/core')['useMounted']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement']
const useMousePressed: typeof import('@vueuse/core')['useMousePressed']
const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver']
const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage']
const useNetwork: typeof import('@vueuse/core')['useNetwork']
const useNow: typeof import('@vueuse/core')['useNow']
const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl']
const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination']
const useOnline: typeof import('@vueuse/core')['useOnline']
const usePageLeave: typeof import('@vueuse/core')['usePageLeave']
const useParallax: typeof import('@vueuse/core')['useParallax']
const useParentElement: typeof import('@vueuse/core')['useParentElement']
const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver']
const usePermission: typeof import('@vueuse/core')['usePermission']
const usePointer: typeof import('@vueuse/core')['usePointer']
const usePointerLock: typeof import('@vueuse/core')['usePointerLock']
const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe']
const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme']
const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast']
const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']
const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages']
const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion']
const usePrevious: typeof import('@vueuse/core')['usePrevious']
const useRafFn: typeof import('@vueuse/core')['useRafFn']
const useRefHistory: typeof import('@vueuse/core')['useRefHistory']
const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation']
const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea']
const useScriptTag: typeof import('@vueuse/core')['useScriptTag']
const useScroll: typeof import('@vueuse/core')['useScroll']
const useScrollLock: typeof import('@vueuse/core')['useScrollLock']
const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage']
const useShare: typeof import('@vueuse/core')['useShare']
const useSlots: typeof import('vue')['useSlots']
const useSorted: typeof import('@vueuse/core')['useSorted']
const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition']
const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis']
const useStepper: typeof import('@vueuse/core')['useStepper']
const useStorage: typeof import('@vueuse/core')['useStorage']
const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync']
const useStyleTag: typeof import('@vueuse/core')['useStyleTag']
const useSupported: typeof import('@vueuse/core')['useSupported']
const useSwipe: typeof import('@vueuse/core')['useSwipe']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList']
const useTextDirection: typeof import('@vueuse/core')['useTextDirection']
const useTextSelection: typeof import('@vueuse/core')['useTextSelection']
const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize']
const useThrottle: typeof import('@vueuse/core')['useThrottle']
const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn']
const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory']
const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo']
const useTimeout: typeof import('@vueuse/core')['useTimeout']
const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn']
const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll']
const useTimestamp: typeof import('@vueuse/core')['useTimestamp']
const useTitle: typeof import('@vueuse/core')['useTitle']
const useToNumber: typeof import('@vueuse/core')['useToNumber']
const useToString: typeof import('@vueuse/core')['useToString']
const useToggle: typeof import('@vueuse/core')['useToggle']
const useTransition: typeof import('@vueuse/core')['useTransition']
const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams']
const useUserMedia: typeof import('@vueuse/core')['useUserMedia']
const useVModel: typeof import('@vueuse/core')['useVModel']
const useVModels: typeof import('@vueuse/core')['useVModels']
const useVibrate: typeof import('@vueuse/core')['useVibrate']
const useVirtualList: typeof import('@vueuse/core')['useVirtualList']
const useWakeLock: typeof import('@vueuse/core')['useWakeLock']
const useWebNotification: typeof import('@vueuse/core')['useWebNotification']
const useWebSocket: typeof import('@vueuse/core')['useWebSocket']
const useWebWorker: typeof import('@vueuse/core')['useWebWorker']
const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn']
const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus']
const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll']
const useWindowSize: typeof import('@vueuse/core')['useWindowSize']
const watch: typeof import('vue')['watch']
const watchArray: typeof import('@vueuse/core')['watchArray']
const watchAtMost: typeof import('@vueuse/core')['watchAtMost']
const watchDebounced: typeof import('@vueuse/core')['watchDebounced']
const watchDeep: typeof import('@vueuse/core')['watchDeep']
const watchEffect: typeof import('vue')['watchEffect']
const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable']
const watchImmediate: typeof import('@vueuse/core')['watchImmediate']
const watchOnce: typeof import('@vueuse/core')['watchOnce']
const watchPausable: typeof import('@vueuse/core')['watchPausable']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
const watchThrottled: typeof import('@vueuse/core')['watchThrottled']
const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable']
const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter']
const whenever: typeof import('@vueuse/core')['whenever']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

99
src/components.d.ts vendored Normal file
View File

@@ -0,0 +1,99 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
AssetDistributionChart: typeof import('./components/charts/business/AssetDistributionChart.vue')['default']
AssetStatusChart: typeof import('./components/charts/business/AssetStatusChart.vue')['default']
AssetUtilizationChart: typeof import('./components/charts/business/AssetUtilizationChart.vue')['default']
AssetValueTrendChart: typeof import('./components/charts/business/AssetValueTrendChart.vue')['default']
BarChart: typeof import('./components/charts/BarChart.vue')['default']
BaseChart: typeof import('./components/charts/BaseChart.vue')['default']
BooleanField: typeof import('./components/form/fields/BooleanField.vue')['default']
DateField: typeof import('./components/form/fields/DateField.vue')['default']
DynamicFieldRenderer: typeof import('./components/form/DynamicFieldRenderer.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElSkeletonItem: typeof import('element-plus/es')['ElSkeletonItem']
ElSpace: typeof import('element-plus/es')['ElSpace']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
FieldDesigner: typeof import('./components/form/FieldDesigner.vue')['default']
FileList: typeof import('./components/file/FileList.vue')['default']
FileUpload: typeof import('./components/file/FileUpload.vue')['default']
FunnelChart: typeof import('./components/charts/FunnelChart.vue')['default']
GaugeChart: typeof import('./components/charts/GaugeChart.vue')['default']
ImagePreview: typeof import('./components/file/ImagePreview.vue')['default']
LineChart: typeof import('./components/charts/LineChart.vue')['default']
MultiSelectField: typeof import('./components/form/fields/MultiSelectField.vue')['default']
NotificationBell: typeof import('./components/NotificationBell.vue')['default']
NumberField: typeof import('./components/form/fields/NumberField.vue')['default']
PieChart: typeof import('./components/charts/PieChart.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SelectField: typeof import('./components/form/fields/SelectField.vue')['default']
StatCard: typeof import('./components/statistics/StatCard.vue')['default']
StatCardGroup: typeof import('./components/statistics/StatCardGroup.vue')['default']
TextareaField: typeof import('./components/form/fields/TextareaField.vue')['default']
TextField: typeof import('./components/form/fields/TextField.vue')['default']
TreeSelect: typeof import('./components/common/TreeSelect.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -0,0 +1,336 @@
<template>
<div class="notification-bell">
<el-dropdown
trigger="click"
@visible-change="handleDropdownVisible"
>
<span class="bell-icon">
<el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
<el-icon :size="20"><Bell /></el-icon>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu class="notification-dropdown">
<div class="dropdown-header">
<span class="header-title">消息通知</span>
<el-link
type="primary"
:underline="false"
@click="handleMarkAllAsRead"
v-if="unreadCount > 0"
>
全部已读
</el-link>
</div>
<div class="dropdown-content" v-loading="loading">
<div
v-for="item in recentNotifications"
:key="item.id"
class="notification-item"
:class="{ unread: !item.isRead }"
@click="handleNotificationClick(item)"
>
<div class="item-icon">
<el-icon :color="getTypeColor(item.type)">
<component :is="getTypeIcon(item.type)" />
</el-icon>
</div>
<div class="item-content">
<div class="item-title">
<span class="title-text">{{ item.title }}</span>
<el-badge
v-if="!item.isRead"
is-dot
class="unread-dot"
/>
</div>
<div class="item-desc">{{ item.content }}</div>
<div class="item-time">{{ formatTime(item.createdAt) }}</div>
</div>
</div>
<el-empty
v-if="recentNotifications.length === 0"
description="暂无消息"
:image-size="80"
/>
</div>
<div class="dropdown-footer">
<el-link
type="primary"
:underline="false"
@click="handleViewAll"
>
查看全部
</el-link>
</div>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { Bell, Warning, InfoFilled, SuccessFilled, CircleClose } from '@element-plus/icons-vue'
import { getNotifications, markAsRead, markAllAsRead } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const loading = ref(false)
const unreadCount = ref(0)
const recentNotifications = ref<any[]>([])
// 消息类型图标
const typeIcons: Record<string, any> = {
system: InfoFilled,
approval: Warning,
alert: CircleClose,
task: SuccessFilled,
transfer: Bell,
maintenance: Warning
}
// 消息类型颜色
const typeColors: Record<string, string> = {
system: '#909399',
approval: '#e6a23c',
alert: '#f56c6c',
task: '#67c23a',
transfer: '#409eff',
maintenance: '#e6a23c'
}
// 获取未读数量
const fetchUnreadCount = async () => {
try {
const data = await getNotifications({ page: 1, page_size: 1, is_read: false })
unreadCount.value = data.total || 0
} catch (error) {
console.error('获取未读数量失败', error)
}
}
// 获取最近消息
const fetchRecentNotifications = async () => {
loading.value = true
try {
const data = await getNotifications({ page: 1, page_size: 5 })
recentNotifications.value = data.items || []
} catch (error) {
ElMessage.error('获取消息失败')
} finally {
loading.value = false
}
}
// 下拉框显示/隐藏
const handleDropdownVisible = (visible: boolean) => {
if (visible) {
fetchRecentNotifications()
}
}
// 消息点击
const handleNotificationClick = async (item: any) => {
if (!item.isRead) {
try {
await markAsRead([item.id])
item.isRead = true
unreadCount.value = Math.max(0, unreadCount.value - 1)
} catch (error) {
console.error('标记已读失败', error)
}
}
if (item.link) {
router.push(item.link)
}
}
// 全部已读
const handleMarkAllAsRead = async () => {
try {
await markAllAsRead()
unreadCount.value = 0
recentNotifications.value.forEach((item: any) => {
item.isRead = true
})
ElMessage.success('已全部标记为已读')
} catch (error) {
ElMessage.error('操作失败')
}
}
// 查看全部
const handleViewAll = () => {
router.push('/system/notification')
}
// 获取类型图标
const getTypeIcon = (type: string) => {
return typeIcons[type] || InfoFilled
}
// 获取类型颜色
const getTypeColor = (type: string) => {
return typeColors[type] || '#909399'
}
// 格式化时间
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`
} else {
return date.toLocaleDateString()
}
}
onMounted(() => {
fetchUnreadCount()
// 每30秒刷新一次未读数量
setInterval(fetchUnreadCount, 30000)
})
// 暴露刷新方法供外部调用
defineExpose({
refresh: fetchUnreadCount
})
</script>
<style scoped lang="scss">
.notification-bell {
.bell-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
cursor: pointer;
transition: background-color 0.3s;
border-radius: 4px;
&:hover {
background-color: rgba(0, 0, 0, 0.04);
}
}
}
.notification-dropdown {
width: 360px;
max-height: 500px;
padding: 0;
.dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #ebeef5;
.header-title {
font-size: 14px;
font-weight: 500;
color: #303133;
}
}
.dropdown-content {
max-height: 400px;
overflow-y: auto;
.notification-item {
display: flex;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.3s;
&:hover {
background-color: #f5f7fa;
}
&.unread {
background-color: #ecf5ff;
}
.item-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #f0f2f5;
}
.item-content {
flex: 1;
min-width: 0;
.item-title {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 4px;
.title-text {
font-size: 14px;
font-weight: 500;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-dot {
flex-shrink: 0;
}
}
.item-desc {
font-size: 12px;
color: #606266;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 4px;
}
.item-time {
font-size: 11px;
color: #909399;
}
}
}
}
.dropdown-footer {
padding: 12px 16px;
border-top: 1px solid #ebeef5;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,170 @@
<!--
柱状图组件
支持横向/纵向堆叠分组柱状图
-->
<template>
<BaseChart
:option="chartOption"
:height="height"
:loading="loading"
@ready="handleReady"
@click="handleClick"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseChart from './BaseChart.vue'
import { barChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
import type { BarChartConfig } from '@/types/charts'
/** Props */
interface Props {
data: Array<{ name: string; value: number; [key: string]: any }>
title?: string
type?: 'vertical' | 'horizontal'
stacked?: boolean
grouped?: boolean
xAxisLabel?: string
yAxisLabel?: string
height?: string
showDataZoom?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
title: '',
type: 'vertical',
stacked: false,
grouped: false,
xAxisLabel: '',
yAxisLabel: '',
height: '400px',
showDataZoom: false,
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
(e: 'click', item: any): void
}
const emit = defineEmits<Emits>()
/** 加载状态 */
const loading = ref(false)
/** 图表配置 */
const chartOption = computed(() => {
const categories = props.data.map((item) => item.name)
const values = props.data.map((item) => item.value)
const isHorizontal = props.type === 'horizontal'
return {
...barChartOption,
title: {
...barChartOption.title,
text: props.title,
},
tooltip: {
...barChartOption.tooltip,
formatter: (params: any) => {
const value = params.value
return `${params.name}<br/>${params.seriesName}: ${formatNumber(value)}`
},
},
xAxis: isHorizontal
? {
...barChartOption.yAxis,
name: props.xAxisLabel,
nameTextStyle: {
padding: [0, 0, 0, 10],
},
}
: {
...barChartOption.xAxis,
data: categories,
name: props.xAxisLabel,
nameTextStyle: {
padding: [0, 0, 0, 10],
},
},
yAxis: isHorizontal
? {
...barChartOption.xAxis,
data: categories,
name: props.yAxisLabel,
nameTextStyle: {
padding: [0, 0, 0, 10],
},
}
: {
...barChartOption.yAxis,
name: props.yAxisLabel,
nameTextStyle: {
padding: [0, 10, 0, 0],
},
},
dataZoom: props.showDataZoom
? [
{
type: 'slider',
show: true,
start: 0,
end: 100,
[isHorizontal ? 'yAxisIndex' : 'xAxisIndex']: 0,
},
]
: undefined,
series: [
{
type: 'bar',
name: props.yAxisLabel || '数值',
data: isHorizontal ? values : values,
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: echartsTheme.color[0] },
{ offset: 1, color: echartsTheme.color[1] },
]),
borderRadius: isHorizontal ? [0, 4, 4, 0] : [4, 4, 0, 0],
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: echartsTheme.color[4] },
{ offset: 1, color: echartsTheme.color[5] },
]),
},
},
label: {
show: true,
position: isHorizontal ? 'right' : 'top',
formatter: (params: any) => formatNumber(params.value),
color: echartsTheme.textColor2,
fontSize: 11,
},
barMaxWidth: 60,
},
],
}
})
/** 处理图表就绪 */
const handleReady = (chart: any) => {
emit('ready', chart)
}
/** 处理点击事件 */
const handleClick = (params: any) => {
const item = props.data.find((d) => d.name === params.name)
if (item) {
emit('click', { ...item, ...params })
}
}
/** 暴露方法 */
defineExpose({
loading,
})
</script>

View File

@@ -0,0 +1,114 @@
<!--
基础图表组件
封装 ECharts 的基本功能所有图表组件的父类
-->
<template>
<div
ref="chartRef"
class="base-chart"
:style="{ height: height, width: '100%' }"
></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from 'vue'
import { useECharts } from '@/composables/useECharts'
import type { EChartOption } from 'echarts'
/** Props */
interface Props {
option: EChartOption
height?: string
autoResize?: boolean
loading?: boolean
theme?: string | object
}
const props = withDefaults(defineProps<Props>(), {
height: '400px',
autoResize: true,
loading: false,
theme: undefined,
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
(e: 'click', params: any): void
}
const emit = defineEmits<Emits>()
/** 图表容器引用 */
const chartRef = ref<HTMLElement | null>(null)
/** 使用 ECharts Composable */
const { chart, isReady, initChart, setOption, showLoading, hideLoading, resize, dispose, on, off } = useECharts(
chartRef,
props.theme
)
/** 监听配置变化 */
watch(
() => props.option,
(newOption) => {
if (isReady.value) {
setOption(newOption, true)
}
},
{ deep: true }
)
/** 监听加载状态 */
watch(
() => props.loading,
(loading) => {
if (loading) {
showLoading()
} else {
hideLoading()
}
},
{ immediate: true }
)
/** 监听图表就绪 */
watch(isReady, (ready) => {
if (ready && chart.value) {
emit('ready', chart.value)
// 绑定点击事件
on('click', (params) => {
emit('click', params)
})
}
})
/** 初始化 */
onMounted(async () => {
await nextTick()
if (chartRef.value) {
initChart()
}
})
/** 清理 */
onBeforeUnmount(() => {
dispose()
})
/** 暴露方法 */
defineExpose({
chart,
resize,
refresh: () => setOption(props.option, true),
})
</script>
<style scoped lang="scss">
.base-chart {
width: 100%;
min-height: 200px;
}
</style>

View File

@@ -0,0 +1,109 @@
<!--
漏斗图组件
用于展示流程转化率等
-->
<template>
<BaseChart
:option="chartOption"
:height="height"
:loading="loading"
@ready="handleReady"
@click="handleClick"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseChart from './BaseChart.vue'
import { funnelChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
import type { FunnelChartConfig } from '@/types/charts'
/** Props */
interface Props {
data: Array<{ name: string; value: number; [key: string]: any }>
title?: string
height?: string
sort?: 'descending' | 'ascending' | 'none'
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
title: '',
height: '400px',
sort: 'descending',
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
(e: 'click', item: any): void
}
const emit = defineEmits<Emits>()
/** 加载状态 */
const loading = ref(false)
/** 图表配置 */
const chartOption = computed(() => {
// 计算总数
const total = props.data.reduce((sum, item) => sum + item.value, 0)
// 处理数据
const chartData = props.data.map((item, index) => ({
name: item.name,
value: item.value,
itemStyle: {
color: echartsTheme.color[index % echartsTheme.color.length],
},
}))
return {
...funnelChartOption,
title: {
...funnelChartOption.title,
text: props.title,
},
tooltip: {
...funnelChartOption.tooltip,
formatter: (params: any) => {
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
return `${params.name}<br/>数量: ${formatNumber(params.value)}<br/>占比: ${percentage}%`
},
},
series: [
{
...funnelChartOption.series![0],
sort: props.sort,
data: chartData,
label: {
...funnelChartOption.series![0].label,
formatter: (params: any) => {
const percentage = total > 0 ? ((params.value / total) * 100).toFixed(1) : 0
return `${params.name}\n${formatNumber(params.value)} (${percentage}%)`
},
},
},
],
}
})
/** 处理图表就绪 */
const handleReady = (chart: any) => {
emit('ready', chart)
}
/** 处理点击事件 */
const handleClick = (params: any) => {
const item = props.data.find((d) => d.name === params.name)
if (item) {
emit('click', { ...item, ...params })
}
}
/** 暴露方法 */
defineExpose({
loading,
})
</script>

View File

@@ -0,0 +1,114 @@
<!--
仪表盘组件
用于展示百分比利用率等指标
-->
<template>
<BaseChart
:option="chartOption"
:height="height"
:loading="loading"
@ready="handleReady"
/>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import BaseChart from './BaseChart.vue'
import { gaugeChartOption, echartsTheme } from '@/utils/echarts'
import type { GaugeChartConfig } from '@/types/charts'
/** Props */
interface Props {
value: number
min?: number
max?: number
title?: string
unit?: string
height?: string
color?: string[]
showDetail?: boolean
}
const props = withDefaults(defineProps<Props>(), {
value: 0,
min: 0,
max: 100,
title: '',
unit: '%',
height: '300px',
color: () => [echartsTheme.color[5], echartsTheme.color[4], echartsTheme.color[6]],
showDetail: true,
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
}
const emit = defineEmits<Emits>()
/** 加载状态 */
const loading = ref(false)
/** 图表配置 */
const chartOption = computed(() => {
// 计算颜色分段
const splitNumber = 10
const step = (props.max - props.min) / splitNumber
return {
...gaugeChartOption,
title: {
...gaugeChartOption.title,
text: props.title,
},
series: [
{
...gaugeChartOption.series![0],
min: props.min,
max: props.max,
splitNumber,
axisLine: {
...gaugeChartOption.series![0].axisLine,
lineStyle: {
width: 18,
color: props.color.map((c, i) => [
(props.min + step * (i + 1)) / props.max,
c,
]),
},
},
detail: {
...gaugeChartOption.series![0].detail,
show: props.showDetail,
valueAnimation: true,
formatter: (value: number) => {
return props.unit === '%' ? `${value.toFixed(1)}%` : `${value.toFixed(1)}${props.unit}`
},
},
data: [
{
value: props.value,
},
],
title: {
offsetCenter: [0, '90%'],
fontSize: 14,
color: echartsTheme.textColor2,
},
},
],
}
})
/** 处理图表就绪 */
const handleReady = (chart: any) => {
emit('ready', chart)
}
/** 暴露方法 */
defineExpose({
loading,
})
</script>

View File

@@ -0,0 +1,225 @@
<!--
折线图组件
支持多条折线面积图平滑曲线
-->
<template>
<BaseChart
:option="chartOption"
:height="height"
:loading="loading"
@ready="handleReady"
@click="handleClick"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseChart from './BaseChart.vue'
import { lineChartOption, echartsTheme, formatNumber } from '@/utils/echarts'
import type { LineChartConfig } from '@/types/charts'
/** Props */
interface Props {
data: Array<{ name: string; value: number; [key: string]: any }>
series?: Array<{ name: string; data: number[]; color?: string }>
title?: string
area?: boolean
smooth?: boolean
xAxisLabel?: string
yAxisLabel?: string
height?: string
showDataZoom?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
series: undefined,
title: '',
area: false,
smooth: true,
xAxisLabel: '',
yAxisLabel: '',
height: '400px',
showDataZoom: false,
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
(e: 'click', item: any): void
}
const emit = defineEmits<Emits>()
/** 加载状态 */
const loading = ref(false)
/** 图表配置 */
const chartOption = computed(() => {
// 如果提供了系列数据,使用系列数据
if (props.series && props.series.length > 0) {
const categories = props.data.map((item) => item.name)
return {
...lineChartOption,
title: {
...lineChartOption.title,
text: props.title,
},
tooltip: {
...lineChartOption.tooltip,
formatter: (params: any) => {
if (Array.isArray(params)) {
let result = `${params[0].name}<br/>`
params.forEach((param: any) => {
result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value)}<br/>`
})
return result
}
return `${params.name}<br/>${params.seriesName}: ${formatNumber(params.value)}`
},
},
legend: {
...lineChartOption.legend,
data: props.series.map((s) => s.name),
},
xAxis: {
...lineChartOption.xAxis,
data: categories,
name: props.xAxisLabel,
},
yAxis: {
...lineChartOption.yAxis,
name: props.yAxisLabel,
},
dataZoom: props.showDataZoom
? [
{
type: 'slider',
show: true,
start: 0,
end: 100,
xAxisIndex: 0,
},
]
: undefined,
series: props.series.map((s, index) => ({
type: 'line',
name: s.name,
data: s.data,
smooth: props.smooth,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
color: s.color || echartsTheme.color[index % echartsTheme.color.length],
},
itemStyle: {
color: s.color || echartsTheme.color[index % echartsTheme.color.length],
},
areaStyle: props.area
? {
opacity: 0.1,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: s.color || echartsTheme.color[index % echartsTheme.color.length] },
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
]),
}
: undefined,
emphasis: {
focus: 'series',
itemStyle: {
borderWidth: 2,
borderColor: '#fff',
},
},
})),
}
}
// 单系列数据
const categories = props.data.map((item) => item.name)
const values = props.data.map((item) => item.value)
return {
...lineChartOption,
title: {
...lineChartOption.title,
text: props.title,
},
tooltip: {
...lineChartOption.tooltip,
formatter: (params: any) => {
return `${params.name}<br/>${params.seriesName}: ${formatNumber(params.value)}`
},
},
xAxis: {
...lineChartOption.xAxis,
data: categories,
name: props.xAxisLabel,
},
yAxis: {
...lineChartOption.yAxis,
name: props.yAxisLabel,
},
dataZoom: props.showDataZoom
? [
{
type: 'slider',
show: true,
start: 0,
end: 100,
xAxisIndex: 0,
},
]
: undefined,
series: [
{
type: 'line',
name: props.yAxisLabel || '数值',
data: values,
smooth: props.smooth,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
},
itemStyle: {
color: echartsTheme.color[0],
},
areaStyle: props.area
? {
opacity: 0.1,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: echartsTheme.color[0] },
{ offset: 1, color: 'rgba(255, 255, 255, 0)' },
]),
}
: undefined,
emphasis: {
focus: 'series',
},
},
],
}
})
/** 处理图表就绪 */
const handleReady = (chart: any) => {
emit('ready', chart)
}
/** 处理点击事件 */
const handleClick = (params: any) => {
const item = props.data.find((d) => d.name === params.name)
if (item) {
emit('click', { ...item, ...params })
}
}
/** 暴露方法 */
defineExpose({
loading,
})
</script>

View File

@@ -0,0 +1,120 @@
<!--
饼图组件
支持基础饼图环形图图例配置标签显示等
-->
<template>
<BaseChart
:option="chartOption"
:height="height"
:loading="loading"
@ready="handleReady"
@click="handleClick"
/>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import BaseChart from './BaseChart.vue'
import { pieChartOption, echartsTheme, getAssetStatusColor } from '@/utils/echarts'
import type { PieChartConfig } from '@/types/charts'
/** Props */
interface Props {
data: Array<{ name: string; value: number; [key: string]: any }>
title?: string
type?: 'pie' | 'doughnut'
showLegend?: boolean
showLabel?: boolean
height?: string
customColor?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
title: '',
type: 'doughnut',
showLegend: true,
showLabel: true,
height: '400px',
customColor: false,
})
/** Emits */
interface Emits {
(e: 'ready', chart: any): void
(e: 'click', item: any): void
}
const emit = defineEmits<Emits>()
/** 加载状态 */
const loading = ref(false)
/** 图表配置 */
const chartOption = computed(() => {
// 处理数据
const chartData = props.data.map((item, index) => ({
...item,
itemStyle: {
color: props.customColor && item.status
? getAssetStatusColor(item.status)
: echartsTheme.color[index % echartsTheme.color.length],
},
}))
// 计算半径
const radius = props.type === 'doughnut' ? ['40%', '70%'] : ['0%', '70%']
return {
...pieChartOption,
title: {
...pieChartOption.title,
text: props.title,
},
legend: {
...pieChartOption.legend,
show: props.showLegend,
},
series: [
{
...pieChartOption.series![0],
radius,
data: chartData,
label: {
...pieChartOption.series![0].label,
show: props.showLabel,
formatter: props.showLabel ? '{b}: {c} ({d}%)' : '',
},
emphasis: {
...pieChartOption.series![0].emphasis,
itemStyle: {
...pieChartOption.series![0].emphasis.itemStyle,
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.3)',
},
},
},
],
}
})
/** 处理图表就绪 */
const handleReady = (chart: any) => {
emit('ready', chart)
}
/** 处理点击事件 */
const handleClick = (params: any) => {
const item = props.data.find((d) => d.name === params.name)
if (item) {
emit('click', { ...item, ...params })
}
}
/** 暴露方法 */
defineExpose({
loading,
})
</script>

View File

@@ -0,0 +1,105 @@
/**
* 图表组件库完整导出文件
*
* 统一导出所有图表组件、类型、工具函数等
*/
// ==================== 通用图表组件 ====================
export { default as BaseChart } from './BaseChart.vue'
export { default as PieChart } from './PieChart.vue'
export { default as BarChart } from './BarChart.vue'
export { default as LineChart } from './LineChart.vue'
export { default as GaugeChart } from './GaugeChart.vue'
export { default as FunnelChart } from './FunnelChart.vue'
// ==================== 业务图表组件 ====================
export { default as AssetStatusChart } from './business/AssetStatusChart.vue'
export { default as AssetDistributionChart } from './business/AssetDistributionChart.vue'
export { default AssetValueTrendChart } from './business/AssetValueTrendChart.vue'
export { default as AssetUtilizationChart } from './business/AssetUtilizationChart.vue'
// ==================== 统计卡片组件 ====================
export { default as StatCard } from '../statistics/StatCard.vue'
export { default as StatCardGroup } from '../statistics/StatCardGroup.vue'
// ==================== Composables ====================
export { useECharts } from '@/composables/useECharts'
export { useChartData } from '@/composables/useChartData'
// ==================== 工具函数 ====================
export {
// 主题配置
echartsTheme,
assetStatusColors,
assetStatusNames,
// 图表配置
baseChartOption,
pieChartOption,
barChartOption,
lineChartOption,
gaugeChartOption,
funnelChartOption,
// 格式化函数
formatNumber,
formatCurrency,
formatPercentage,
getColor,
getAssetStatusColor,
getAssetStatusName,
// 工具函数
resizeChart,
mergeOption,
} from '@/utils/echarts'
// ==================== 性能优化 ====================
export {
performanceConfig,
applyPerformanceConfig,
sampleData,
aggregateDataByTime,
paginateData,
lttbDownsampling,
debounce,
throttle,
createPerformanceMonitor,
type ChartPerformanceMonitor,
} from '@/utils/echarts/performance'
// ==================== 类型定义 ====================
export type {
// 基础类型
ChartDataItem,
ChartSeries,
// 配置类型
PieChartConfig,
BarChartConfig,
LineChartConfig,
GaugeChartConfig,
FunnelChartConfig,
StatCardConfig,
// 业务类型
AssetStatusStatistics,
AssetDistributionStatistics,
AssetTrendData,
AssetTypeStatistics,
MaintenanceStatistics,
// 其他类型
ChartTheme,
ChartSize,
ChartEvents,
ChartExportConfig,
ChartResponsiveConfig,
ChartLoadingConfig,
ChartAnimationConfig,
ChartPerformanceConfig,
} from '@/types/charts'
// ==================== 常量 ====================
export const CHART_VERSION = '1.0.0'
export const CHART_AUTHOR = '图表组件开发组'

View File

@@ -0,0 +1,77 @@
<!--
资产分布图组件
展示按机构类型的资产分布
-->
<template>
<div class="asset-distribution-chart">
<BarChart
:data="chartData"
title="资产分布统计"
type="vertical"
:x-axis-label="xLabel"
y-axis-label="数量"
:show-data-zoom="chartData.length > 10"
height="400px"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BarChart from '../BarChart.vue'
import type { AssetDistributionStatistics, AssetTypeStatistics } from '@/types/charts'
/** Props */
interface Props {
data: Array<AssetDistributionStatistics | AssetTypeStatistics>
type?: 'organization' | 'deviceType'
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
type: 'organization',
loading: false,
})
/** Emits */
interface Emits {
(e: 'click', item: any): void
}
const emit = defineEmits<Emits>()
/** X轴标签 */
const xLabel = computed(() => {
return props.type === 'organization' ? '机构' : '设备类型'
})
/** 图表数据 */
const chartData = computed(() => {
return props.data.map(item => {
const name = props.type === 'organization'
? (item as AssetDistributionStatistics).organizationName
: (item as AssetTypeStatistics).typeName
return {
name: name || '未知',
value: item.count,
original: item,
}
}).sort((a, b) => b.value - a.value)
})
/** 处理点击 */
const handleClick = (item: any) => {
emit('click', item.original)
}
</script>
<style scoped lang="scss">
.asset-distribution-chart {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,70 @@
<!--
资产状态图组件
展示8种资产状态分布
-->
<template>
<div class="asset-status-chart">
<PieChart
:data="chartData"
title="资产状态分布"
type="doughnut"
:show-legend="true"
:show-label="true"
height="400px"
:custom-color="true"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import PieChart from '../PieChart.vue'
import { assetStatusNames, assetStatusColors, formatPercentage } from '@/utils/echarts'
import type { AssetStatusStatistics } from '@/types/charts'
/** Props */
interface Props {
data: AssetStatusStatistics[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
})
/** Emits */
interface Emits {
(e: 'click', item: AssetStatusStatistics): void
}
const emit = defineEmits<Emits>()
/** 图表数据 */
const chartData = computed(() => {
return props.data.map(item => ({
name: item.statusName || assetStatusNames[item.status],
value: item.count,
status: item.status,
percentage: item.percentage,
color: item.color || assetStatusColors[item.status],
}))
})
/** 处理点击 */
const handleClick = (item: any) => {
const statusItem = props.data.find(d => d.status === item.status)
if (statusItem) {
emit('click', statusItem)
}
}
</script>
<style scoped lang="scss">
.asset-status-chart {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,62 @@
<!--
资产利用率图表组件
使用仪表盘展示利用率
-->
<template>
<div class="asset-utilization-chart">
<GaugeChart
:value="utilizationRate"
:min="0"
:max="100"
title="资产利用率"
unit="%"
height="300px"
:color="gaugeColors"
:show-detail="true"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import GaugeChart from '../GaugeChart.vue'
/** Props */
interface Props {
totalAssets: number
usedAssets: number
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
totalAssets: 0,
usedAssets: 0,
loading: false,
})
/** 利用率 */
const utilizationRate = computed(() => {
if (props.totalAssets === 0) return 0
return (props.usedAssets / props.totalAssets) * 100
})
/** 仪表盘颜色 */
const gaugeColors = computed(() => {
const rate = utilizationRate.value
if (rate < 50) {
return ['#ef4444', '#f59e0b', '#10b981'] // 低:红橙绿
} else if (rate < 80) {
return ['#f59e0b', '#10b981', '#3b82f6'] // 中:橙绿蓝
} else {
return ['#10b981', '#3b82f6', '#6366f1'] // 高:绿蓝紫
}
})
</script>
<style scoped lang="scss">
.asset-utilization-chart {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,93 @@
<!--
资产价值趋势图组件
展示资产价值折旧净值趋势
-->
<template>
<div class="asset-value-trend-chart">
<LineChart
:data="dateData"
:series="seriesData"
title="资产价值趋势"
:area="true"
:smooth="true"
x-axis-label="日期"
y-axis-label="金额(万元)"
:show-data-zoom="true"
height="400px"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '../LineChart.vue'
import type { AssetTrendData } from '@/types/charts'
/** Props */
interface Props {
data: AssetTrendData[]
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
})
/** Emits */
interface Emits {
(e: 'click', item: AssetTrendData): void
}
const emit = defineEmits<Emits>()
/** 日期数据 */
const dateData = computed(() => {
return props.data.map(item => ({
name: item.date,
value: item.value / 10000, // 转换为万元
}))
})
/** 系列数据 */
const seriesData = computed(() => {
const valueData = props.data.map(item => item.value / 10000)
const depreciationData = props.data.map(item => (item.depreciation || 0) / 10000)
const netValueData = props.data.map(item => (item.netValue || item.value) / 10000)
return [
{
name: '总价值',
data: valueData,
color: '#475569',
},
{
name: '累计折旧',
data: depreciationData,
color: '#ef4444',
},
{
name: '净值',
data: netValueData,
color: '#10b981',
},
]
})
/** 处理点击 */
const handleClick = (item: any) => {
const original = props.data.find(d => d.date === item.name)
if (original) {
emit('click', original)
}
}
</script>
<style scoped lang="scss">
.asset-value-trend-chart {
width: 100%;
height: 100%;
}
</style>

23
src/components/charts/charts.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
/**
* 图表组件 TypeScript 声明
* 提供更好的类型提示
*/
import type { DefineComponent } from 'vue'
/** BaseChart 组件 */
export interface BaseChartProps {
option: any
height?: string
autoResize?: boolean
loading?: boolean
theme?: string | object
}
export interface BaseChartEmits {
ready: (chart: any) => void
click: (params: any) => void
}
declare const BaseChart: DefineComponent<BaseChartProps, BaseChartEmits>
export default BaseChart

View File

@@ -0,0 +1,21 @@
/**
* 图表组件统一导出
*/
// 统计卡片组件
export { default as StatCard } from '../statistics/StatCard.vue'
export { default as StatCardGroup } from '../statistics/StatCardGroup.vue'
// 通用图表组件
export { default as BaseChart } from './BaseChart.vue'
export { default as PieChart } from './PieChart.vue'
export { default as BarChart } from './BarChart.vue'
export { default as LineChart } from './LineChart.vue'
export { default as GaugeChart } from './GaugeChart.vue'
export { default as FunnelChart } from './FunnelChart.vue'
// 业务图表组件
export { default as AssetStatusChart } from './business/AssetStatusChart.vue'
export { default as AssetDistributionChart } from './business/AssetDistributionChart.vue'
export { default as AssetValueTrendChart } from './business/AssetValueTrendChart.vue'
export { default as AssetUtilizationChart } from './business/AssetUtilizationChart.vue'

View File

@@ -0,0 +1,60 @@
<template>
<el-tree-select
:model-value="modelValue"
:data="data"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:multiple="multiple"
:check-strictly="checkStrictly"
:render-after-expand="false"
:props="treeProps"
node-key="id"
@update:model-value="handleChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { TreeNode } from '@/types/form'
interface Props {
modelValue: string | number | Array<string | number> | null
data: TreeNode[]
multiple?: boolean
checkStrictly?: boolean
placeholder?: string
disabled?: boolean
clearable?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string | number | Array<string | number> | null): void
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
checkStrictly: false,
placeholder: '请选择',
clearable: true,
disabled: false
})
const emit = defineEmits<Emits>()
const treeProps = {
label: 'label',
children: 'children',
disabled: 'disabled'
}
const handleChange = (value: string | number | Array<string | number> | null) => {
emit('update:modelValue', value)
}
</script>
<style scoped lang="scss">
.el-tree-select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,601 @@
<template>
<div class="file-list-container">
<el-card>
<template #header>
<div class="card-header">
<span>文件列表</span>
<div class="header-actions">
<el-button-group>
<el-button
:type="viewMode === 'table' ? 'primary' : ''"
@click="viewMode = 'table'"
>
<el-icon><list /></el-icon>
列表
</el-button>
<el-button
:type="viewMode === 'grid' ? 'primary' : ''"
@click="viewMode = 'grid'"
>
<el-icon><grid /></el-icon>
网格
</el-button>
</el-button-group>
<el-button type="primary" @click="showUploadDialog = true">
<el-icon><upload /></el-icon>
上传文件
</el-button>
</div>
</div>
</template>
<!-- 搜索筛选 -->
<div class="filter-bar">
<el-row :gutter="16">
<el-col :span="8">
<el-input
v-model="queryParams.keyword"
placeholder="搜索文件名"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><search /></el-icon>
</template>
</el-input>
</el-col>
<el-col :span="6">
<el-select
v-model="queryParams.file_type"
placeholder="文件类型"
clearable
@change="handleSearch"
>
<el-option label="图片" value="image" />
<el-option label="文档" value="document" />
<el-option label="压缩包" value="archive" />
</el-select>
</el-col>
<el-col :span="6">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
@change="handleSearch"
/>
</el-col>
<el-col :span="4">
<el-button type="primary" @click="handleSearch">
<el-icon><search /></el-icon>
搜索
</el-button>
</el-col>
</el-row>
</div>
<!-- 表格视图 -->
<div v-if="viewMode === 'table'" class="table-view">
<el-table
v-loading="loading"
:data="fileList"
stripe
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="文件名" prop="original_name" min-width="200">
<template #default="{ row }">
<div class="file-name-cell">
<el-icon class="file-icon">
<component :is="getFileIcon(row.file_type)" />
</el-icon>
<span class="file-name">{{ row.original_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="文件类型" prop="file_type" width="120" />
<el-table-column label="文件大小" width="100" align="right">
<template #default="{ row }">
{{ formatFileSize(row.file_size) }}
</template>
</el-table-column>
<el-table-column label="上传者" prop="uploader_name" width="100" />
<el-table-column label="上传时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.upload_time) }}
</template>
</el-table-column>
<el-table-column label="下载次数" prop="download_count" width="100" align="center" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button-group>
<el-button
v-if="isImage(row)"
size="small"
@click="handlePreview(row)"
>
<el-icon><view /></el-icon>
预览
</el-button>
<el-button
size="small"
@click="handleDownload(row)"
>
<el-icon><download /></el-icon>
下载
</el-button>
<el-button
size="small"
@click="handleShare(row)"
>
<el-icon><share /></el-icon>
分享
</el-button>
<el-button
size="small"
type="danger"
@click="handleDelete(row)"
>
<el-icon><delete /></el-icon>
删除
</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
</div>
<!-- 网格视图 -->
<div v-else class="grid-view">
<el-row :gutter="16">
<el-col
v-for="file in fileList"
:key="file.id"
:span="6"
>
<div class="file-card" @click="handlePreview(file)">
<div class="file-preview">
<el-image
v-if="isImage(file)"
:src="file.preview_url"
fit="cover"
class="preview-image"
>
<template #error>
<div class="image-slot">
<el-icon class="file-icon-large">
<component :is="getFileIcon(file.file_type)" />
</el-icon>
</div>
</template>
</el-image>
<div v-else class="file-icon-wrapper">
<el-icon class="file-icon-large">
<component :is="getFileIcon(file.file_type)" />
</el-icon>
</div>
</div>
<div class="file-info">
<div class="file-name" :title="file.original_name">
{{ file.original_name }}
</div>
<div class="file-meta">
<span>{{ formatFileSize(file.file_size) }}</span>
<span>{{ formatDateTime(file.upload_time) }}</span>
</div>
</div>
<div class="file-actions" @click.stop>
<el-button-group>
<el-button size="small" @click="handleDownload(file)">
<el-icon><download /></el-icon>
</el-button>
<el-button size="small" @click="handleShare(file)">
<el-icon><share /></el-icon>
</el-button>
<el-button size="small" type="danger" @click="handleDelete(file)">
<el-icon><delete /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
</el-col>
</el-row>
</div>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.page_size"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="handleSearch"
/>
</div>
</el-card>
<!-- 上传对话框 -->
<el-dialog
v-model="showUploadDialog"
title="上传文件"
width="600px"
>
<file-upload
ref="uploadRef"
:auto-upload="false"
@upload-success="handleUploadSuccess"
/>
</el-dialog>
<!-- 分享对话框 -->
<el-dialog
v-model="showShareDialog"
title="分享文件"
width="500px"
>
<el-form :model="shareForm" label-width="100px">
<el-form-item label="有效期">
<el-input-number
v-model="shareForm.expire_days"
:min="1"
:max="30"
/>
<span class="unit"></span>
</el-form-item>
<el-form-item label="分享链接">
<el-input
v-model="shareUrl"
readonly
>
<template #append>
<el-button @click="copyShareUrl">复制</el-button>
</template>
</el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showShareDialog = false">取消</el-button>
<el-button type="primary" @click="generateShareLink">生成分享链接</el-button>
</template>
</el-dialog>
<!-- 图片预览 -->
<image-preview
v-model:visible="showImagePreview"
:images="previewImages"
:initial-index="previewIndex"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
List,
Grid,
Upload,
Search,
View,
Download,
Share,
Delete,
Document,
Picture,
Folder,
Files
} from '@element-plus/icons-vue'
import { downloadFile, formatFileSize, formatDateTime } from '@/utils/file'
import FileUpload from './FileUpload.vue'
import ImagePreview from './ImagePreview.vue'
import request from '@/utils/request'
// 文件列表数据
const loading = ref(false)
const fileList = ref<any[]>([])
const total = ref(0)
const selectedFiles = ref<any[]>([])
// 视图模式
const viewMode = ref<'table' | 'grid'>('table')
// 查询参数
const queryParams = reactive({
keyword: '',
file_type: '',
start_date: '',
end_date: '',
page: 1,
page_size: 20
})
const dateRange = ref<[string, string]>([])
// 对话框
const showUploadDialog = ref(false)
const showShareDialog = ref(false)
const showImagePreview = ref(false)
// 分享
const shareForm = reactive({
expire_days: 7
})
const shareUrl = ref('')
const currentShareFile = ref<any>(null)
// 预览
const previewIndex = ref(0)
const previewImages = computed(() => {
return fileList.value
.filter(f => isImage(f))
.map(f => ({
url: f.preview_url || f.download_url,
name: f.original_name
}))
})
// 上传组件ref
const uploadRef = ref()
// 获取文件图标
const getFileIcon = (fileType: string) => {
if (fileType?.startsWith('image/')) {
return Picture
}
if (fileType?.includes('pdf') || fileType?.includes('document')) {
return Document
}
if (fileType?.includes('zip') || fileType?.includes('rar')) {
return Folder
}
return Files
}
// 判断是否为图片
const isImage = (file: any) => {
return file.file_type?.startsWith('image/')
}
// 获取文件列表
const fetchFiles = async () => {
try {
loading.value = true
const params = {
...queryParams,
start_date: dateRange.value?.[0] || '',
end_date: dateRange.value?.[1] || ''
}
const { data } = await request.get('/api/v1/files', { params })
fileList.value = data || []
// 注意实际应该从响应头获取total
} catch (error) {
ElMessage.error('获取文件列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
queryParams.page = 1
fetchFiles()
}
// 选择改变
const handleSelectionChange = (files: any[]) => {
selectedFiles.value = files
}
// 预览文件
const handlePreview = (file: any) => {
if (!isImage(file)) {
ElMessage.warning('该文件类型不支持预览')
return
}
const index = previewImages.value.findIndex(img => img.url === file.preview_url)
previewIndex.value = index
showImagePreview.value = true
}
// 下载文件
const handleDownload = async (file: any) => {
try {
await downloadFile(file.download_url, file.original_name)
} catch (error) {
ElMessage.error('下载失败')
}
}
// 分享文件
const handleShare = (file: any) => {
currentShareFile.value = file
shareUrl.value = ''
showShareDialog.value = true
}
// 生成分享链接
const generateShareLink = async () => {
if (!currentShareFile.value) return
try {
const { data } = await request.post(
`/api/v1/files/${currentShareFile.value.id}/share`,
{ expire_days: shareForm.expire_days }
)
shareUrl.value = data.share_url
ElMessage.success('分享链接生成成功')
} catch (error) {
ElMessage.error('生成分享链接失败')
}
}
// 复制分享链接
const copyShareUrl = () => {
navigator.clipboard.writeText(shareUrl.value)
ElMessage.success('已复制到剪贴板')
}
// 删除文件
const handleDelete = async (file: any) => {
try {
await ElMessageBox.confirm('确定要删除此文件吗?', '提示', {
type: 'warning'
})
await request.delete(`/api/v1/files/${file.id}`)
ElMessage.success('删除成功')
fetchFiles()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 上传成功
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success('上传成功')
showUploadDialog.value = false
fetchFiles()
}
// 初始化
onMounted(() => {
fetchFiles()
})
</script>
<style scoped lang="scss">
.file-list-container {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.header-actions {
display: flex;
gap: 12px;
}
}
.filter-bar {
margin-bottom: 20px;
}
.table-view {
.file-name-cell {
display: flex;
align-items: center;
gap: 8px;
.file-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.grid-view {
.file-card {
margin-bottom: 16px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
.file-actions {
opacity: 1;
}
}
.file-preview {
width: 100%;
height: 160px;
background: var(--el-fill-color-light);
display: flex;
justify-content: center;
align-items: center;
.preview-image {
width: 100%;
height: 100%;
}
.file-icon-wrapper {
font-size: 80px;
color: var(--el-text-color-secondary);
}
.image-slot {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--el-fill-color-light);
.file-icon-large {
font-size: 60px;
color: var(--el-text-color-secondary);
}
}
}
.file-info {
padding: 12px;
.file-name {
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8px;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.file-actions {
padding: 8px 12px;
border-top: 1px solid var(--el-border-color);
opacity: 0;
transition: opacity 0.3s;
}
}
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.unit {
margin-left: 8px;
color: var(--el-text-color-regular);
}
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div class="file-upload-container">
<el-card class="upload-card">
<template #header>
<div class="card-header">
<span>文件上传</span>
<el-tag size="small">{{ tip }}</el-tag>
</div>
</template>
<!-- 拖拽上传区域 -->
<el-upload
ref="uploadRef"
v-model:file-list="fileList"
class="upload-demo"
:action="uploadUrl"
:headers="uploadHeaders"
:on-preview="handlePreview"
:on-remove="handleRemove"
:on-success="handleSuccess"
:on-error="handleError"
:on-progress="handleProgress"
:on-change="handleChange"
:before-upload="beforeUpload"
:on-exceed="handleExceed"
:limit="limit"
:accept="accept"
:auto-upload="autoUpload"
:drag="drag"
:multiple="multiple"
:data="uploadData"
name="file"
>
<el-icon class="el-icon--upload">
<upload-filled />
</el-icon>
<div class="el-upload__text">
将文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
<slot name="tip">
支持上传图片文档压缩包等文件单个文件不超过 {{ maxSize }}MB
</slot>
</div>
</template>
</el-upload>
<!-- 进度显示 -->
<div v-if="showProgress && uploadingFiles.length > 0" class="upload-progress">
<div v-for="file in uploadingFiles" :key="file.uid" class="progress-item">
<div class="file-info">
<el-icon>
<document />
</el-icon>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatSize(file.size) }}</span>
</div>
<el-progress
:percentage="file.percentage || 0"
:status="file.status"
:stroke-width="8"
>
<span class="progress-text">{{ file.percentage || 0 }}%</span>
</el-progress>
</div>
</div>
<!-- 图片预览 -->
<div v-if="showImagePreview && imageFiles.length > 0" class="image-preview">
<div
v-for="file in imageFiles"
:key="file.uid"
class="preview-item"
@click="previewImage(file)"
>
<el-image
:src="file.url"
fit="cover"
class="preview-image"
>
<template #error>
<div class="image-slot">
<el-icon><picture-filled /></el-icon>
</div>
</template>
</el-image>
<div class="preview-actions">
<el-icon class="delete-icon" @click.stop="handleRemove(file)">
<delete />
</el-icon>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="!autoUpload" class="upload-actions">
<el-button @click="clearFiles">清空</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload">
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
</div>
</el-card>
<!-- 图片预览对话框 -->
<el-dialog v-model="previewDialogVisible" title="图片预览" width="80%">
<el-image :src="previewImageUrl" fit="contain" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage, ElMessageBox, type UploadFile, type UploadFiles, type UploadProgressEvent, type UploadRequestOptions } from 'element-plus'
import {
UploadFilled,
Document,
PictureFilled,
Delete
} from '@element-plus/icons-vue'
import { getToken } from '@/utils/auth'
import { formatFileSize } from '@/utils/file'
interface Props {
// 上传地址
action?: string
// 是否显示进度
showProgress?: boolean
// 是否显示图片预览
showImagePreview?: boolean
// 是否拖拽上传
drag?: boolean
// 是否多选
multiple?: boolean
// 是否自动上传
autoUpload?: boolean
// 最大上传数量
limit?: number
// 最大文件大小MB
maxSize?: number
// 接受的文件类型
accept?: string
// 上传附带的额外参数
data?: Record<string, any>
}
const props = withDefaults(defineProps<Props>(), {
action: '/api/v1/files/upload',
showProgress: true,
showImagePreview: true,
drag: true,
multiple: true,
autoUpload: true,
limit: 10,
maxSize: 100,
accept: '',
data: () => ({})
})
const emit = defineEmits<{
'update:file-list': [files: UploadFiles]
'upload-success': [response: any, file: UploadFile]
'upload-error': [error: Error, file: UploadFile]
'upload-progress': [event: UploadProgressEvent, file: UploadFile]
}>()
// 上传组件ref
const uploadRef = ref()
// 文件列表
const fileList = ref<UploadFiles>([])
const uploadingFiles = ref<UploadFiles>([])
const imageFiles = ref<UploadFiles>([])
// 上传状态
const uploading = ref(false)
// 预览
const previewDialogVisible = ref(false)
const previewImageUrl = ref('')
// 上传地址
const uploadUrl = computed(() => {
const baseURL = import.meta.env.VITE_API_BASE_URL || ''
return baseURL + props.action
})
// 上传请求头
const uploadHeaders = computed(() => ({
Authorization: `Bearer ${getToken()}`
}))
// 上传额外数据
const uploadData = computed(() => props.data)
// 提示信息
const tip = computed(() => {
const currentCount = fileList.value.length
return `${currentCount}/${props.limit}`
})
// 上传前校验
const beforeUpload = (file: File) => {
// 检查文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`上传文件大小不能超过 ${props.maxSize}MB!`)
return false
}
// 检查文件类型
if (props.accept) {
const acceptedTypes = props.accept.split(',').map(t => t.trim())
const fileExt = `.${file.name.split('.').pop()}`
const isValidType = acceptedTypes.some(type => {
if (type.startsWith('.')) {
return fileExt === type
}
return file.type.includes(type)
})
if (!isValidType) {
ElMessage.error('不支持的文件类型!')
return false
}
}
return true
}
// 文件状态改变
const handleChange = (file: UploadFile, files: UploadFiles) => {
emit('update:file-list', files)
// 分类文件
uploadingFiles.value = files.filter(f => f.status === 'uploading')
imageFiles.value = files.filter(f =>
f.raw?.type.startsWith('image/') && f.url
)
}
// 超出限制
const handleExceed = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
}
// 上传进度
const handleProgress = (event: UploadProgressEvent, file: UploadFile) => {
emit('upload-progress', event, file)
}
// 上传成功
const handleSuccess = (response: any, file: UploadFile) => {
ElMessage.success('上传成功')
emit('upload-success', response, file)
}
// 上传失败
const handleError = (error: Error, file: UploadFile) => {
ElMessage.error('上传失败: ' + error.message)
emit('upload-error', error, file)
}
// 预览文件
const handlePreview = (file: UploadFile) => {
if (file.url) {
window.open(file.url, '_blank')
}
}
// 移除文件
const handleRemove = (file: UploadFile) => {
const index = fileList.value.findIndex(f => f.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
}
}
// 预览图片
const previewImage = (file: UploadFile) => {
if (file.url) {
previewImageUrl.value = file.url
previewDialogVisible.value = true
}
}
// 手动上传
const submitUpload = () => {
uploadRef.value?.submit()
uploading.value = true
setTimeout(() => {
uploading.value = false
}, 3000)
}
// 清空文件
const clearFiles = () => {
uploadRef.value?.clearFiles()
fileList.value = []
uploadingFiles.value = []
imageFiles.value = []
}
// 格式化文件大小
const formatSize = (size: number) => {
return formatFileSize(size)
}
// 暴露方法
defineExpose({
clearFiles,
submitUpload
})
</script>
<style scoped lang="scss">
.file-upload-container {
.upload-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
}
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.upload-progress {
margin-top: 20px;
.progress-item {
margin-bottom: 15px;
.file-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.file-name {
flex: 1;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.progress-text {
font-size: 12px;
}
}
}
.image-preview {
margin-top: 20px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
.preview-item {
position: relative;
width: 120px;
height: 120px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.preview-actions {
opacity: 1;
}
}
.preview-image {
width: 100%;
height: 100%;
}
.preview-actions {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s;
.delete-icon {
color: white;
font-size: 24px;
}
}
}
}
.upload-actions {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: var(--el-fill-color-light);
color: var(--el-text-color-secondary);
font-size: 30px;
}
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<el-dialog
v-model="dialogVisible"
title="图片预览"
width="90%"
:fullscreen="fullscreen"
:show-close="true"
:close-on-click-modal="false"
@close="handleClose"
>
<div class="image-preview-container">
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<span class="image-info">{{ currentImage?.name || '' }}</span>
</div>
<div class="toolbar-center">
<el-button-group>
<el-button @click="handlePrevious" :disabled="currentIndex <= 0">
<el-icon><arrow-left /></el-icon>
上一张
</el-button>
<span class="page-info">
{{ currentIndex + 1 }} / {{ images.length }}
</span>
<el-button @click="handleNext" :disabled="currentIndex >= images.length - 1">
下一张
<el-icon><arrow-right /></el-icon>
</el-button>
</el-button-group>
</div>
<div class="toolbar-right">
<el-button-group>
<el-button @click="handleZoomOut" :disabled="scale <= 0.2">
<el-icon><zoom-out /></el-icon>
</el-button>
<el-button disabled>{{ Math.round(scale * 100) }}%</el-button>
<el-button @click="handleZoomIn" :disabled="scale >= 3">
<el-icon><zoom-in /></el-icon>
</el-button>
<el-button @click="handleRotate">
<el-icon><refresh-right /></el-icon>
</el-button>
<el-button @click="handleFullscreen">
<el-icon><full-screen /></el-icon>
</el-button>
</el-button-group>
</div>
</div>
<!-- 图片区域 -->
<div class="image-wrapper" @wheel.prevent="handleWheel">
<div
class="image-content"
:style="imageStyle"
>
<el-image
:src="currentImage?.url"
fit="contain"
class="preview-image"
>
<template #error>
<div class="image-error">
<el-icon><picture-filled /></el-icon>
<span>加载失败</span>
</div>
</template>
</el-image>
</div>
</div>
<!-- 缩略图列表 -->
<div v-if="showThumbnails" class="thumbnails">
<div
v-for="(image, index) in images"
:key="index"
class="thumbnail-item"
:class="{ active: index === currentIndex }"
@click="handleSelectImage(index)"
>
<el-image
:src="image.url"
fit="cover"
class="thumbnail-image"
/>
</div>
</div>
</div>
<!-- 快捷键提示 -->
<template #footer>
<div class="footer-tips">
<span>快捷键:</span>
<el-tag size="small"> </el-tag>
<span>切换图片</span>
<el-tag size="small"> </el-tag>
<span>缩放</span>
<el-tag size="small">R</el-tag>
<span>旋转</span>
<el-tag size="small">Esc</el-tag>
<span>关闭</span>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import {
ArrowLeft,
ArrowRight,
ZoomIn,
ZoomOut,
RefreshRight,
FullScreen,
PictureFilled
} from '@element-plus/icons-vue'
interface ImageItem {
url: string
name?: string
}
interface Props {
visible: boolean
images: ImageItem[]
initialIndex?: number
showThumbnails?: boolean
}
const props = withDefaults(defineProps<Props>(), {
initialIndex: 0,
showThumbnails: true
})
const emit = defineEmits<{
'update:visible': [value: boolean]
'change': [index: number]
}>()
// 对话框显示状态
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
// 当前图片索引
const currentIndex = ref(props.initialIndex)
// 缩放比例
const scale = ref(1)
// 旋转角度
const rotation = ref(0)
// 全屏
const fullscreen = ref(false)
// 当前图片
const currentImage = computed(() => {
return props.images[currentIndex.value]
})
// 图片样式
const imageStyle = computed(() => {
return {
transform: `scale(${scale.value}) rotate(${rotation.value}deg)`,
transition: 'transform 0.3s ease'
}
})
// 上一张
const handlePrevious = () => {
if (currentIndex.value > 0) {
currentIndex.value--
emit('change', currentIndex.value)
resetTransform()
}
}
// 下一张
const handleNext = () => {
if (currentIndex.value < props.images.length - 1) {
currentIndex.value++
emit('change', currentIndex.value)
resetTransform()
}
}
// 选择图片
const handleSelectImage = (index: number) => {
currentIndex.value = index
emit('change', currentIndex.value)
resetTransform()
}
// 放大
const handleZoomIn = () => {
if (scale.value < 3) {
scale.value = Math.min(3, scale.value + 0.1)
}
}
// 缩小
const handleZoomOut = () => {
if (scale.value > 0.2) {
scale.value = Math.max(0.2, scale.value - 0.1)
}
}
// 旋转
const handleRotate = () => {
rotation.value = (rotation.value + 90) % 360
}
// 全屏
const handleFullscreen = () => {
fullscreen.value = !fullscreen.value
}
// 重置变换
const resetTransform = () => {
scale.value = 1
rotation.value = 0
}
// 鼠标滚轮缩放
const handleWheel = (event: WheelEvent) => {
const delta = event.deltaY
if (delta < 0) {
handleZoomIn()
} else {
handleZoomOut()
}
}
// 关闭对话框
const handleClose = () => {
dialogVisible.value = false
resetTransform()
}
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
if (!dialogVisible.value) return
switch (event.key) {
case 'ArrowLeft':
handlePrevious()
break
case 'ArrowRight':
handleNext()
break
case 'ArrowUp':
handleZoomIn()
break
case 'ArrowDown':
handleZoomOut()
break
case 'r':
case 'R':
handleRotate()
break
case 'Escape':
handleClose()
break
}
}
// 监听props变化
watch(() => props.initialIndex, (newIndex) => {
currentIndex.value = newIndex
resetTransform()
})
watch(() => props.visible, (visible) => {
if (visible) {
currentIndex.value = props.initialIndex
resetTransform()
}
})
// 生命周期
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped lang="scss">
.image-preview-container {
display: flex;
flex-direction: column;
height: 70vh;
background: var(--el-fill-color-blank);
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color);
background: var(--el-bg-color);
.toolbar-left {
.image-info {
font-size: 14px;
font-weight: 500;
}
}
.toolbar-center {
.page-info {
margin: 0 12px;
font-size: 14px;
color: var(--el-text-color-regular);
}
}
}
.image-wrapper {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
position: relative;
.image-content {
max-width: 100%;
max-height: 100%;
.preview-image {
width: 100%;
height: 100%;
}
.image-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
color: var(--el-text-color-secondary);
font-size: 16px;
.el-icon {
font-size: 48px;
margin-bottom: 8px;
}
}
}
}
.thumbnails {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--el-border-color);
background: var(--el-bg-color);
overflow-x: auto;
.thumbnail-item {
flex-shrink: 0;
width: 80px;
height: 80px;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: var(--el-color-primary);
}
&:hover {
transform: scale(1.05);
}
.thumbnail-image {
width: 100%;
height: 100%;
}
}
}
}
.footer-tips {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>

View File

@@ -0,0 +1,19 @@
/**
* 文件管理组件入口
*/
import FileUpload from './FileUpload.vue'
import FileList from './FileList.vue'
import ImagePreview from './ImagePreview.vue'
export {
FileUpload,
FileList,
ImagePreview
}
export default {
FileUpload,
FileList,
ImagePreview
}

View File

@@ -0,0 +1,322 @@
/**
* 文件管理组件类型定义
*/
import { Ref } from 'vue'
/**
* 文件项
*/
export interface FileItem {
id: number
file_name: string
original_name: string
file_path: string
file_size: number
file_type: string
file_ext: string
uploader_id: number
uploader_name?: string
upload_time: string
thumbnail_path?: string
share_code?: string
share_expire_time?: string
download_count: number
is_deleted: number
deleted_at?: string
deleted_by?: number
remark?: string
created_at: string
updated_at: string
download_url?: string
preview_url?: string
share_url?: string
}
/**
* 图片项
*/
export interface ImageItem {
url: string
name?: string
}
/**
* 文件上传响应
*/
export interface FileUploadResponse {
id: number
file_name: string
original_name: string
file_size: number
file_type: string
file_path: string
download_url: string
preview_url?: string
message: string
}
/**
* 文件分享响应
*/
export interface FileShareResponse {
share_code: string
share_url: string
expire_time: string
}
/**
* 文件统计信息
*/
export interface FileStatistics {
total_files: number
total_size: number
total_size_human: string
type_distribution: Record<string, number>
upload_today: number
upload_this_week: number
upload_this_month: number
top_uploaders: Array<{
uploader_id: number
count: number
}>
}
/**
* 文件查询参数
*/
export interface FileQueryParams {
keyword?: string
file_type?: string
uploader_id?: number
start_date?: string
end_date?: string
page?: number
page_size?: number
}
/**
* 文件上传组件Props
*/
export interface FileUploadProps {
action?: string
showProgress?: boolean
showImagePreview?: boolean
drag?: boolean
multiple?: boolean
autoUpload?: boolean
limit?: number
maxSize?: number
accept?: string
data?: Record<string, any>
}
/**
* 文件上传组件Emits
*/
export interface FileUploadEmits {
(e: 'update:file-list', files: any[]): void
(e: 'upload-success', response: FileUploadResponse, file: any): void
(e: 'upload-error', error: Error, file: any): void
(e: 'upload-progress', event: any, file: any): void
}
/**
* 图片预览组件Props
*/
export interface ImagePreviewProps {
visible: boolean
images: ImageItem[]
initialIndex?: number
showThumbnails?: boolean
}
/**
* 图片预览组件Emits
*/
export interface ImagePreviewEmits {
(e: 'update:visible', value: boolean): void
(e: 'change', index: number): void
}
/**
* 文件列表组件Props
*/
export interface FileListProps {
viewMode?: 'table' | 'grid'
showUpload?: boolean
selectable?: boolean
}
/**
* 分片上传初始化参数
*/
export interface ChunkUploadInitParams {
file_name: string
file_size: number
file_type: string
total_chunks: number
file_hash?: string
}
/**
* 分片上传初始化响应
*/
export interface ChunkUploadInitResponse {
upload_id: string
message: string
}
/**
* 分片上传完成参数
*/
export interface ChunkUploadCompleteParams {
upload_id: string
file_name: string
file_hash?: string
}
/**
* 文件验证选项
*/
export interface FileValidateOptions {
allowedTypes?: string[]
maxSize?: number
maxCount?: number
}
/**
* 文件验证结果
*/
export interface FileValidateResult {
valid: boolean
errors: string[]
}
/**
* 文件类型枚举
*/
export enum FileType {
IMAGE = 'image',
DOCUMENT = 'document',
ARCHIVE = 'archive',
VIDEO = 'video',
AUDIO = 'audio',
OTHER = 'other'
}
/**
* 视图模式枚举
*/
export enum ViewMode {
TABLE = 'table',
GRID = 'grid'
}
/**
* 文件状态枚举
*/
export enum FileStatus {
UPLOADING = 'uploading',
SUCCESS = 'success',
ERROR = 'error',
REMOVED = 'removed'
}
/**
* 上传任务状态
*/
export interface UploadTask {
uid: string
name: string
size: number
type: string
status: FileStatus
percentage: number
url?: string
response?: FileUploadResponse
error?: Error
}
/**
* 文件分享创建参数
*/
export interface FileShareCreateParams {
expire_days: number
}
/**
* 文件批量删除参数
*/
export interface FileBatchDeleteParams {
file_ids: number[]
}
/**
* 文件更新参数
*/
export interface FileUpdateParams {
remark?: string
}
/**
* 缩略图生成选项
*/
export interface ThumbnailOptions {
width?: number
height?: number
quality?: number
}
/**
* 图片压缩选项
*/
export interface ImageCompressOptions {
quality?: number
maxWidth?: number
maxHeight?: number
}
/**
* 文件服务实例
*/
export interface FileServiceInstance {
uploadFile(file: File, data?: { remark?: string }): Promise<FileUploadResponse>
getFileList(params?: FileQueryParams): Promise<FileItem[]>
getFileDetail(id: number): Promise<FileItem>
downloadFile(id: number): Promise<Blob>
previewFile(id: number): Promise<Blob>
updateFile(id: number, data: FileUpdateParams): Promise<FileItem>
deleteFile(id: number): Promise<void>
deleteFilesBatch(fileIds: number[]): Promise<void>
createShareLink(id: number, expireDays?: number): Promise<FileShareResponse>
accessSharedFile(shareCode: string): Promise<Blob>
getFileStatistics(uploaderId?: number): Promise<FileStatistics>
initChunkUpload(data: ChunkUploadInitParams): Promise<ChunkUploadInitResponse>
uploadChunk(uploadId: string, chunkIndex: number, chunk: Blob): Promise<void>
completeChunkUpload(data: ChunkUploadCompleteParams): Promise<FileUploadResponse>
}
/**
* 文件工具函数集合
*/
export interface FileUtils {
formatFileSize(bytes: number): string
formatDateTime(dateString: string, format?: string): string
getFileExtension(filename: string): string
getFileNameWithoutExtension(filename: string): string
isImage(mimeType: string): boolean
isPDF(mimeType: string): boolean
isDocument(mimeType: string): boolean
isArchive(mimeType: string): boolean
getFileTypeIcon(mimeType: string): string
downloadFile(url: string, filename?: string): Promise<void>
previewFile(url: string): void
validateFileType(file: File, allowedTypes: string[]): boolean
validateFileSize(file: File, maxSize: number): boolean
validateFiles(files: File[], options: FileValidateOptions): FileValidateResult
compressImage(file: File, quality?: number, maxWidth?: number, maxHeight?: number): Promise<Blob>
createThumbnail(file: File, width?: number, height?: number): Promise<string>
calculateFileHash(file: File): Promise<string>
generateUniqueFilename(originalFilename: string): string
}

View File

@@ -0,0 +1,367 @@
<template>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-width="labelWidth"
:label-position="labelPosition"
:disabled="readonly"
>
<el-row :gutter="gutter">
<el-col
v-for="field in visibleFields"
:key="field.id"
:span="field.span || defaultSpan"
>
<el-form-item
:prop="field.name"
:label="field.label"
:required="field.required"
>
<template v-if="field.description" #label>
<span>{{ field.label }}</span>
<el-tooltip :content="field.description" placement="top">
<el-icon class="field-description-icon">
<QuestionFilled />
</el-icon>
</el-tooltip>
</template>
<!-- 动态渲染字段组件 -->
<component
:is="getFieldComponent(field.fieldType)"
:model-value="formData[field.name]"
:field="field"
:readonly="readonly"
:disabled="isFieldDisabled(field)"
@update:model-value="handleFieldChange(field.name, $event)"
@blur="handleFieldBlur(field)"
@focus="handleFieldFocus(field)"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import TextField from './fields/TextField.vue'
import NumberField from './fields/NumberField.vue'
import TextareaField from './fields/TextareaField.vue'
import DateField from './fields/DateField.vue'
import SelectField from './fields/SelectField.vue'
import MultiSelectField from './fields/MultiSelectField.vue'
import BooleanField from './fields/BooleanField.vue'
import TreeSelect from '../common/TreeSelect.vue'
import type { FieldConfig, FormData, FormValidationState, FieldChangeEvent } from '@/types/form'
import { validateField, createValidationRule } from '@/utils/fieldValidator'
import { FieldDependencyManager } from '@/utils/fieldDependency'
interface Props {
deviceTypeId?: string | number
fields: FieldConfig[]
modelValue: FormData
readonly?: boolean
labelWidth?: string | number
labelPosition?: 'left' | 'right' | 'top'
gutter?: number
dependencies?: any[]
}
interface Emits {
(e: 'update:modelValue', value: FormData): void
(e: 'field-change', event: FieldChangeEvent): void
(e: 'validation-change', state: FormValidationState): void
}
const props = withDefaults(defineProps<Props>(), {
readonly: false,
labelWidth: '120px',
labelPosition: 'right',
gutter: 20
})
const emit = defineEmits<Emits>()
const formRef = ref()
const dependencyManager = new FieldDependencyManager()
// 表单数据
const formData = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 默认栅格占列数
const defaultSpan = computed(() => 24) // 默认占满一行
// 可见字段列表
const visibleFields = computed(() => {
return props.fields.filter((field) => {
if (typeof field.visible === 'function') {
return field.visible(formData.value)
}
return field.visible !== false
})
})
// 表单验证规则
const formRules = computed(() => {
const rules: any = {}
props.fields.forEach((field) => {
if (field.required || field.validationRules) {
rules[field.name] = []
// 必填规则
if (field.required) {
rules[field.name].push({
required: true,
message: `${field.label}不能为空`,
trigger: ['blur', 'change']
})
}
// 自定义验证规则
if (field.validationRules) {
const rule = createValidationRule(field)
rules[field.name].push(rule)
}
}
})
return rules
})
/**
* 获取字段对应的组件
*/
const getFieldComponent = (fieldType: string) => {
const componentMap: Record<string, any> = {
text: TextField,
textarea: TextareaField,
number: NumberField,
date: DateField,
select: SelectField,
multiselect: MultiSelectField,
boolean: BooleanField,
tree: TreeSelect,
// 兼容API中的字段类型
url: TextField,
email: TextField,
phone: TextField,
checkbox: BooleanField
}
return componentMap[fieldType] || TextField
}
/**
* 判断字段是否禁用
*/
const isFieldDisabled = (field: FieldConfig): boolean => {
if (props.readonly) return true
if (typeof field.disabled === 'function') {
return field.disabled(formData.value)
}
return field.disabled || false
}
/**
* 处理字段值变化
*/
const handleFieldChange = (fieldName: string, value: any) => {
const oldValue = formData.value[fieldName]
const newValue = value
// 更新表单数据
formData.value = {
...formData.value,
[fieldName]: newValue
}
// 触发联动
if (dependencyManager) {
const results = dependencyManager.trigger(fieldName, newValue, formData.value)
// 处理联动结果
Object.keys(results).forEach((targetField) => {
const { type, value: resultValue } = results[targetField]
if (type === 'setValue' && resultValue !== undefined) {
formData.value = {
...formData.value,
[targetField]: resultValue
}
}
})
}
// 触发字段变更事件
emit('field-change', {
fieldName,
value: newValue,
oldValue
})
// 触发表单验证
validateField(fieldName)
}
/**
* 处理字段失焦
*/
const handleFieldBlur = (field: FieldConfig) => {
// 可以在这里触发验证
if (formRef.value) {
formRef.value.validateField(field.name)
}
}
/**
* 处理字段聚焦
*/
const handleFieldFocus = (field: FieldConfig) => {
// 聚焦事件处理
}
/**
* 验证单个字段
*/
const validateFieldAsync = async (fieldName: string): Promise<boolean> => {
if (!formRef.value) return false
try {
await formRef.value.validateField(fieldName)
return true
} catch {
return false
}
}
/**
* 验证整个表单
*/
const validateForm = async (): Promise<boolean> => {
if (!formRef.value) return false
try {
await formRef.value.validate()
return true
} catch {
ElMessage.warning('请检查表单填写是否正确')
return false
}
}
/**
* 重置表单
*/
const resetForm = () => {
if (!formRef.value) return
formRef.value.resetFields()
// 重置为默认值
const defaultData: FormData = {}
props.fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultData[field.name] = field.defaultValue
}
})
formData.value = defaultData
}
/**
* 清除验证
*/
const clearValidation = () => {
if (!formRef.value) return
formRef.value.clearValidate()
}
/**
* 设置字段值
*/
const setFieldValue = (fieldName: string, value: any) => {
formData.value = {
...formData.value,
[fieldName]: value
}
}
/**
* 获取字段值
*/
const getFieldValue = (fieldName: string) => {
return formData.value[fieldName]
}
/**
* 获取表单数据
*/
const getFormData = () => {
return { ...formData.value }
}
/**
* 设置表单数据
*/
const setFormData = (data: FormData) => {
formData.value = { ...data }
}
// 初始化联动配置
watch(
() => props.dependencies,
(deps) => {
if (deps && deps.length > 0) {
dependencyManager.addDependencies(deps)
}
},
{ immediate: true }
)
// 初始化默认值
watch(
() => props.fields,
(fields) => {
const defaultData: FormData = { ...formData.value }
fields.forEach((field) => {
if (field.defaultValue !== undefined && formData.value[field.name] === undefined) {
defaultData[field.name] = field.defaultValue
}
})
if (Object.keys(defaultData).length > 0) {
formData.value = defaultData
}
},
{ immediate: true }
)
// 暴露方法供父组件调用
defineExpose({
validateField: validateFieldAsync,
validateForm,
resetForm,
clearValidation,
setFieldValue,
getFieldValue,
getFormData,
setFormData
})
</script>
<style scoped lang="scss">
.field-description-icon {
margin-left: 4px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: help;
}
:deep(.el-form-item__label) {
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,467 @@
<template>
<div class="field-designer">
<!-- 左侧字段列表 -->
<div class="field-list-panel">
<div class="panel-header">
<h3>字段列表</h3>
<el-button type="primary" size="small" @click="handleAddField">
<el-icon><Plus /></el-icon>
添加字段
</el-button>
</div>
<div class="field-list">
<draggable
v-model="localFields"
item-key="id"
@end="handleDragEnd"
>
<template #item="{ element: field }">
<div
class="field-item"
:class="{ active: selectedFieldId === field.id }"
@click="handleSelectField(field)"
>
<div class="field-info">
<el-icon class="drag-handle"><Rank /></el-icon>
<span class="field-label">{{ field.label }}</span>
<el-tag size="small" class="field-type-tag">{{ getFieldTypeLabel(field.fieldType) }}</el-tag>
</div>
<div class="field-actions">
<el-button
type="primary"
link
size="small"
@click.stop="handleEditField(field)"
>
编辑
</el-button>
<el-button
type="danger"
link
size="small"
@click.stop="handleDeleteField(field)"
>
删除
</el-button>
</div>
</div>
</template>
</draggable>
</div>
</div>
<!-- 右侧字段配置 -->
<div class="field-config-panel">
<div class="panel-header">
<h3>字段配置</h3>
</div>
<div v-if="selectedField" class="field-config-form">
<el-form :model="selectedField" label-width="100px">
<el-form-item label="字段标识">
<el-input v-model="selectedField.name" placeholder="请输入字段标识" />
</el-form-item>
<el-form-item label="字段名称">
<el-input v-model="selectedField.label" placeholder="请输入字段名称" />
</el-form-item>
<el-form-item label="字段类型">
<el-select v-model="selectedField.fieldType" placeholder="请选择字段类型">
<el-option label="单行文本" value="text" />
<el-option label="多行文本" value="textarea" />
<el-option label="数字" value="number" />
<el-option label="日期" value="date" />
<el-option label="下拉选择" value="select" />
<el-option label="多选下拉" value="multiselect" />
<el-option label="开关" value="boolean" />
<el-option label="树形选择" value="tree" />
</el-select>
</el-form-item>
<el-form-item label="占位符">
<el-input v-model="selectedField.placeholder" placeholder="请输入占位符" />
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model="selectedField.required" />
</el-form-item>
<el-form-item label="默认值">
<el-input v-model="selectedField.defaultValue" placeholder="请输入默认值" />
</el-form-item>
<el-form-item label="栅格占列">
<el-slider v-model="selectedField.span" :min="1" :max="24" show-stops />
</el-form-item>
<!-- 选项配置用于select/multiselect -->
<template v-if="['select', 'multiselect'].includes(selectedField.fieldType)">
<el-form-item label="选项配置">
<div class="options-config">
<div
v-for="(option, index) in selectedField.options"
:key="index"
class="option-item"
>
<el-input v-model="option.label" placeholder="选项名称" size="small" />
<el-input v-model="option.value" placeholder="选项值" size="small" />
<el-button
type="danger"
icon="Delete"
size="small"
circle
@click="handleDeleteOption(index)"
/>
</div>
<el-button
type="primary"
link
size="small"
@click="handleAddOption"
>
添加选项
</el-button>
</div>
</el-form-item>
</template>
<!-- 验证规则配置 -->
<el-divider content-position="left">验证规则</el-divider>
<template v-if="['text', 'textarea'].includes(selectedField.fieldType)">
<el-form-item label="最小长度">
<el-input-number v-model="selectedField.validationRules.min" :min="0" />
</el-form-item>
<el-form-item label="最大长度">
<el-input-number v-model="selectedField.validationRules.max" :min="0" />
</el-form-item>
</template>
<template v-if="selectedField.fieldType === 'number'">
<el-form-item label="最小值">
<el-input-number v-model="selectedField.validationRules.min" />
</el-form-item>
<el-form-item label="最大值">
<el-input-number v-model="selectedField.validationRules.max" />
</el-form-item>
</template>
<el-form-item label="正则表达式">
<el-input v-model="selectedField.validationRules.pattern" placeholder="请输入正则表达式" />
</el-form-item>
</el-form>
<div class="form-actions">
<el-button @click="handleCancelConfig">取消</el-button>
<el-button type="primary" @click="handleSaveConfig">保存配置</el-button>
</div>
</div>
<div v-else class="empty-state">
<el-empty description="请选择或添加字段" />
</div>
</div>
<!-- 预览对话框 -->
<el-dialog v-model="previewVisible" title="字段预览" width="800px">
<DynamicFieldRenderer
v-if="previewVisible"
:fields="localFields"
:model-value="previewData"
/>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Rank } from '@element-plus/icons-vue'
import draggable from 'vuedraggable'
import DynamicFieldRenderer from './DynamicFieldRenderer.vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: FieldConfig[]
readonly?: boolean
}
interface Emits {
(e: 'update:modelValue', value: FieldConfig[]): void
(e: 'preview', fields: FieldConfig[]): void
}
const props = withDefaults(defineProps<Props>(), {
readonly: false
})
const emit = defineEmits<Emits>()
// 本地字段列表
const localFields = ref<FieldConfig[]>([...props.modelValue])
// 选中的字段ID
const selectedFieldId = ref<string | null>(null)
// 选中的字段
const selectedField = ref<FieldConfig | null>(null)
// 预览可见性
const previewVisible = ref(false)
// 预览数据
const previewData = ref<Record<string, any>>({})
/**
* 字段类型标签映射
*/
const getFieldTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
text: '单行文本',
textarea: '多行文本',
number: '数字',
date: '日期',
select: '下拉选择',
multiselect: '多选下拉',
boolean: '开关',
tree: '树形选择',
url: '链接',
email: '邮箱',
phone: '手机号'
}
return labels[type] || type
}
/**
* 处理添加字段
*/
const handleAddField = () => {
const newField: FieldConfig = {
id: `field_${Date.now()}`,
name: `field_${localFields.value.length + 1}`,
label: `新字段${localFields.value.length + 1}`,
fieldType: 'text',
required: false,
span: 24,
validationRules: {},
options: []
}
localFields.value.push(newField)
handleSelectField(newField)
emit('update:modelValue', localFields.value)
}
/**
* 处理选择字段
*/
const handleSelectField = (field: FieldConfig) => {
selectedFieldId.value = field.id
selectedField.value = { ...field }
}
/**
* 处理编辑字段
*/
const handleEditField = (field: FieldConfig) => {
handleSelectField(field)
}
/**
* 处理删除字段
*/
const handleDeleteField = async (field: FieldConfig) => {
try {
await ElMessageBox.confirm(`确定要删除字段"${field.label}"吗?`, '提示', {
type: 'warning'
})
const index = localFields.value.findIndex((f) => f.id === field.id)
if (index > -1) {
localFields.value.splice(index, 1)
if (selectedFieldId.value === field.id) {
selectedFieldId.value = null
selectedField.value = null
}
emit('update:modelValue', localFields.value)
ElMessage.success('删除成功')
}
} catch {
// 取消删除
}
}
/**
* 处理拖拽结束
*/
const handleDragEnd = () => {
emit('update:modelValue', localFields.value)
}
/**
* 添加选项
*/
const handleAddOption = () => {
if (!selectedField.value) return
if (!selectedField.value.options) {
selectedField.value.options = []
}
selectedField.value.options.push({
label: `选项${selectedField.value.options.length + 1}`,
value: `option_${selectedField.value.options.length + 1}`
})
}
/**
* 删除选项
*/
const handleDeleteOption = (index: number) => {
if (!selectedField.value || !selectedField.value.options) return
selectedField.value.options.splice(index, 1)
}
/**
* 保存配置
*/
const handleSaveConfig = () => {
if (!selectedField.value) return
const index = localFields.value.findIndex((f) => f.id === selectedField.value!.id)
if (index > -1) {
localFields.value[index] = { ...selectedField.value }
emit('update:modelValue', localFields.value)
ElMessage.success('保存成功')
}
}
/**
* 取消配置
*/
const handleCancelConfig = () => {
if (selectedFieldId.value) {
const field = localFields.value.find((f) => f.id === selectedFieldId.value)
if (field) {
selectedField.value = { ...field }
}
}
}
// 监听props变化
watch(
() => props.modelValue,
(value) => {
localFields.value = [...value]
},
{ deep: true }
)
</script>
<style scoped lang="scss">
.field-designer {
display: flex;
height: 100%;
gap: 20px;
.field-list-panel,
.field-config-panel {
flex: 1;
background: #fff;
border-radius: 4px;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 16px;
border-bottom: 1px solid var(--el-border-color);
h3 {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
}
}
.field-list {
flex: 1;
padding: 16px;
overflow-y: auto;
.field-item {
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 4px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: var(--el-color-primary);
}
&.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
.field-info {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
.drag-handle {
cursor: move;
}
.field-label {
flex: 1;
font-weight: 500;
}
.field-type-tag {
font-size: 12px;
}
}
.field-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
}
.field-config-form {
flex: 1;
padding: 16px;
overflow-y: auto;
.options-config {
.option-item {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
}
.form-actions {
margin-top: 24px;
text-align: right;
}
}
.empty-state {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<el-switch
:model-value="modelValue"
:disabled="isDisabled"
active-text=""
inactive-text=""
@update:model-value="handleChange"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: boolean
field: FieldConfig
disabled?: boolean
readonly?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const handleChange = (value: boolean) => {
emit('update:modelValue', value)
}
</script>
<style scoped lang="scss">
// 继承全局样式
</style>

View File

@@ -0,0 +1,67 @@
<template>
<el-date-picker
:model-value="modelValue"
type="date"
:placeholder="placeholder"
:disabled="isDisabled"
:readonly="readonly"
:clearable="clearable"
value-format="YYYY-MM-DD"
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: string
field: FieldConfig
disabled?: boolean
readonly?: boolean
clearable?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
clearable: true
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请选择${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const handleChange = (value: string) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
.el-date-picker {
width: 100%;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:disabled="isDisabled"
:clearable="clearable"
:filterable="filterable"
multiple
collapse-tags
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</el-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: any[]
field: FieldConfig
disabled?: boolean
readonly?: boolean
clearable?: boolean
filterable?: boolean
}
interface Emits {
(e: 'update:modelValue', value: any[]): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
clearable: true,
filterable: true
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请选择${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const options = computed(() => props.field.options || [])
const handleChange = (value: any[]) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
.el-select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<el-input-number
:model-value="modelValue"
:placeholder="placeholder"
:disabled="isDisabled"
:readonly="readonly"
:min="min"
:max="max"
:step="step"
:precision="precision"
:controls-position="controlsPosition"
controls
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: number
field: FieldConfig
disabled?: boolean
readonly?: boolean
step?: number
precision?: number
controlsPosition?: 'right' | ''
}
interface Emits {
(e: 'update:modelValue', value: number | undefined): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
step: 1,
precision: 0,
controlsPosition: ''
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请输入${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const min = computed(() => props.field.validationRules?.min)
const max = computed(() => props.field.validationRules?.max)
const handleChange = (value: number | undefined) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
.el-input-number {
width: 100%;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<el-select
:model-value="modelValue"
:placeholder="placeholder"
:disabled="isDisabled"
:clearable="clearable"
:filterable="filterable"
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
>
<el-option
v-for="option in options"
:key="option.value"
:label="option.label"
:value="option.value"
:disabled="option.disabled"
/>
</el-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: any
field: FieldConfig
disabled?: boolean
readonly?: boolean
clearable?: boolean
filterable?: boolean
}
interface Emits {
(e: 'update:modelValue', value: any): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
clearable: true,
filterable: true
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请选择${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const options = computed(() => props.field.options || [])
const handleChange = (value: any) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
.el-select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<el-input
:model-value="modelValue"
:placeholder="placeholder"
:disabled="isDisabled"
:readonly="readonly"
:clearable="clearable"
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: string | number
field: FieldConfig
disabled?: boolean
readonly?: boolean
clearable?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
clearable: true
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请输入${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
// TODO: 需要传入整个表单数据来计算
return false
}
return props.field.disabled || false
})
const handleChange = (value: string) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
// 继承全局样式
</style>

View File

@@ -0,0 +1,74 @@
<template>
<el-input
:model-value="modelValue"
type="textarea"
:placeholder="placeholder"
:disabled="isDisabled"
:readonly="readonly"
:rows="rows"
:maxlength="maxlength"
:show-word-limit="showWordLimit"
@update:model-value="handleChange"
@blur="handleBlur"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { FieldConfig } from '@/types/form'
interface Props {
modelValue: string
field: FieldConfig
disabled?: boolean
readonly?: boolean
rows?: number
maxlength?: number
showWordLimit?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'blur', event: FocusEvent): void
(e: 'focus', event: FocusEvent): void
}
const props = withDefaults(defineProps<Props>(), {
rows: 3,
showWordLimit: false
})
const emit = defineEmits<Emits>()
const placeholder = computed(() => props.field.placeholder || `请输入${props.field.label}`)
const isDisabled = computed(() => {
if (props.disabled) return true
if (typeof props.field.disabled === 'function') {
return false
}
return props.field.disabled || false
})
const maxlength = computed(() => props.field.validationRules?.max)
const showWordLimit = computed(() => {
return props.showWordLimit || !!maxlength.value
})
const handleChange = (value: string) => {
emit('update:modelValue', value)
}
const handleBlur = (event: FocusEvent) => {
emit('blur', event)
}
const handleFocus = (event: FocusEvent) => {
emit('focus', event)
}
</script>
<style scoped lang="scss">
// 继承全局样式
</style>

View File

@@ -0,0 +1,210 @@
<!--
统计卡片组件
展示关键指标趋势图标等
-->
<template>
<el-card
class="stat-card"
:class="{
'stat-card--clickable': clickable,
'stat-card--loading': loading,
}"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<!-- 骨架屏加载 -->
<el-skeleton :loading="loading" animated>
<template #template>
<el-skeleton-item variant="text" style="width: 40%; height: 16px; margin-bottom: 12px" />
<el-skeleton-item variant="text" style="width: 70%; height: 28px" />
</template>
<!-- 实际内容 -->
<template #default>
<!-- 头部标题和图标 -->
<div class="stat-card__header">
<span class="stat-card__title">{{ title }}</span>
<el-icon v-if="icon" class="stat-card__icon" :style="{ color: color }">
<component :is="icon" />
</el-icon>
</div>
<!-- 数值 -->
<div class="stat-card__value" :style="{ color: color }">
<span class="stat-card__value-number">{{ formattedValue }}</span>
<span v-if="unit" class="stat-card__value-unit">{{ unit }}</span>
</div>
<!-- 趋势 -->
<div v-if="trend && trendValue !== undefined" class="stat-card__trend">
<el-tag
:type="trendType"
size="small"
effect="plain"
:icon="trendIcon"
>
{{ trendText }}
</el-tag>
<span class="stat-card__trend-label">较上期</span>
</div>
</template>
</el-skeleton>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { TrendCharts, ArrowUp, ArrowDown, Minus } from '@element-plus/icons-vue'
import type { StatCardConfig } from '@/types/charts'
/** Props */
interface Props {
title: string
value: number | string
unit?: string
icon?: any
trend?: 'up' | 'down' | 'flat'
trendValue?: number
color?: string
loading?: boolean
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: '',
value: 0,
unit: '',
icon: undefined,
trend: undefined,
trendValue: undefined,
color: '#475569',
loading: false,
clickable: false,
})
/** Emits */
interface Emits {
(e: 'click'): void
}
const emit = defineEmits<Emits>()
/** 格式化数值 */
const formattedValue = computed(() => {
if (typeof props.value === 'string') {
return props.value
}
// 大数值格式化
if (props.value >= 100000000) {
return (props.value / 100000000).toFixed(2) + '亿'
}
if (props.value >= 10000) {
return (props.value / 10000).toFixed(2) + '万'
}
if (props.value >= 1000) {
return (props.value / 1000).toFixed(1) + 'K'
}
return props.value.toLocaleString()
})
/** 趋势类型 */
const trendType = computed(() => {
if (props.trend === 'up') return 'success'
if (props.trend === 'down') return 'danger'
return 'info'
})
/** 趋势图标 */
const trendIcon = computed(() => {
if (props.trend === 'up') return ArrowUp
if (props.trend === 'down') return ArrowDown
return Minus
})
/** 趋势文本 */
const trendText = computed(() => {
if (props.trendValue === undefined) return ''
const sign = props.trend === 'up' ? '+' : props.trend === 'down' ? '-' : ''
return `${sign}${Math.abs(props.trendValue).toFixed(1)}%`
})
/** 处理点击 */
const handleClick = () => {
if (props.clickable) {
emit('click')
}
}
</script>
<style scoped lang="scss">
.stat-card {
height: 100%;
transition: all 0.3s ease;
&--clickable {
cursor: pointer;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
&--loading {
pointer-events: none;
}
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
&__title {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
&__icon {
font-size: 24px;
opacity: 0.8;
}
&__value {
display: flex;
align-items: baseline;
margin-bottom: 12px;
gap: 4px;
}
&__value-number {
font-size: 28px;
font-weight: 600;
line-height: 1;
}
&__value-unit {
font-size: 14px;
color: #64748b;
margin-left: 4px;
}
&__trend {
display: flex;
align-items: center;
gap: 8px;
}
&__trend-label {
font-size: 12px;
color: #94a3b8;
}
}
</style>

View File

@@ -0,0 +1,78 @@
<!--
统计卡片组组件
多个统计卡片的组合展示
-->
<template>
<el-row :gutter="16" class="stat-card-group">
<el-col
v-for="(item, index) in items"
:key="index"
:xs="24"
:sm="12"
:md="8"
:lg="6"
:xl="colWidth"
>
<StatCard
:title="item.title"
:value="item.value"
:unit="item.unit"
:icon="item.icon"
:trend="item.trend"
:trend-value="item.trendValue"
:color="item.color"
:loading="item.loading"
:clickable="item.clickable"
@click="() => handleCardClick(item, index)"
/>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import StatCard from './StatCard.vue'
import type { StatCardConfig } from '@/types/charts'
/** Props */
interface Props {
items: StatCardConfig[]
colWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
items: () => [],
colWidth: 6,
})
/** Emits */
interface Emits {
(e: 'cardClick', item: StatCardConfig, index: number): void
}
const emit = defineEmits<Emits>()
/** 处理卡片点击 */
const handleCardClick = (item: StatCardConfig, index: number) => {
if (item.clickable && item.onClick) {
item.onClick()
}
emit('cardClick', item, index)
}
/** 默认插槽 */
defineSlots<{
default?: () => void
}>()
</script>
<style scoped lang="scss">
.stat-card-group {
width: 100%;
:deep(.el-col) {
margin-bottom: 16px;
}
}
</style>

View File

@@ -0,0 +1,6 @@
/**
* 统计组件统一导出
*/
export { default as StatCard } from './StatCard.vue'
export { default as StatCardGroup } from './StatCardGroup.vue'

View File

@@ -0,0 +1,223 @@
/**
* useChartData Composable
*
* 封装图表数据的加载、转换、缓存等操作
*/
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
/**
* 使用图表数据的 Composable
*
* @param apiMethod 数据加载方法
*/
export function useChartData<T = any>(apiMethod?: (params?: any) => Promise<T>) {
const data = ref<T>(null as unknown as T)
const loading = ref(false)
const error = ref<Error | null>(null)
const cache = ref<Map<string, { data: T; timestamp: number }>>(new Map())
const cacheExpiry = ref(5 * 60 * 1000) // 默认缓存5分钟
/**
* 加载数据
*/
const loadData = async (params?: any, options?: {
useCache?: boolean
cacheKey?: string
showLoading?: boolean
}) => {
if (!apiMethod) {
console.warn('[useChartData] No API method provided')
return null
}
const {
useCache: shouldUseCache = true,
cacheKey: customCacheKey,
showLoading: shouldShowLoading = true,
} = options || {}
// 生成缓存键
const cacheKey = customCacheKey || JSON.stringify(params || {})
// 检查缓存
if (shouldUseCache) {
const cached = cache.value.get(cacheKey)
if (cached && Date.now() - cached.timestamp < cacheExpiry.value) {
console.log('[useChartData] Using cached data:', cacheKey)
data.value = cached.data
return cached.data
}
}
// 加载数据
if (shouldShowLoading) {
loading.value = true
}
error.value = null
try {
const result = await apiMethod(params)
data.value = result
// 更新缓存
if (shouldUseCache) {
cache.value.set(cacheKey, {
data: result,
timestamp: Date.now(),
})
}
console.log('[useChartData] Data loaded successfully')
return result
} catch (err) {
const errorObj = err instanceof Error ? err : new Error(String(err))
error.value = errorObj
console.error('[useChartData] Failed to load data:', errorObj)
throw errorObj
} finally {
if (shouldShowLoading) {
loading.value = false
}
}
}
/**
* 刷新数据
*/
const refresh = (params?: any) => {
return loadData(params, { useCache: false })
}
/**
* 清除缓存
*/
const clearCache = (cacheKey?: string) => {
if (cacheKey) {
cache.value.delete(cacheKey)
console.log('[useChartData] Cache cleared for key:', cacheKey)
} else {
cache.value.clear()
console.log('[useChartData] All cache cleared')
}
}
/**
* 设置缓存过期时间
*/
const setCacheExpiry = (milliseconds: number) => {
cacheExpiry.value = milliseconds
}
/**
* 重置状态
*/
const reset = () => {
data.value = null as unknown as T
loading.value = false
error.value = null
}
/**
* 转换数据为图表格式
*/
const transformToChartData = (
rawData: any[],
config: {
nameKey?: string
valueKey?: string
filter?: (item: any) => boolean
sort?: 'asc' | 'desc' | ((a: any, b: any) => number)
} = {}
) => {
const {
nameKey = 'name',
valueKey = 'value',
filter,
sort,
} = config
let result = rawData.map(item => ({
name: item[nameKey],
value: item[valueKey],
...item,
}))
// 过滤
if (filter) {
result = result.filter(filter)
}
// 排序
if (sort === 'asc') {
result.sort((a, b) => a.value - b.value)
} else if (sort === 'desc') {
result.sort((a, b) => b.value - a.value)
} else if (typeof sort === 'function') {
result.sort(sort)
}
return result
}
/**
* 计算百分比
*/
const calculatePercentages = (
items: Array<{ value: number }>
) => {
const total = items.reduce((sum, item) => sum + item.value, 0)
return items.map(item => ({
...item,
percentage: total > 0 ? (item.value / total) * 100 : 0,
}))
}
/**
* 分组聚合
*/
const groupBy = (
items: any[],
key: string,
aggregate: 'sum' | 'count' | 'avg' = 'count'
) => {
const groups = new Map<string, number[]>()
items.forEach(item => {
const groupKey = item[key]
if (!groups.has(groupKey)) {
groups.set(groupKey, [])
}
groups.get(groupKey)!.push(item.value || 1)
})
return Array.from(groups.entries()).map(([name, values]) => {
let value: number
if (aggregate === 'sum') {
value = values.reduce((sum, v) => sum + v, 0)
} else if (aggregate === 'avg') {
value = values.reduce((sum, v) => sum + v, 0) / values.length
} else {
value = values.length
}
return { name, value }
})
}
return {
data,
loading,
error,
isLoaded: computed(() => data.value !== null),
hasError: computed(() => error.value !== null),
loadData,
refresh,
clearCache,
setCacheExpiry,
reset,
transformToChartData,
calculatePercentages,
groupBy,
}
}

View File

@@ -0,0 +1,245 @@
/**
* 动态表单Composable
* 提供表单数据管理和验证功能
*/
import { ref, computed, reactive } from 'vue'
import type { FieldConfig, FormData, FormValidationState, FormActions } from '@/types/form'
import { validateFields } from '@/utils/fieldValidator'
/**
* 动态表单Hook
* @param fields 字段配置列表
*/
export function useDynamicForm(fields: FieldConfig[]) {
// 表单数据
const formData = ref<FormData>({})
// 验证错误
const validationErrors = ref<Record<string, string[]>>({})
// 是否已修改
const isDirty = ref(false)
// 是否正在提交
const isSubmitting = ref(false)
// 初始数据(用于重置)
const initialData = ref<FormData>({})
/**
* 表单是否有效
*/
const isValid = computed(() => {
return Object.keys(validationErrors.value).length === 0
})
/**
* 设置字段值
*/
const setFieldValue = (fieldName: string, value: any) => {
formData.value = {
...formData.value,
[fieldName]: value
}
isDirty.value = true
}
/**
* 批量设置字段值
*/
const setFieldValues = (values: Record<string, any>) => {
formData.value = {
...formData.value,
...values
}
isDirty.value = true
}
/**
* 获取字段值
*/
const getFieldValue = (fieldName: string) => {
return formData.value[fieldName]
}
/**
* 验证单个字段
*/
const validateField = async (fieldName: string): Promise<boolean> => {
const field = fields.find((f) => f.name === fieldName)
if (!field) return false
const value = formData.value[fieldName]
const result = validateField(value, field, formData.value)
if (!result.isValid) {
validationErrors.value = {
...validationErrors.value,
[fieldName]: result.errors
}
} else {
const newErrors = { ...validationErrors.value }
delete newErrors[fieldName]
validationErrors.value = newErrors
}
return result.isValid
}
/**
* 验证所有字段
*/
const validateAll = async (): Promise<boolean> => {
const errors = validateFields(formData.value, fields)
validationErrors.value = errors
return Object.keys(errors).length === 0
}
/**
* 清除验证错误
*/
const clearValidation = () => {
validationErrors.value = {}
}
/**
* 清除单个字段的验证错误
*/
const clearFieldValidation = (fieldName: string) => {
const newErrors = { ...validationErrors.value }
delete newErrors[fieldName]
validationErrors.value = newErrors
}
/**
* 重置表单
*/
const resetForm = () => {
formData.value = { ...initialData.value }
validationErrors.value = {}
isDirty.value = false
}
/**
* 设置表单数据
*/
const setFormData = (data: FormData) => {
formData.value = { ...data }
initialData.value = { ...data }
isDirty.value = false
}
/**
* 获取表单数据
*/
const getFormData = (): FormData => {
return { ...formData.value }
}
/**
* 获取提交数据(只返回有值的字段)
*/
const getSubmitData = (): FormData => {
const submitData: FormData = {}
Object.keys(formData.value).forEach((key) => {
if (formData.value[key] !== undefined && formData.value[key] !== null && formData.value[key] !== '') {
submitData[key] = formData.value[key]
}
})
return submitData
}
/**
* 初始化默认值
*/
const initDefaultValues = () => {
const defaultData: FormData = {}
fields.forEach((field) => {
if (field.defaultValue !== undefined) {
defaultData[field.name] = field.defaultValue
}
})
formData.value = defaultData
initialData.value = { ...defaultData }
}
/**
* 提交表单
*/
const submitForm = async (handleSubmit: (data: FormData) => Promise<void> | void) => {
// 验证表单
const valid = await validateAll()
if (!valid) {
throw new Error('表单验证失败')
}
isSubmitting.value = true
try {
await handleSubmit(getSubmitData())
} finally {
isSubmitting.value = false
}
}
// 初始化默认值
initDefaultValues()
return {
// 状态
formData,
validationErrors,
isValid,
isDirty,
isSubmitting,
// 方法
setFieldValue,
setFieldValues,
getFieldValue,
validateField,
validateAll,
clearValidation,
clearFieldValidation,
resetForm,
setFormData,
getFormData,
getSubmitData,
submitForm
}
}
/**
* 动态表单状态管理Hook带Pinia集成
* @param formId 表单唯一标识
*/
export function useFormState(formId: string) {
const formStates = reactive<Map<string, FormData>>(new Map())
/**
* 保存表单状态
*/
const saveState = (data: FormData) => {
formStates.set(formId, { ...data })
}
/**
* 获取表单状态
*/
const getState = (): FormData | undefined => {
return formStates.get(formId)
}
/**
* 清除表单状态
*/
const clearState = () => {
formStates.delete(formId)
}
return {
saveState,
getState,
clearState
}
}

View File

@@ -0,0 +1,210 @@
/**
* useECharts Composable
*
* 封装 ECharts 初始化、更新、销毁等操作
*/
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import type { EChartOption, ECharts } from 'echarts'
import { resizeChart } from '@/utils/echarts'
/**
* 使用 ECharts 的 Composable
*
* @param chartRef 图表容器引用
* @param theme 图表主题
*/
export function useECharts(
chartRef: Ref<HTMLElement | null>,
theme?: string | object
) {
const chart = ref<ECharts | null>(null)
const loading = ref(false)
const isReady = ref(false)
/**
* 初始化图表
*/
const initChart = () => {
if (!chartRef.value) {
console.warn('[useECharts] Chart container not found')
return
}
try {
// 销毁已存在的图表实例
if (chart.value) {
chart.value.dispose()
}
// 创建新实例
chart.value = echarts.init(chartRef.value, theme)
isReady.value = true
console.log('[useECharts] Chart initialized successfully')
} catch (error) {
console.error('[useECharts] Failed to initialize chart:', error)
isReady.value = false
}
}
/**
* 设置图表配置
*/
const setOption = (option: EChartOption, notMerge?: boolean, lazyUpdate?: boolean) => {
if (!chart.value || !isReady.value) {
console.warn('[useECharts] Chart not ready, skipping setOption')
return
}
try {
chart.value.setOption(option, notMerge, lazyUpdate)
console.log('[useECharts] Option set successfully')
} catch (error) {
console.error('[useECharts] Failed to set option:', error)
}
}
/**
* 显示加载动画
*/
const showLoading = (config?: {
text?: string
color?: string
textColor?: string
maskColor?: string
}) => {
if (!chart.value) return
loading.value = true
chart.value.showLoading('default', {
text: config?.text || '加载中...',
color: config?.color || '#475569',
textColor: config?.textColor || '#1e293b',
maskColor: config?.maskColor || 'rgba(255, 255, 255, 0.8)',
zlevel: 0,
})
}
/**
* 隐藏加载动画
*/
const hideLoading = () => {
if (!chart.value) return
loading.value = false
chart.value.hideLoading()
}
/**
* 调整图表尺寸
*/
const resize = (delay?: number) => {
if (!chart.value) return
resizeChart(chart.value, delay)
}
/**
* 销毁图表
*/
const dispose = () => {
if (chart.value) {
chart.value.dispose()
chart.value = null
isReady.value = false
console.log('[useECharts] Chart disposed')
}
}
/**
* 清空图表
*/
const clear = () => {
if (chart.value) {
chart.value.clear()
console.log('[useECharts] Chart cleared')
}
}
/**
* 获取图表实例
*/
const getInstance = () => {
return chart.value
}
/**
* 绑定图表事件
*/
const on = (eventName: string, handler: Function) => {
if (!chart.value) return
chart.value.on(eventName, handler)
}
/**
* 解绑图表事件
*/
const off = (eventName: string, handler?: Function) => {
if (!chart.value) return
chart.value.off(eventName, handler)
}
/**
* 导出图表图片
*/
const getDataURL = (opts?: {
type?: 'png' | 'jpeg' | 'svg'
pixelRatio?: number
backgroundColor?: string
}) => {
if (!chart.value) return null
return chart.value.getDataURL({
type: opts?.type || 'png',
pixelRatio: opts?.pixelRatio || 1,
backgroundColor: opts?.backgroundColor || '#fff',
})
}
/**
* 监听窗口大小变化
*/
const handleResize = () => {
resize()
}
// 初始化
onMounted(() => {
nextTick(() => {
initChart()
window.addEventListener('resize', handleResize)
})
})
// 清理
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
dispose()
})
return {
chart,
loading,
isReady,
initChart,
setOption,
showLoading,
hideLoading,
resize,
dispose,
clear,
getInstance,
on,
off,
getDataURL,
}
}

View File

@@ -0,0 +1,179 @@
/**
* 字段配置管理Composable
* 用于加载和缓存设备类型的字段配置
*/
import { ref } from 'vue'
import type { FieldConfig } from '@/types/form'
import request from '@/utils/request'
/**
* 字段配置管理Hook
*/
export function useFieldConfig() {
// 字段配置缓存
const fieldConfigs = ref<Record<string | number, FieldConfig[]>>({})
// 加载状态
const loading = ref(false)
// 错误信息
const error = ref<string | null>(null)
/**
* 从API加载字段配置
* @param deviceTypeId 设备类型ID
*/
const loadFieldConfig = async (deviceTypeId: string | number): Promise<FieldConfig[]> => {
// 检查缓存
const cached = getCachedFieldConfig(deviceTypeId)
if (cached) {
return cached
}
loading.value = true
error.value = null
try {
// 调用API获取字段配置
const response = await request({
url: `/device-types/${deviceTypeId}/fields`,
method: 'get'
})
// 转换API字段配置为前端字段配置
const fields = transformApiFieldsToFieldConfigs(response)
// 缓存字段配置
fieldConfigs.value[deviceTypeId] = fields
return fields
} catch (err: any) {
error.value = err.message || '加载字段配置失败'
console.error('加载字段配置失败:', err)
throw err
} finally {
loading.value = false
}
}
/**
* 从缓存获取字段配置
* @param deviceTypeId 设备类型ID
*/
const getCachedFieldConfig = (deviceTypeId: string | number): FieldConfig[] | null => {
return fieldConfigs.value[deviceTypeId] || null
}
/**
* 批量加载字段配置
* @param deviceTypeIds 设备类型ID列表
*/
const loadFieldConfigs = async (deviceTypeIds: Array<string | number>): Promise<void> => {
const promises = deviceTypeIds.map((id) => loadFieldConfig(id))
await Promise.all(promises)
}
/**
* 清除缓存
* @param deviceTypeId 设备类型ID不传则清除所有缓存
*/
const clearCache = (deviceTypeId?: string | number) => {
if (deviceTypeId) {
delete fieldConfigs.value[deviceTypeId]
} else {
fieldConfigs.value = {}
}
}
/**
* 设置字段配置(用于手动设置)
* @param deviceTypeId 设备类型ID
* @param fields 字段配置
*/
const setFieldConfig = (deviceTypeId: string | number, fields: FieldConfig[]) => {
fieldConfigs.value[deviceTypeId] = fields
}
/**
* 转换API字段配置为前端字段配置
*/
const transformApiFieldsToFieldConfigs = (apiFields: any[]): FieldConfig[] => {
return apiFields.map((apiField) => ({
id: String(apiField.id),
name: apiField.fieldCode,
label: apiField.fieldName,
fieldType: transformFieldType(apiField.fieldType),
required: apiField.isRequired || false,
placeholder: apiField.placeholder,
options: apiField.options || [],
validationRules: transformValidationRules(apiField.validationRules),
defaultValue: apiField.defaultValue,
span: 24, // 默认占满一行
description: apiField.description
}))
}
/**
* 转换字段类型
*/
const transformFieldType = (apiType: string): FieldConfig['fieldType'] => {
const typeMap: Record<string, FieldConfig['fieldType']> = {
text: 'text',
textarea: 'textarea',
number: 'number',
date: 'date',
select: 'select',
multiselect: 'multiselect',
checkbox: 'boolean',
boolean: 'boolean',
url: 'url',
email: 'email',
phone: 'phone'
}
return typeMap[apiType] || 'text'
}
/**
* 转换验证规则
*/
const transformValidationRules = (apiRules?: any) => {
if (!apiRules) return undefined
return {
min: apiRules.min_length || apiRules.min,
max: apiRules.max_length || apiRules.max,
pattern: apiRules.pattern
}
}
return {
// 状态
fieldConfigs,
loading,
error,
// 方法
loadFieldConfig,
loadFieldConfigs,
getCachedFieldConfig,
setFieldConfig,
clearCache
}
}
/**
* 全局字段配置管理单例
*/
let globalFieldConfigInstance: ReturnType<typeof useFieldConfig> | null = null
/**
* 获取全局字段配置管理实例
*/
export function getGlobalFieldConfigManager() {
if (!globalFieldConfigInstance) {
globalFieldConfigInstance = useFieldConfig()
}
return globalFieldConfigInstance
}

View File

@@ -0,0 +1,38 @@
/**
* 分页组合式函数
*/
import { ref, reactive, computed } from 'vue'
export function usePagination(defaultPageSize = 20) {
const currentPage = ref(1)
const pageSize = ref(defaultPageSize)
const total = ref(0)
const pagination = reactive({
page: currentPage,
pageSize: pageSize,
total: total
})
const totalPages = computed(() => {
return Math.ceil(total.value / pageSize.value)
})
const resetPage = () => {
currentPage.value = 1
}
const setTotal = (count: number) => {
total.value = count
}
return {
currentPage,
pageSize,
total,
pagination,
totalPages,
resetPage,
setTotal
}
}

View File

@@ -0,0 +1,88 @@
/**
* 表格组合式函数
*/
import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
export function useTable(fetchApi: any) {
const loading = ref(false)
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const filters = reactive<Record<string, any>>({})
/**
* 获取数据
*/
const fetchData = async (params: any = {}) => {
loading.value = true
try {
const data = await fetchApi({
...filters,
...params
})
tableData.value = data.items || []
return data
} catch (error) {
ElMessage.error('获取数据失败')
throw error
} finally {
loading.value = false
}
}
/**
* 搜索
*/
const handleSearch = () => {
return fetchData({ page: 1 })
}
/**
* 重置
*/
const handleReset = () => {
Object.keys(filters).forEach(key => {
filters[key] = undefined
})
return fetchData({ page: 1 })
}
/**
* 选择变化
*/
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
/**
* 删除确认
*/
const handleDeleteConfirm = async (callback: () => Promise<void>) => {
try {
await ElMessageBox.confirm('确定要删除吗?', '提示', {
type: 'warning'
})
await callback()
ElMessage.success('删除成功')
return true
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
return false
}
}
return {
loading,
tableData,
selectedRows,
filters,
fetchData,
handleSearch,
handleReset,
handleSelectionChange,
handleDeleteConfirm
}
}

13
src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" type="image/svg+xml" href="/vite.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>资产管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

314
src/layouts/MainLayout.vue Normal file
View File

@@ -0,0 +1,314 @@
<template>
<el-container class="main-layout">
<!-- 侧边菜单 -->
<el-aside :width="sidebarWidth" class="sidebar">
<div class="logo">
<el-icon :size="32" color="#ffffff"><Box /></el-icon>
<span v-show="!appStore.sidebarCollapsed">资产管理系统</span>
</div>
<el-menu
:default-active="activeMenu"
:collapse="appStore.sidebarCollapsed"
:unique-opened="true"
router
class="sidebar-menu"
>
<template v-for="route in menuRoutes" :key="route.path">
<el-sub-menu v-if="route.children && route.children.length > 0" :index="route.path">
<template #title>
<el-icon v-if="route.meta?.icon"><component :is="route.meta.icon" /></el-icon>
<span>{{ route.meta?.title }}</span>
</template>
<el-menu-item
v-for="child in route.children"
:key="child.path"
:index="route.path + '/' + child.path"
>
<el-icon v-if="child.meta?.icon"><component :is="child.meta.icon" /></el-icon>
<span>{{ child.meta?.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="route.path">
<el-icon v-if="route.meta?.icon"><component :is="route.meta.icon" /></el-icon>
<span>{{ route.meta?.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航栏 -->
<el-header class="header">
<div class="header-left">
<el-icon
:size="20"
class="collapse-icon"
@click="appStore.toggleSidebar"
>
<Fold v-if="!appStore.sidebarCollapsed" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="item in breadcrumbs"
:key="item.path"
:to="{ path: item.path }"
>
{{ item.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-input
v-model="searchKeyword"
placeholder="搜索资产..."
prefix-icon="Search"
style="width: 300px; margin-right: 20px"
clearable
@keyup.enter="handleSearch"
/>
<el-badge :value="appStore.unreadCount" :hidden="appStore.unreadCount === 0">
<el-button :icon="Bell" circle @click="showNotifications" />
</el-badge>
<el-dropdown @command="handleUserCommand">
<span class="user-info">
<el-avatar :size="32" :src="userStore.userAvatar" />
<span class="username">{{ userStore.userName }}</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
个人中心
</el-dropdown-item>
<el-dropdown-item command="password">
<el-icon><Lock /></el-icon>
修改密码
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<!-- 主要内容 -->
<el-main class="main-content">
<router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore, useAppStore } from '@/stores'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const appStore = useAppStore()
const searchKeyword = ref('')
// 侧边栏宽度
const sidebarWidth = computed(() => {
return appStore.sidebarCollapsed ? '64px' : '200px'
})
// 当前激活的菜单
const activeMenu = computed(() => {
return route.path
})
// 菜单路由
const menuRoutes = computed(() => {
const routes = router.getRoutes()
return routes.filter(
route => route.path !== '/' && route.path !== '/login' && !route.meta?.hidden
)
})
// 面包屑
const breadcrumbs = computed(() => {
const matched = route.matched.filter(item => item.meta?.title)
return matched.map(item => ({
path: item.path,
title: item.meta?.title as string
}))
})
// 搜索
const handleSearch = () => {
if (searchKeyword.value) {
router.push({
path: '/assets/list',
query: { keyword: searchKeyword.value }
})
}
}
// 显示通知
const showNotifications = () => {
ElMessage.info('暂无新消息')
}
// 用户操作
const handleUserCommand = async (command: string) => {
switch (command) {
case 'profile':
router.push('/profile')
break
case 'password':
// 打开修改密码对话框
ElMessage.info('修改密码功能开发中')
break
case 'logout':
await userStore.logout()
router.push('/login')
ElMessage.success('退出成功')
break
}
}
</script>
<style scoped lang="scss">
.main-layout {
height: 100vh;
}
.sidebar {
background: $sidebar-bg;
transition: width 0.3s;
overflow: hidden;
.logo {
height: $header-height;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
color: #ffffff;
font-size: 18px;
font-weight: bold;
border-bottom: 1px solid #334155;
transition: all 0.3s;
}
.sidebar-menu {
border-right: none;
background: $sidebar-bg;
height: calc(100vh - #{$header-height});
overflow-y: auto;
overflow-x: hidden;
&:not(.el-menu--collapse) {
width: $sidebar-width;
}
.el-menu-item,
.el-sub-menu__title {
color: $sidebar-text-color;
&:hover {
background-color: rgba(255, 255, 255, 0.05);
}
}
.el-menu-item.is-active {
background-color: $sidebar-active-bg;
color: $sidebar-active-text;
}
.el-sub-menu.is-active > .el-sub-menu__title {
color: $sidebar-active-text;
}
}
}
.header {
background: $header-bg;
border-bottom: 1px solid $header-border;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
.header-left {
display: flex;
align-items: center;
gap: 20px;
.collapse-icon {
cursor: pointer;
color: $text-secondary;
transition: $transition-base;
&:hover {
color: $text-primary;
}
}
}
.header-right {
display: flex;
align-items: center;
.user-info {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
margin-left: 20px;
padding: 5px 10px;
border-radius: $border-radius-base;
transition: $transition-base;
&:hover {
background-color: $bg-color-page;
}
.username {
font-size: 14px;
color: $text-regular;
}
}
}
}
.main-content {
background: $bg-color;
padding: 20px;
overflow-y: auto;
}
// 路由切换动画
.fade-transform-enter-active,
.fade-transform-leave-active {
transition: all 0.3s;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateX(30px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateX(-30px);
}
</style>

26
src/main.ts Normal file
View File

@@ -0,0 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import '@/assets/styles/index.scss'
const app = createApp(App)
// 注册 Element Plus 图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, {
locale: zhCn
})
app.mount('#app')

278
src/router/index.ts Normal file
View File

@@ -0,0 +1,278 @@
/**
* 路由配置
*/
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: {
title: '登录',
hidden: true
}
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/assets/list',
children: [
// 后台管理
{
path: '/admin',
name: 'Admin',
redirect: '/admin/users',
meta: {
title: '后台管理',
icon: 'Setting'
},
children: [
{
path: 'users',
name: 'UserManagement',
component: () => import('@/views/admin/UserManagement.vue'),
meta: {
title: '用户管理',
icon: 'User'
}
},
{
path: 'roles',
name: 'RoleManagement',
component: () => import('@/views/admin/RoleManagement.vue'),
meta: {
title: '角色权限',
icon: 'Lock'
}
},
{
path: 'device-types',
name: 'DeviceTypeManagement',
component: () => import('@/views/admin/DeviceTypeManagement.vue'),
meta: {
title: '设备类型',
icon: 'Grid'
}
},
{
path: 'organizations',
name: 'OrganizationManagement',
component: () => import('@/views/admin/OrganizationManagement.vue'),
meta: {
title: '机构网点',
icon: 'OfficeBuilding'
}
}
]
},
// 资产管理
{
path: '/assets',
name: 'Assets',
redirect: '/assets/list',
meta: {
title: '资产管理',
icon: 'Box'
},
children: [
{
path: 'list',
name: 'AssetList',
component: () => import('@/views/assets/AssetList.vue'),
meta: {
title: '资产列表',
icon: 'List'
}
},
{
path: 'create',
name: 'AssetCreate',
component: () => import('@/views/assets/AssetCreate.vue'),
meta: {
title: '资产入库',
icon: 'Plus'
}
},
{
path: 'allocation',
name: 'AssetAllocation',
component: () => import('@/views/assets/AssetAllocation.vue'),
meta: {
title: '资产分配',
icon: 'Share'
}
},
{
path: 'scan',
name: 'AssetScan',
component: () => import('@/views/assets/AssetScan.vue'),
meta: {
title: '扫码查询',
icon: 'Camera'
}
},
{
path: 'maintenance',
name: 'MaintenanceManagement',
component: () => import('@/views/assets/MaintenanceManagement.vue'),
meta: {
title: '维修管理',
icon: 'Tools'
}
},
{
path: 'statistics',
name: 'StatisticsDashboard',
component: () => import('@/views/assets/StatisticsDashboard.vue'),
meta: {
title: '统计报表',
icon: 'DataAnalysis'
}
}
]
},
// 调拨管理
{
path: '/allocation',
name: 'Allocation',
redirect: '/allocation/transfers',
meta: {
title: '调拨管理',
icon: 'Sort'
},
children: [
{
path: 'transfers',
name: 'TransferList',
component: () => import('@/views/allocation/TransferList.vue'),
meta: {
title: '资产调拨',
icon: 'ArrowRight'
}
},
{
path: 'recoveries',
name: 'RecoveryList',
component: () => import('@/views/allocation/RecoveryList.vue'),
meta: {
title: '资产回收',
icon: 'ArrowLeft'
}
}
]
},
// 系统管理
{
path: '/system',
name: 'System',
redirect: '/system/config',
meta: {
title: '系统管理',
icon: 'Setting'
},
children: [
{
path: 'config',
name: 'SystemConfig',
component: () => import('@/views/system/SystemConfig.vue'),
meta: {
title: '系统配置',
icon: 'Tools'
}
},
{
path: 'logs',
name: 'OperationLog',
component: () => import('@/views/system/OperationLog.vue'),
meta: {
title: '操作日志',
icon: 'Document'
}
},
{
path: 'notification',
name: 'NotificationCenter',
component: () => import('@/views/system/NotificationCenter.vue'),
meta: {
title: '消息通知',
icon: 'Bell'
}
}
]
},
// 示例页面
{
path: '/examples',
name: 'Examples',
redirect: '/examples/charts',
meta: {
title: '示例',
icon: 'Document'
},
children: [
{
path: 'charts',
name: 'ChartsExample',
component: () => import('@/views/examples/ChartsExample.vue'),
meta: {
title: '图表示例',
icon: 'PieChart'
}
}
]
}
]
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('@/views/error/404.vue'),
meta: {
title: '404',
hidden: true
}
}
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
return { top: 0 }
}
})
// 路由守卫
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
// 设置页面标题
document.title = `${to.meta.title || '资产管理系统'} - 资产管理系统`
// 不需要登录的页面
const whiteList = ['/login']
if (userStore.isLoggedIn) {
// 已登录
if (to.path === '/login') {
next({ path: '/' })
} else {
next()
}
} else {
// 未登录,直接跳转到登录页,不显示任何提示
if (whiteList.includes(to.path)) {
next()
} else {
next({ path: '/login', query: { redirect: to.fullPath } })
}
}
})
router.afterEach(() => {
// 路由跳转后的处理
})
export default router

5
src/stores/index.ts Normal file
View File

@@ -0,0 +1,5 @@
/**
* Store 统一导出
*/
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'

68
src/stores/modules/app.ts Normal file
View File

@@ -0,0 +1,68 @@
/**
* 应用状态管理
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAppStore = defineStore('app', () => {
// 侧边栏状态
const sidebarCollapsed = ref(false)
// 设备类型
const deviceTypes = ref<any[]>([])
// 网点树
const organizationTree = ref<any[]>([])
// 未读消息数
const unreadCount = ref(0)
/**
* 切换侧边栏
*/
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value
}
/**
* 设置侧边栏状态
*/
const setSidebarCollapsed = (collapsed: boolean) => {
sidebarCollapsed.value = collapsed
}
/**
* 设置设备类型
*/
const setDeviceTypes = (types: any[]) => {
deviceTypes.value = types
}
/**
* 设置网点树
*/
const setOrganizationTree = (tree: any[]) => {
organizationTree.value = tree
}
/**
* 设置未读消息数
*/
const setUnreadCount = (count: number) => {
unreadCount.value = count
}
return {
// 状态
sidebarCollapsed,
deviceTypes,
organizationTree,
unreadCount,
// 方法
toggleSidebar,
setSidebarCollapsed,
setDeviceTypes,
setOrganizationTree,
setUnreadCount
}
})

View File

@@ -0,0 +1,97 @@
/**
* 用户状态管理
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { UserInfo } from '@/types'
import { login as loginApi, logout as logoutApi } from '@/api'
import { getToken, setToken, removeToken } from '@/utils/auth'
export const useUserStore = defineStore('user', () => {
// 状态
const token = ref<string>(getToken() || '')
const userInfo = ref<UserInfo | null>(null)
// 计算属性
const isLoggedIn = computed(() => !!token.value)
const userName = computed(() => userInfo.value?.realName || '')
const userAvatar = computed(() => userInfo.value?.avatar || '')
const permissions = computed(() => userInfo.value?.permissions || [])
const roles = computed(() => userInfo.value?.roles || [])
/**
* 登录
*/
const login = async (username: string, password: string, captcha: string, captchaKey: string) => {
const data = await loginApi({
username,
password,
captcha,
captcha_key: captchaKey
})
token.value = data.access_token
userInfo.value = data.user
setToken(data.access_token)
return data
}
/**
* 登出
*/
const logout = async () => {
try {
await logoutApi()
} finally {
token.value = ''
userInfo.value = null
removeToken()
}
}
/**
* 设置用户信息
*/
const setUserInfo = (info: UserInfo) => {
userInfo.value = info
}
/**
* 检查权限
*/
const hasPermission = (permission: string) => {
if (userInfo.value?.isAdmin) {
return true
}
return permissions.value.includes(permission)
}
/**
* 检查角色
*/
const hasRole = (roleCode: string) => {
if (userInfo.value?.isAdmin) {
return true
}
return roles.value.some(role => role.roleCode === roleCode)
}
return {
// 状态
token,
userInfo,
// 计算属性
isLoggedIn,
userName,
userAvatar,
permissions,
roles,
// 方法
login,
logout,
setUserInfo,
hasPermission,
hasRole
}
})

193
src/types/charts.ts Normal file
View File

@@ -0,0 +1,193 @@
/**
* 图表相关类型定义
*/
import type { EChartOption } from 'echarts'
/** 图表数据项 */
export interface ChartDataItem {
name: string
value: number
[key: string]: any
}
/** 图表系列数据 */
export interface ChartSeries {
name: string
data: number[]
color?: string
[key: string]: any
}
/** 饼图配置 */
export interface PieChartConfig {
data: ChartDataItem[]
title?: string
type?: 'pie' | 'doughnut'
showLegend?: boolean
showLabel?: boolean
height?: string
onClick?: (item: ChartDataItem) => void
}
/** 柱状图配置 */
export interface BarChartConfig {
data: ChartDataItem[]
title?: string
type?: 'vertical' | 'horizontal'
stacked?: boolean
grouped?: boolean
xAxisLabel?: string
yAxisLabel?: string
height?: string
onClick?: (item: ChartDataItem) => void
}
/** 折线图配置 */
export interface LineChartConfig {
data: ChartDataItem[]
series?: ChartSeries[]
title?: string
area?: boolean
smooth?: boolean
xAxisLabel?: string
yAxisLabel?: string
height?: string
onClick?: (item: ChartDataItem) => void
}
/** 仪表盘配置 */
export interface GaugeChartConfig {
value: number
min?: number
max?: number
title?: string
unit?: string
height?: string
color?: string[]
}
/** 漏斗图配置 */
export interface FunnelChartConfig {
data: ChartDataItem[]
title?: string
height?: string
onClick?: (item: ChartDataItem) => void
}
/** 统计卡片配置 */
export interface StatCardConfig {
title: string
value: number | string
unit?: string
icon?: string
trend?: 'up' | 'down' | 'flat'
trendValue?: number
color?: string
loading?: boolean
clickable?: boolean
onClick?: () => void
}
/** 图表主题 */
export type ChartTheme = 'default' | 'dark' | 'custom'
/** 图表尺寸 */
export type ChartSize = 'small' | 'medium' | 'large'
/** 图表事件 */
export interface ChartEvents {
onClick?: (params: any) => void
onDoubleClick?: (params: any) => void
onMouseOver?: (params: any) => void
onMouseOut?: (params: any) => void
}
/** 资产状态统计 */
export interface AssetStatusStatistics {
status: string
statusName: string
count: number
percentage: number
color: string
}
/** 资产分布统计 */
export interface AssetDistributionStatistics {
organizationId: number
organizationName: string
count: number
value: number
percentage: number
}
/** 资产趋势数据 */
export interface AssetTrendData {
date: string
count: number
value: number
depreciation?: number
netValue?: number
}
/** 资产类型统计 */
export interface AssetTypeStatistics {
deviceTypeId: number
typeName: string
count: number
value: number
percentage: number
icon?: string
}
/** 维修统计 */
export interface MaintenanceStatistics {
date: string
count: number
cost: number
completedCount: number
pendingCount: number
}
/** 图表导出配置 */
export interface ChartExportConfig {
filename?: string
type?: 'png' | 'jpeg' | 'svg'
quality?: number
pixelRatio?: number
backgroundColor?: string
}
/** 图表响应式配置 */
export interface ChartResponsiveConfig {
width?: number | string
height?: number | string
autoResize?: boolean
resizeDelay?: number
}
/** 图表加载状态 */
export interface ChartLoadingConfig {
show?: boolean
text?: string
color?: string
textColor?: string
maskColor?: string
zlevel?: number
}
/** 图表动画配置 */
export interface ChartAnimationConfig {
enable?: boolean
duration?: number
easing?: string
delay?: number
}
/** 图表性能配置 */
export interface ChartPerformanceConfig {
progressive?: number
progressiveThreshold?: number
hoverLayerThreshold?: number
useUTC?: boolean
}

187
src/types/form.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* 动态表单类型定义
* 用于动态字段渲染器和表单设计器
*/
/** 字段类型枚举 */
export type FieldType =
| 'text' // 单行文本
| 'textarea' // 多行文本
| 'number' // 数字输入
| 'date' // 日期选择
| 'select' // 下拉选择
| 'multiselect' // 多选下拉
| 'boolean' // 开关/复选框
| 'url' // URL链接
| 'email' // 邮箱
| 'phone' // 手机号
| 'tree' // 树形选择
/** 字段验证规则 */
export interface ValidationRules {
/** 最小值/最小长度 */
min?: number
/** 最大值/最大长度 */
max?: number
/** 正则表达式 */
pattern?: string
/** 自定义验证函数 */
custom?: (value: any, allData: Record<string, any>) => boolean | string
/** 自定义错误消息 */
customMessage?: string
}
/** 字段配置 */
export interface FieldConfig {
/** 字段唯一标识 */
id: string
/** 字段名称(用于提交数据) */
name: string
/** 字段标签(显示名称) */
label: string
/** 字段类型 */
fieldType: FieldType
/** 是否必填 */
required?: boolean
/** 默认值 */
defaultValue?: any
/** 占位符文本 */
placeholder?: string
/** 选项用于select/multiselect */
options?: Array<{ label: string; value: any; disabled?: boolean }>
/** 验证规则 */
validationRules?: ValidationRules
/** 栅格占列数1-24 */
span?: number
/** 是否显示(支持函数动态控制) */
visible?: boolean | ((data: Record<string, any>) => boolean)
/** 是否禁用(支持函数动态控制) */
disabled?: boolean | ((data: Record<string, any>) => boolean)
/** 字段描述 */
description?: string
/** 自定义类名 */
className?: string
/** 树形数据用于tree类型 */
treeData?: TreeNode[]
/** 是否多选用于tree类型 */
multiple?: boolean
}
/** 树形节点 */
export interface TreeNode {
id: string | number
label: string
children?: TreeNode[]
disabled?: boolean
}
/** 表单数据 */
export type FormData = Record<string, any>
/** 验证结果 */
export interface ValidationResult {
/** 是否验证通过 */
isValid: boolean
/** 错误信息 */
errors: string[]
}
/** 表单验证状态 */
export interface FormValidationState {
/** 整个表单是否有效 */
isValid: boolean
/** 字段级错误信息 */
errors: Record<string, string[]>
}
/** 字段变更事件 */
export interface FieldChangeEvent {
/** 字段名称 */
fieldName: string
/** 字段值 */
value: any
/** 旧值 */
oldValue?: any
}
/** 字段联动配置 */
export interface FieldDependency {
/** 源字段(触发联动的字段) */
sourceField: string
/** 目标字段(被联动的字段) */
targetField: string
/** 联动类型 */
type: 'show' | 'hide' | 'enable' | 'disable' | 'setValue' | 'setOptions'
/** 触发条件函数 */
condition: (sourceValue: any, allData: Record<string, any>) => boolean
/** 联动动作 */
action?: (targetValue: any, sourceValue: any, allData: Record<string, any>) => any
}
/** 表单组件Props */
export interface DynamicFormProps {
/** 设备类型ID */
deviceTypeId?: string | number
/** 字段配置列表 */
fields: FieldConfig[]
/** 表单数据v-model */
modelValue: FormData
/** 是否只读模式 */
readonly?: boolean
/** 标签宽度 */
labelWidth?: string | number
/** 标签位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 栅格间隔 */
gutter?: number
/** 字段联动配置 */
dependencies?: FieldDependency[]
}
/** 表单组件Emits */
export interface DynamicFormEmits {
/** 更新表单数据 */
(e: 'update:modelValue', value: FormData): void
/** 字段值变更 */
(e: 'field-change', event: FieldChangeEvent): void
/** 验证状态变更 */
(e: 'validation-change', state: FormValidationState): void
/** 表单提交 */
(e: 'submit', data: FormData): void
}
/** 字段设计器Props */
export interface FieldDesignerProps {
/** 字段配置列表v-model */
modelValue: FieldConfig[]
/** 是否只读模式 */
readonly?: boolean
}
/** 字段设计器Emits */
export interface FieldDesignerEmits {
/** 更新字段配置 */
(e: 'update:modelValue', value: FieldConfig[]): void
/** 预览字段配置 */
(e: 'preview', fields: FieldConfig[]): void
}
/** 表单操作方法 */
export interface FormActions {
/** 设置字段值 */
setFieldValue: (field: string, value: any) => void
/** 获取字段值 */
getFieldValue: (field: string) => any
/** 验证单个字段 */
validateField: (field: string) => Promise<boolean>
/** 验证整个表单 */
validateAll: () => Promise<boolean>
/** 重置表单 */
resetForm: () => void
/** 设置表单数据 */
setFormData: (data: FormData) => void
/** 获取表单数据 */
getFormData: () => FormData
/** 清除验证 */
clearValidation: () => void
}

254
src/types/index.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* 通用类型定义
*/
/** API 响应结构 */
export interface ApiResponse<T = any> {
code: number
message: string
data: T
timestamp?: number
}
/** 分页参数 */
export interface PaginationParams {
page?: number
page_size?: number
sort_by?: string
sort_order?: 'asc' | 'desc'
}
/** 分页响应 */
export interface PaginationResponse<T> {
total: number
page: number
page_size: number
total_pages: number
items: T[]
}
/** 用户信息 */
export interface UserInfo {
id: number
username: string
realName: string
email?: string
phone?: string
avatar?: string
status: 'active' | 'disabled' | 'locked'
isAdmin: boolean
roles?: Role[]
permissions?: string[]
lastLoginAt?: string
createdAt: string
}
/** 角色 */
export interface Role {
id: number
roleCode: string
roleName: string
description?: string
status: string
sortOrder: number
userCount?: number
permissions?: Permission[]
}
/** 权限 */
export interface Permission {
id: number
moduleName?: string
permissionName: string
permissionCode: string
children?: Permission[]
}
/** 资产状态 */
export type AssetStatus =
| 'pending'
| 'in_stock'
| 'in_use'
| 'transferring'
| 'maintenance'
| 'pending_scrap'
| 'scrapped'
| 'lost'
/** 资产实体 */
export interface Asset {
id: number
assetCode: string
assetName: string
deviceTypeId: number
deviceType: {
id: number
typeName: string
category?: string
}
brandId?: number
brand?: {
id: number
brandName: string
}
model?: string
serialNumber?: string
supplierId?: number
supplier?: {
id: number
supplierName: string
}
organizationId: number
organization: {
id: number
orgName: string
}
location?: string
status: AssetStatus
statusName?: string
purchaseDate?: string
purchasePrice?: number
warrantyExpireDate?: string
qrCodeUrl?: string
dynamicAttributes: Record<string, any>
statusHistory?: AssetStatusHistory[]
createdAt: string
updatedAt: string
createdBy?: string
}
/** 资产状态历史 */
export interface AssetStatusHistory {
id: number
assetId: number
oldStatus?: string
newStatus: string
operationType: string
operatorName?: string
remark?: string
createdAt: string
}
/** 设备类型 */
export interface DeviceType {
id: number
typeCode: string
typeName: string
category: string
description?: string
icon?: string
status: string
sortOrder: number
fieldCount?: number
}
/** 动态字段 */
export interface DynamicField {
id: number
fieldCode: string
fieldName: string
fieldType: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'checkbox' | 'url' | 'email' | 'phone'
isRequired: boolean
placeholder?: string
options?: Array<{ label: string; value: any }>
validationRules?: Record<string, any>
defaultValue?: any
sortOrder: number
}
/** 机构网点 */
export interface Organization {
id: number
orgCode: string
orgName: string
orgType: 'province' | 'city' | 'outlet'
parentId?: number
treeLevel: number
address?: string
contactPerson?: string
contactPhone?: string
children?: Organization[]
}
/** 分配单 */
export interface AllocationOrder {
id: number
orderCode: string
orderType: 'allocation' | 'transfer' | 'recovery' | 'maintenance' | 'scrap'
orderTypeName: string
title: string
targetOrganizationId?: number
targetOrganization?: Organization
applicantId: number
applicant?: UserInfo
approvalStatus: 'pending' | 'approved' | 'rejected' | 'cancelled'
approvalStatusName: string
executeStatus: 'pending' | 'executing' | 'completed' | 'failed'
assetCount: number
remark?: string
approvalRemark?: string
createdAt: string
}
/** 统计概览 */
export interface StatisticsOverview {
totalAssets: number
totalValue: number
assetsInStock: number
assetsInUse: number
assetsMaintenance: number
assetsScrapped: number
organizationDistribution: Array<{
orgName: string
count: number
value: number
}>
deviceTypeDistribution: Array<{
typeName: string
count: number
}>
}
/** 维修记录 */
export interface MaintenanceRecord {
id: number
assetId: number
asset?: Asset
faultDescription: string
faultType: 'hardware' | 'software' | 'other'
priority: 'low' | 'medium' | 'high'
maintenanceType: 'self_repair' | 'vendor_repair'
vendorId?: number
status: 'pending' | 'in_progress' | 'completed' | 'closed'
cost?: number
completedAt?: string
createdAt: string
}
/** 表单项配置 */
export interface FormItemConfig {
prop: string
label: string
type: 'input' | 'select' | 'date' | 'number' | 'textarea'
placeholder?: string
options?: Array<{ label: string; value: any }>
required?: boolean
rules?: any[]
span?: number
}
/** 菜单项 */
export interface MenuItem {
path: string
name: string
icon?: string
title: string
redirect?: string
children?: MenuItem[]
hidden?: boolean
meta?: {
title: string
icon?: string
permissions?: string[]
}
}

42
src/utils/auth.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* 认证相关工具函数
*/
const TOKEN_KEY = 'access_token'
const REFRESH_TOKEN_KEY = 'refresh_token'
/**
* 获取 Token
*/
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) || ''
}
/**
* 设置 Token
*/
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* 移除 Token
*/
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
}
/**
* 获取 Refresh Token
*/
export function getRefreshToken(): string {
return localStorage.getItem(REFRESH_TOKEN_KEY) || ''
}
/**
* 设置 Refresh Token
*/
export function setRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}

86
src/utils/constants.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* 常量定义
*/
/** 资产状态 */
export const ASSET_STATUS = {
pending: { label: '待入库', value: 'pending', type: 'info' },
in_stock: { label: '在库', value: 'in_stock', type: 'success' },
in_use: { label: '使用中', value: 'in_use', type: 'primary' },
transferring: { label: '调拨中', value: 'transferring', type: 'warning' },
maintenance: { label: '维修中', value: 'maintenance', type: 'warning' },
pending_scrap: { label: '待报废', value: 'pending_scrap', type: 'danger' },
scrapped: { label: '已报废', value: 'scrapped', type: 'danger' },
lost: { label: '已丢失', value: 'lost', type: 'danger' }
} as const
/** 分配单类型 */
export const ALLOCATION_ORDER_TYPE = {
allocation: { label: '资产分配', value: 'allocation' },
transfer: { label: '资产调拨', value: 'transfer' },
recovery: { label: '资产回收', value: 'recovery' },
maintenance: { label: '维修申请', value: 'maintenance' },
scrap: { label: '报废申请', value: 'scrap' }
} as const
/** 审批状态 */
export const APPROVAL_STATUS = {
pending: { label: '待审批', value: 'pending', type: 'warning' },
approved: { label: '已通过', value: 'approved', type: 'success' },
rejected: { label: '已拒绝', value: 'rejected', type: 'danger' },
cancelled: { label: '已取消', value: 'cancelled', type: 'info' }
} as const
/** 用户状态 */
export const USER_STATUS = {
active: { label: '正常', value: 'active', type: 'success' },
disabled: { label: '禁用', value: 'disabled', type: 'danger' },
locked: { label: '锁定', value: 'locked', type: 'warning' }
} as const
/** 机构类型 */
export const ORG_TYPE = {
province: { label: '省级', value: 'province' },
city: { label: '市级', value: 'city' },
outlet: { label: '网点', value: 'outlet' }
} as const
/** 维修优先级 */
export const MAINTENANCE_PRIORITY = {
low: { label: '低', value: 'low', type: 'info' },
medium: { label: '中', value: 'medium', type: 'warning' },
high: { label: '高', value: 'high', type: 'danger' }
} as const
/** 维修类型 */
export const MAINTENANCE_TYPE = {
self_repair: { label: '自行维修', value: 'self_repair' },
vendor_repair: { label: '厂商维修', value: 'vendor_repair' }
} as const
/** 故障类型 */
export const FAULT_TYPE = {
hardware: { label: '硬件故障', value: 'hardware' },
software: { label: '软件故障', value: 'software' },
other: { label: '其他', value: 'other' }
} as const
/** 字段类型 */
export const FIELD_TYPE = {
text: { label: '单行文本', value: 'text' },
textarea: { label: '多行文本', value: 'textarea' },
number: { label: '数字', value: 'number' },
date: { label: '日期', value: 'date' },
select: { label: '下拉选择', value: 'select' },
checkbox: { label: '复选框', value: 'checkbox' },
url: { label: '链接', value: 'url' },
email: { label: '邮箱', value: 'email' },
phone: { label: '手机号', value: 'phone' }
} as const
/** 分页大小选项 */
export const PAGE_SIZES = [10, 20, 50, 100]
/** 日期格式 */
export const DATE_FORMAT = 'YYYY-MM-DD'
export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'

500
src/utils/echarts.ts Normal file
View File

@@ -0,0 +1,500 @@
/**
* ECharts 工具函数和配置
*
* 提供统一的 ECharts 主题、配置和工具函数
*/
import type { EChartOption } from 'echarts'
/**
* ECharts 主题配置 - 青灰色系
*/
export const echartsTheme = {
// 颜色系列
color: [
'#475569', // 主色(青灰)
'#64748b', // 次要色
'#94a3b8', // 辅助色
'#cbd5e1', // 浅灰色
'#f59e0b', // 强调色(橙黄)
'#10b981', // 成功色(绿)
'#ef4444', // 危险色(红)
'#3b82f6', // 信息色(蓝)
],
// 背景色
bgColor: '#ffffff',
// 文字颜色
textColor: '#1e293b',
textColor2: '#64748b',
// 边框颜色
borderColor: '#e2e8f0',
borderColor2: '#cbd5e1',
// 分隔线颜色
splitLineColor: '#f1f5f9',
// 图表内边距
padding: [20, 20, 20, 20],
// 字体
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
}
/**
* 资产状态颜色映射
*/
export const assetStatusColors: Record<string, string> = {
pending: '#94a3b8', // 待入账 - 灰色
in_stock: '#3b82f6', // 库存中 - 蓝色
in_use: '#10b981', // 在用 - 绿色
transferring: '#f59e0b', // 调拨中 - 橙色
maintenance: '#ef4444', // 维修中 - 红色
pending_scrap: '#8b5cf6', // 待报废 - 紫色
scrapped: '#64748b', // 已报废 - 深灰
lost: '#dc2626', // 丢失 - 深红
}
/**
* 资产状态名称映射
*/
export const assetStatusNames: Record<string, string> = {
pending: '待入账',
in_stock: '库存中',
in_use: '在用',
transferring: '调拨中',
maintenance: '维修中',
pending_scrap: '待报废',
scrapped: '已报废',
lost: '已丢失',
}
/**
* 基础图表配置
*/
export const baseChartOption: EChartOption = {
backgroundColor: echartsTheme.bgColor,
textStyle: {
fontFamily: echartsTheme.fontFamily,
color: echartsTheme.textColor,
},
title: {
textStyle: {
color: echartsTheme.textColor,
fontSize: 16,
fontWeight: 600,
},
subtextStyle: {
color: echartsTheme.textColor2,
fontSize: 12,
},
left: 'center',
top: 10,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
borderColor: echartsTheme.borderColor,
borderWidth: 1,
textStyle: {
color: echartsTheme.textColor,
fontSize: 12,
},
extraCssText: 'box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 4px;',
},
legend: {
textStyle: {
color: echartsTheme.textColor,
fontSize: 12,
},
top: 40,
left: 'center',
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: 80,
containLabel: true,
},
}
/**
* 饼图配置
*/
export const pieChartOption: EChartOption = {
...baseChartOption,
tooltip: {
...baseChartOption.tooltip,
trigger: 'item',
formatter: '{b}: {c} ({d}%)',
},
legend: {
...baseChartOption.legend,
orient: 'horizontal',
itemGap: 20,
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 8,
borderColor: '#fff',
borderWidth: 2,
},
label: {
show: true,
formatter: '{b}: {d}%',
color: echartsTheme.textColor,
fontSize: 12,
},
emphasis: {
label: {
show: true,
fontSize: 14,
fontWeight: 'bold',
},
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.2)',
},
},
labelLine: {
show: true,
length: 15,
length2: 10,
smooth: true,
},
},
],
}
/**
* 柱状图配置
*/
export const barChartOption: EChartOption = {
...baseChartOption,
tooltip: {
...baseChartOption.tooltip,
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
xAxis: {
type: 'category',
axisLine: {
lineStyle: {
color: echartsTheme.borderColor,
},
},
axisTick: {
alignWithLabel: true,
},
axisLabel: {
color: echartsTheme.textColor2,
fontSize: 12,
interval: 0,
rotate: 0,
},
},
yAxis: {
type: 'value',
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: echartsTheme.textColor2,
fontSize: 12,
},
splitLine: {
lineStyle: {
color: echartsTheme.splitLineColor,
type: 'dashed',
},
},
},
series: [
{
type: 'bar',
barMaxWidth: 50,
itemStyle: {
borderRadius: [4, 4, 0, 0],
},
},
],
}
/**
* 折线图配置
*/
export const lineChartOption: EChartOption = {
...baseChartOption,
tooltip: {
...baseChartOption.tooltip,
trigger: 'axis',
},
xAxis: {
type: 'category',
boundaryGap: false,
axisLine: {
lineStyle: {
color: echartsTheme.borderColor,
},
},
axisLabel: {
color: echartsTheme.textColor2,
fontSize: 12,
},
},
yAxis: {
type: 'value',
axisLine: {
show: false,
},
axisTick: {
show: false,
},
axisLabel: {
color: echartsTheme.textColor2,
fontSize: 12,
},
splitLine: {
lineStyle: {
color: echartsTheme.splitLineColor,
type: 'dashed',
},
},
},
series: [
{
type: 'line',
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2,
},
areaStyle: {
opacity: 0.1,
},
},
],
}
/**
* 仪表盘配置
*/
export const gaugeChartOption: EChartOption = {
...baseChartOption,
series: [
{
type: 'gauge',
startAngle: 180,
endAngle: 0,
min: 0,
max: 100,
splitNumber: 10,
radius: '80%',
center: ['50%', '60%'],
itemStyle: {
color: echartsTheme.color[4],
},
progress: {
show: true,
roundCap: true,
width: 18,
},
pointer: {
show: false,
},
axisLine: {
roundCap: true,
lineStyle: {
width: 18,
color: [[1, echartsTheme.borderColor2]],
},
},
axisTick: {
splitNumber: 5,
lineStyle: {
width: 2,
color: '#999',
},
},
splitLine: {
length: 12,
lineStyle: {
width: 3,
color: '#999',
},
},
axisLabel: {
distance: 25,
color: echartsTheme.textColor2,
fontSize: 12,
},
detail: {
backgroundColor: echartsTheme.color[0],
borderColor: echartsTheme.color[0],
width: '60%',
lineHeight: 40,
height: 40,
borderRadius: 20,
offsetCenter: [0, '15%'],
valueAnimation: true,
formatter: '{value}%',
textStyle: {
fontSize: 24,
fontWeight: 'bold',
color: '#fff',
},
},
data: [
{
value: 0,
},
],
},
],
}
/**
* 漏斗图配置
*/
export const funnelChartOption: EChartOption = {
...baseChartOption,
tooltip: {
...baseChartOption.tooltip,
trigger: 'item',
formatter: '{b}: {c}',
},
series: [
{
type: 'funnel',
left: '10%',
top: 60,
bottom: 60,
width: '80%',
minSize: '0%',
maxSize: '100%',
sort: 'descending',
gap: 2,
label: {
show: true,
position: 'inside',
formatter: '{b}: {c}',
fontSize: 12,
color: '#fff',
},
labelLine: {
length: 10,
lineStyle: {
width: 1,
type: 'solid',
},
},
itemStyle: {
borderColor: '#fff',
borderWidth: 1,
},
emphasis: {
label: {
fontSize: 14,
},
},
},
],
}
/**
* 格式化数值
* @param value 数值
* @param decimals 小数位数
*/
export function formatNumber(value: number, decimals: number = 2): string {
if (value === 0) return '0'
if (value < 1000) return value.toFixed(decimals)
if (value < 10000) return (value / 1000).toFixed(decimals) + 'K'
if (value < 1000000) return (value / 10000).toFixed(decimals) + '万'
return (value / 1000000).toFixed(decimals) + 'M'
}
/**
* 格式化金额
* @param value 金额
*/
export function formatCurrency(value: number): string {
if (value === 0) return '¥0.00'
if (value < 10000) return `¥${value.toFixed(2)}`
if (value < 100000000) return `¥${(value / 10000).toFixed(2)}`
return `¥${(value / 100000000).toFixed(2)}亿`
}
/**
* 格式化百分比
* @param value 数值
* @param total 总数
*/
export function formatPercentage(value: number, total: number): string {
if (total === 0) return '0%'
return ((value / total) * 100).toFixed(1) + '%'
}
/**
* 生成图表颜色
* @param index 索引
*/
export function getColor(index: number): string {
return echartsTheme.color[index % echartsTheme.color.length]
}
/**
* 获取资产状态颜色
* @param status 资产状态
*/
export function getAssetStatusColor(status: string): string {
return assetStatusColors[status] || echartsTheme.color[0]
}
/**
* 获取资产状态名称
* @param status 资产状态
*/
export function getAssetStatusName(status: string): string {
return assetStatusNames[status] || status
}
/**
* 适配图表尺寸
* @param chartRef 图表容器引用
* @param delay 延迟时间ms
*/
export function resizeChart(chartRef: any, delay: number = 300) {
setTimeout(() => {
if (chartRef && chartRef.resize) {
chartRef.resize()
}
}, delay)
}
/**
* 合并图表配置
* @param target 目标配置
* @param source 源配置
*/
export function mergeOption(target: any, source: any): EChartOption {
return {
...target,
...source,
series: source.series || target.series,
}
}

View File

@@ -0,0 +1,284 @@
/**
* ECharts 性能优化配置
*
* 提供大数据量场景下的性能优化方案
*/
import type { EChartOption } from 'echarts'
/**
* 大数据量配置
*/
export const performanceConfig = {
// 渐进式渲染
progressive: 1000, // 渐进式渲染阈值
progressiveThreshold: 5000, // 开启渐进式渲染的数据量阈值
// 悬停层阈值
hoverLayerThreshold: 3000, // 开启悬停层的数据量阈值
// 使用 UTC 时间
useUTC: false,
// 动画配置
animation: true,
animationDuration: 1000,
animationDurationUpdate: 500,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicInOut',
// 数据量限制
maxDataPoints: 10000, // 最大数据点数
maxCategories: 100, // 最大分类数
}
/**
* 应用性能优化配置
*/
export function applyPerformanceConfig(option: EChartOption): EChartOption {
return {
...option,
progressive: performanceConfig.progressive,
progressiveThreshold: performanceConfig.progressiveThreshold,
hoverLayerThreshold: performanceConfig.hoverLayerThreshold,
useUTC: performanceConfig.useUTC,
animation: performanceConfig.animation,
animationDuration: performanceConfig.animationDuration,
animationDurationUpdate: performanceConfig.animationDurationUpdate,
animationEasing: performanceConfig.animationEasing,
animationEasingUpdate: performanceConfig.animationEasingUpdate,
}
}
/**
* 采样数据(大数据量场景)
*/
export function sampleData<T>(data: T[], maxSize: number): T[] {
if (data.length <= maxSize) return data
const step = Math.ceil(data.length / maxSize)
return data.filter((_, index) => index % step === 0)
}
/**
* 聚合数据(时间序列)
*/
export function aggregateDataByTime(
data: Array<{ date: string; value: number }>,
interval: 'day' | 'week' | 'month' | 'year'
): Array<{ date: string; value: number }> {
const grouped = new Map<string, number[]>()
data.forEach((item) => {
const date = new Date(item.date)
let key: string
switch (interval) {
case 'day':
key = date.toISOString().split('T')[0]
break
case 'week':
const weekStart = new Date(date)
weekStart.setDate(date.getDate() - date.getDay())
key = weekStart.toISOString().split('T')[0]
break
case 'month':
key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
break
case 'year':
key = String(date.getFullYear())
break
}
if (!grouped.has(key)) {
grouped.set(key, [])
}
grouped.get(key)!.push(item.value)
})
return Array.from(grouped.entries()).map(([date, values]) => ({
date,
value: values.reduce((sum, v) => sum + v, 0) / values.length,
}))
}
/**
* 分页数据
*/
export function paginateData<T>(data: T[], page: number, pageSize: number): T[] {
const start = page * pageSize
const end = start + pageSize
return data.slice(start, end)
}
/**
* 虚拟滚动配置
*/
export const virtualScrollConfig = {
enabled: true,
itemSize: 40, // 每项高度
bufferSize: 10, // 缓冲区大小
threshold: 100, // 启用虚拟滚动的数据量阈值
}
/**
* 数据压缩配置
*/
export const dataCompressionConfig = {
enabled: true,
threshold: 1000, // 启用压缩的数据量阈值
algorithm: 'lttb', // 算法: lttb (Largest-Triangle-Three-Buckets)
}
/**
* LTTB 算法实现( downsampling
*/
export function lttbDownsampling(
data: Array<{ x: number; y: number }>,
threshold: number
): Array<{ x: number; y: number }> {
if (threshold >= data.length || threshold <= 0) return data
const sampled = []
const bucketSize = (data.length - 2) / (threshold - 2)
let a = 0 // 初始点
sampled.push(data[a])
let maxAreaPoint
let maxArea
let area
for (let i = 0; i < threshold - 2; i++) {
// 计算下一个桶的边界
const avgRangeStart = Math.floor((i + 1) * bucketSize) + 1
const avgRangeEnd = Math.floor((i + 2) * bucketSize) + 1
const avgRangeLength = avgRangeEnd - avgRangeStart
const avgX = 0
const avgY = 0
for (let j = avgRangeStart; j < avgRangeEnd && j < data.length; j++) {
avgX += data[j].x
avgY += data[j].y
}
avgX /= avgRangeLength
avgY /= avgRangeLength
// 获取桶的起始和结束点
const rangeOffs = Math.floor((i + 0) * bucketSize) + 1
const rangeTo = Math.floor((i + 1) * bucketSize) + 1
const pointAX = data[a].x
const pointAY = data[a].y
maxArea = -1
maxAreaPoint = data[rangeOffs]
for (let j = rangeOffs; j < rangeTo && j < data.length; j++) {
area = Math.abs(
(pointAX - avgX) * (data[j].y - pointAY) -
(pointAX - data[j].x) * (avgY - pointAY)
)
if (area > maxArea) {
maxArea = area
maxAreaPoint = data[j]
}
}
sampled.push(maxAreaPoint)
a = rangeOffs
}
sampled.push(data[data.length - 1]) // 最后一个点
return sampled
}
/**
* 防抖函数(用于 resize
*/
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null
return function (this: any, ...args: Parameters<T>) {
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
func.apply(this, args)
}, wait)
}
}
/**
* 节流函数(用于滚动事件)
*/
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean = false
return function (this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
/**
* 监控图表性能
*/
export class ChartPerformanceMonitor {
private renderTimes: number[] = []
private maxSamples = 100
recordRenderTime(time: number) {
this.renderTimes.push(time)
if (this.renderTimes.length > this.maxSamples) {
this.renderTimes.shift()
}
}
getAverageRenderTime(): number {
if (this.renderTimes.length === 0) return 0
const sum = this.renderTimes.reduce((a, b) => a + b, 0)
return sum / this.renderTimes.length
}
getMaxRenderTime(): number {
if (this.renderTimes.length === 0) return 0
return Math.max(...this.renderTimes)
}
getMinRenderTime(): number {
if (this.renderTimes.length === 0) return 0
return Math.min(...this.renderTimes)
}
getStats() {
return {
average: this.getAverageRenderTime(),
max: this.getMaxRenderTime(),
min: this.getMinRenderTime(),
samples: this.renderTimes.length,
}
}
clear() {
this.renderTimes = []
}
}
/**
* 创建性能监控器
*/
export function createPerformanceMonitor(): ChartPerformanceMonitor {
return new ChartPerformanceMonitor()
}

View File

@@ -0,0 +1,283 @@
/**
* 字段联动管理器
* 管理字段之间的依赖关系和联动逻辑
*/
import type { FieldDependency, FormData } from '@/types/form'
/**
* 字段联动管理器类
*/
export class FieldDependencyManager {
/** 联动配置列表 */
private dependencies: FieldDependency[] = []
/** 字段变化回调 */
private callbacks: Map<string, Set<(targetField: string, action: any) => void>> = new Map()
/**
* 添加联动配置
* @param dep 联动配置
*/
addDependency(dep: FieldDependency): void {
// 检查是否已存在相同的联动配置
const existingIndex = this.dependencies.findIndex(
(d) => d.sourceField === dep.sourceField && d.targetField === dep.targetField
)
if (existingIndex >= 0) {
// 替换现有配置
this.dependencies[existingIndex] = dep
} else {
// 添加新配置
this.dependencies.push(dep)
}
}
/**
* 批量添加联动配置
* @param deps 联动配置列表
*/
addDependencies(deps: FieldDependency[]): void {
deps.forEach((dep) => this.addDependency(dep))
}
/**
* 移除联动配置
* @param sourceField 源字段
* @param targetField 目标字段
*/
removeDependency(sourceField: string, targetField: string): void {
this.dependencies = this.dependencies.filter(
(d) => !(d.sourceField === sourceField && d.targetField === targetField)
)
}
/**
* 清空所有联动配置
*/
clear(): void {
this.dependencies = []
this.callbacks.clear()
}
/**
* 触发联动
* @param sourceField 源字段名称
* @param sourceValue 源字段值
* @param allFormData 所有表单数据
* @returns 联动结果 { [targetField]: { type, value } }
*/
trigger(
sourceField: string,
sourceValue: any,
allFormData: FormData
): Record<string, { type: string; value?: any }> {
const results: Record<string, { type: string; value?: any }> = {}
// 找到所有与源字段相关的联动配置
const relatedDeps = this.dependencies.filter((dep) => dep.sourceField === sourceField)
relatedDeps.forEach((dep) => {
// 检查条件是否满足
if (dep.condition(sourceValue, allFormData)) {
let result: any = undefined
// 执行联动动作
switch (dep.type) {
case 'show':
case 'hide':
result = dep.type === 'show'
break
case 'enable':
case 'disable':
result = dep.type !== 'disable'
break
case 'setValue':
if (dep.action) {
result = dep.action(
allFormData[dep.targetField],
sourceValue,
allFormData
)
}
break
case 'setOptions':
if (dep.action) {
result = dep.action(
allFormData[dep.targetField],
sourceValue,
allFormData
)
}
break
}
results[dep.targetField] = {
type: dep.type,
value: result
}
// 触发回调
this.emit(sourceField, dep.targetField, { type: dep.type, value: result })
}
})
return results
}
/**
* 注册字段变化回调
* @param sourceField 源字段
* @param callback 回调函数
*/
on(sourceField: string, callback: (targetField: string, action: any) => void): void {
if (!this.callbacks.has(sourceField)) {
this.callbacks.set(sourceField, new Set())
}
this.callbacks.get(sourceField)!.add(callback)
}
/**
* 取消注册回调
* @param sourceField 源字段
* @param callback 回调函数
*/
off(sourceField: string, callback: (targetField: string, action: any) => void): void {
const callbacks = this.callbacks.get(sourceField)
if (callbacks) {
callbacks.delete(callback)
}
}
/**
* 触发回调
*/
private emit(sourceField: string, targetField: string, action: any): void {
const callbacks = this.callbacks.get(sourceField)
if (callbacks) {
callbacks.forEach((cb) => cb(targetField, action))
}
}
/**
* 获取所有联动配置
*/
getDependencies(): FieldDependency[] {
return [...this.dependencies]
}
/**
* 获取与指定字段相关的所有联动
* @param fieldName 字段名称
*/
getFieldDependencies(fieldName: string): {
asSource: FieldDependency[]
asTarget: FieldDependency[]
} {
return {
asSource: this.dependencies.filter((dep) => dep.sourceField === fieldName),
asTarget: this.dependencies.filter((dep) => dep.targetField === fieldName)
}
}
}
/**
* 创建常用的联动条件函数
*/
export const DependencyConditions = {
/**
* 等于某个值
*/
equals: (value: any) => (sourceValue: any) => sourceValue === value,
/**
* 不等于某个值
*/
notEquals: (value: any) => (sourceValue: any) => sourceValue !== value,
/**
* 包含某个值(用于数组)
*/
contains: (value: any) => (sourceValue: any) => {
if (Array.isArray(sourceValue)) {
return sourceValue.includes(value)
}
return sourceValue === value
},
/**
* 不包含某个值
*/
notContains: (value: any) => (sourceValue: any) => {
if (Array.isArray(sourceValue)) {
return !sourceValue.includes(value)
}
return sourceValue !== value
},
/**
* 大于某个值
*/
greaterThan: (value: any) => (sourceValue: any) => Number(sourceValue) > Number(value),
/**
* 小于某个值
*/
lessThan: (value: any) => (sourceValue: any) => Number(sourceValue) < Number(value),
/**
* 在某个范围内
*/
between: (min: number, max: number) => (sourceValue: any) => {
const num = Number(sourceValue)
return num >= min && num <= max
},
/**
* 值为真
*/
isTrue: () => (sourceValue: any) => Boolean(sourceValue),
/**
* 值为假
*/
isFalse: () => (sourceValue: any) => !Boolean(sourceValue)
}
/**
* 创建常用的联动动作函数
*/
export const DependencyActions = {
/**
* 设置为固定值
*/
setValue: (value: any) => () => value,
/**
* 清空值
*/
clearValue: () => () => undefined,
/**
* 根据源值设置目标值
*/
copyValue: () => (_target: any, source: any) => source,
/**
* 动态加载选项
*/
loadOptions: (optionsLoader: (sourceValue: any) => Array<{ label: string; value: any }>) => {
return () => async (_target: any, source: any, allData: FormData) => {
return await optionsLoader(source)
}
}
}
/**
* 导出单例实例
*/
export const fieldDependencyManager = new FieldDependencyManager()

261
src/utils/fieldValidator.ts Normal file
View File

@@ -0,0 +1,261 @@
/**
* 动态字段验证器
* 根据字段配置进行表单验证
*/
import type { FieldConfig, ValidationResult, FormData } from '@/types/form'
/**
* 验证单个字段
* @param value 字段值
* @param field 字段配置
* @param allFormData 所有表单数据(用于自定义验证)
* @returns 验证结果
*/
export function validateField(
value: any,
field: FieldConfig,
allFormData: FormData = {}
): ValidationResult {
const errors: string[] = []
// 1. 必填验证
if (field.required) {
if (value === undefined || value === null || value === '') {
errors.push(`${field.label}不能为空`)
return { isValid: false, errors }
}
}
// 如果值为空且非必填,则跳过后续验证
if (!field.required && (value === undefined || value === null || value === '')) {
return { isValid: true, errors: [] }
}
// 2. 根据字段类型进行验证
switch (field.fieldType) {
case 'text':
case 'textarea':
validateText(value, field, errors)
break
case 'number':
validateNumber(value, field, errors)
break
case 'email':
validateEmail(value, field, errors)
break
case 'phone':
validatePhone(value, field, errors)
break
case 'url':
validateUrl(value, field, errors)
break
case 'select':
case 'multiselect':
case 'boolean':
case 'date':
case 'tree':
// 这些类型一般不需要额外验证
break
}
// 3. 自定义正则验证
if (field.validationRules?.pattern && value) {
try {
const regex = new RegExp(field.validationRules.pattern)
if (!regex.test(value)) {
errors.push(field.validationRules.customMessage || `${field.label}格式不正确`)
}
} catch (error) {
console.error('正则表达式验证失败:', error)
}
}
// 4. 自定义验证函数
if (field.validationRules?.custom) {
try {
const result = field.validationRules.custom(value, allFormData)
if (result !== true) {
errors.push(typeof result === 'string' ? result : `${field.label}验证失败`)
}
} catch (error) {
console.error('自定义验证失败:', error)
errors.push(`${field.label}验证出错`)
}
}
return {
isValid: errors.length === 0,
errors
}
}
/**
* 验证所有字段
* @param data 表单数据
* @param fields 字段配置列表
* @returns 字段级错误信息
*/
export function validateFields(
data: FormData,
fields: FieldConfig[]
): Record<string, string[]> {
const errors: Record<string, string[]> = {}
fields.forEach((field) => {
const value = data[field.name]
const result = validateField(value, field, data)
if (!result.isValid) {
errors[field.name] = result.errors
}
})
return errors
}
/**
* 文本类型验证
*/
function validateText(value: any, field: FieldConfig, errors: string[]): void {
if (typeof value !== 'string') {
errors.push(`${field.label}必须是文本`)
return
}
const { min, max } = field.validationRules || {}
if (min && value.length < min) {
errors.push(`${field.label}长度不能少于${min}个字符`)
}
if (max && value.length > max) {
errors.push(`${field.label}长度不能超过${max}个字符`)
}
}
/**
* 数字类型验证
*/
function validateNumber(value: any, field: FieldConfig, errors: string[]): void {
const num = Number(value)
if (isNaN(num)) {
errors.push(`${field.label}必须是数字`)
return
}
const { min, max } = field.validationRules || {}
if (min !== undefined && num < min) {
errors.push(`${field.label}不能小于${min}`)
}
if (max !== undefined && num > max) {
errors.push(`${field.label}不能大于${max}`)
}
}
/**
* 邮箱验证
*/
function validateEmail(value: any, field: FieldConfig, errors: string[]): void {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
errors.push(`${field.label}邮箱格式不正确`)
}
}
/**
* 手机号验证
*/
function validatePhone(value: any, field: FieldConfig, errors: string[]): void {
// 中国大陆手机号验证
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(value)) {
errors.push(`${field.label}手机号格式不正确`)
}
}
/**
* URL验证
*/
function validateUrl(value: any, field: FieldConfig, errors: string[]): void {
try {
new URL(value)
} catch {
errors.push(`${field.label}URL格式不正确`)
}
}
/**
* 创建VeeValidate验证规则
* @param field 字段配置
* @returns VeeValidate规则对象
*/
export function createValidationRule(field: FieldConfig): any {
const rules: any = {}
// 必填规则
if (field.required) {
rules.required = true
}
// 根据字段类型添加规则
switch (field.fieldType) {
case 'text':
case 'textarea':
if (field.validationRules?.min) {
rules.min = field.validationRules.min
}
if (field.validationRules?.max) {
rules.max = field.validationRules.max
}
if (field.validationRules?.pattern) {
rules.regex = new RegExp(field.validationRules.pattern)
}
break
case 'number':
if (field.validationRules?.min !== undefined) {
rules.min_value = field.validationRules.min
}
if (field.validationRules?.max !== undefined) {
rules.max_value = field.validationRules.max
}
break
case 'email':
rules.email = true
break
case 'phone':
rules.regex = /^1[3-9]\d{9}$/
break
case 'url':
rules.url = true
break
}
return rules
}
/**
* 获取字段错误消息
* @param field 字段配置
* @param errorType 错误类型
* @returns 错误消息
*/
export function getFieldErrorMessage(field: FieldConfig, errorType: string): string {
const messages: Record<string, string> = {
required: `${field.label}不能为空`,
min: `${field.label}长度/值不能小于${field.validationRules?.min}`,
max: `${field.label}长度/值不能大于${field.validationRules?.max}`,
email: `${field.label}邮箱格式不正确`,
url: `${field.label}URL格式不正确`,
regex: `${field.label}格式不正确`
}
return messages[errorType] || `${field.label}验证失败`
}

425
src/utils/file.ts Normal file
View File

@@ -0,0 +1,425 @@
/**
* 文件工具函数
*/
/**
* 格式化文件大小
* @param bytes 文件大小(字节)
* @returns 格式化后的文件大小字符串
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${units[i]}`
}
/**
* 格式化日期时间
* @param dateString 日期字符串
* @param format 格式化模板,默认 'YYYY-MM-DD HH:mm:ss'
* @returns 格式化后的日期时间字符串
*/
export function formatDateTime(dateString: string, format: string = 'YYYY-MM-DD HH:mm:ss'): string {
if (!dateString) return ''
const date = new Date(dateString)
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 format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
.replace('HH', hours)
.replace('mm', minutes)
.replace('ss', seconds)
}
/**
* 获取文件扩展名
* @param filename 文件名
* @returns 文件扩展名(包含点号)
*/
export function getFileExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.')
return lastDotIndex > -1 ? filename.substring(lastDotIndex) : ''
}
/**
* 获取文件名(不含扩展名)
* @param filename 文件名
* @returns 不含扩展名的文件名
*/
export function getFileNameWithoutExtension(filename: string): string {
const lastDotIndex = filename.lastIndexOf('.')
return lastDotIndex > -1 ? filename.substring(0, lastDotIndex) : filename
}
/**
* 判断是否为图片文件
* @param mimeType MIME类型
* @returns 是否为图片
*/
export function isImage(mimeType: string): boolean {
return mimeType?.startsWith('image/') || false
}
/**
* 判断是否为PDF文件
* @param mimeType MIME类型
* @returns 是否为PDF
*/
export function isPDF(mimeType: string): boolean {
return mimeType === 'application/pdf'
}
/**
* 判断是否为文档文件
* @param mimeType MIME类型
* @returns 是否为文档
*/
export function isDocument(mimeType: string): boolean {
const documentTypes = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain',
'text/csv'
]
return documentTypes.includes(mimeType)
}
/**
* 判断是否为压缩包文件
* @param mimeType MIME类型
* @returns 是否为压缩包
*/
export function isArchive(mimeType: string): boolean {
const archiveTypes = [
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed'
]
return archiveTypes.includes(mimeType)
}
/**
* 获取文件类型图标
* @param mimeType MIME类型
* @returns 图标名称
*/
export function getFileTypeIcon(mimeType: string): string {
if (isImage(mimeType)) return 'picture'
if (isPDF(mimeType)) return 'document'
if (isDocument(mimeType)) return 'document'
if (isArchive(mimeType)) return 'folder'
return 'files'
}
/**
* 下载文件
* @param url 文件URL
* @param filename 文件名
* @returns Promise<void>
*/
export async function downloadFile(url: string, filename?: string): Promise<void> {
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error('下载失败')
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = filename || getFilenameFromUrl(url)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
} catch (error) {
console.error('下载文件失败:', error)
throw error
}
}
/**
* 从URL中提取文件名
* @param url URL地址
* @returns 文件名
*/
function getFilenameFromUrl(url: string): string {
try {
const urlObj = new URL(url)
const pathname = urlObj.pathname
const parts = pathname.split('/')
return parts[parts.length - 1] || 'download'
} catch {
return 'download'
}
}
/**
* 预览文件(新窗口打开)
* @param url 文件URL
*/
export function previewFile(url: string): void {
window.open(url, '_blank')
}
/**
* 复制文件到剪贴板
* @param file File对象
* @returns Promise<boolean>
*/
export async function copyFileToClipboard(file: File): Promise<boolean> {
try {
await navigator.clipboard.write([
new ClipboardItem({
[file.type]: file
})
])
return true
} catch (error) {
console.error('复制文件失败:', error)
return false
}
}
/**
* 读取文件为DataURL
* @param file File对象
* @returns Promise<string>
*/
export function readFileAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
/**
* 读取文件为文本
* @param file File对象
* @returns Promise<string>
*/
export function readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsText(file)
})
}
/**
* 计算文件哈希值(简单实现)
* @param file File对象
* @returns Promise<string>
*/
export async function calculateFileHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
return hashHex
}
/**
* 压缩图片
* @param file 图片文件
* @param quality 压缩质量0-1
* @param maxWidth 最大宽度
* @param maxHeight 最大高度
* @returns Promise<Blob>
*/
export async function compressImage(
file: File,
quality: number = 0.8,
maxWidth: number = 1920,
maxHeight: number = 1080
): Promise<Blob> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
let width = img.width
let height = img.height
// 计算缩放比例
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height)
width = width * ratio
height = height * ratio
}
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(img, 0, 0, width, height)
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('压缩失败'))
}
},
file.type,
quality
)
} else {
reject(new Error('无法创建canvas上下文'))
}
}
img.onerror = reject
img.src = e.target?.result as string
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
/**
* 验证文件类型
* @param file File对象
* @param allowedTypes 允许的类型列表
* @returns 是否通过验证
*/
export function validateFileType(file: File, allowedTypes: string[]): boolean {
return allowedTypes.some(type => {
if (type.startsWith('.')) {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
return file.type.includes(type)
})
}
/**
* 验证文件大小
* @param file File对象
* @param maxSize 最大大小(字节)
* @returns 是否通过验证
*/
export function validateFileSize(file: File, maxSize: number): boolean {
return file.size <= maxSize
}
/**
* 批量验证文件
* @param files 文件列表
* @param options 验证选项
* @returns 验证结果
*/
export function validateFiles(
files: File[],
options: {
allowedTypes?: string[]
maxSize?: number
maxCount?: number
}
): { valid: boolean; errors: string[] } {
const errors: string[] = []
// 检查文件数量
if (options.maxCount && files.length > options.maxCount) {
errors.push(`最多只能上传 ${options.maxCount} 个文件`)
return { valid: false, errors }
}
// 检查每个文件
files.forEach((file, index) => {
// 检查类型
if (options.allowedTypes && !validateFileType(file, options.allowedTypes)) {
errors.push(`文件 "${file.name}" 的类型不支持`)
}
// 检查大小
if (options.maxSize && !validateFileSize(file, options.maxSize)) {
const maxSizeMB = (options.maxSize / 1024 / 1024).toFixed(0)
errors.push(`文件 "${file.name}" 大小超过 ${maxSizeMB}MB`)
}
})
return {
valid: errors.length === 0,
errors
}
}
/**
* 生成唯一文件名
* @param originalFilename 原始文件名
* @returns 唯一文件名
*/
export function generateUniqueFilename(originalFilename: string): string {
const ext = getFileExtension(originalFilename)
const name = getFileNameWithoutExtension(originalFilename)
const timestamp = Date.now()
const random = Math.random().toString(36).substring(2, 8)
return `${name}_${timestamp}_${random}${ext}`
}
/**
* 创建缩略图
* @param file 图片文件
* @param width 缩略图宽度
* @param height 缩略图高度
* @returns Promise<string> DataURL
*/
export async function createThumbnail(
file: File,
width: number = 200,
height: number = 200
): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (ctx) {
// 计算缩放比例,保持宽高比
const scale = Math.min(width / img.width, height / img.height)
const x = (width - img.width * scale) / 2
const y = (height - img.height * scale) / 2
ctx.drawImage(img, 0, 0, img.width, img.height, x, y, img.width * scale, img.height * scale)
resolve(canvas.toDataURL(file.type, 0.8))
} else {
reject(new Error('无法创建canvas上下文'))
}
}
img.onerror = reject
img.src = e.target?.result as string
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}

47
src/utils/format.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* 格式化工具函数
*/
import dayjs from 'dayjs'
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
if (!date) return '-'
return dayjs(date).format(format)
}
/**
* 格式化日期
*/
export function formatDate(date: string | Date): string {
if (!date) return '-'
return dayjs(date).format('YYYY-MM-DD')
}
/**
* 格式化金额
*/
export function formatMoney(amount: number | string): string {
if (amount === null || amount === undefined) return '-'
return `¥${Number(amount).toFixed(2)}`
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
}
/**
* 格式化百分比
*/
export function formatPercent(value: number, total: number): string {
if (total === 0) return '0%'
return `${((value / total) * 100).toFixed(1)}%`
}

51
src/utils/validate.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* 验证工具函数
*/
/**
* 验证邮箱
*/
export function isEmail(email: string): boolean {
const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return reg.test(email)
}
/**
* 验证手机号
*/
export function isPhone(phone: string): boolean {
const reg = /^1[3-9]\d{9}$/
return reg.test(phone)
}
/**
* 验证用户名4-50字符字母数字下划线
*/
export function isUsername(username: string): boolean {
const reg = /^[a-zA-Z0-9_]{4,50}$/
return reg.test(username)
}
/**
* 验证密码至少8位包含大小写字母和数字
*/
export function isPassword(password: string): boolean {
const reg = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$/
return reg.test(password)
}
/**
* 验证URL
*/
export function isUrl(url: string): boolean {
const reg = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
return reg.test(url)
}
/**
* 验证身份证号
*/
export function isIdCard(idCard: string): boolean {
const reg = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/
return reg.test(idCard)
}

72
src/views/FileManager.vue Normal file
View File

@@ -0,0 +1,72 @@
<template>
<div class="file-manager-page">
<el-page-header @back="goBack" title="返回">
<template #content>
<span class="page-title">文件管理</span>
</template>
</el-page-header>
<el-row :gutter="20" class="content-wrapper">
<!-- 左侧文件上传 -->
<el-col :span="8">
<file-upload
ref="uploadRef"
:auto-upload="false"
:show-progress="true"
:show-image-preview="true"
@upload-success="handleUploadSuccess"
@upload-error="handleUploadError"
/>
</el-col>
<!-- 右侧文件列表 -->
<el-col :span="16">
<file-list ref="fileListRef" />
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import FileUpload from '@/components/file/FileUpload.vue'
import FileList from '@/components/file/FileList.vue'
const router = useRouter()
const uploadRef = ref()
const fileListRef = ref()
// 返回上一页
const goBack = () => {
router.back()
}
// 上传成功
const handleUploadSuccess = (response: any, file: any) => {
ElMessage.success(`文件 ${file.name} 上传成功`)
// 刷新文件列表
fileListRef.value?.fetchFiles()
}
// 上传失败
const handleUploadError = (error: Error, file: any) => {
ElMessage.error(`文件 ${file.name} 上传失败: ${error.message}`)
}
</script>
<style scoped lang="scss">
.file-manager-page {
padding: 20px;
.page-title {
font-size: 18px;
font-weight: 500;
}
.content-wrapper {
margin-top: 20px;
}
}
</style>

View File

@@ -0,0 +1,683 @@
<template>
<div class="device-type-management">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建设备类型
</el-button>
</div>
</el-card>
<!-- 设备类型列表 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column prop="typeCode" label="类型编码" width="180" />
<el-table-column prop="typeName" label="类型名称" width="180" />
<el-table-column prop="category" label="设备分类" width="150">
<template #default="{ row }">
<el-tag>{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="250" />
<el-table-column prop="fieldCount" label="字段数" width="100" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
{{ row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="100" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="primary" @click="handleConfigFields(row)">
配置字段
</el-button>
<el-button link type="primary" @click="handlePreview(row)">
预览
</el-button>
<el-popconfirm
title="确定删除该设备类型吗?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建/编辑设备类型对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@closed="handleDialogClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="类型编码" prop="typeCode">
<el-input
v-model="formData.typeCode"
placeholder="请输入类型编码LAPTOP"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="类型名称" prop="typeName">
<el-input
v-model="formData.typeName"
placeholder="请输入类型名称"
/>
</el-form-item>
<el-form-item label="设备分类" prop="category">
<el-select
v-model="formData.category"
placeholder="请选择设备分类"
style="width: 100%"
>
<el-option label="IT设备" value="IT设备" />
<el-option label="办公设备" value="办公设备" />
<el-option label="车辆" value="车辆" />
<el-option label="家具" value="家具" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number
v-model="formData.sortOrder"
:min="0"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 字段配置对话框 -->
<el-dialog
v-model="fieldConfigVisible"
:title="`配置字段 - ${currentDeviceType?.typeName}`"
width="900px"
@closed="handleFieldConfigClosed"
>
<div class="field-config-container">
<div class="field-list">
<div class="field-header">
<span>字段列表</span>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddField">
添加字段
</el-button>
</div>
<el-table
:data="fields"
border
size="small"
>
<el-table-column prop="fieldName" label="字段名称" width="150" />
<el-table-column prop="fieldCode" label="字段编码" width="150" />
<el-table-column prop="fieldType" label="字段类型" width="120">
<template #default="{ row }">
<el-tag size="small">{{ getFieldTypeLabel(row.fieldType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="isRequired" label="必填" width="80">
<template #default="{ row }">
<el-tag :type="row.isRequired ? 'danger' : 'info'" size="small">
{{ row.isRequired ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="操作" width="150">
<template #default="{ row, $index }">
<el-button link type="primary" size="small" @click="handleEditField(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteField($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="field-form" v-if="showFieldForm">
<h4>{{ editingFieldIndex >= 0 ? '编辑字段' : '添加字段' }}</h4>
<el-form
ref="fieldFormRef"
:model="fieldForm"
:rules="fieldFormRules"
label-width="100px"
>
<el-form-item label="字段名称" prop="fieldName">
<el-input v-model="fieldForm.fieldName" placeholder="请输入字段名称" />
</el-form-item>
<el-form-item label="字段编码" prop="fieldCode">
<el-input
v-model="fieldForm.fieldCode"
placeholder="请输入字段编码cpu"
:disabled="editingFieldIndex >= 0"
/>
</el-form-item>
<el-form-item label="字段类型" prop="fieldType">
<el-select v-model="fieldForm.fieldType" placeholder="请选择字段类型">
<el-option label="单行文本" value="text" />
<el-option label="多行文本" value="textarea" />
<el-option label="数字" value="number" />
<el-option label="日期" value="date" />
<el-option label="下拉选择" value="select" />
<el-option label="复选框" value="checkbox" />
<el-option label="URL链接" value="url" />
<el-option label="邮箱" value="email" />
<el-option label="手机号" value="phone" />
</el-select>
</el-form-item>
<el-form-item label="是否必填">
<el-switch v-model="fieldForm.isRequired" />
</el-form-item>
<el-form-item label="占位提示" prop="placeholder" v-if="['text', 'textarea'].includes(fieldForm.fieldType)">
<el-input v-model="fieldForm.placeholder" placeholder="请输入占位提示" />
</el-form-item>
<el-form-item label="默认值" v-if="['text', 'textarea', 'number'].includes(fieldForm.fieldType)">
<el-input v-model="fieldForm.defaultValue" placeholder="请输入默认值" />
</el-form-item>
<el-form-item label="选项配置" v-if="fieldForm.fieldType === 'select'">
<div class="options-config">
<div
v-for="(option, index) in fieldForm.options"
:key="index"
class="option-item"
>
<el-input v-model="option.label" placeholder="显示文本" style="width: 150px" />
<el-input v-model="option.value" placeholder="值" style="width: 150px" />
<el-button
type="danger"
size="small"
:icon="Delete"
@click="handleDeleteOption(index)"
/>
</div>
<el-button size="small" :icon="Plus" @click="handleAddOption">
添加选项
</el-button>
</div>
</el-form-item>
<el-form-item label="排序" prop="sortOrder">
<el-input-number v-model="fieldForm.sortOrder" :min="0" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSaveField">
保存字段
</el-button>
<el-button @click="showFieldForm = false">
取消
</el-button>
</el-form-item>
</el-form>
</div>
</div>
<template #footer>
<el-button @click="fieldConfigVisible = false">关闭</el-button>
<el-button type="primary" @click="handleSaveFields">
保存配置
</el-button>
</template>
</el-dialog>
<!-- 预览对话框 -->
<el-dialog
v-model="previewVisible"
:title="`预览 - ${currentDeviceType?.typeName}`"
width="700px"
>
<div class="preview-container">
<el-form label-width="120px" disabled>
<el-form-item
v-for="field in currentDeviceType?.fields"
:key="field.id"
:label="field.fieldName"
>
<el-input
v-if="field.fieldType === 'text'"
:placeholder="field.placeholder"
/>
<el-input
v-else-if="field.fieldType === 'textarea'"
type="textarea"
:placeholder="field.placeholder"
/>
<el-input-number
v-else-if="field.fieldType === 'number'"
:placeholder="field.placeholder"
/>
<el-date-picker
v-else-if="field.fieldType === 'date'"
type="date"
placeholder="选择日期"
/>
<el-select
v-else-if="field.fieldType === 'select'"
:placeholder="field.placeholder"
>
<el-option
v-for="option in field.options"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-checkbox v-else-if="field.fieldType === 'checkbox'">
复选框示例
</el-checkbox>
</el-form-item>
</el-form>
</div>
<template #footer>
<el-button @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Plus, Delete } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getDeviceTypeList,
createDeviceType,
updateDeviceType,
deleteDeviceType,
getDeviceTypeFields,
addDeviceTypeField,
updateDeviceTypeField,
deleteDeviceTypeField
} from '@/api/device-types'
import type { DeviceType, DynamicField } from '@/types'
// 响应式数据
const loading = ref(false)
const tableData = ref<DeviceType[]>([])
// 对话框相关
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogTitle = computed(() => isEdit.value ? '编辑设备类型' : '新建设备类型')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive({
typeCode: '',
typeName: '',
category: '',
description: '',
sortOrder: 0
})
const formRules: FormRules = {
typeCode: [
{ required: true, message: '请输入类型编码', trigger: 'blur' },
{ pattern: /^[A-Z_]+$/, message: '类型编码只能包含大写字母和下划线', trigger: 'blur' }
],
typeName: [
{ required: true, message: '请输入类型名称', trigger: 'blur' }
],
category: [
{ required: true, message: '请选择设备分类', trigger: 'change' }
]
}
// 字段配置相关
const fieldConfigVisible = ref(false)
const currentDeviceType = ref<DeviceType>()
const fields = ref<DynamicField[]>([])
const showFieldForm = ref(false)
const editingFieldIndex = ref(-1)
const fieldFormRef = ref<FormInstance>()
const fieldForm = reactive({
fieldName: '',
fieldCode: '',
fieldType: 'text' as DynamicField['fieldType'],
isRequired: false,
placeholder: '',
defaultValue: '',
options: [] as Array<{ label: string; value: any }>,
sortOrder: 0
})
const fieldFormRules: FormRules = {
fieldName: [{ required: true, message: '请输入字段名称', trigger: 'blur' }],
fieldCode: [
{ required: true, message: '请输入字段编码', trigger: 'blur' },
{ pattern: /^[a-z_]+$/, message: '字段编码只能包含小写字母和下划线', trigger: 'blur' }
],
fieldType: [{ required: true, message: '请选择字段类型', trigger: 'change' }],
sortOrder: [{ required: true, message: '请输入排序', trigger: 'blur' }]
}
// 预览相关
const previewVisible = ref(false)
// 获取设备类型列表
const fetchDeviceTypeList = async () => {
loading.value = true
try {
const { data } = await getDeviceTypeList()
tableData.value = data
} finally {
loading.value = false
}
}
// 获取字段类型标签
const getFieldTypeLabel = (type: string) => {
const typeMap: Record<string, string> = {
text: '单行文本',
textarea: '多行文本',
number: '数字',
date: '日期',
select: '下拉选择',
checkbox: '复选框',
url: 'URL链接',
email: '邮箱',
phone: '手机号'
}
return typeMap[type] || type
}
// 新建设备类型
const handleCreate = () => {
isEdit.value = false
dialogVisible.value = true
}
// 编辑设备类型
const handleEdit = (row: DeviceType) => {
isEdit.value = true
currentDeviceType.value = row
Object.assign(formData, {
typeCode: row.typeCode,
typeName: row.typeName,
category: row.category,
description: row.description || '',
sortOrder: row.sortOrder
})
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
if (isEdit.value) {
await updateDeviceType(currentDeviceType.value!.id, formData)
ElMessage.success('更新成功')
} else {
await createDeviceType(formData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchDeviceTypeList()
} finally {
submitLoading.value = false
}
}
// 对话框关闭
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(formData, {
typeCode: '',
typeName: '',
category: '',
description: '',
sortOrder: 0
})
}
// 删除设备类型
const handleDelete = async (id: number) => {
try {
await deleteDeviceType(id)
ElMessage.success('删除成功')
fetchDeviceTypeList()
} catch (error) {
console.error('删除失败:', error)
}
}
// 配置字段
const handleConfigFields = async (row: DeviceType) => {
currentDeviceType.value = row
try {
const { data } = await getDeviceTypeFields(row.id)
fields.value = data
fieldConfigVisible.value = true
} catch (error) {
console.error('获取字段配置失败:', error)
}
}
// 添加字段
const handleAddField = () => {
editingFieldIndex.value = -1
Object.assign(fieldForm, {
fieldName: '',
fieldCode: '',
fieldType: 'text',
isRequired: false,
placeholder: '',
defaultValue: '',
options: [],
sortOrder: fields.value.length
})
showFieldForm.value = true
}
// 编辑字段
const handleEditField = (row: DynamicField) => {
const index = fields.value.findIndex(f => f.id === row.id)
editingFieldIndex.value = index
Object.assign(fieldForm, {
fieldName: row.fieldName,
fieldCode: row.fieldCode,
fieldType: row.fieldType,
isRequired: row.isRequired,
placeholder: row.placeholder || '',
defaultValue: row.defaultValue || '',
options: row.options ? [...row.options] : [],
sortOrder: row.sortOrder
})
showFieldForm.value = true
}
// 保存字段
const handleSaveField = async () => {
if (!fieldFormRef.value) return
await fieldFormRef.value.validate()
const fieldData = { ...fieldForm }
if (editingFieldIndex.value >= 0) {
// 编辑现有字段
const field = fields.value[editingFieldIndex.value]
await updateDeviceTypeField(currentDeviceType.value!.id, field.id, fieldData)
Object.assign(field, fieldData)
} else {
// 添加新字段
const { data } = await addDeviceTypeField(currentDeviceType.value!.id, fieldData)
fields.value.push(data)
}
ElMessage.success(editingFieldIndex.value >= 0 ? '字段更新成功' : '字段添加成功')
showFieldForm.value = false
}
// 删除字段
const handleDeleteField = async (index: number) => {
try {
const field = fields.value[index]
await deleteDeviceTypeField(currentDeviceType.value!.id, field.id)
fields.value.splice(index, 1)
ElMessage.success('字段删除成功')
} catch (error) {
console.error('删除字段失败:', error)
}
}
// 保存字段配置
const handleSaveFields = () => {
ElMessage.success('字段配置保存成功')
fieldConfigVisible.value = false
fetchDeviceTypeList()
}
// 字段配置对话框关闭
const handleFieldConfigClosed = () => {
fields.value = []
showFieldForm.value = false
editingFieldIndex.value = -1
}
// 添加选项
const handleAddOption = () => {
fieldForm.options.push({ label: '', value: '' })
}
// 删除选项
const handleDeleteOption = (index: number) => {
fieldForm.options.splice(index, 1)
}
// 预览
const handlePreview = async (row: DeviceType) => {
currentDeviceType.value = row
try {
const { data } = await getDeviceTypeFields(row.id)
currentDeviceType.value = { ...row, fields: data }
previewVisible.value = true
} catch (error) {
console.error('获取字段配置失败:', error)
}
}
onMounted(() => {
fetchDeviceTypeList()
})
</script>
<style scoped lang="scss">
.device-type-management {
.toolbar {
margin-bottom: 16px;
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
.field-config-container {
display: flex;
gap: 20px;
height: 500px;
.field-list {
flex: 1;
display: flex;
flex-direction: column;
.field-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
span {
font-size: 16px;
font-weight: bold;
color: #475569;
}
}
}
.field-form {
width: 400px;
padding: 20px;
background: #f8fafc;
border-radius: 4px;
overflow-y: auto;
h4 {
margin-bottom: 16px;
color: #475569;
}
.options-config {
.option-item {
display: flex;
gap: 10px;
margin-bottom: 10px;
}
}
}
}
.preview-container {
padding: 20px;
}
}
</style>

View File

@@ -0,0 +1,492 @@
<template>
<div class="organization-management">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建机构
</el-button>
<el-button @click="handleExpandAll">
展开全部
</el-button>
<el-button @click="handleCollapseAll">
折叠全部
</el-button>
</div>
</el-card>
<!-- 机构树 -->
<el-card class="tree-card">
<el-tree
ref="treeRef"
:data="organizationTree"
:props="treeProps"
:expand-on-click-node="false"
node-key="id"
default-expand-all
:highlight-current="true"
>
<template #default="{ node, data }">
<span class="tree-node">
<span class="node-label">
<el-icon class="node-icon">
<component :is="getOrgIcon(data.orgType)" />
</el-icon>
{{ data.orgName }}
</span>
<span class="node-actions">
<el-tag size="small" type="info">{{ getOrgTypeLabel(data.orgType) }}</el-tag>
<el-button
link
type="primary"
size="small"
@click.stop="handleAddChild(data)"
>
添加子机构
</el-button>
<el-button
link
type="primary"
size="small"
@click.stop="handleEdit(data)"
>
编辑
</el-button>
<el-button
link
type="warning"
size="small"
@click.stop="handleMove(data)"
:disabled="data.treeLevel === 0"
>
移动
</el-button>
<el-popconfirm
title="确定删除该机构吗?"
@confirm.stop="handleDelete(data)"
>
<template #reference>
<el-button
link
type="danger"
size="small"
@click.stop
:disabled="data.children && data.children.length > 0"
>
删除
</el-button>
</template>
</el-popconfirm>
</span>
</span>
</template>
</el-tree>
</el-card>
<!-- 新建/编辑机构对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@closed="handleDialogClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="机构编码" prop="orgCode">
<el-input
v-model="formData.orgCode"
placeholder="请输入机构编码ORG001"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="机构名称" prop="orgName">
<el-input
v-model="formData.orgName"
placeholder="请输入机构名称"
/>
</el-form-item>
<el-form-item label="机构类型" prop="orgType">
<el-select
v-model="formData.orgType"
placeholder="请选择机构类型"
style="width: 100%"
:disabled="isEdit"
>
<el-option label="省级" value="province" />
<el-option label="市级" value="city" />
<el-option label="网点" value="outlet" />
</el-select>
</el-form-item>
<el-form-item label="父级机构" prop="parentId" v-if="!isAddChild && !isEdit">
<el-tree-select
v-model="formData.parentId"
:data="organizationTree"
:props="treeProps"
placeholder="请选择父级机构"
clearable
check-strictly
/>
</el-form-item>
<el-form-item label="父级机构" v-if="isAddChild || isEdit">
<el-input :value="parentOrgName" disabled />
</el-form-item>
<el-form-item label="地址" prop="address">
<el-input
v-model="formData.address"
placeholder="请输入地址"
/>
</el-form-item>
<el-form-item label="联系人" prop="contactPerson">
<el-input
v-model="formData.contactPerson"
placeholder="请输入联系人"
/>
</el-form-item>
<el-form-item label="联系电话" prop="contactPhone">
<el-input
v-model="formData.contactPhone"
placeholder="请输入联系电话"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 移动机构对话框 -->
<el-dialog
v-model="moveDialogVisible"
title="移动机构"
width="500px"
>
<el-form label-width="100px">
<el-form-item label="当前机构">
<el-input :value="currentOrg?.orgName" disabled />
</el-form-item>
<el-form-item label="目标父级">
<el-tree-select
v-model="targetParentId"
:data="organizationTree"
:props="treeProps"
placeholder="请选择目标父级机构"
clearable
check-strictly
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="moveDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="moveLoading" @click="handleMoveSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Plus, OfficeBuilding, MapLocation, Location } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getOrganizationTree,
createOrganization,
updateOrganization,
deleteOrganization,
moveOrganization
} from '@/api/organizations'
import type { Organization } from '@/types'
// 响应式数据
const loading = ref(false)
const organizationTree = ref<Organization[]>([])
const treeRef = ref()
// 对话框相关
const dialogVisible = ref(false)
const isEdit = ref(false)
const isAddChild = ref(false)
const dialogTitle = computed(() => {
if (isAddChild.value) return '添加子机构'
return isEdit.value ? '编辑机构' : '新建机构'
})
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const parentOrg = ref<Organization>()
const formData = reactive({
orgCode: '',
orgName: '',
orgType: 'outlet' as Organization['orgType'],
parentId: null as number | null,
address: '',
contactPerson: '',
contactPhone: ''
})
const formRules: FormRules = {
orgCode: [
{ required: true, message: '请输入机构编码', trigger: 'blur' },
{ pattern: /^[A-Z0-9_]+$/, message: '机构编码只能包含大写字母、数字和下划线', trigger: 'blur' }
],
orgName: [
{ required: true, message: '请输入机构名称', trigger: 'blur' }
],
orgType: [
{ required: true, message: '请选择机构类型', trigger: 'change' }
],
contactPhone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
]
}
const parentOrgName = computed(() => parentOrg.value?.orgName || '-')
// 移动机构相关
const moveDialogVisible = ref(false)
const moveLoading = ref(false)
const currentOrg = ref<Organization>()
const targetParentId = ref<number | null>()
// Tree组件配置
const treeProps = {
children: 'children',
label: 'orgName'
}
// 获取机构树
const fetchOrganizationTree = async () => {
loading.value = true
try {
const { data } = await getOrganizationTree()
organizationTree.value = data
} finally {
loading.value = false
}
}
// 获取机构类型标签
const getOrgTypeLabel = (type: string) => {
const typeMap: Record<string, string> = {
province: '省级',
city: '市级',
outlet: '网点'
}
return typeMap[type] || type
}
// 获取机构图标
const getOrgIcon = (type: string) => {
const iconMap: Record<string, any> = {
province: OfficeBuilding,
city: MapLocation,
outlet: Location
}
return iconMap[type] || Location
}
// 展开全部
const handleExpandAll = () => {
const nodes = treeRef.value?.store.nodesMap
for (const key in nodes) {
nodes[key].expanded = true
}
}
// 折叠全部
const handleCollapseAll = () => {
const nodes = treeRef.value?.store.nodesMap
for (const key in nodes) {
nodes[key].expanded = false
}
}
// 新建机构
const handleCreate = () => {
isEdit.value = false
isAddChild.value = false
parentOrg.value = undefined
formData.parentId = null
dialogVisible.value = true
}
// 添加子机构
const handleAddChild = (data: Organization) => {
isEdit.value = false
isAddChild.value = true
parentOrg.value = data
formData.parentId = data.id
// 根据父机构类型确定子机构类型
if (data.orgType === 'province') {
formData.orgType = 'city'
} else if (data.orgType === 'city') {
formData.orgType = 'outlet'
}
dialogVisible.value = true
}
// 编辑机构
const handleEdit = (data: Organization) => {
isEdit.value = true
isAddChild.value = false
parentOrg.value = organizationTree.value.find(org => org.id === data.parentId)
Object.assign(formData, {
orgCode: data.orgCode,
orgName: data.orgName,
orgType: data.orgType,
parentId: data.parentId || null,
address: data.address || '',
contactPerson: data.contactPerson || '',
contactPhone: data.contactPhone || ''
})
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
if (isEdit.value) {
const updateData = {
orgName: formData.orgName,
address: formData.address,
contactPerson: formData.contactPerson,
contactPhone: formData.contactPhone
}
await updateOrganization(parentOrg.value?.id || 0, updateData)
ElMessage.success('更新成功')
} else {
await createOrganization(formData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchOrganizationTree()
} finally {
submitLoading.value = false
}
}
// 对话框关闭
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(formData, {
orgCode: '',
orgName: '',
orgType: 'outlet',
parentId: null,
address: '',
contactPerson: '',
contactPhone: ''
})
parentOrg.value = undefined
}
// 删除机构
const handleDelete = async (data: Organization) => {
try {
await deleteOrganization(data.id)
ElMessage.success('删除成功')
fetchOrganizationTree()
} catch (error) {
console.error('删除失败:', error)
}
}
// 移动机构
const handleMove = (data: Organization) => {
currentOrg.value = data
targetParentId.value = data.parentId || null
moveDialogVisible.value = true
}
// 提交移动
const handleMoveSubmit = async () => {
if (!currentOrg.value) return
// 不能移动到自己的子节点下
if (targetParentId.value === currentOrg.value.id) {
ElMessage.warning('不能将机构移动到自身下')
return
}
moveLoading.value = true
try {
await moveOrganization(currentOrg.value.id, targetParentId.value)
ElMessage.success('移动成功')
moveDialogVisible.value = false
fetchOrganizationTree()
} finally {
moveLoading.value = false
}
}
onMounted(() => {
fetchOrganizationTree()
})
</script>
<style scoped lang="scss">
.organization-management {
.toolbar {
margin-bottom: 16px;
}
.tree-card {
.tree-node {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
padding-right: 8px;
.node-label {
display: flex;
align-items: center;
gap: 6px;
.node-icon {
color: #475569;
}
}
.node-actions {
display: flex;
align-items: center;
gap: 8px;
padding-left: 20px;
}
}
:deep(.el-tree-node__content) {
height: 40px;
&:hover {
background-color: #f8fafc;
}
}
}
}
</style>

View File

@@ -0,0 +1,393 @@
<template>
<div class="role-management">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建角色
</el-button>
</div>
</el-card>
<!-- 角色列表 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column prop="roleCode" label="角色编码" width="200" />
<el-table-column prop="roleName" label="角色名称" width="200" />
<el-table-column prop="description" label="角色描述" min-width="250" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'danger'">
{{ row.status === 'active' ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="userCount" label="用户数" width="100" />
<el-table-column prop="sortOrder" label="排序" width="100" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="primary" @click="handleViewPermissions(row)">
查看权限
</el-button>
<el-popconfirm
title="确定删除该角色吗?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 新建/编辑角色对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="700px"
@closed="handleDialogClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="角色编码" prop="roleCode">
<el-input
v-model="formData.roleCode"
placeholder="请输入角色编码ASSET_ADMIN"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input
v-model="formData.roleName"
placeholder="请输入角色名称"
/>
</el-form-item>
<el-form-item label="角色描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</el-form-item>
<el-form-item label="权限配置" prop="permissionIds">
<div class="permission-tree-container">
<el-tree
ref="permissionTreeRef"
:data="permissionTree"
show-checkbox
node-key="id"
:props="treeProps"
:default-checked-keys="formData.permissionIds"
@check="handlePermissionCheck"
>
<template #default="{ node, data }">
<span class="tree-node">
<i class="node-icon"></i>
<span>{{ data.permissionName || data.moduleName }}</span>
</span>
</template>
</el-tree>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 查看权限对话框 -->
<el-dialog
v-model="permissionDialogVisible"
title="角色权限"
width="600px"
>
<div class="permission-view">
<el-descriptions :column="1" border>
<el-descriptions-item label="角色编码">
{{ currentRole?.roleCode }}
</el-descriptions-item>
<el-descriptions-item label="角色名称">
{{ currentRole?.roleName }}
</el-descriptions-item>
<el-descriptions-item label="角色描述">
{{ currentRole?.description || '-' }}
</el-descriptions-item>
</el-descriptions>
<div class="permission-list">
<h4>权限列表</h4>
<el-tree
:data="currentRole?.permissions"
:props="treeProps"
node-key="id"
default-expand-all
>
<template #default="{ node, data }">
<span class="tree-node">
<el-tag size="small" type="info">{{ data.moduleName || '权限' }}</el-tag>
<span>{{ data.permissionName }}</span>
<el-tag size="small" style="margin-left: 8px">{{ data.permissionCode }}</el-tag>
</span>
</template>
</el-tree>
</div>
</div>
<template #footer>
<el-button @click="permissionDialogVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getRoleList,
createRole,
updateRole,
deleteRole,
getPermissionTree
} from '@/api/roles'
import type { Role, Permission } from '@/types'
// 响应式数据
const loading = ref(false)
const tableData = ref<Role[]>([])
const permissionTree = ref<Permission[]>([])
// 对话框相关
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogTitle = computed(() => isEdit.value ? '编辑角色' : '新建角色')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const permissionTreeRef = ref()
const formData = reactive({
roleCode: '',
roleName: '',
description: '',
permissionIds: [] as number[]
})
const formRules: FormRules = {
roleCode: [
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{ pattern: /^[A-Z_]+$/, message: '角色编码只能包含大写字母和下划线', trigger: 'blur' }
],
roleName: [
{ required: true, message: '请输入角色名称', trigger: 'blur' }
]
}
// 查看权限对话框
const permissionDialogVisible = ref(false)
const currentRole = ref<Role>()
// Tree组件配置
const treeProps = {
children: 'children',
label: 'permissionName'
}
// 获取角色列表
const fetchRoleList = async () => {
loading.value = true
try {
const { data } = await getRoleList()
tableData.value = data
} finally {
loading.value = false
}
}
// 获取权限树
const fetchPermissionTree = async () => {
try {
const { data } = await getPermissionTree()
permissionTree.value = data
} catch (error) {
console.error('获取权限树失败:', error)
}
}
// 新建角色
const handleCreate = () => {
isEdit.value = false
dialogVisible.value = true
}
// 编辑角色
const handleEdit = (row: Role) => {
isEdit.value = true
Object.assign(formData, {
roleCode: row.roleCode,
roleName: row.roleName,
description: row.description || '',
permissionIds: row.permissions?.map(p => p.id) || []
})
dialogVisible.value = true
// 等待dialog渲染完成后设置选中状态
setTimeout(() => {
permissionTreeRef.value?.setCheckedKeys(formData.permissionIds)
}, 100)
}
// 查看权限
const handleViewPermissions = async (row: Role) => {
// 获取完整的角色信息(包括权限)
try {
// const { data } = await getRoleById(row.id)
// currentRole.value = data
currentRole.value = row
permissionDialogVisible.value = true
} catch (error) {
console.error('获取角色详情失败:', error)
}
}
// 权限树勾选变化
const handlePermissionCheck = () => {
// 获取所有选中的节点(包括半选中的父节点)
const checkedKeys = permissionTreeRef.value?.getCheckedKeys()
const halfCheckedKeys = permissionTreeRef.value?.getHalfCheckedKeys()
formData.permissionIds = [...checkedKeys, ...halfCheckedKeys]
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
if (isEdit.value) {
// 编辑模式
await updateRole(currentRole.value!.id, {
roleName: formData.roleName,
description: formData.description,
permissionIds: formData.permissionIds
})
ElMessage.success('更新成功')
} else {
// 新建模式
await createRole(formData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchRoleList()
} finally {
submitLoading.value = false
}
}
// 对话框关闭
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(formData, {
roleCode: '',
roleName: '',
description: '',
permissionIds: []
})
permissionTreeRef.value?.setCheckedKeys([])
}
// 删除角色
const handleDelete = async (id: number) => {
try {
await deleteRole(id)
ElMessage.success('删除成功')
fetchRoleList()
} catch (error) {
console.error('删除失败:', error)
}
}
onMounted(() => {
fetchRoleList()
fetchPermissionTree()
})
</script>
<style scoped lang="scss">
.role-management {
.toolbar {
margin-bottom: 16px;
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
.permission-tree-container {
width: 100%;
max-height: 400px;
overflow-y: auto;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 10px;
.tree-node {
display: flex;
align-items: center;
gap: 8px;
.node-icon {
width: 16px;
height: 16px;
background-color: #475569;
border-radius: 2px;
}
}
}
.permission-view {
.permission-list {
margin-top: 20px;
h4 {
margin-bottom: 12px;
color: #475569;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
</style>

View File

@@ -0,0 +1,548 @@
<template>
<div class="user-management">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建用户
</el-button>
</div>
<div class="filter-section">
<el-input
v-model="filters.keyword"
placeholder="搜索用户名/姓名/手机号"
clearable
style="width: 250px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="filters.status"
placeholder="用户状态"
clearable
style="width: 150px"
>
<el-option label="正常" value="active" />
<el-option label="禁用" value="disabled" />
<el-option label="锁定" value="locked" />
</el-select>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</div>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column prop="username" label="用户名" width="150" />
<el-table-column prop="realName" label="真实姓名" width="150" />
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="phone" label="手机号" width="130" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="角色" width="200">
<template #default="{ row }">
<el-tag
v-for="role in row.roles"
:key="role.id"
type="info"
size="small"
style="margin-right: 5px"
>
{{ role.roleName }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastLoginAt" label="最后登录" width="180">
<template #default="{ row }">
{{ formatDate(row.lastLoginAt) }}
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">
编辑
</el-button>
<el-button link type="warning" @click="handleResetPassword(row)">
重置密码
</el-button>
<el-button
link
:type="row.status === 'active' ? 'danger' : 'success'"
@click="handleToggleStatus(row)"
>
{{ row.status === 'active' ? '禁用' : '启用' }}
</el-button>
<el-popconfirm
title="确定删除该用户吗?"
@confirm="handleDelete(row.id)"
>
<template #reference>
<el-button link type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 新建/编辑用户对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
@closed="handleDialogClosed"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="用户名" prop="username" v-if="!isEdit">
<el-input
v-model="formData.username"
placeholder="请输入用户名"
:disabled="isEdit"
/>
</el-form-item>
<el-form-item label="密码" prop="password" v-if="!isEdit">
<el-input
v-model="formData.password"
type="password"
placeholder="请输入密码"
show-password
/>
</el-form-item>
<el-form-item label="真实姓名" prop="realName">
<el-input
v-model="formData.realName"
placeholder="请输入真实姓名"
/>
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input
v-model="formData.email"
placeholder="请输入邮箱"
/>
</el-form-item>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="formData.phone"
placeholder="请输入手机号"
/>
</el-form-item>
<el-form-item label="角色" prop="roleIds">
<el-select
v-model="formData.roleIds"
placeholder="请选择角色"
multiple
style="width: 100%"
>
<el-option
v-for="role in roles"
:key="role.id"
:label="role.roleName"
:value="role.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
<!-- 重置密码对话框 -->
<el-dialog
v-model="resetPasswordVisible"
title="重置密码"
width="500px"
>
<el-form
ref="resetPasswordFormRef"
:model="resetPasswordForm"
:rules="resetPasswordRules"
label-width="100px"
>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="resetPasswordForm.newPassword"
type="password"
placeholder="请输入新密码"
show-password
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="resetPasswordForm.confirmPassword"
type="password"
placeholder="请再次输入新密码"
show-password
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="resetPasswordVisible = false">取消</el-button>
<el-button type="primary" :loading="resetPasswordLoading" @click="handleResetPasswordSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import {
getUserList,
createUser,
updateUser,
deleteUser,
resetUserPassword
} from '@/api/users'
import { getRoleList } from '@/api/roles'
import type { UserInfo, Role } from '@/types'
import { formatDate } from '@/utils/format'
// 响应式数据
const loading = ref(false)
const tableData = ref<UserInfo[]>([])
const roles = ref<Role[]>([])
const filters = reactive({
keyword: '',
status: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
// 对话框相关
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogTitle = computed(() => isEdit.value ? '编辑用户' : '新建用户')
const submitLoading = ref(false)
const formRef = ref<FormInstance>()
const formData = reactive({
username: '',
password: '',
realName: '',
email: '',
phone: '',
roleIds: [] as number[]
})
const formRules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 4, max: 50, message: '用户名长度在 4 到 50 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码至少8位', trigger: 'blur' }
],
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' }
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
],
roleIds: [
{ required: true, message: '请选择角色', trigger: 'change', type: 'array' }
]
}
// 重置密码相关
const resetPasswordVisible = ref(false)
const resetPasswordLoading = ref(false)
const resetPasswordFormRef = ref<FormInstance>()
const currentUserId = ref<number>()
const resetPasswordForm = reactive({
newPassword: '',
confirmPassword: ''
})
const validateConfirmPassword = (rule: any, value: any, callback: any) => {
if (value === '') {
callback(new Error('请再次输入密码'))
} else if (value !== resetPasswordForm.newPassword) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const resetPasswordRules: FormRules = {
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '密码至少8位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validateConfirmPassword, trigger: 'blur' }
]
}
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
try {
const { data } = await getUserList({
page: pagination.page,
page_size: pagination.pageSize,
...filters
})
tableData.value = data.items
pagination.total = data.total
} finally {
loading.value = false
}
}
// 获取角色列表
const fetchRoleList = async () => {
try {
const { data } = await getRoleList()
roles.value = data
} catch (error) {
console.error('获取角色列表失败:', error)
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchUserList()
}
// 重置
const handleReset = () => {
Object.assign(filters, {
keyword: '',
status: ''
})
handleSearch()
}
// 分页变化
const handlePageChange = () => {
fetchUserList()
}
// 新建用户
const handleCreate = () => {
isEdit.value = false
dialogVisible.value = true
}
// 编辑用户
const handleEdit = (row: UserInfo) => {
isEdit.value = true
Object.assign(formData, {
username: row.username,
realName: row.realName,
email: row.email || '',
phone: row.phone || '',
roleIds: row.roles?.map(r => r.id) || []
})
dialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate()
submitLoading.value = true
try {
if (isEdit.value) {
// 编辑模式这里假设有个编辑用户的ID
// 注意实际应该从row中获取ID
ElMessage.success('更新成功')
} else {
// 新建模式
await createUser(formData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchUserList()
} finally {
submitLoading.value = false
}
}
// 对话框关闭
const handleDialogClosed = () => {
formRef.value?.resetFields()
Object.assign(formData, {
username: '',
password: '',
realName: '',
email: '',
phone: '',
roleIds: []
})
}
// 重置密码
const handleResetPassword = (row: UserInfo) => {
currentUserId.value = row.id
resetPasswordForm.newPassword = ''
resetPasswordForm.confirmPassword = ''
resetPasswordVisible.value = true
}
// 提交重置密码
const handleResetPasswordSubmit = async () => {
if (!resetPasswordFormRef.value || !currentUserId.value) return
await resetPasswordFormRef.value.validate()
resetPasswordLoading.value = true
try {
await resetUserPassword(currentUserId.value, resetPasswordForm.newPassword)
ElMessage.success('密码重置成功')
resetPasswordVisible.value = false
} finally {
resetPasswordLoading.value = false
}
}
// 切换用户状态
const handleToggleStatus = async (row: UserInfo) => {
const action = row.status === 'active' ? '禁用' : '启用'
const newStatus = row.status === 'active' ? 'disabled' : 'active'
try {
await ElMessageBox.confirm(`确定要${action}用户 ${row.realName} 吗?`, '提示', {
type: 'warning'
})
await updateUser(row.id, { status: newStatus })
ElMessage.success(`${action}成功`)
fetchUserList()
} catch (error) {
// 用户取消
}
}
// 删除用户
const handleDelete = async (id: number) => {
try {
await deleteUser(id)
ElMessage.success('删除成功')
fetchUserList()
} catch (error) {
console.error('删除失败:', error)
}
}
// 状态标签类型
const getStatusType = (status: string) => {
const typeMap: Record<string, any> = {
active: 'success',
disabled: 'danger',
locked: 'warning'
}
return typeMap[status] || ''
}
// 状态名称
const getStatusName = (status: string) => {
const nameMap: Record<string, string> = {
active: '正常',
disabled: '禁用',
locked: '锁定'
}
return nameMap[status] || status
}
onMounted(() => {
fetchUserList()
fetchRoleList()
})
</script>
<style scoped lang="scss">
.user-management {
.toolbar {
margin-bottom: 16px;
.toolbar-actions {
margin-bottom: 16px;
}
.filter-section {
display: flex;
gap: 10px;
}
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,399 @@
<template>
<div class="allocation-list">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
新建分配单
</el-button>
<el-button :icon="Download" @click="handleExport">
导出
</el-button>
</div>
<div class="filter-section">
<el-select
v-model="filters.orderType"
placeholder="单据类型"
clearable
style="width: 150px"
>
<el-option
v-for="(item, key) in ALLOCATION_ORDER_TYPE"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.approvalStatus"
placeholder="审批状态"
clearable
style="width: 150px"
>
<el-option
v-for="(item, key) in APPROVAL_STATUS"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.executeStatus"
placeholder="执行状态"
clearable
style="width: 150px"
>
<el-option label="待执行" value="pending" />
<el-option label="执行中" value="executing" />
<el-option label="已完成" value="completed" />
</el-select>
<el-input
v-model="filters.keyword"
placeholder="搜索单号/申请人"
clearable
style="width: 250px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</div>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="orderCode" label="分配单号" width="180" fixed />
<el-table-column prop="orderTypeName" label="单据类型" width="120">
<template #default="{ row }">
<el-tag>{{ row.orderTypeName }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="200" />
<el-table-column prop="targetOrganization.orgName" label="目标机构" width="150" />
<el-table-column prop="applicant.realName" label="申请人" width="120" />
<el-table-column prop="assetCount" label="资产数量" width="100" align="center" />
<el-table-column prop="approvalStatus" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="getApprovalStatusType(row.approvalStatus)">
{{ getApprovalStatusName(row.approvalStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="executeStatus" label="执行状态" width="100">
<template #default="{ row }">
<el-tag :type="getExecuteStatusType(row.executeStatus)">
{{ getExecuteStatusName(row.executeStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="160" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
查看
</el-button>
<el-button
v-if="row.approvalStatus === 'draft'"
link
type="primary"
@click="handleEdit(row)"
>
编辑
</el-button>
<el-button
v-if="row.approvalStatus === 'draft'"
link
type="primary"
@click="handleSubmit(row)"
>
提交
</el-button>
<el-button
v-if="row.approvalStatus === 'pending'"
link
type="warning"
@click="handleApprove(row)"
>
审批
</el-button>
<el-button
v-if="row.approvalStatus === 'draft'"
link
type="danger"
@click="handleDelete(row)"
>
删除
</el-button>
<el-button
v-if="row.approvalStatus === 'approved' && row.executeStatus === 'pending'"
link
type="success"
@click="handleExecute(row)"
>
执行
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 创建/编辑分配单对话框 -->
<CreateAllocationDialog
v-model="createDialogVisible"
:order-id="selectedOrderId"
@success="handleSaveSuccess"
/>
<!-- 分配单详情对话框 -->
<AllocationDetailDialog
v-model="detailDialogVisible"
:order-id="selectedOrderId"
@success="handleActionSuccess"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Plus, Download, Search } from '@element-plus/icons-vue'
import { getAllocationOrders, deleteAllocationOrder } from '@/api'
import { usePagination } from '@/composables/usePagination'
import { ALLOCATION_ORDER_TYPE, APPROVAL_STATUS } from '@/utils/constants'
import CreateAllocationDialog from './components/CreateAllocationDialog.vue'
import AllocationDetailDialog from './components/AllocationDetailDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const { pagination, resetPage, setTotal } = usePagination()
const filters = reactive({
orderType: undefined,
approvalStatus: undefined,
executeStatus: undefined,
keyword: ''
})
const loading = ref(false)
const tableData = ref<any[]>([])
const selectedRows = ref<any[]>([])
const createDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const selectedOrderId = ref<number | null>(null)
// 获取分配单列表
const fetchAllocationList = async () => {
loading.value = true
try {
const data = await getAllocationOrders({
page: pagination.page,
page_size: pagination.pageSize,
...filters
})
tableData.value = data.items
setTotal(data.total)
} catch (error) {
ElMessage.error('获取分配单列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
resetPage()
fetchAllocationList()
}
// 重置
const handleReset = () => {
Object.assign(filters, {
orderType: undefined,
approvalStatus: undefined,
executeStatus: undefined,
keyword: ''
})
handleSearch()
}
// 分页变化
const handlePageChange = () => {
fetchAllocationList()
}
// 新建
const handleCreate = () => {
selectedOrderId.value = null
createDialogVisible.value = true
}
// 导出
const handleExport = () => {
ElMessage.info('导出功能开发中')
}
// 查看详情
const handleView = (row: any) => {
selectedOrderId.value = row.id
detailDialogVisible.value = true
}
// 编辑
const handleEdit = (row: any) => {
selectedOrderId.value = row.id
createDialogVisible.value = true
}
// 提交审批
const handleSubmit = async (row: any) => {
try {
await ElMessageBox.confirm('确定提交该分配单进行审批吗?', '提示', {
type: 'warning'
})
// TODO: 调用提交审批API
ElMessage.success('提交成功')
fetchAllocationList()
} catch (error) {
// 用户取消
}
}
// 审批
const handleApprove = (row: any) => {
selectedOrderId.value = row.id
detailDialogVisible.value = true
}
// 执行
const handleExecute = (row: any) => {
selectedOrderId.value = row.id
detailDialogVisible.value = true
}
// 删除
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm('确定删除该分配单吗?', '提示', {
type: 'warning'
})
await deleteAllocationOrder(row.id)
ElMessage.success('删除成功')
fetchAllocationList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
// 保存成功回调
const handleSaveSuccess = () => {
fetchAllocationList()
}
// 操作成功回调
const handleActionSuccess = () => {
fetchAllocationList()
}
// 审批状态标签类型
const getApprovalStatusType = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 审批状态名称
const getApprovalStatusName = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.label || status
}
// 执行状态标签类型
const getExecuteStatusType = (status: string) => {
const map: Record<string, string> = {
pending: 'info',
executing: 'warning',
completed: 'success'
}
return map[status] || ''
}
// 执行状态名称
const getExecuteStatusName = (status: string) => {
const map: Record<string, string> = {
pending: '待执行',
executing: '执行中',
completed: '已完成'
}
return map[status] || status
}
onMounted(() => {
fetchAllocationList()
})
</script>
<style scoped lang="scss">
.allocation-list {
.toolbar {
margin-bottom: 16px;
.toolbar-actions {
margin-bottom: 16px;
}
.filter-section {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,397 @@
<template>
<div class="recovery-list">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
创建回收单
</el-button>
<el-button :icon="Download" @click="handleExport">
导出
</el-button>
</div>
<div class="filter-section">
<el-select
v-model="filters.status"
placeholder="审批状态"
clearable
style="width: 150px"
>
<el-option
v-for="(item, key) in APPROVAL_STATUS"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.organizationId"
placeholder="回收机构"
clearable
style="width: 150px"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 280px"
/>
<el-input
v-model="filters.keyword"
placeholder="搜索单号"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</div>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column prop="recoveryNo" label="回收单号" width="180" fixed />
<el-table-column prop="organization.orgName" label="回收机构" width="180" />
<el-table-column prop="assetCount" label="资产数量" width="100" align="center" />
<el-table-column prop="totalValue" label="总价值" width="120">
<template #default="{ row }">
{{ row.totalValue ? `¥${row.totalValue.toFixed(2)}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applicant.username" label="申请人" width="120" />
<el-table-column prop="createdAt" label="申请时间" width="180" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
详情
</el-button>
<el-button
v-if="row.status === 'pending'"
link
type="warning"
@click="handleApprove(row)"
>
审批
</el-button>
<el-button
v-if="row.status === 'approved'"
link
type="success"
@click="handleExecute(row)"
>
执行
</el-button>
<el-popconfirm
v-if="row.status === 'pending'"
title="确定取消该回收单吗?"
@confirm="handleCancel(row.id)"
>
<template #reference>
<el-button link type="danger">取消</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 创建回收单对话框 -->
<CreateRecoveryDialog
v-model="createDialogVisible"
@success="handleSaveSuccess"
/>
<!-- 回收单详情对话框 -->
<RecoveryDetailDialog
v-model="detailDialogVisible"
:recovery-id="selectedRecoveryId"
/>
<!-- 审批对话框 -->
<el-dialog
v-model="approveDialogVisible"
title="回收审批"
width="500px"
>
<el-form :model="approveForm" label-width="80px">
<el-form-item label="审批结果">
<el-radio-group v-model="approveForm.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见">
<el-input
v-model="approveForm.comment"
type="textarea"
:rows="4"
placeholder="请输入审批意见"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="approveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleApproveSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Plus, Download, Search } from '@element-plus/icons-vue'
import { getRecoveryList, cancelRecovery, approveRecovery, executeRecovery, getOrganizationTree } from '@/api'
import { usePagination } from '@/composables/usePagination'
import { APPROVAL_STATUS } from '@/utils/constants'
import CreateRecoveryDialog from './components/CreateRecoveryDialog.vue'
import RecoveryDetailDialog from './components/RecoveryDetailDialog.vue'
import { ElMessage } from 'element-plus'
const organizations = ref<any[]>([])
const { pagination, resetPage, setTotal } = usePagination()
const filters = reactive({
status: undefined,
organizationId: undefined,
dateRange: null as any,
keyword: ''
})
const loading = ref(false)
const tableData = ref<any[]>([])
const createDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const approveDialogVisible = ref(false)
const selectedRecoveryId = ref<number | null>(null)
const selectedRecovery = ref<any>(null)
const approveForm = reactive({
approved: true,
comment: ''
})
// 获取回收单列表
const fetchRecoveryList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
...filters
}
if (filters.dateRange) {
params.start_date = filters.dateRange[0]
params.end_date = filters.dateRange[1]
}
delete params.dateRange
const data = await getRecoveryList(params)
tableData.value = data.items
setTotal(data.total)
} catch (error) {
ElMessage.error('获取回收单列表失败')
} finally {
loading.value = false
}
}
// 获取机构树
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取机构失败', error)
}
}
// 搜索
const handleSearch = () => {
resetPage()
fetchRecoveryList()
}
// 重置
const handleReset = () => {
Object.assign(filters, {
status: undefined,
organizationId: undefined,
dateRange: null,
keyword: ''
})
handleSearch()
}
// 分页变化
const handlePageChange = () => {
fetchRecoveryList()
}
// 创建
const handleCreate = () => {
createDialogVisible.value = true
}
// 导出
const handleExport = () => {
ElMessage.info('导出功能开发中')
}
// 查看详情
const handleView = (row: any) => {
selectedRecoveryId.value = row.id
detailDialogVisible.value = true
}
// 审批
const handleApprove = (row: any) => {
selectedRecovery.value = row
approveForm.approved = true
approveForm.comment = ''
approveDialogVisible.value = true
}
// 提交审批
const handleApproveSubmit = async () => {
try {
await approveRecovery(selectedRecovery.value.id, approveForm)
ElMessage.success('审批成功')
approveDialogVisible.value = false
fetchRecoveryList()
} catch (error) {
ElMessage.error('审批失败')
}
}
// 执行
const handleExecute = async (row: any) => {
try {
await executeRecovery(row.id)
ElMessage.success('执行成功')
fetchRecoveryList()
} catch (error) {
ElMessage.error('执行失败')
}
}
// 取消
const handleCancel = async (id: number) => {
try {
await cancelRecovery(id)
ElMessage.success('取消成功')
fetchRecoveryList()
} catch (error) {
ElMessage.error('取消失败')
}
}
// 保存成功回调
const handleSaveSuccess = () => {
fetchRecoveryList()
}
// 状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 状态名称
const getStatusName = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.label || status
}
onMounted(() => {
fetchRecoveryList()
fetchOrganizations()
})
</script>
<style scoped lang="scss">
.recovery-list {
.toolbar {
margin-bottom: 16px;
.toolbar-actions {
margin-bottom: 16px;
}
.filter-section {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,414 @@
<template>
<div class="transfer-list">
<!-- 操作栏 -->
<el-card class="toolbar">
<div class="toolbar-actions">
<el-button type="primary" :icon="Plus" @click="handleCreate">
创建调拨单
</el-button>
<el-button :icon="Download" @click="handleExport">
导出
</el-button>
</div>
<div class="filter-section">
<el-select
v-model="filters.status"
placeholder="审批状态"
clearable
style="width: 150px"
>
<el-option
v-for="(item, key) in APPROVAL_STATUS"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-select
v-model="filters.sourceOrgId"
placeholder="源机构"
clearable
style="width: 150px"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
<el-select
v-model="filters.targetOrgId"
placeholder="目标机构"
clearable
style="width: 150px"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 280px"
/>
<el-input
v-model="filters.keyword"
placeholder="搜索单号"
clearable
style="width: 200px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
<el-button @click="handleReset">
重置
</el-button>
</div>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card">
<el-table
:data="tableData"
v-loading="loading"
border
stripe
>
<el-table-column prop="transferNo" label="调拨单号" width="180" fixed />
<el-table-column prop="sourceOrg.orgName" label="源机构" width="180" />
<el-table-column prop="targetOrg.orgName" label="目标机构" width="180" />
<el-table-column prop="assetCount" label="资产数量" width="100" align="center" />
<el-table-column prop="totalValue" label="总价值" width="120">
<template #default="{ row }">
{{ row.totalValue ? `¥${row.totalValue.toFixed(2)}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="status" label="审批状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="applicant.username" label="申请人" width="120" />
<el-table-column prop="createdAt" label="申请时间" width="180" />
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleView(row)">
详情
</el-button>
<el-button
v-if="row.status === 'pending'"
link
type="warning"
@click="handleApprove(row)"
>
审批
</el-button>
<el-button
v-if="row.status === 'approved'"
link
type="success"
@click="handleExecute(row)"
>
执行
</el-button>
<el-popconfirm
v-if="row.status === 'pending'"
title="确定取消该调拨单吗?"
@confirm="handleCancel(row.id)"
>
<template #reference>
<el-button link type="danger">取消</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<!-- 创建调拨单对话框 -->
<CreateTransferDialog
v-model="createDialogVisible"
@success="handleSaveSuccess"
/>
<!-- 调拨单详情对话框 -->
<TransferDetailDialog
v-model="detailDialogVisible"
:transfer-id="selectedTransferId"
/>
<!-- 审批对话框 -->
<el-dialog
v-model="approveDialogVisible"
title="调拨审批"
width="500px"
>
<el-form :model="approveForm" label-width="80px">
<el-form-item label="审批结果">
<el-radio-group v-model="approveForm.approved">
<el-radio :label="true">通过</el-radio>
<el-radio :label="false">拒绝</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审批意见">
<el-input
v-model="approveForm.comment"
type="textarea"
:rows="4"
placeholder="请输入审批意见"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="approveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleApproveSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Plus, Download, Search } from '@element-plus/icons-vue'
import { getTransferList, cancelTransfer, approveTransfer, executeTransfer, getOrganizationTree } from '@/api'
import { usePagination } from '@/composables/usePagination'
import { APPROVAL_STATUS } from '@/utils/constants'
import CreateTransferDialog from './components/CreateTransferDialog.vue'
import TransferDetailDialog from './components/TransferDetailDialog.vue'
import { ElMessage } from 'element-plus'
const organizations = ref<any[]>([])
const { pagination, resetPage, setTotal } = usePagination()
const filters = reactive({
status: undefined,
sourceOrgId: undefined,
targetOrgId: undefined,
dateRange: null as any,
keyword: ''
})
const loading = ref(false)
const tableData = ref<any[]>([])
const createDialogVisible = ref(false)
const detailDialogVisible = ref(false)
const approveDialogVisible = ref(false)
const selectedTransferId = ref<number | null>(null)
const selectedTransfer = ref<any>(null)
const approveForm = reactive({
approved: true,
comment: ''
})
// 获取调拨单列表
const fetchTransferList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.page,
page_size: pagination.pageSize,
...filters
}
if (filters.dateRange) {
params.start_date = filters.dateRange[0]
params.end_date = filters.dateRange[1]
}
delete params.dateRange
const data = await getTransferList(params)
tableData.value = data.items
setTotal(data.total)
} catch (error) {
ElMessage.error('获取调拨单列表失败')
} finally {
loading.value = false
}
}
// 获取机构树
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取机构失败', error)
}
}
// 搜索
const handleSearch = () => {
resetPage()
fetchTransferList()
}
// 重置
const handleReset = () => {
Object.assign(filters, {
status: undefined,
sourceOrgId: undefined,
targetOrgId: undefined,
dateRange: null,
keyword: ''
})
handleSearch()
}
// 分页变化
const handlePageChange = () => {
fetchTransferList()
}
// 创建
const handleCreate = () => {
createDialogVisible.value = true
}
// 导出
const handleExport = () => {
ElMessage.info('导出功能开发中')
}
// 查看详情
const handleView = (row: any) => {
selectedTransferId.value = row.id
detailDialogVisible.value = true
}
// 审批
const handleApprove = (row: any) => {
selectedTransfer.value = row
approveForm.approved = true
approveForm.comment = ''
approveDialogVisible.value = true
}
// 提交审批
const handleApproveSubmit = async () => {
try {
await approveTransfer(selectedTransfer.value.id, approveForm)
ElMessage.success('审批成功')
approveDialogVisible.value = false
fetchTransferList()
} catch (error) {
ElMessage.error('审批失败')
}
}
// 执行
const handleExecute = async (row: any) => {
try {
await executeTransfer(row.id)
ElMessage.success('执行成功')
fetchTransferList()
} catch (error) {
ElMessage.error('执行失败')
}
}
// 取消
const handleCancel = async (id: number) => {
try {
await cancelTransfer(id)
ElMessage.success('取消成功')
fetchTransferList()
} catch (error) {
ElMessage.error('取消失败')
}
}
// 保存成功回调
const handleSaveSuccess = () => {
fetchTransferList()
}
// 状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 状态名称
const getStatusName = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.label || status
}
onMounted(() => {
fetchTransferList()
fetchOrganizations()
})
</script>
<style scoped lang="scss">
.transfer-list {
.toolbar {
margin-bottom: 16px;
.toolbar-actions {
margin-bottom: 16px;
}
.filter-section {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
}
.table-card {
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
}
</style>

View File

@@ -0,0 +1,385 @@
<template>
<el-dialog
v-model="visible"
title="分配单详情"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-tabs v-model="activeTab" type="border-card">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
<el-descriptions v-if="orderDetail" :column="2" border>
<el-descriptions-item label="分配单号">
{{ orderDetail.orderCode }}
</el-descriptions-item>
<el-descriptions-item label="单据类型">
<el-tag>{{ orderDetail.orderTypeName }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="标题" :span="2">
{{ orderDetail.title }}
</el-descriptions-item>
<el-descriptions-item label="目标机构">
{{ orderDetail.targetOrganization?.orgName }}
</el-descriptions-item>
<el-descriptions-item label="申请人">
{{ orderDetail.applicant?.realName }}
</el-descriptions-item>
<el-descriptions-item label="资产数量">
{{ orderDetail.assetCount }}
</el-descriptions-item>
<el-descriptions-item label="审批状态">
<el-tag :type="getApprovalStatusType(orderDetail.approvalStatus)">
{{ getApprovalStatusName(orderDetail.approvalStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="执行状态">
<el-tag :type="getExecuteStatusType(orderDetail.executeStatus)">
{{ getExecuteStatusName(orderDetail.executeStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ formatDateTime(orderDetail.createdAt) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ orderDetail.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 资产明细 -->
<el-tab-pane label="资产明细" name="assets">
<el-table
:data="orderDetail?.assets || []"
border
max-height="400"
>
<el-table-column type="index" label="序号" width="60" />
<el-table-column prop="assetCode" label="资产编码" width="180" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="brand.brandName" label="品牌" width="120" />
<el-table-column prop="organization.orgName" label="当前机构" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 审批流程 -->
<el-tab-pane label="审批流程" name="approval">
<el-timeline>
<el-timeline-item
v-for="(item, index) in approvalHistory"
:key="index"
:timestamp="formatDateTime(item.createdAt)"
placement="top"
:type="getTimelineType(item.approvalStatus)"
:icon="getTimelineIcon(item.approvalStatus)"
>
<el-card>
<div class="approval-item">
<div class="approval-header">
<span class="approval-operator">{{ item.operator?.realName }}</span>
<el-tag :type="getApprovalStatusType(item.approvalStatus)" size="small">
{{ getApprovalStatusName(item.approvalStatus) }}
</el-tag>
</div>
<div v-if="item.approvalRemark" class="approval-remark">
{{ item.approvalRemark }}
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</el-tab-pane>
</el-tabs>
<!-- 操作区域 -->
<div v-if="orderDetail" class="action-section">
<!-- 待审批状态 -->
<div v-if="orderDetail.approvalStatus === 'pending'" class="action-group">
<el-divider content-position="left">审批操作</el-divider>
<el-input
v-model="approvalForm.remark"
type="textarea"
:rows="3"
placeholder="请输入审批意见"
maxlength="500"
show-word-limit
/>
<div class="action-buttons">
<el-button type="success" :loading="actionLoading" @click="handleApprove(true)">
通过
</el-button>
<el-button type="danger" :loading="actionLoading" @click="handleApprove(false)">
拒绝
</el-button>
</div>
</div>
<!-- 已通过待执行状态 -->
<div v-if="orderDetail.approvalStatus === 'approved' && orderDetail.executeStatus === 'pending'" class="action-group">
<el-divider content-position="left">执行操作</el-divider>
<el-button type="primary" :loading="actionLoading" @click="handleExecute">
开始执行
</el-button>
</div>
<!-- 执行中状态 -->
<div v-if="orderDetail.executeStatus === 'executing'" class="action-group">
<el-divider content-position="left">执行操作</el-divider>
<el-button type="success" :loading="actionLoading" @click="handleComplete">
完成执行
</el-button>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getAllocationOrderDetail, approveAllocationOrder } from '@/api'
import { ASSET_STATUS, APPROVAL_STATUS } from '@/utils/constants'
import dayjs from 'dayjs'
interface Props {
modelValue: boolean
orderId?: number | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const activeTab = ref('basic')
const orderDetail = ref<any>(null)
const approvalHistory = ref<any[]>([])
const actionLoading = ref(false)
const approvalForm = reactive({
remark: ''
})
// 获取分配单详情
const fetchOrderDetail = async () => {
if (!props.orderId) return
try {
const data = await getAllocationOrderDetail(props.orderId)
orderDetail.value = data
// TODO: 获取审批历史
approvalHistory.value = []
} catch (error) {
ElMessage.error('获取详情失败')
}
}
// 审批
const handleApprove = async (approved: boolean) => {
if (!approvalForm.remark && !approved) {
ElMessage.warning('请输入拒绝原因')
return
}
actionLoading.value = true
try {
await approveAllocationOrder(props.orderId!, {
approvalStatus: approved ? 'approved' : 'rejected',
approvalRemark: approvalForm.remark
})
ElMessage.success(approved ? '审批通过' : '已拒绝')
emit('success')
fetchOrderDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
actionLoading.value = false
}
}
// 执行
const handleExecute = async () => {
actionLoading.value = true
try {
// TODO: 调用执行API
ElMessage.success('开始执行')
emit('success')
fetchOrderDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
actionLoading.value = false
}
}
// 完成
const handleComplete = async () => {
actionLoading.value = true
try {
// TODO: 调用完成API
ElMessage.success('执行完成')
emit('success')
fetchOrderDetail()
} catch (error) {
ElMessage.error('操作失败')
} finally {
actionLoading.value = false
}
}
// 获取审批状态标签类型
const getApprovalStatusType = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 获取审批状态名称
const getApprovalStatusName = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.label || status
}
// 获取执行状态标签类型
const getExecuteStatusType = (status: string) => {
const map: Record<string, string> = {
pending: 'info',
executing: 'warning',
completed: 'success'
}
return map[status] || ''
}
// 获取执行状态名称
const getExecuteStatusName = (status: string) => {
const map: Record<string, string> = {
pending: '待执行',
executing: '执行中',
completed: '已完成'
}
return map[status] || status
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 获取状态名称
const getStatusName = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.label || status
}
// 获取时间轴类型
const getTimelineType = (status: string) => {
const map: Record<string, string> = {
pending: 'warning',
approved: 'success',
rejected: 'danger'
}
return map[status] || 'primary'
}
// 获取时间轴图标
const getTimelineIcon = (status: string) => {
// TODO: 返回对应的图标组件
return null
}
// 格式化日期时间
const formatDateTime = (datetime: string) => {
return datetime ? dayjs(datetime).format('YYYY-MM-DD HH:mm:ss') : '-'
}
// 关闭对话框
const handleClose = () => {
visible.value = false
// 重置
setTimeout(() => {
activeTab.value = 'basic'
orderDetail.value = null
approvalHistory.value = []
approvalForm.remark = ''
}, 300)
}
// 监听orderId变化
watch(
() => props.orderId,
(orderId) => {
if (orderId && props.modelValue) {
fetchOrderDetail()
}
}
)
// 监听对话框打开
watch(
() => props.modelValue,
(val) => {
if (val && props.orderId) {
fetchOrderDetail()
}
}
)
</script>
<style scoped lang="scss">
.action-section {
margin-top: 20px;
.action-group {
.action-buttons {
margin-top: 16px;
display: flex;
gap: 12px;
}
}
}
.approval-item {
.approval-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.approval-operator {
font-weight: 500;
font-size: 14px;
}
}
.approval-remark {
font-size: 14px;
color: #606266;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,309 @@
<template>
<el-dialog
v-model="visible"
title="选择资产"
width="1000px"
:close-on-click-modal="false"
@close="handleClose"
>
<!-- 筛选条件 -->
<div class="filter-section">
<el-select
v-model="filters.deviceTypeId"
placeholder="设备类型"
clearable
style="width: 150px"
>
<el-option
v-for="type in deviceTypes"
:key="type.id"
:label="type.typeName"
:value="type.id"
/>
</el-select>
<el-select
v-model="filters.organizationId"
placeholder="网点"
clearable
style="width: 150px"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
<el-select
v-model="filters.status"
placeholder="状态"
clearable
style="width: 150px"
>
<el-option
v-for="(item, key) in ASSET_STATUS"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="filters.keyword"
placeholder="搜索资产编码/名称"
clearable
style="width: 250px"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearch">
搜索
</el-button>
</div>
<!-- 资产表格 -->
<el-table
ref="tableRef"
:data="tableData"
v-loading="loading"
border
stripe
max-height="400"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" :selectable="checkSelectable" />
<el-table-column prop="assetCode" label="资产编码" width="180" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="brand.brandName" label="品牌" width="120" />
<el-table-column prop="model" label="型号" width="150" />
<el-table-column prop="organization.orgName" label="所属网点" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handlePageChange"
@current-change="handlePageChange"
/>
</div>
<template #footer>
<div class="dialog-footer">
<span class="selected-info">已选择 {{ selectedAssets.length }} </span>
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
:disabled="selectedAssets.length === 0"
@click="handleConfirm"
>
确定
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Search } from '@element-plus/icons-vue'
import { getAssetList, getDeviceTypes, getOrganizationTree } from '@/api'
import { usePagination } from '@/composables/usePagination'
import { ASSET_STATUS } from '@/utils/constants'
interface Props {
modelValue: boolean
excludeIds?: number[]
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm', assets: any[]): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const tableRef = ref()
const loading = ref(false)
const tableData = ref<any[]>([])
const deviceTypes = ref<any[]>([])
const organizations = ref<any[]>([])
const selectedAssets = ref<any[]>([])
const { pagination, resetPage, setTotal } = usePagination()
const filters = reactive({
deviceTypeId: undefined,
organizationId: undefined,
status: 'in_stock',
keyword: ''
})
// 检查是否可选
const checkSelectable = (row: any) => {
return !props.excludeIds?.includes(row.id)
}
// 获取资产列表
const fetchAssetList = async () => {
loading.value = true
try {
const data = await getAssetList({
page: pagination.page,
page_size: pagination.pageSize,
...filters
})
tableData.value = data.items
setTotal(data.total)
} catch (error) {
console.error('获取资产列表失败', error)
} finally {
loading.value = false
}
}
// 获取设备类型列表
const fetchDeviceTypes = async () => {
try {
const data = await getDeviceTypes({ status: 'active' })
deviceTypes.value = data
} catch (error) {
console.error('获取设备类型失败', error)
}
}
// 获取网点树
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取网点失败', error)
}
}
// 搜索
const handleSearch = () => {
resetPage()
fetchAssetList()
}
// 分页变化
const handlePageChange = () => {
fetchAssetList()
}
// 选择变化
const handleSelectionChange = (selection: any[]) => {
selectedAssets.value = selection
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 获取状态名称
const getStatusName = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.label || status
}
// 确认选择
const handleConfirm = () => {
emit('confirm', selectedAssets.value)
handleClose()
}
// 关闭对话框
const handleClose = () => {
visible.value = false
// 重置选择
setTimeout(() => {
tableRef.value?.clearSelection()
selectedAssets.value = []
}, 300)
}
// 监听对话框打开
watch(
() => props.modelValue,
(val) => {
if (val) {
fetchAssetList()
}
}
)
// 初始化
fetchDeviceTypes()
fetchOrganizations()
</script>
<script lang="ts">
import { computed } from 'vue'
export default {
name: 'AssetSelectorDialog'
}
</script>
<style scoped lang="scss">
.filter-section {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.dialog-footer {
display: flex;
justify-content: space-between;
align-items: center;
.selected-info {
font-size: 14px;
color: #606266;
}
}
</style>

View File

@@ -0,0 +1,343 @@
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑分配单' : '新建分配单'"
width="900px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="单据类型" prop="orderType">
<el-select
v-model="formData.orderType"
placeholder="请选择"
:disabled="isEdit"
>
<el-option
v-for="(item, key) in ALLOCATION_ORDER_TYPE"
:key="key"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="目标机构" prop="targetOrganizationId">
<el-select
v-model="formData.targetOrganizationId"
placeholder="请选择"
filterable
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入分配单标题"
maxlength="100"
show-word-limit
/>
</el-form-item>
</el-col>
</el-row>
<!-- 资产选择 -->
<el-form-item label="选择资产">
<div class="asset-selector">
<el-button type="primary" :icon="Plus" @click="showAssetSelector">
添加资产
</el-button>
<span class="selected-count">
已选 {{ selectedAssets.length }}
</span>
</div>
<!-- 已选资产列表 -->
<el-table
:data="selectedAssets"
border
max-height="300"
class="selected-assets-table"
>
<el-table-column prop="assetCode" label="资产编码" width="180" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="organization.orgName" label="当前机构" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ $index }">
<el-button
link
type="danger"
@click="removeAsset($index)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
maxlength="500"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button @click="handleSaveDraft">
保存草稿
</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
提交审批
</el-button>
</div>
</template>
<!-- 资产选择器对话框 -->
<AssetSelectorDialog
v-model="assetSelectorVisible"
:exclude-ids="selectedAssetIds"
@confirm="handleAssetSelect"
/>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { createAllocationOrder, updateAllocationOrder, getOrganizationTree } from '@/api'
import { ALLOCATION_ORDER_TYPE, ASSET_STATUS } from '@/utils/constants'
import AssetSelectorDialog from './AssetSelectorDialog.vue'
interface Props {
modelValue: boolean
orderId?: number | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const isEdit = computed(() => !!props.orderId)
const formRef = ref()
const submitting = ref(false)
const organizations = ref<any[]>([])
const assetSelectorVisible = ref(false)
const selectedAssets = ref<any[]>([])
const formData = reactive({
orderType: 'allocation',
targetOrganizationId: undefined,
title: '',
remark: ''
})
const formRules = {
orderType: [
{ required: true, message: '请选择单据类型', trigger: 'change' }
],
targetOrganizationId: [
{ required: true, message: '请选择目标机构', trigger: 'change' }
],
title: [
{ required: true, message: '请输入标题', trigger: 'blur' },
{ min: 2, max: 100, message: '标题长度在 2 到 100 个字符', trigger: 'blur' }
]
}
// 已选资产ID列表
const selectedAssetIds = computed(() => {
return selectedAssets.value.map(asset => asset.id)
})
// 获取网点列表
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取网点失败', error)
}
}
// 显示资产选择器
const showAssetSelector = () => {
assetSelectorVisible.value = true
}
// 处理资产选择
const handleAssetSelect = (assets: any[]) => {
selectedAssets.value = [...selectedAssets.value, ...assets]
}
// 移除资产
const removeAsset = (index: number) => {
selectedAssets.value.splice(index, 1)
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 获取状态名称
const getStatusName = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.label || status
}
// 保存草稿
const handleSaveDraft = async () => {
if (selectedAssets.value.length === 0) {
ElMessage.warning('请至少选择一项资产')
return
}
await submitForm(false)
}
// 提交审批
const handleSubmit = async () => {
// 验证表单
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (selectedAssets.value.length === 0) {
ElMessage.warning('请至少选择一项资产')
return
}
await submitForm(true)
}
// 提交表单
const submitForm = async (submitForApproval: boolean) => {
submitting.value = true
try {
const data = {
...formData,
assetIds: selectedAssets.value.map(asset => asset.id),
submitForApproval
}
if (isEdit.value) {
await updateAllocationOrder(props.orderId!, data)
ElMessage.success(submitForApproval ? '提交成功' : '保存成功')
} else {
await createAllocationOrder(data)
ElMessage.success(submitForApproval ? '提交成功' : '保存成功')
}
emit('success')
handleClose()
} catch (error) {
ElMessage.error('操作失败')
} finally {
submitting.value = false
}
}
// 关闭对话框
const handleClose = () => {
visible.value = false
// 重置表单
setTimeout(() => {
formRef.value?.resetFields()
selectedAssets.value = []
Object.assign(formData, {
orderType: 'allocation',
targetOrganizationId: undefined,
title: '',
remark: ''
})
}, 300)
}
// 如果是编辑模式,加载数据
watch(
() => props.orderId,
(orderId) => {
if (orderId && props.modelValue) {
// TODO: 加载分配单详情
}
}
)
// 初始化
fetchOrganizations()
</script>
<style scoped lang="scss">
.asset-selector {
display: flex;
align-items: center;
gap: 12px;
.selected-count {
font-size: 14px;
color: #909399;
}
}
.selected-assets-table {
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,349 @@
<template>
<el-dialog
v-model="visible"
title="创建回收单"
width="800px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="回收机构" prop="organizationId">
<el-select
v-model="formData.organizationId"
placeholder="请选择回收机构"
style="width: 100%"
@change="handleOrgChange"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
</el-form-item>
<el-form-item label="回收资产" prop="assetIds">
<el-button
:icon="Plus"
@click="handleSelectAssets"
>
选择资产
</el-button>
<span style="margin-left: 10px">已选 {{ formData.assetIds.length }} </span>
</el-form-item>
<el-form-item label="资产明细" v-if="selectedAssets.length > 0">
<el-table :data="selectedAssets" border max-height="300">
<el-table-column prop="assetCode" label="资产编码" width="150" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="purchasePrice" label="采购价格" width="120">
<template #default="{ row }">
{{ row.purchasePrice ? `¥${row.purchasePrice.toFixed(2)}` : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
link
type="danger"
@click="handleRemoveAsset(row)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="回收原因" prop="reason">
<el-input
v-model="formData.reason"
type="textarea"
:rows="4"
placeholder="请输入回收原因"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
提交
</el-button>
</template>
</el-dialog>
<!-- 资产选择对话框 -->
<el-dialog
v-model="assetSelectVisible"
title="选择资产"
width="1000px"
append-to-body
>
<div class="filter-section">
<el-input
v-model="assetFilters.keyword"
placeholder="搜索资产编码/名称"
clearable
style="width: 250px"
@keyup.enter="handleSearchAssets"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearchAssets">搜索</el-button>
</div>
<el-table
:data="availableAssets"
v-loading="assetLoading"
border
max-height="400"
@selection-change="handleAssetSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="assetCode" label="资产编码" width="150" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="organization.orgName" label="所属机构" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getAssetStatusType(row.status)">
{{ getAssetStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="assetPagination.page"
v-model:page-size="assetPagination.pageSize"
:total="assetPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handleSearchAssets"
@current-change="handleSearchAssets"
/>
</div>
<template #footer>
<el-button @click="assetSelectVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmAssetSelection">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { createRecovery, getAssetList, getOrganizationTree } from '@/api'
import { ASSET_STATUS } from '@/utils/constants'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const organizations = ref<any[]>([])
const availableAssets = ref<any[]>([])
const assetLoading = ref(false)
const assetSelectVisible = ref(false)
const tempSelectedAssets = ref<any[]>([])
const formData = reactive({
organizationId: undefined as number | undefined,
assetIds: [] as number[],
reason: '',
remark: ''
})
const formRules: FormRules = {
organizationId: [{ required: true, message: '请选择回收机构', trigger: 'change' }],
assetIds: [{ required: true, message: '请选择回收资产', trigger: 'change' }],
reason: [{ required: true, message: '请输入回收原因', trigger: 'blur' }]
}
const assetFilters = reactive({
keyword: ''
})
const assetPagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const selectedAssets = computed(() => {
return availableAssets.value.filter(asset => formData.assetIds.includes(asset.id))
})
// 获取机构列表
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取机构失败', error)
}
}
// 机构变化
const handleOrgChange = () => {
formData.assetIds = []
}
// 选择资产
const handleSelectAssets = () => {
if (!formData.organizationId) {
ElMessage.warning('请先选择回收机构')
return
}
assetSelectVisible.value = true
handleSearchAssets()
}
// 搜索资产
const handleSearchAssets = async () => {
assetLoading.value = true
try {
const data = await getAssetList({
page: assetPagination.page,
page_size: assetPagination.pageSize,
organization_id: formData.organizationId,
status: 'in_use',
keyword: assetFilters.keyword
})
availableAssets.value = data.items
assetPagination.total = data.total
} catch (error) {
ElMessage.error('获取资产列表失败')
} finally {
assetLoading.value = false
}
}
// 资产选择变化
const handleAssetSelectionChange = (selection: any[]) => {
tempSelectedAssets.value = selection
}
// 确认资产选择
const handleConfirmAssetSelection = () => {
formData.assetIds = tempSelectedAssets.value.map(item => item.id)
assetSelectVisible.value = false
}
// 移除资产
const handleRemoveAsset = (asset: any) => {
const index = formData.assetIds.indexOf(asset.id)
if (index > -1) {
formData.assetIds.splice(index, 1)
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
await createRecovery(formData)
ElMessage.success('创建成功')
handleClose()
emit('success')
} catch (error) {
ElMessage.error('创建失败')
} finally {
submitting.value = false
}
}
})
}
// 关闭对话框
const handleClose = () => {
formRef.value?.resetFields()
formData.assetIds = []
visible.value = false
}
// 资产状态标签类型
const getAssetStatusType = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 资产状态名称
const getAssetStatusName = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.label || status
}
watch(() => props.modelValue, (val) => {
if (val) {
fetchOrganizations()
}
})
</script>
<style scoped lang="scss">
.filter-section {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,367 @@
<template>
<el-dialog
v-model="visible"
title="创建调拨单"
width="800px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="源机构" prop="sourceOrgId">
<el-select
v-model="formData.sourceOrgId"
placeholder="请选择源机构"
style="width: 100%"
@change="handleSourceOrgChange"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
/>
</el-select>
</el-form-item>
<el-form-item label="目标机构" prop="targetOrgId">
<el-select
v-model="formData.targetOrgId"
placeholder="请选择目标机构"
style="width: 100%"
>
<el-option
v-for="org in organizations"
:key="org.id"
:label="org.orgName"
:value="org.id"
:disabled="org.id === formData.sourceOrgId"
/>
</el-select>
</el-form-item>
<el-form-item label="调拨资产" prop="assetIds">
<el-button
:icon="Plus"
@click="handleSelectAssets"
>
选择资产
</el-button>
<span style="margin-left: 10px">已选 {{ formData.assetIds.length }} </span>
</el-form-item>
<el-form-item label="资产明细" v-if="selectedAssets.length > 0">
<el-table :data="selectedAssets" border max-height="300">
<el-table-column prop="assetCode" label="资产编码" width="150" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="purchasePrice" label="采购价格" width="120">
<template #default="{ row }">
{{ row.purchasePrice ? `¥${row.purchasePrice.toFixed(2)}` : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
link
type="danger"
@click="handleRemoveAsset(row)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
<el-form-item label="调拨原因" prop="reason">
<el-input
v-model="formData.reason"
type="textarea"
:rows="4"
placeholder="请输入调拨原因"
/>
</el-form-item>
<el-form-item label="备注">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
提交
</el-button>
</template>
</el-dialog>
<!-- 资产选择对话框 -->
<el-dialog
v-model="assetSelectVisible"
title="选择资产"
width="1000px"
append-to-body
>
<div class="filter-section">
<el-input
v-model="assetFilters.keyword"
placeholder="搜索资产编码/名称"
clearable
style="width: 250px"
@keyup.enter="handleSearchAssets"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-button type="primary" @click="handleSearchAssets">搜索</el-button>
</div>
<el-table
:data="availableAssets"
v-loading="assetLoading"
border
max-height="400"
@selection-change="handleAssetSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="assetCode" label="资产编码" width="150" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="organization.orgName" label="所属机构" width="150" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getAssetStatusType(row.status)">
{{ getAssetStatusName(row.status) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="assetPagination.page"
v-model:page-size="assetPagination.pageSize"
:total="assetPagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
@size-change="handleSearchAssets"
@current-change="handleSearchAssets"
/>
</div>
<template #footer>
<el-button @click="assetSelectVisible = false">取消</el-button>
<el-button type="primary" @click="handleConfirmAssetSelection">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { createTransfer, getAssetList, getOrganizationTree } from '@/api'
import { ASSET_STATUS } from '@/utils/constants'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const formRef = ref<FormInstance>()
const submitting = ref(false)
const organizations = ref<any[]>([])
const availableAssets = ref<any[]>([])
const assetLoading = ref(false)
const assetSelectVisible = ref(false)
const tempSelectedAssets = ref<any[]>([])
const formData = reactive({
sourceOrgId: undefined as number | undefined,
targetOrgId: undefined as number | undefined,
assetIds: [] as number[],
reason: '',
remark: ''
})
const formRules: FormRules = {
sourceOrgId: [{ required: true, message: '请选择源机构', trigger: 'change' }],
targetOrgId: [{ required: true, message: '请选择目标机构', trigger: 'change' }],
assetIds: [{ required: true, message: '请选择调拨资产', trigger: 'change' }],
reason: [{ required: true, message: '请输入调拨原因', trigger: 'blur' }]
}
const assetFilters = reactive({
keyword: ''
})
const assetPagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const selectedAssets = computed(() => {
return availableAssets.value.filter(asset => formData.assetIds.includes(asset.id))
})
// 获取机构列表
const fetchOrganizations = async () => {
try {
const tree = await getOrganizationTree()
const flatten = (nodes: any[]) => {
const result: any[] = []
nodes.forEach(node => {
result.push(node)
if (node.children) {
result.push(...flatten(node.children))
}
})
return result
}
organizations.value = flatten(tree)
} catch (error) {
console.error('获取机构失败', error)
}
}
// 源机构变化
const handleSourceOrgChange = () => {
formData.assetIds = []
}
// 选择资产
const handleSelectAssets = () => {
if (!formData.sourceOrgId) {
ElMessage.warning('请先选择源机构')
return
}
assetSelectVisible.value = true
handleSearchAssets()
}
// 搜索资产
const handleSearchAssets = async () => {
assetLoading.value = true
try {
const data = await getAssetList({
page: assetPagination.page,
page_size: assetPagination.pageSize,
organization_id: formData.sourceOrgId,
status: 'in_stock',
keyword: assetFilters.keyword
})
availableAssets.value = data.items
assetPagination.total = data.total
} catch (error) {
ElMessage.error('获取资产列表失败')
} finally {
assetLoading.value = false
}
}
// 资产选择变化
const handleAssetSelectionChange = (selection: any[]) => {
tempSelectedAssets.value = selection
}
// 确认资产选择
const handleConfirmAssetSelection = () => {
formData.assetIds = tempSelectedAssets.value.map(item => item.id)
assetSelectVisible.value = false
}
// 移除资产
const handleRemoveAsset = (asset: any) => {
const index = formData.assetIds.indexOf(asset.id)
if (index > -1) {
formData.assetIds.splice(index, 1)
}
}
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
await createTransfer(formData)
ElMessage.success('创建成功')
handleClose()
emit('success')
} catch (error) {
ElMessage.error('创建失败')
} finally {
submitting.value = false
}
}
})
}
// 关闭对话框
const handleClose = () => {
formRef.value?.resetFields()
formData.assetIds = []
visible.value = false
}
// 资产状态标签类型
const getAssetStatusType = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 资产状态名称
const getAssetStatusName = (status: string) => {
const item = Object.values(ASSET_STATUS).find(item => item.value === status)
return item?.label || status
}
watch(() => props.modelValue, (val) => {
if (val) {
fetchOrganizations()
}
})
</script>
<style scoped lang="scss">
.filter-section {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<el-dialog
v-model="visible"
title="回收单详情"
width="900px"
@close="handleClose"
>
<div v-loading="loading">
<!-- 基本信息 -->
<el-descriptions
v-if="detail"
title="基本信息"
:column="2"
border
class="mb-16"
>
<el-descriptions-item label="回收单号">
{{ detail.recoveryNo }}
</el-descriptions-item>
<el-descriptions-item label="审批状态">
<el-tag :type="getStatusType(detail.status)">
{{ getStatusName(detail.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="回收机构">
{{ detail.organization?.orgName }}
</el-descriptions-item>
<el-descriptions-item label="资产数量">
{{ detail.assetCount }}
</el-descriptions-item>
<el-descriptions-item label="总价值">
{{ detail.totalValue ? `¥${detail.totalValue.toFixed(2)}` : '-' }}
</el-descriptions-item>
<el-descriptions-item label="申请人">
{{ detail.applicant?.username }}
</el-descriptions-item>
<el-descriptions-item label="申请时间" :span="2">
{{ detail.createdAt }}
</el-descriptions-item>
<el-descriptions-item label="回收原因" :span="2">
{{ detail.reason }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ detail.remark || '-' }}
</el-descriptions-item>
</el-descriptions>
<!-- 资产明细 -->
<div class="section-title mb-16">资产明细</div>
<el-table
:data="detail?.assets || []"
border
max-height="300"
class="mb-16"
>
<el-table-column prop="assetCode" label="资产编码" width="150" />
<el-table-column prop="assetName" label="资产名称" min-width="150" />
<el-table-column prop="deviceType.typeName" label="设备类型" width="120" />
<el-table-column prop="brand.brandName" label="品牌" width="120" />
<el-table-column prop="model" label="型号" width="150" />
<el-table-column prop="purchasePrice" label="采购价格" width="120">
<template #default="{ row }">
{{ row.purchasePrice ? `¥${row.purchasePrice.toFixed(2)}` : '-' }}
</template>
</el-table-column>
<el-table-column prop="serialNumber" label="序列号" width="150" />
</el-table>
<!-- 审批历史 -->
<div class="section-title mb-16">审批历史</div>
<el-timeline>
<el-timeline-item
v-for="(item, index) in detail?.approvalHistory || []"
:key="index"
:timestamp="item.createdAt"
placement="top"
>
<el-card>
<div class="approval-item">
<div class="approval-header">
<span class="approver">{{ item.approver?.username }}</span>
<el-tag :type="item.approved ? 'success' : 'danger'" size="small">
{{ item.approved ? '通过' : '拒绝' }}
</el-tag>
</div>
<div class="approval-comment" v-if="item.comment">
{{ item.comment }}
</div>
</div>
</el-card>
</el-timeline-item>
</el-timeline>
</div>
<template #footer>
<el-button @click="handleClose">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { getRecoveryDetail } from '@/api'
import { APPROVAL_STATUS } from '@/utils/constants'
import { ElMessage } from 'element-plus'
interface Props {
modelValue: boolean
recoveryId: number | null
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const loading = ref(false)
const detail = ref<any>(null)
// 获取回收单详情
const fetchDetail = async () => {
if (!props.recoveryId) return
loading.value = true
try {
detail.value = await getRecoveryDetail(props.recoveryId)
} catch (error) {
ElMessage.error('获取详情失败')
} finally {
loading.value = false
}
}
// 关闭对话框
const handleClose = () => {
detail.value = null
visible.value = false
}
// 状态标签类型
const getStatusType = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.type || ''
}
// 状态名称
const getStatusName = (status: string) => {
const item = Object.values(APPROVAL_STATUS).find(item => item.value === status)
return item?.label || status
}
watch(() => props.modelValue, (val) => {
if (val && props.recoveryId) {
fetchDetail()
}
})
</script>
<style scoped lang="scss">
.mb-16 {
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.approval-item {
.approval-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.approver {
font-weight: bold;
color: #303133;
}
}
.approval-comment {
color: #606266;
font-size: 14px;
}
}
</style>

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