Merge remote-tracking branch 'origin/master'
合并远程前端源代码与本地后端修复 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
31
.editorconfig
Normal file
31
.editorconfig
Normal 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
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
# 开发环境配置
|
||||
VITE_API_BASE_URL=http://localhost:8000/api/v1
|
||||
VITE_APP_TITLE=资产管理系统
|
||||
3
.env.production
Normal file
3
.env.production
Normal 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
312
.eslintrc-auto-import.json
Normal 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
41
.eslintrc.cjs
Normal 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
72
.gitignore
vendored
@@ -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
9
.prettierrc
Normal 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
64
CHARTS_FILES.txt
Normal 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
12
Dockerfile
Normal 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
217
README.md
Normal 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
13
index.html
Normal 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
23
nginx.conf
Normal 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
6381
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
56
package.json
Normal file
56
package.json
Normal 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
107
playwright.config.ts
Normal 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
14
src/App.vue
Normal 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
102
src/api/assets.ts
Normal 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
78
src/api/auth.ts
Normal 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
103
src/api/device-types.ts
Normal 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
204
src/api/file.ts
Normal 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
244
src/api/index.ts
Normal 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
61
src/api/organizations.ts
Normal 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
196
src/api/request.ts
Normal 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
65
src/api/roles.ts
Normal 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
80
src/api/users.ts
Normal 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 })
|
||||
}
|
||||
256
src/assets/styles/index.scss
Normal file
256
src/assets/styles/index.scss
Normal 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;
|
||||
}
|
||||
86
src/assets/styles/variables.scss
Normal file
86
src/assets/styles/variables.scss
Normal 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
308
src/auto-imports.d.ts
vendored
Normal 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
99
src/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
336
src/components/NotificationBell.vue
Normal file
336
src/components/NotificationBell.vue
Normal 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>
|
||||
170
src/components/charts/BarChart.vue
Normal file
170
src/components/charts/BarChart.vue
Normal 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>
|
||||
114
src/components/charts/BaseChart.vue
Normal file
114
src/components/charts/BaseChart.vue
Normal 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>
|
||||
109
src/components/charts/FunnelChart.vue
Normal file
109
src/components/charts/FunnelChart.vue
Normal 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>
|
||||
114
src/components/charts/GaugeChart.vue
Normal file
114
src/components/charts/GaugeChart.vue
Normal 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>
|
||||
225
src/components/charts/LineChart.vue
Normal file
225
src/components/charts/LineChart.vue
Normal 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>
|
||||
120
src/components/charts/PieChart.vue
Normal file
120
src/components/charts/PieChart.vue
Normal 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>
|
||||
105
src/components/charts/README.md
Normal file
105
src/components/charts/README.md
Normal 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 = '图表组件开发组'
|
||||
77
src/components/charts/business/AssetDistributionChart.vue
Normal file
77
src/components/charts/business/AssetDistributionChart.vue
Normal 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>
|
||||
70
src/components/charts/business/AssetStatusChart.vue
Normal file
70
src/components/charts/business/AssetStatusChart.vue
Normal 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>
|
||||
62
src/components/charts/business/AssetUtilizationChart.vue
Normal file
62
src/components/charts/business/AssetUtilizationChart.vue
Normal 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>
|
||||
93
src/components/charts/business/AssetValueTrendChart.vue
Normal file
93
src/components/charts/business/AssetValueTrendChart.vue
Normal 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
23
src/components/charts/charts.d.ts
vendored
Normal 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
|
||||
21
src/components/charts/index.ts
Normal file
21
src/components/charts/index.ts
Normal 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'
|
||||
60
src/components/common/TreeSelect.vue
Normal file
60
src/components/common/TreeSelect.vue
Normal 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>
|
||||
601
src/components/file/FileList.vue
Normal file
601
src/components/file/FileList.vue
Normal 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>
|
||||
438
src/components/file/FileUpload.vue
Normal file
438
src/components/file/FileUpload.vue
Normal 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>
|
||||
400
src/components/file/ImagePreview.vue
Normal file
400
src/components/file/ImagePreview.vue
Normal 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>
|
||||
19
src/components/file/index.ts
Normal file
19
src/components/file/index.ts
Normal 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
|
||||
}
|
||||
322
src/components/file/types.ts
Normal file
322
src/components/file/types.ts
Normal 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
|
||||
}
|
||||
367
src/components/form/DynamicFieldRenderer.vue
Normal file
367
src/components/form/DynamicFieldRenderer.vue
Normal 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>
|
||||
467
src/components/form/FieldDesigner.vue
Normal file
467
src/components/form/FieldDesigner.vue
Normal 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>
|
||||
45
src/components/form/fields/BooleanField.vue
Normal file
45
src/components/form/fields/BooleanField.vue
Normal 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>
|
||||
67
src/components/form/fields/DateField.vue
Normal file
67
src/components/form/fields/DateField.vue
Normal 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>
|
||||
79
src/components/form/fields/MultiSelectField.vue
Normal file
79
src/components/form/fields/MultiSelectField.vue
Normal 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>
|
||||
77
src/components/form/fields/NumberField.vue
Normal file
77
src/components/form/fields/NumberField.vue
Normal 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>
|
||||
77
src/components/form/fields/SelectField.vue
Normal file
77
src/components/form/fields/SelectField.vue
Normal 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>
|
||||
64
src/components/form/fields/TextField.vue
Normal file
64
src/components/form/fields/TextField.vue
Normal 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>
|
||||
74
src/components/form/fields/TextareaField.vue
Normal file
74
src/components/form/fields/TextareaField.vue
Normal 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>
|
||||
210
src/components/statistics/StatCard.vue
Normal file
210
src/components/statistics/StatCard.vue
Normal 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>
|
||||
78
src/components/statistics/StatCardGroup.vue
Normal file
78
src/components/statistics/StatCardGroup.vue
Normal 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>
|
||||
6
src/components/statistics/index.ts
Normal file
6
src/components/statistics/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 统计组件统一导出
|
||||
*/
|
||||
|
||||
export { default as StatCard } from './StatCard.vue'
|
||||
export { default as StatCardGroup } from './StatCardGroup.vue'
|
||||
223
src/composables/useChartData.ts
Normal file
223
src/composables/useChartData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
245
src/composables/useDynamicForm.ts
Normal file
245
src/composables/useDynamicForm.ts
Normal 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
|
||||
}
|
||||
}
|
||||
210
src/composables/useECharts.ts
Normal file
210
src/composables/useECharts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
179
src/composables/useFieldConfig.ts
Normal file
179
src/composables/useFieldConfig.ts
Normal 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
|
||||
}
|
||||
38
src/composables/usePagination.ts
Normal file
38
src/composables/usePagination.ts
Normal 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
|
||||
}
|
||||
}
|
||||
88
src/composables/useTable.ts
Normal file
88
src/composables/useTable.ts
Normal 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
13
src/index.html
Normal 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
314
src/layouts/MainLayout.vue
Normal 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
26
src/main.ts
Normal 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
278
src/router/index.ts
Normal 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
5
src/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Store 统一导出
|
||||
*/
|
||||
export { useUserStore } from './modules/user'
|
||||
export { useAppStore } from './modules/app'
|
||||
68
src/stores/modules/app.ts
Normal file
68
src/stores/modules/app.ts
Normal 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
|
||||
}
|
||||
})
|
||||
97
src/stores/modules/user.ts
Normal file
97
src/stores/modules/user.ts
Normal 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
193
src/types/charts.ts
Normal 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
187
src/types/form.ts
Normal 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
254
src/types/index.ts
Normal 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
42
src/utils/auth.ts
Normal 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
86
src/utils/constants.ts
Normal 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
500
src/utils/echarts.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
284
src/utils/echarts/performance.ts
Normal file
284
src/utils/echarts/performance.ts
Normal 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()
|
||||
}
|
||||
283
src/utils/fieldDependency.ts
Normal file
283
src/utils/fieldDependency.ts
Normal 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
261
src/utils/fieldValidator.ts
Normal 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
425
src/utils/file.ts
Normal 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
47
src/utils/format.ts
Normal 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
51
src/utils/validate.ts
Normal 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
72
src/views/FileManager.vue
Normal 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>
|
||||
683
src/views/admin/DeviceTypeManagement.vue
Normal file
683
src/views/admin/DeviceTypeManagement.vue
Normal 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>
|
||||
492
src/views/admin/OrganizationManagement.vue
Normal file
492
src/views/admin/OrganizationManagement.vue
Normal 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>
|
||||
393
src/views/admin/RoleManagement.vue
Normal file
393
src/views/admin/RoleManagement.vue
Normal 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>
|
||||
548
src/views/admin/UserManagement.vue
Normal file
548
src/views/admin/UserManagement.vue
Normal 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>
|
||||
399
src/views/allocation/AllocationList.vue
Normal file
399
src/views/allocation/AllocationList.vue
Normal 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>
|
||||
397
src/views/allocation/RecoveryList.vue
Normal file
397
src/views/allocation/RecoveryList.vue
Normal 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>
|
||||
414
src/views/allocation/TransferList.vue
Normal file
414
src/views/allocation/TransferList.vue
Normal 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>
|
||||
385
src/views/allocation/components/AllocationDetailDialog.vue
Normal file
385
src/views/allocation/components/AllocationDetailDialog.vue
Normal 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>
|
||||
309
src/views/allocation/components/AssetSelectorDialog.vue
Normal file
309
src/views/allocation/components/AssetSelectorDialog.vue
Normal 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>
|
||||
343
src/views/allocation/components/CreateAllocationDialog.vue
Normal file
343
src/views/allocation/components/CreateAllocationDialog.vue
Normal 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>
|
||||
349
src/views/allocation/components/CreateRecoveryDialog.vue
Normal file
349
src/views/allocation/components/CreateRecoveryDialog.vue
Normal 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>
|
||||
367
src/views/allocation/components/CreateTransferDialog.vue
Normal file
367
src/views/allocation/components/CreateTransferDialog.vue
Normal 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>
|
||||
195
src/views/allocation/components/RecoveryDetailDialog.vue
Normal file
195
src/views/allocation/components/RecoveryDetailDialog.vue
Normal 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
Reference in New Issue
Block a user