Compare commits

...

50 Commits

Author SHA1 Message Date
Mr.j e3ec37fa79 Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-11 11:14:16 +08:00
Mr.j 281c99edd5 连接本地服务器 2025-08-11 11:07:32 +08:00
Mr.j 48632707cc 实现了消息中心显示最新申请消息,并可以查看消息然后点击跳转到具体的审批台 2025-08-11 10:54:45 +08:00
chabai ff2c80e1e8 Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-11 10:44:10 +08:00
chabai 45fe56a4f9 放弃跟踪 2025-08-11 10:43:06 +08:00
admin123 2ca37ad3c9 更新 .env.production 2025-08-11 10:38:27 +08:00
admin123 50e93bf8be 更新 .env.production 2025-08-11 10:31:42 +08:00
chabai 40ae745dfb 商务模块的文件夹结构优化成树形层级结构 2025-08-11 10:24:17 +08:00
马诗敏 a80a23fd74 Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-10 20:46:42 +08:00
马诗敏 3fc298de9b 完善团队成员明细展示 2025-08-10 20:41:24 +08:00
何德超 a1966feef6 视频监测原数据管理原型 2025-08-10 20:23:18 +08:00
何德超 b3778ec7c2 Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-10 20:19:57 +08:00
何德超 3dd794d7f0 形变接口原型 2025-08-10 20:10:47 +08:00
Maple 16743cfc7f fix:支出合同的编辑功能实现 2025-08-08 17:59:38 +08:00
Mr.j d5929bdc2d Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 17:49:24 +08:00
Mr.j a0912edc6a 实现申请采购推送信息到审批台出现语音提示和闪烁提示 2025-08-08 17:35:49 +08:00
chabai 0401a28037 完善商务模块文件上传功能 2025-08-08 17:02:32 +08:00
chabai 196a5d864a Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 16:40:10 +08:00
chabai 099b3ee406 实现商务模块可以选择多个文件上传 2025-08-08 16:40:05 +08:00
Mr.j 37d2d21318 Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 16:37:46 +08:00
Mr.j f7641f6439 完善审批台接收推送信息 2025-08-08 16:37:36 +08:00
Maple f870f50be0 fix:收入合同的编辑功能实现 2025-08-08 15:51:17 +08:00
Mr.j 5a13911694 实现审批台接收到采购申请信息 2025-08-08 15:44:56 +08:00
马诗敏 ae29bb661f 修改团队成员管理bug 2025-08-08 15:23:57 +08:00
mashimin a79c4f3489 项目中控台 2025-08-08 14:26:22 +08:00
chabai aa3e6c732c Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 13:51:36 +08:00
chabai fdd20e9a9b 开发商务模块的文件预览功能 2025-08-08 13:51:32 +08:00
Maple 348c0396eb fix:收入合同的详情以及显示,以及完成和后端api的调用。 2025-08-08 10:49:58 +08:00
Mr.j 0d515ab87f Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 10:28:17 +08:00
chabai e098cfd8fa 开发商务模块文件预览功能 2025-08-08 10:26:36 +08:00
Mr.j 478aaf4a0a Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-08 10:24:59 +08:00
Mr.j 68b98eea7b 优化有两个资产管理菜单 2025-08-08 10:21:37 +08:00
Mr.j d6f87cd0db 优化审批台的搜索框看不清的问题 2025-08-07 17:38:41 +08:00
Mr.j e83579dc57 完成审批台基础页面搭建并实现查询功能 2025-08-07 16:46:44 +08:00
Maple 19cdc0998f fix:收入合同 2025-08-07 16:15:23 +08:00
chabai 7efb1fa115 商务模块更改位置 2025-08-07 16:05:06 +08:00
chabai 66f6cce01b Merge branch 'devlopment' of http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web into devlopment 2025-08-07 15:26:43 +08:00
chabai 53a81f0b01 完善商务模块重命名功能,修复文件上传bug 2025-08-07 15:26:29 +08:00
Maple 946c965935 Merge remote-tracking branch 'origin/devlopment' into devlopment 2025-08-07 14:14:50 +08:00
Mr.j 36db98a343 实现设备采购新增功能,并规范设备状态,添加未入库状态 2025-08-07 12:01:46 +08:00
Maple 4d4e6fdc2c Merge remote-tracking branch 'origin/devlopment' into devlopment
# Conflicts:
#	src/router/route.ts
2025-08-07 11:51:25 +08:00
chabai e9d68d7ae3 制度管理模块功能修复 2025-08-07 09:15:20 +08:00
chabai 9a4d9232fc 优化商务数据库信息模块 2025-08-06 17:31:05 +08:00
Mr.j 9be59bee8a 优化完成设备管理和培训模块的加入到devlopment 2025-08-06 15:35:51 +08:00
Mr.j b44bcebf0f 解决冲突并合并 2025-08-06 14:59:01 +08:00
Mr.j 45d54c50d5 把设备管理和培训模块加入到devlopment分支 2025-08-06 14:53:27 +08:00
admin123 2fa43bf7cf 更新 .gitignore 2025-08-06 11:45:43 +08:00
admin123 79527eb506 更新 .gitignore 2025-08-06 11:43:42 +08:00
chabai 817a709639 商务信息库修复 2025-08-06 10:55:54 +08:00
admin123 6317d898a3 Merge pull request 'main' (#1) from main into devlopment
Reviewed-on: http://pms.dtyx.net:3000/wuxueyu/Industrial-image-management-system---web/pulls/1
2025-08-06 08:48:40 +08:00
73 changed files with 21389 additions and 1418 deletions

View File

@ -4,10 +4,14 @@ VITE_API_PREFIX = '/dev-api'
# 接口地址 # 接口地址
# VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/' # VITE_API_BASE_URL = 'http://pms.dtyx.net:9158/'
VITE_API_BASE_URL = 'http://localhost:8888/' # VITE_API_BASE_URL = 'http://localhost:8888/'
VITE_API_BASE_URL = 'http://10.18.34.163:8888/'
# VITE_API_BASE_URL = 'http://10.18.34.213:8888/'
# 接口地址 (WebSocket) # 接口地址 (WebSocket)
VITE_API_WS_URL = 'ws://localhost:8000' # VITE_API_WS_URL = 'ws://localhost:8000'
VITE_API_WS_URL = 'ws://10.18.34.163:8000'
# VITE_API_WS_URL = 'ws://10.18.34.213:8000'
# 地址前缀 # 地址前缀
VITE_BASE = '/' VITE_BASE = '/'

3
.gitignore vendored
View File

@ -1 +1,4 @@
node_modules node_modules
dist
.gitignore
.env.development

88
PREVIEW_FEATURE_GUIDE.md Normal file
View File

@ -0,0 +1,88 @@
# 商务数据库文件预览功能说明
## 功能概述
商务数据库信息模块的文件预览功能已经实现,支持多种文件类型的在线预览,为用户提供便捷的文件查看体验。
## 支持的文件类型
### 1. 图片文件
- **支持格式**: JPG, JPEG, PNG, GIF, BMP, WebP
- **预览方式**: 在模态窗口中直接显示图片
- **特性**:
- 自适应大小最大高度为70vh
- 圆角和阴影效果提升视觉体验
- 显示文件名信息
### 2. PDF文件
- **支持格式**: PDF
- **预览方式**: 在新窗口中打开PDF文件
- **特性**: 利用浏览器原生PDF查看器
### 3. 文本文件
- **支持格式**: TXT, MD, JSON, XML, CSV
- **预览方式**: 在模态窗口中显示文本内容
- **特性**:
- 等宽字体显示,保持格式
- 400px高度的滚动区域
- 保留原文本格式和换行
### 4. Office文档
- **支持格式**: DOC, DOCX, XLS, XLSX, PPT, PPTX
- **预览方式**: 提示用户下载查看
- **说明**: 由于Office文档需要特殊渲染建议下载后使用相应软件打开
### 5. 其他文件类型
- **处理方式**: 询问用户是否下载查看
- **说明**: 对于不支持在线预览的文件类型,系统会友好提示用户
## 功能特性
### 用户体验优化
1. **加载提示**: 预览过程中显示"正在加载预览..."提示
2. **错误处理**: 针对不同错误状态提供详细的错误信息
3. **资源管理**: 自动清理预览使用的内存资源
### 错误处理机制
- **404错误**: "文件不存在或已被删除"
- **403错误**: "没有权限访问该文件"
- **500错误**: "服务器内部错误,请稍后重试"
- **网络错误**: "预览文件失败,请检查网络连接"
### 安全性
- 使用Blob URL进行文件预览确保安全性
- 10秒后自动释放URL资源防止内存泄漏
## 使用方法
1. 在文件列表中找到要预览的文件
2. 点击文件行中的"眼睛"图标按钮
3. 系统会根据文件类型自动选择合适的预览方式
4. 对于不支持的文件类型,系统会提供下载选项
## 技术实现
### API接口
- **接口地址**: `/businessData/file/preview`
- **请求方式**: GET
- **参数**: `fileId` (必需)
- **响应类型**: Blob
### 核心函数
- `handlePreview(file)`: 主预览函数
- `showImagePreview(url, fileName)`: 图片预览组件
- `showTextPreview(blob, fileName)`: 文本预览组件
## 注意事项
1. **文件大小限制**: 大文件可能需要较长加载时间
2. **浏览器兼容性**: 确保浏览器支持Blob和URL.createObjectURL
3. **网络要求**: 稳定的网络连接确保预览效果
4. **权限控制**: 需要有相应的文件访问权限
## 后续优化建议
1. 添加文件预览缓存机制
2. 支持更多文件格式的在线预览
3. 添加图片预览的缩放和旋转功能
4. 支持文档的分页预览功能

580
package-lock.json generated
View File

@ -60,21 +60,27 @@
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^2.16.3", "@antfu/eslint-config": "^2.16.3",
"@arco-design/web-vue": "^2.57.0", "@arco-design/web-vue": "^2.57.0",
"@eslint/js": "^9.32.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.2.5", "@types/node": "^20.2.5",
"@types/query-string": "^6.3.0", "@types/query-string": "^6.3.0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"eslint": "^9.0.0", "eslint": "^9.32.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"less": "^4.1.3", "less": "^4.1.3",
"less-loader": "^11.0.0", "less-loader": "^11.0.0",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"sass": "^1.62.1", "sass": "^1.62.1",
"sass-loader": "^13.2.2", "sass-loader": "^13.2.2",
"typescript": "~5.0.4", "typescript": "~5.0.4",
"typescript-eslint": "^8.39.0",
"unplugin-auto-import": "^0.16.4", "unplugin-auto-import": "^0.16.4",
"unplugin-vue-components": "^0.25.1", "unplugin-vue-components": "^0.25.1",
"vite": "^5.1.5", "vite": "^5.1.5",
@ -210,6 +216,18 @@
} }
} }
}, },
"node_modules/@antfu/eslint-config/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmmirror.com/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": { "node_modules/@antfu/install-pkg": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.4.1.tgz", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-0.4.1.tgz",
@ -3324,17 +3342,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz",
"integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "integrity": "sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.10.0", "@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.39.0",
"@typescript-eslint/type-utils": "8.38.0", "@typescript-eslint/type-utils": "8.39.0",
"@typescript-eslint/utils": "8.38.0", "@typescript-eslint/utils": "8.39.0",
"@typescript-eslint/visitor-keys": "8.38.0", "@typescript-eslint/visitor-keys": "8.39.0",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^7.0.0", "ignore": "^7.0.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@ -3348,9 +3365,9 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.38.0", "@typescript-eslint/parser": "^8.39.0",
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
@ -3364,16 +3381,15 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.39.0.tgz",
"integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "integrity": "sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.39.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/typescript-estree": "8.39.0",
"@typescript-eslint/visitor-keys": "8.38.0", "@typescript-eslint/visitor-keys": "8.39.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -3385,18 +3401,17 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.39.0.tgz",
"integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "integrity": "sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.38.0", "@typescript-eslint/tsconfig-utils": "^8.39.0",
"@typescript-eslint/types": "^8.38.0", "@typescript-eslint/types": "^8.39.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -3407,18 +3422,17 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz",
"integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", "integrity": "sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"@typescript-eslint/visitor-keys": "8.38.0" "@typescript-eslint/visitor-keys": "8.39.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3429,11 +3443,10 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz",
"integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "integrity": "sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@ -3442,19 +3455,18 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz",
"integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "integrity": "sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"@typescript-eslint/typescript-estree": "8.38.0", "@typescript-eslint/typescript-estree": "8.39.0",
"@typescript-eslint/utils": "8.38.0", "@typescript-eslint/utils": "8.39.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^2.1.0" "ts-api-utils": "^2.1.0"
}, },
@ -3467,15 +3479,14 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.39.0.tgz",
"integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "integrity": "sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@ -3485,16 +3496,15 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz",
"integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "integrity": "sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.38.0", "@typescript-eslint/project-service": "8.39.0",
"@typescript-eslint/tsconfig-utils": "8.38.0", "@typescript-eslint/tsconfig-utils": "8.39.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"@typescript-eslint/visitor-keys": "8.38.0", "@typescript-eslint/visitor-keys": "8.39.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -3510,20 +3520,19 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.39.0.tgz",
"integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "integrity": "sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.7.0", "@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.38.0", "@typescript-eslint/scope-manager": "8.39.0",
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"@typescript-eslint/typescript-estree": "8.38.0" "@typescript-eslint/typescript-estree": "8.39.0"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -3534,17 +3543,16 @@
}, },
"peerDependencies": { "peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0", "eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.38.0", "version": "8.39.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz",
"integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "integrity": "sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.38.0", "@typescript-eslint/types": "8.39.0",
"eslint-visitor-keys": "^4.2.1" "eslint-visitor-keys": "^4.2.1"
}, },
"engines": { "engines": {
@ -4823,6 +4831,28 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.9.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-abstract": "^1.24.0",
"es-object-atoms": "^1.1.1",
"get-intrinsic": "^1.3.0",
"is-string": "^1.1.1",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array-unique": { "node_modules/array-unique": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz",
@ -4833,6 +4863,78 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmmirror.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
"integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-shim-unscopables": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.flat": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
"integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.5",
"es-shim-unscopables": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.flatmap": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
"integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.5",
"es-shim-unscopables": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/array.prototype.tosorted": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
"integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.3",
"es-errors": "^1.3.0",
"es-shim-unscopables": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/arraybuffer.prototype.slice": { "node_modules/arraybuffer.prototype.slice": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
@ -6437,6 +6539,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"dev": true,
"dependencies": {
"esutils": "^2.0.2"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": { "node_modules/dom-serializer": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@ -6764,6 +6878,33 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-iterator-helpers": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
"integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.6",
"es-errors": "^1.3.0",
"es-set-tostringtag": "^2.0.3",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.6",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
"has-property-descriptors": "^1.0.2",
"has-proto": "^1.2.0",
"has-symbols": "^1.1.0",
"internal-slot": "^1.1.0",
"iterator.prototype": "^1.1.4",
"safe-array-concat": "^1.1.3"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "0.9.3", "version": "0.9.3",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz",
@ -6798,6 +6939,18 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-shim-unscopables": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
"integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
"dev": true,
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
@ -7645,6 +7798,18 @@
"eslint": ">=8.23.0" "eslint": ">=8.23.0"
} }
}, },
"node_modules/eslint-plugin-n/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmmirror.com/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-no-only-tests": { "node_modules/eslint-plugin-no-only-tests": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-no-only-tests/-/eslint-plugin-no-only-tests-3.3.0.tgz",
@ -7692,6 +7857,86 @@
} }
} }
}, },
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmmirror.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
"integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.8",
"array.prototype.findlast": "^1.2.5",
"array.prototype.flatmap": "^1.3.3",
"array.prototype.tosorted": "^1.1.4",
"doctrine": "^2.1.0",
"es-iterator-helpers": "^1.2.1",
"estraverse": "^5.3.0",
"hasown": "^2.0.2",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
"minimatch": "^3.1.2",
"object.entries": "^1.1.9",
"object.fromentries": "^2.0.8",
"object.values": "^1.2.1",
"prop-types": "^15.8.1",
"resolve": "^2.0.0-next.5",
"semver": "^6.3.1",
"string.prototype.matchall": "^4.0.12",
"string.prototype.repeat": "^1.0.0"
},
"engines": {
"node": ">=4"
},
"peerDependencies": {
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
}
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/eslint-plugin-react/node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/eslint-plugin-react/node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz",
"integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
"dev": true,
"dependencies": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/eslint-plugin-react/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/eslint-plugin-regexp": { "node_modules/eslint-plugin-regexp": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-2.9.0.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-2.9.0.tgz",
@ -7786,6 +8031,18 @@
"eslint": ">=8.56.0" "eslint": ">=8.56.0"
} }
}, },
"node_modules/eslint-plugin-unicorn/node_modules/globals": {
"version": "15.15.0",
"resolved": "https://registry.npmmirror.com/globals/-/globals-15.15.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==",
"dev": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eslint-plugin-unused-imports": { "node_modules/eslint-plugin-unused-imports": {
"version": "4.1.4", "version": "4.1.4",
"resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz",
@ -8839,11 +9096,10 @@
"peer": true "peer": true
}, },
"node_modules/globals": { "node_modules/globals": {
"version": "15.15.0", "version": "16.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", "resolved": "https://registry.npmmirror.com/globals/-/globals-16.3.0.tgz",
"integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==",
"dev": true, "dev": true,
"license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -9995,6 +10251,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
"integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
"dev": true,
"dependencies": {
"define-data-property": "^1.1.4",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.6",
"get-proto": "^1.0.0",
"has-symbols": "^1.1.0",
"set-function-name": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/jest-worker": { "node_modules/jest-worker": {
"version": "27.5.1", "version": "27.5.1",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
@ -10191,6 +10464,21 @@
"html2canvas": "^1.0.0-rc.5" "html2canvas": "^1.0.0-rc.5"
} }
}, },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
"dev": true,
"dependencies": {
"array-includes": "^3.1.6",
"array.prototype.flat": "^1.3.1",
"object.assign": "^4.1.4",
"object.values": "^1.1.6"
},
"engines": {
"node": ">=4.0"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -10576,6 +10864,18 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lower-case": { "node_modules/lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -11412,6 +11712,39 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/object.entries": {
"version": "1.1.9",
"resolved": "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz",
"integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.1.1"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/object.fromentries": {
"version": "2.0.8",
"resolved": "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz",
"integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/object.pick": { "node_modules/object.pick": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz",
@ -11435,6 +11768,24 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/object.values": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz",
"integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -12053,6 +12404,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prosemirror-changeset": { "node_modules/prosemirror-changeset": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
@ -12365,6 +12727,12 @@
"safe-buffer": "^5.1.0" "safe-buffer": "^5.1.0"
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/read-pkg": { "node_modules/read-pkg": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
@ -13897,6 +14265,43 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
"integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
"dev": true,
"dependencies": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.6",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.6",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"internal-slot": "^1.1.0",
"regexp.prototype.flags": "^1.5.3",
"set-function-name": "^2.0.2",
"side-channel": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/string.prototype.repeat": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
"integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
"dev": true,
"dependencies": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
}
},
"node_modules/string.prototype.trim": { "node_modules/string.prototype.trim": {
"version": "1.2.10", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",
@ -14888,6 +15293,29 @@
"node": ">=12.20" "node": ">=12.20"
} }
}, },
"node_modules/typescript-eslint": {
"version": "8.39.0",
"resolved": "https://registry.npmmirror.com/typescript-eslint/-/typescript-eslint-8.39.0.tgz",
"integrity": "sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.39.0",
"@typescript-eslint/parser": "8.39.0",
"@typescript-eslint/typescript-estree": "8.39.0",
"@typescript-eslint/utils": "8.39.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/uc.micro": { "node_modules/uc.micro": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@ -66,21 +66,27 @@
"devDependencies": { "devDependencies": {
"@antfu/eslint-config": "^2.16.3", "@antfu/eslint-config": "^2.16.3",
"@arco-design/web-vue": "^2.57.0", "@arco-design/web-vue": "^2.57.0",
"@eslint/js": "^9.32.0",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^20.2.5", "@types/node": "^20.2.5",
"@types/query-string": "^6.3.0", "@types/query-string": "^6.3.0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.1.3", "@vue/tsconfig": "^0.1.3",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.3.0",
"less": "^4.1.3", "less": "^4.1.3",
"less-loader": "^11.0.0", "less-loader": "^11.0.0",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
"sass": "^1.62.1", "sass": "^1.62.1",
"sass-loader": "^13.2.2", "sass-loader": "^13.2.2",
"typescript": "~5.0.4", "typescript": "~5.0.4",
"typescript-eslint": "^8.39.0",
"unplugin-auto-import": "^0.16.4", "unplugin-auto-import": "^0.16.4",
"unplugin-vue-components": "^0.25.1", "unplugin-vue-components": "^0.25.1",
"vite": "^5.1.5", "vite": "^5.1.5",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -26,6 +26,15 @@ export function getFolderTreeApi() {
}) })
} }
// 获取文件夹列表
export function getFolderListApi(params?: { page?: number; pageSize?: number; folderName?: string }) {
return request<{ list: KnowledgeFolder[]; total: number }>({
url: '/knowledge/folder-list',
method: 'get',
params,
})
}
// 获取文件列表(按文件夹) // 获取文件列表(按文件夹)
export function getFilesApi(folderId: string) { export function getFilesApi(folderId: string) {
return request<KnowledgeFile[]>({ return request<KnowledgeFile[]>({
@ -35,6 +44,8 @@ export function getFilesApi(folderId: string) {
}) })
} }
// 创建文件夹 // 创建文件夹
export function createFolderApi(data: { name: string; parentId?: string }) { export function createFolderApi(data: { name: string; parentId?: string }) {
return request({ return request({
@ -44,6 +55,23 @@ export function createFolderApi(data: { name: string; parentId?: string }) {
}) })
} }
// 更新文件夹
export function updateFolderApi(folderId: string, data: { name: string }) {
return request({
url: `/knowledge/update-folder/${folderId}`,
method: 'put',
data,
})
}
// 删除文件夹
export function deleteFolderApi(folderId: string) {
return request({
url: `/knowledge/delete-folder/${folderId}`,
method: 'delete',
})
}
// 删除文件 // 删除文件
export function deleteFileApi(fileId: string) { export function deleteFileApi(fileId: string) {
return request({ return request({
@ -60,3 +88,32 @@ export function downloadFileApi(fileId: string) {
responseType: 'blob', responseType: 'blob',
}) })
} }
// 上传文件
export function uploadFileApi(data: FormData) {
return request({
url: '/knowledge/upload',
method: 'post',
data,
headers: {
'Content-Type': 'multipart/form-data',
},
})
}
// 更新文件名
export function updateFileNameApi(fileId: string, newName: string) {
return request({
url: `/knowledge/update-file-name/${fileId}`,
method: 'put',
data: { name: newName },
})
}
// 预览文件
export function previewFileApi(fileId: string) {
return request({
url: `/knowledge/preview/${fileId}`,
method: 'get',
})
}

186
src/apis/bussiness/index.ts Normal file
View File

@ -0,0 +1,186 @@
// @/apis/bussiness/index.ts - 商务数据库信息模块API
import http from '@/utils/http'
import type {
FolderInfo,
FileInfo,
FolderListParams,
FileListParams,
FolderListResponse,
FileListResponse,
CreateFolderParams,
RenameFolderParams,
DeleteFolderParams,
UploadFileParams,
DownloadFileParams,
DeleteFileParams,
PreviewFileParams,
RenameFileParams
} from './type'
const { request, requestRaw } = http
// 导出类型定义
export type {
FolderInfo,
FileInfo,
FolderListParams,
FileListParams,
FolderListResponse,
FileListResponse,
CreateFolderParams,
RenameFolderParams,
DeleteFolderParams,
UploadFileParams,
DownloadFileParams,
DeleteFileParams,
PreviewFileParams,
RenameFileParams
}
// 获取文件夹列表(分页)
export function getFolderListApi(params?: FolderListParams) {
return request<FolderListResponse>({
url: '/businessData/folder/list',
method: 'get',
params: {
page: params?.page || 1,
pageSize: params?.pageSize || 10,
folderName: params?.folderName
}
})
}
// 获取文件列表(分页)
export function getFilesApi(params?: FileListParams) {
return request<FileListResponse>({
url: '/businessData/file/list',
method: 'get',
params: {
page: params?.page || 1,
pageSize: params?.pageSize || 10,
folderId: params?.folderId || '0',
fileName: params?.fileName
}
})
}
// 创建文件夹
export function createFolderApi(data: CreateFolderParams) {
return request({
url: '/businessData/folder/creatFolder',
method: 'post',
data: {
name: data.name,
parentId: data.parentId || '0'
}
})
}
// 重命名文件夹
export function updateFolderApi(folderId: string, newName: string) {
return request({
url: '/businessData/folder/rename',
method: 'put',
params: {
folderId: folderId,
newName: newName
}
})
}
// 删除文件夹
export function deleteFolderApi(folderId: string) {
return request({
url: '/businessData/folder/delete',
method: 'delete',
params: {
folderId: folderId
}
})
}
// 上传文件
export function uploadFileApi(
file: File,
folderId: string,
onUploadProgress?: (progressEvent: any) => void,
cancelToken?: any
) {
const formData = new FormData()
formData.append('file', file)
return requestRaw({
url: '/businessData/file/add',
method: 'post',
params: {
folderId: folderId
},
data: formData,
onUploadProgress,
cancelToken,
headers: {
'Content-Type': 'multipart/form-data'
}
}).then(response => response.data)
.catch(error => {
// 确保错误不会抛出,而是返回一个错误对象
console.error('上传文件API错误:', error)
return {
code: 500,
msg: error.message || '上传失败',
success: false
}
})
}
// 下载文件
export function downloadFileApi(fileId: string) {
return request({
url: '/businessData/file/download',
method: 'get',
params: {
fileId: fileId
},
responseType: 'blob'
})
}
// 删除文件
export function deleteFileApi(fileId: string) {
return request({
url: '/businessData/file/delete',
method: 'delete',
params: {
fileId: fileId
}
})
}
// 预览文件
export function previewFileApi(fileId: string) {
return request({
url: '/businessData/file/preview',
method: 'get',
params: {
fileId: fileId
},
responseType: 'blob'
})
}
// 重命名文件
export function renameFileApi(fileId: string, newFileName: string) {
return request({
url: '/businessData/file/rename',
method: 'put',
params: {
fileId: fileId,
newFileName: newFileName
}
})
}
// 重命名文件(兼容旧接口)
export function updateFileNameApi(fileId: string, data: RenameFileParams) {
return renameFileApi(fileId, data.newFileName)
}

View File

@ -0,0 +1,96 @@
/** 文件夹信息接口 */
export interface FolderInfo {
folderId: string
name: string
parentId: string
createTime?: string
updateTime?: string
}
/** 文件信息接口 */
export interface FileInfo {
fileId: string
fileName: string
fileSize: number
fileType: string
folderId: string
createTime?: string
updateTime?: string
}
/** 文件夹列表查询参数 */
export interface FolderListParams {
page?: number
pageSize?: number
folderName?: string
}
/** 文件列表查询参数 */
export interface FileListParams {
page?: number
pageSize?: number
folderId?: string
fileName?: string
}
/** 文件夹列表响应 */
export interface FolderListResponse {
data: FolderInfo[]
total: number
current: number
size: number
}
/** 文件列表响应 */
export interface FileListResponse {
data: FileInfo[]
total: number
current: number
size: number
}
/** 创建文件夹请求参数 */
export interface CreateFolderParams {
name: string
parentId?: string
}
/** 重命名文件夹请求参数 */
export interface RenameFolderParams {
folderId: string
newName: string
}
/** 删除文件夹请求参数 */
export interface DeleteFolderParams {
folderId: string
}
/** 上传文件请求参数 */
export interface UploadFileParams {
file: File
folderId: string
onUploadProgress?: (progressEvent: any) => void
cancelToken?: any
}
/** 下载文件请求参数 */
export interface DownloadFileParams {
fileId: string
}
/** 删除文件请求参数 */
export interface DeleteFileParams {
fileId: string
}
/** 预览文件请求参数 */
export interface PreviewFileParams {
fileId: string
}
/** 重命名文件请求参数 */
export interface RenameFileParams {
fileId: string
newFileName: string
}

View File

@ -0,0 +1,64 @@
import request from '@/utils/http'
// 合同查询参数类型
export interface ContractQueryParams {
page?: number
pageSize?: number
code?: string
customer?: string
contractStatus?: string
type?: string
signDate?: string
paymentDate?: string
performanceDeadline?: string
salespersonId?: string
departmentId?: string
projectId?: string
}
// 合同响应数据类型
export interface ContractData {
accountNumber: string
amount: number
code: string
contractId: string
contractStatus: string
contractText: string
customer: string
departmentId: string
duration: string
notes: string
page: number
pageSize: number
paymentAddress: string
paymentDate: string
performanceDeadline: string
productService: string
projectId: string
projectName: string
receivedAmount: number
salespersonDeptName: string
salespersonId: string
salespersonName: string
settlementAmount: number
signDate: string
type: string
}
// 合同列表响应类型
export interface ContractListResponse {
code: number
msg: string
rows: ContractData[]
total: number
}
const BASE_URL = '/contract'
/**
*
* @param params
*/
export function getContractList(params: ContractQueryParams) {
return request.get<ContractListResponse>(`${BASE_URL}/list`, params)
}

View File

@ -0,0 +1,70 @@
import http from '@/utils/http'
import type { EquipmentApprovalListReq, EquipmentApprovalResp } from './type'
/**
* API
*/
export const equipmentApprovalApi = {
/**
*
*/
getPendingApprovals(params: EquipmentApprovalListReq) {
return http.get<EquipmentApprovalResp[]>('/equipment/approval/pending', params)
},
/**
*
*/
getApprovedApprovals(params: EquipmentApprovalListReq) {
return http.get<EquipmentApprovalResp[]>('/equipment/approval/approved', params)
},
/**
*
*/
approve(approvalId: string, data: any) {
return http.post(`/equipment/approval/${approvalId}/approve`, data)
},
/**
*
*/
reject(approvalId: string, data: any) {
return http.post(`/equipment/approval/${approvalId}/reject`, data)
},
/**
*
*/
getApprovalDetail(approvalId: string) {
return http.get<EquipmentApprovalResp>(`/equipment/approval/${approvalId}`)
},
/**
*
*/
getApprovalStats() {
return http.get('/equipment/approval/stats')
},
/**
*
*/
submitProcurementApplication(data: any) {
return http.post('/equipment/approval/procurement/apply', data)
},
/**
*
*/
getMyProcurementApplications(params: EquipmentApprovalListReq) {
return http.get<EquipmentApprovalResp[]>('/equipment/approval/procurement/my-applications', params)
},
/**
*
*/
withdrawProcurementApplication(approvalId: string) {
return http.post(`/equipment/approval/procurement/${approvalId}/withdraw`)
}
}

View File

@ -47,3 +47,6 @@ export function assignEquipment(equipmentId: string, userId: string) {
export function returnEquipment(equipmentId: string) { export function returnEquipment(equipmentId: string) {
return http.put(`${BASE_URL}/${equipmentId}/return`) return http.put(`${BASE_URL}/${equipmentId}/return`)
} }
// 导出设备采购 API
export * from './procurement'

View File

@ -0,0 +1,125 @@
import http from '@/utils/http'
import type { EquipmentListReq, EquipmentReq, EquipmentResp } from './type'
/**
* API
*/
export const equipmentProcurementApi = {
/**
*
*/
page: (params: EquipmentListReq) => {
console.log('🔍 API - equipmentProcurementApi.page 被调用')
console.log('🔍 API - 接收到的参数:', params)
console.log('🔍 API - 参数类型:', typeof params)
console.log('🔍 API - 参数的键值对:')
Object.entries(params).forEach(([key, value]) => {
console.log(` ${key}: ${value} (${typeof value})`)
})
// 确保参数格式正确
const requestParams = {
...params,
// 确保分页参数存在
page: params.page || 1,
pageSize: params.pageSize || 10,
}
console.log('🔍 API - 最终请求参数:', requestParams)
console.log('🔍 API - 准备发送GET请求到 /equipment/procurement/page')
console.log('🔍 API - 请求参数序列化前:', requestParams)
// 手动序列化参数进行调试使用URLSearchParams
const searchParams = new URLSearchParams()
Object.entries(requestParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value))
}
})
console.log('🔍 API - 手动序列化后的参数:', searchParams.toString())
// 参考设备模块的调用方式,直接将参数作为第二个参数传递
return http.get<ApiRes<PageRes<EquipmentResp>>>('/equipment/procurement/page', requestParams)
},
/**
* - 使API文档中的参数格式
*/
testPage: () => {
console.log('🧪 API - 测试参数传递')
// 使用API文档中的参数格式进行测试
const testParams = {
page: 1,
pageSize: 10,
equipmentName: '测试设备',
supplierName: '测试供应商',
quantity: 10,
unitPrice: 100.50,
totalPrice: 1005.00,
accountNumber: 'TEST001',
brand: '测试品牌',
locationStatus: 'spare',
physicalLocation: '测试位置',
purchaseOrder: 'PO001',
inventoryBasis: '测试依据',
dynamicRecord: '测试记录'
}
console.log('🧪 API - 测试参数:', testParams)
return http.get<ApiRes<PageRes<EquipmentResp>>>('/equipment/procurement/page', testParams)
},
/**
*
*/
add: (data: EquipmentReq) => {
return http.post<ApiRes<null>>('/equipment/procurement', data)
},
/**
*
*/
update: (equipmentId: string, data: EquipmentReq) => {
return http.put<ApiRes<null>>(`/equipment/procurement/${equipmentId}`, data)
},
/**
*
*/
delete: (equipmentId: string) => {
return http.del<ApiRes<null>>(`/equipment/procurement/${equipmentId}`)
},
/**
*
*/
detail: (equipmentId: string) => {
return http.get<ApiRes<EquipmentResp>>(`/equipment/procurement/detail/${equipmentId}`)
},
/**
*
*/
getStats: () => {
return http.get<ApiRes<unknown>>('/equipment/procurement/stats')
},
/**
*
*/
batchDelete: (equipmentIds: string[]) => {
return http.del<ApiRes<null>>('/equipment/procurement/batch', { data: equipmentIds })
},
/**
*
*/
export: (params: EquipmentListReq) => {
return http.get<Blob>('/equipment/procurement/export', {
params,
responseType: 'blob'
})
}
}

View File

@ -275,3 +275,117 @@ export interface EquipmentReq {
/** 动态记录 */ /** 动态记录 */
dynamicRecord?: string dynamicRecord?: string
} }
/**
*
*/
export enum ApprovalStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED'
}
/**
*
*/
export enum BusinessType {
PROCUREMENT = 'PROCUREMENT',
BORROW = 'BORROW',
RETURN = 'RETURN'
}
/**
*
*/
export interface EquipmentApprovalListReq {
/** 设备名称 */
equipmentName?: string
/** 申请人 */
applicantName?: string
/** 审批状态 */
approvalStatus?: ApprovalStatus
/** 业务类型 */
businessType?: BusinessType
/** 申请时间开始 */
applyTimeStart?: string
/** 申请时间结束 */
applyTimeEnd?: string
/** 审批时间开始 */
approvalTimeStart?: string
/** 审批时间结束 */
approvalTimeEnd?: string
/** 当前页码 */
page?: number
/** 页码(后端可能期望的字段名) */
pageNum?: number
/** 每页大小 */
pageSize?: number
/** 排序字段 */
orderBy?: string
/** 排序方向 */
orderDirection?: string
}
/**
*
*/
export interface EquipmentApprovalReq {
/** 审批意见 */
approvalComment?: string
/** 审批结果 */
approvalResult: 'APPROVED' | 'REJECTED'
/** 审批人 */
approverName?: string
/** 审批人ID */
approverId?: string
}
/**
*
*/
export interface EquipmentApprovalResp {
/** 审批ID */
approvalId: string
/** 设备ID */
equipmentId: string
/** 设备名称 */
equipmentName: string
/** 设备类型 */
equipmentType: string
/** 设备型号 */
equipmentModel: string
/** 品牌 */
brand?: string
/** 供应商名称 */
supplierName?: string
/** 采购价格 */
purchasePrice?: number
/** 总价 */
totalPrice?: number
/** 数量 */
quantity?: number
/** 申请人 */
applicantName: string
/** 申请人ID */
applicantId: string
/** 申请时间 */
applyTime: string
/** 申请原因 */
applyReason?: string
/** 业务类型 */
businessType: BusinessType
/** 审批状态 */
approvalStatus: ApprovalStatus
/** 审批人 */
approverName?: string
/** 审批人ID */
approverId?: string
/** 审批时间 */
approvalTime?: string
/** 审批意见 */
approvalComment?: string
/** 创建时间 */
createTime: string
/** 更新时间 */
updateTime: string
}

View File

@ -17,6 +17,9 @@ export * as HealthRecordAPI from './health-record'
export * as InsuranceFileAPI from './insurance-file' export * as InsuranceFileAPI from './insurance-file'
export * as EmployeeAPI from './employee' export * as EmployeeAPI from './employee'
export * as RegulationAPI from './regulation' export * as RegulationAPI from './regulation'
export * as TrainingAPI from './training'
export * as EquipmentAPI from './equipment'
export * as BussinessAPI from './bussiness/bussiness'
export * from './area/type' export * from './area/type'
export * from './auth/type' export * from './auth/type'

View File

@ -0,0 +1,98 @@
import type * as T from './type'
import http from '@/utils/http'
export type * from './type'
const BASE_URL = '/project-member'
// ==================== 项目看板相关接口 ====================
/** @desc 获取项目看板统计数据 */
export function getProjectKanbanStats() {
return http.get<T.ProjectKanbanStats>(`${BASE_URL}/kanban/stats`)
}
/** @desc 获取项目看板数据 */
export function getProjectKanbanData() {
return http.get<T.ProjectKanbanData>(`${BASE_URL}/kanban/data`)
}
/** @desc 获取项目详情 */
export function getProjectDetail(id: string | number) {
return http.get<T.ProjectDetailResp>(`${BASE_URL}/project/${id}/detail`)
}
// ==================== 团队成员管理 ====================
/** @desc 获取项目团队成员列表(支持筛选、分页、搜索) */
export function getProjectTeamMembers(params: T.TeamMemberQuery) {
const { projectId, ...queryParams } = params
return http.get<T.BackendTeamMemberResp[]>(`${BASE_URL}/project/${projectId}/team-members`, { params: queryParams })
}
/** @desc 创建团队成员 */
export function createTeamMember(data: T.CreateTeamMemberForm) {
return http.post<T.TeamMemberResp>(`${BASE_URL}/team-member`, data)
}
/** @desc 更新团队成员信息(支持更新状态) */
export function updateTeamMember(id: string | number, data: T.UpdateTeamMemberForm) {
return http.put<T.TeamMemberResp>(`${BASE_URL}/team-member/${id}`, data)
}
/** @desc 删除团队成员(支持单个或批量删除) */
export function deleteTeamMembers(ids: string | number | (string | number)[]) {
const idArray = Array.isArray(ids) ? ids : [ids]
return http.del(`${BASE_URL}/team-member/batch`, { data: { ids: idArray } })
}
/** @desc 导入团队成员数据 */
export function importTeamMembers(projectId: string | number, file: File) {
const formData = new FormData()
formData.append('file', file)
formData.append('projectId', projectId.toString())
return http.post(`${BASE_URL}/team-member/import`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/** @desc 导出团队成员数据 */
export function exportTeamMembers(params: T.TeamMemberExportQuery) {
return http.get(`${BASE_URL}/team-member/export`, {
params,
responseType: 'blob'
})
}
/** @desc 下载导入模板 */
export function downloadImportTemplate() {
return http.get(`${BASE_URL}/team-member/template`, {
responseType: 'blob'
})
}
// ==================== 项目需求管理 ====================
/** @desc 获取项目需求列表 */
export function getProjectRequirements(projectId: string | number) {
return http.get<T.ProjectRequirementResp[]>(`${BASE_URL}/project/${projectId}/requirements`)
}
/** @desc 发布项目需求 */
export function createProjectRequirement(data: T.CreateProjectRequirementForm) {
return http.post(`${BASE_URL}/requirement`, data)
}
/** @desc 更新项目需求状态 */
export function updateRequirementStatus(data: T.UpdateRequirementStatusForm) {
return http.patch(`${BASE_URL}/requirement/status`, data)
}
/** @desc 删除项目需求 */
export function deleteProjectRequirement(requirementId: string | number) {
return http.del(`${BASE_URL}/requirement/${requirementId}`)
}

View File

@ -86,3 +86,249 @@ export interface TaskQuery {
} }
export interface TaskPageQuery extends TaskQuery, PageQuery {} export interface TaskPageQuery extends TaskQuery, PageQuery {}
// ==================== 人员调度相关类型 ====================
/** 项目看板统计数据 */
export interface ProjectKanbanStats {
totalProjectsCount: string
pendingProjectCount: string
inProgressProjectCount: string
completedProjectCount: string
auditedProjectCount: string
acceptedProjectCount: string
totalTurbineCount: string
pendingTurbineCount: string
inProgressTurbineCount: string
completedTurbineCount: string
auditedTurbineCount: string
acceptedTurbineCount: string
totalTaskCount: string
pendingTaskCount: string
inProgressTaskCount: string
completedTaskCount: string
totalMemberCount: string
projectManagerCount: string
safetyOfficerCount: string
qualityOfficerCount: string
constructorCount: string
teamLeaderCount: string
}
/** 项目看板数据 */
export interface ProjectKanbanData {
inProgressProjects: never[]
preparingProjects: ProjectCard[]
ongoingProjects: ProjectCard[]
pendingProjects: ProjectCard[]
}
/** 项目卡片信息 */
export interface ProjectCard {
id: string | number
name: string
status: 'preparing' | 'ongoing' | 'pending'
budget: number
manager: string
teamSize: number
preparationProgress?: number
progress?: number
startDate?: string
endDate?: string
plannedStartDate?: string
alerts?: ProjectAlert[]
teamMembers: TeamMemberResp[]
requirements: ProjectRequirementResp[]
}
/** 项目异常信息 */
export interface ProjectAlert {
type: 'cost' | 'personnel' | 'schedule' | 'quality'
message: string
}
/** 项目详情响应 */
export interface ProjectDetailResp extends ProjectCard {
// 继承ProjectCard的所有字段可以添加更多详情字段
}
/** 团队成员响应 */
export interface TeamMemberResp {
id: string | number
name: string
position: string
phone?: string
email?: string
avatar?: string
joinDate?: string
performance?: number
remark?: string
status?: 'available' | 'busy' | 'offline'
}
/** 后端返回的团队成员数据结构 */
export interface BackendTeamMemberResp {
memberId: string
projectId: string
projectName: string
turbineId: string | null
turbineName: string | null
taskGroupId: string | null
taskGroupName: string | null
taskId: string | null
taskName: string | null
userId: string
name: string
phone: string | null
email: string | null
position: string
status: 'ACTIVE' | 'BUSY' | 'OFFLINE'
skills: string
joinDate: string
remark: string
userAccount: string
userAvatar: string | null
roleType: string
roleTypeDesc: string
jobCode: string
jobCodeDesc: string
jobDesc: string
leaveDate: string | null
statusDesc: string
}
/** 团队成员查询参数(支持筛选、分页、搜索) */
export interface TeamMemberQuery extends PageQuery {
projectId: string | number
name?: string // 姓名搜索
position?: string // 岗位筛选
status?: string // 状态筛选
joinDateStart?: string // 入职日期开始
joinDateEnd?: string // 入职日期结束
sortBy?: 'name' | 'position' | 'joinDate' | 'status' // 排序字段
sortOrder?: 'asc' | 'desc' // 排序方向
}
/** 团队成员导出查询参数(不包含分页) */
export interface TeamMemberExportQuery {
projectId: string | number
name?: string // 姓名搜索
position?: string // 岗位筛选
status?: string // 状态筛选
joinDateStart?: string // 入职日期开始
joinDateEnd?: string // 入职日期结束
sortBy?: 'name' | 'position' | 'joinDate' | 'status' // 排序字段
sortOrder?: 'asc' | 'desc' // 排序方向
}
/** 创建团队成员表单 */
export interface CreateTeamMemberForm {
projectId: string | number
name: string
phone: string
email?: string
position: string
status?: 'available' | 'busy' | 'offline'
joinDate?: string
remark?: string
}
/** 更新团队成员表单 */
export interface UpdateTeamMemberForm {
name?: string
phone?: string
email?: string
position?: string
status?: 'available' | 'busy' | 'offline'
joinDate?: string
remark?: string
}
/** 批量操作表单 */
export interface BatchOperationForm {
ids: (string | number)[]
operation: 'delete' | 'updateStatus'
status?: 'available' | 'busy' | 'offline'
}
/** 导入结果响应 */
export interface ImportResultResp {
success: boolean
totalCount: number
successCount: number
failCount: number
errors?: Array<{
row: number
message: string
}>
}
/** 人员需求响应 */
export interface PersonnelRequirementResp {
id: string | number
title: string
type: 'personnel' | 'equipment'
position?: string
equipmentType?: string
count: number
skills: string[]
description?: string
priority: 'low' | 'medium' | 'high'
status: 'pending' | 'recruiting' | 'completed'
createTime: string
updateTime: string
}
/** 创建需求表单 */
export interface CreateRequirementForm {
projectId: string | number
type: 'personnel' | 'equipment'
position?: string
equipmentType?: string
count: number
skills: string[]
description?: string
priority: 'low' | 'medium' | 'high'
}
/** 简化项目需求响应类型 */
export interface ProjectRequirementResp {
id: string | number
title: string
description: string
priority: 'low' | 'medium' | 'high'
status: 'pending' | 'recruiting' | 'completed'
createTime: string
updateTime: string
}
/** 简化创建项目需求表单类型 */
export interface CreateProjectRequirementForm {
projectId: string | number
description: string
priority: 'low' | 'medium' | 'high'
}
/** 更新需求状态表单 */
export interface UpdateRequirementStatusForm {
requirementId: string | number
status: 'pending' | 'recruiting' | 'completed'
}
/** 分页查询基础参数 */
export interface PageQuery {
page: number
pageSize: number
}
/** 分页响应 */
export interface PageRes<T> {
list: T[]
total: number
page: number
pageSize: number
}

View File

@ -34,6 +34,7 @@ export const regulationApi = {
level: string level: string
remark?: string remark?: string
createBy?: string createBy?: string
createByName?: string
}) => { }) => {
return http.post('/regulation/proposal', data) return http.post('/regulation/proposal', data)
}, },

View File

@ -45,6 +45,7 @@ export interface CreateProposalRequest {
level: RegulationLevel level: RegulationLevel
remark?: string remark?: string
createBy?: string createBy?: string
createByName?: string
} }
// 分页参数接口 // 分页参数接口

View File

@ -0,0 +1,44 @@
import http from '@/utils/http'
import type * as T from '@/types/training.d'
const BASE_URL = '/training'
/** @desc 分页查询培训计划列表 */
export function pageTrainingPlan(query: T.TrainingPlanPageQuery) {
return http.get<T.TrainingPlanResp[]>(`${BASE_URL}/plan/page`, query)
}
/** @desc 查询培训计划列表 */
export function listTrainingPlan(query?: T.TrainingPlanPageQuery) {
return http.get<T.TrainingPlanResp[]>(`${BASE_URL}/plan/list`, query)
}
/** @desc 查询培训计划详情 */
export function getTrainingPlanDetail(planId: string) {
return http.get<T.TrainingPlanResp>(`${BASE_URL}/plan/detail/${planId}`)
}
/** @desc 新增培训计划 */
export function createTrainingPlan(data: T.TrainingPlanReq) {
return http.post(`${BASE_URL}/plan`, data)
}
/** @desc 更新培训计划 */
export function updateTrainingPlan(planId: string, data: T.TrainingPlanReq) {
return http.put(`${BASE_URL}/plan/${planId}`, data)
}
/** @desc 删除培训计划 */
export function deleteTrainingPlan(planId: string) {
return http.del(`${BASE_URL}/plan/${planId}`)
}
/** @desc 发布培训计划 */
export function publishTrainingPlan(planId: string) {
return http.put(`${BASE_URL}/plan/${planId}/publish`)
}
/** @desc 取消培训计划 */
export function cancelTrainingPlan(planId: string) {
return http.put(`${BASE_URL}/plan/${planId}/cancel`)
}

View File

@ -0,0 +1,620 @@
<template>
<div class="approval-assistant">
<!-- 助手头部 -->
<div class="assistant-header">
<div class="header-left">
<IconRobot style="font-size: 20px; color: var(--color-primary); margin-right: 8px;" />
<h3>智能审批助手</h3>
</div>
<div class="header-right">
<a-switch
v-model="assistantEnabled"
size="small"
@change="toggleAssistant"
>
<template #checked>启用</template>
<template #unchecked>禁用</template>
</a-switch>
</div>
</div>
<!-- 助手内容 -->
<div v-if="assistantEnabled && currentApproval" class="assistant-content">
<!-- 审批建议 -->
<div class="assistant-section">
<div class="section-header">
<IconLightbulb style="color: var(--color-warning); margin-right: 8px;" />
<span>审批建议</span>
</div>
<div class="section-content">
<div class="recommendation-card" :class="getRecommendationClass()">
<div class="recommendation-icon">
<IconCheckCircle v-if="recommendation === 'APPROVE'" />
<IconCloseCircle v-else-if="recommendation === 'REJECT'" />
<IconExclamationCircle v-else />
</div>
<div class="recommendation-content">
<div class="recommendation-title">
{{ getRecommendationTitle() }}
</div>
<div class="recommendation-reason">
{{ getRecommendationReason() }}
</div>
</div>
</div>
</div>
</div>
<!-- 风险评估 -->
<div class="assistant-section">
<div class="section-header">
<IconShield style="color: var(--color-danger); margin-right: 8px;" />
<span>风险评估</span>
</div>
<div class="section-content">
<div class="risk-indicators">
<div
v-for="risk in riskFactors"
:key="risk.type"
class="risk-item"
:class="getRiskClass(risk.level)"
>
<div class="risk-icon">
<IconExclamationCircle v-if="risk.level === 'HIGH'" />
<IconWarning v-else-if="risk.level === 'MEDIUM'" />
<IconCheckCircle v-else />
</div>
<div class="risk-info">
<div class="risk-title">{{ risk.title }}</div>
<div class="risk-description">{{ risk.description }}</div>
</div>
<div class="risk-score">{{ risk.score }}%</div>
</div>
</div>
</div>
</div>
<!-- 快速审批 -->
<div class="assistant-section">
<div class="section-header">
<IconThunderbolt style="color: var(--color-success); margin-right: 8px;" />
<span>快速审批</span>
</div>
<div class="section-content">
<div class="quick-actions">
<a-button
type="primary"
size="small"
:disabled="recommendation !== 'APPROVE'"
@click="handleQuickApprove"
>
<template #icon>
<IconCheckCircle />
</template>
快速通过
</a-button>
<a-button
type="text"
size="small"
status="danger"
:disabled="recommendation !== 'REJECT'"
@click="handleQuickReject"
>
<template #icon>
<IconCloseCircle />
</template>
快速拒绝
</a-button>
<a-button
type="text"
size="small"
@click="handleManualReview"
>
<template #icon>
<IconEdit />
</template>
手动审批
</a-button>
</div>
</div>
</div>
<!-- 审批历史分析 -->
<div class="assistant-section">
<div class="section-header">
<IconBarChart style="color: var(--color-info); margin-right: 8px;" />
<span>审批历史分析</span>
</div>
<div class="section-content">
<div class="history-stats">
<div class="stat-item">
<div class="stat-number">{{ historyStats.total }}</div>
<div class="stat-label">总申请数</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ historyStats.approved }}</div>
<div class="stat-label">通过率</div>
</div>
<div class="stat-item">
<div class="stat-number">{{ historyStats.avgTime }}</div>
<div class="stat-label">平均审批时间</div>
</div>
</div>
</div>
</div>
</div>
<!-- 助手未启用提示 -->
<div v-else-if="!assistantEnabled" class="assistant-disabled">
<IconRobot style="font-size: 48px; color: var(--color-text-3); margin-bottom: 16px;" />
<p>智能审批助手已禁用</p>
<a-button type="primary" size="small" @click="assistantEnabled = true">
启用助手
</a-button>
</div>
<!-- 无审批数据提示 -->
<div v-else class="no-approval-data">
<IconFile style="font-size: 48px; color: var(--color-text-3); margin-bottom: 16px;" />
<p>请选择要审批的申请</p>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import {
IconRobot,
IconLightbulb,
IconShield,
IconThunderbolt,
IconBarChart,
IconFile,
IconCheckCircle,
IconCloseCircle,
IconExclamationCircle,
IconWarning,
IconEdit
} from '@arco-design/web-vue/es/icon'
import message from '@arco-design-design/web-vue/es/message'
import type { EquipmentApprovalResp } from '@/apis/equipment/type'
import { BusinessType, ApprovalStatus } from '@/apis/equipment/type'
defineOptions({ name: 'ApprovalAssistant' })
interface Props {
approvalData?: EquipmentApprovalResp | null
}
interface RiskFactor {
type: string
title: string
description: string
level: 'LOW' | 'MEDIUM' | 'HIGH'
score: number
}
interface HistoryStats {
total: number
approved: number
avgTime: number
}
const props = withDefaults(defineProps<Props>(), {
approvalData: null
})
const emit = defineEmits<{
quickApprove: [data: EquipmentApprovalResp]
quickReject: [data: EquipmentApprovalResp]
manualReview: [data: EquipmentApprovalResp]
}>()
//
const assistantEnabled = ref(true)
const currentApproval = computed(() => props.approvalData)
//
const recommendation = computed(() => {
if (!currentApproval.value) return null
const approval = currentApproval.value
//
if (approval.businessType === BusinessType.PROCUREMENT) {
//
if (approval.purchasePrice && approval.purchasePrice > 100000) {
return 'REJECT' //
}
if (approval.applicantName && approval.applicantName.includes('测试')) {
return 'REJECT' //
}
return 'APPROVE'
} else if (approval.businessType === BusinessType.BORROW) {
//
if (approval.applyReason && approval.applyReason.length < 10) {
return 'REJECT' //
}
return 'APPROVE'
} else if (approval.businessType === BusinessType.RETURN) {
//
return 'APPROVE' //
}
return 'REVIEW' //
})
//
const riskFactors = computed((): RiskFactor[] => {
if (!currentApproval.value) return []
const approval = currentApproval.value
const factors: RiskFactor[] = []
//
if (approval.purchasePrice && approval.purchasePrice > 50000) {
factors.push({
type: 'PRICE',
title: '价格风险',
description: '采购价格较高,建议详细审查',
level: 'MEDIUM',
score: 70
})
}
//
if (approval.applicantName && approval.applicantName.includes('新员工')) {
factors.push({
type: 'APPLICANT',
title: '申请人风险',
description: '新员工申请,建议加强指导',
level: 'LOW',
score: 30
})
}
//
if (approval.equipmentType && ['危险设备', '精密设备'].includes(approval.equipmentType)) {
factors.push({
type: 'EQUIPMENT',
title: '设备类型风险',
description: '特殊设备类型,需要专业评估',
level: 'HIGH',
score: 85
})
}
//
if (approval.applyTime) {
const applyDate = new Date(approval.applyTime)
const now = new Date()
const diffDays = Math.ceil((now.getTime() - applyDate.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays > 30) {
factors.push({
type: 'TIMING',
title: '时间风险',
description: '申请时间较长,可能影响业务',
level: 'MEDIUM',
score: 60
})
}
}
return factors
})
//
const historyStats = computed((): HistoryStats => {
// API
// 使
return {
total: 156,
approved: 89,
avgTime: 2.5
}
})
//
const toggleAssistant = (enabled: boolean) => {
if (enabled) {
message.success('智能审批助手已启用')
} else {
message.info('智能审批助手已禁用')
}
}
const getRecommendationClass = () => {
switch (recommendation.value) {
case 'APPROVE':
return 'recommendation-approve'
case 'REJECT':
return 'recommendation-reject'
default:
return 'recommendation-review'
}
}
const getRecommendationTitle = () => {
switch (recommendation.value) {
case 'APPROVE':
return '建议通过'
case 'REJECT':
return '建议拒绝'
default:
return '需要人工审查'
}
}
const getRecommendationReason = () => {
if (!currentApproval.value) return ''
const approval = currentApproval.value
switch (recommendation.value) {
case 'APPROVE':
if (approval.businessType === BusinessType.PROCUREMENT) {
return '采购申请信息完整,价格合理,建议通过'
} else if (approval.businessType === BusinessType.BORROW) {
return '借用申请理由充分,设备状态良好,建议通过'
} else {
return '申请信息完整,符合审批条件,建议通过'
}
case 'REJECT':
if (approval.businessType === BusinessType.PROCUREMENT) {
return '采购价格过高或信息不完整,建议拒绝'
} else if (approval.businessType === BusinessType.BORROW) {
return '借用理由不充分或设备状态不佳,建议拒绝'
} else {
return '申请信息不完整或不符合条件,建议拒绝'
}
default:
return '申请情况复杂,需要审批人员详细审查后决定'
}
}
const getRiskClass = (level: string) => {
switch (level) {
case 'HIGH':
return 'risk-high'
case 'MEDIUM':
return 'risk-medium'
default:
return 'risk-low'
}
}
const handleQuickApprove = () => {
if (currentApproval.value) {
emit('quickApprove', currentApproval.value)
message.success('已触发快速审批通过')
}
}
const handleQuickReject = () => {
if (currentApproval.value) {
emit('quickReject', currentApproval.value)
message.success('已触发快速审批拒绝')
}
}
const handleManualReview = () => {
if (currentApproval.value) {
emit('manualReview', currentApproval.value)
message.info('请进行手动审批')
}
}
//
watch(() => props.approvalData, (newData) => {
if (newData) {
console.log('审批助手收到新的审批数据:', newData)
}
}, { immediate: true })
</script>
<style scoped lang="scss">
.approval-assistant {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
.assistant-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: var(--color-fill-1);
border-bottom: 1px solid var(--color-border);
.header-left {
display: flex;
align-items: center;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
}
}
.assistant-content {
padding: 16px;
.assistant-section {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
.section-header {
display: flex;
align-items: center;
margin-bottom: 12px;
font-weight: 500;
color: var(--color-text-1);
}
.section-content {
.recommendation-card {
display: flex;
align-items: center;
padding: 16px;
border-radius: 8px;
border: 1px solid var(--color-border);
&.recommendation-approve {
background: var(--color-success-light-1);
border-color: var(--color-success);
}
&.recommendation-reject {
background: var(--color-danger-light-1);
border-color: var(--color-danger);
}
&.recommendation-review {
background: var(--color-warning-light-1);
border-color: var(--color-warning);
}
.recommendation-icon {
margin-right: 12px;
font-size: 24px;
.arco-icon {
&.recommendation-approve & {
color: var(--color-success);
}
&.recommendation-reject & {
color: var(--color-danger);
}
&.recommendation-review & {
color: var(--color-warning);
}
}
}
.recommendation-content {
flex: 1;
.recommendation-title {
font-weight: 600;
margin-bottom: 4px;
}
.recommendation-reason {
font-size: 13px;
color: var(--color-text-2);
line-height: 1.4;
}
}
}
.risk-indicators {
.risk-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
&:last-child {
margin-bottom: 0;
}
&.risk-high {
background: var(--color-danger-light-1);
border-color: var(--color-danger);
}
&.risk-medium {
background: var(--color-warning-light-1);
border-color: var(--color-warning);
}
&.risk-low {
background: var(--color-success-light-1);
border-color: var(--color-success);
}
.risk-icon {
margin-right: 12px;
font-size: 18px;
}
.risk-info {
flex: 1;
.risk-title {
font-weight: 500;
margin-bottom: 2px;
}
.risk-description {
font-size: 12px;
color: var(--color-text-2);
}
}
.risk-score {
font-weight: 600;
font-size: 14px;
color: var(--color-text-1);
}
}
}
.quick-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-stats {
display: flex;
gap: 16px;
.stat-item {
text-align: center;
flex: 1;
.stat-number {
font-size: 24px;
font-weight: 600;
color: var(--color-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 12px;
color: var(--color-text-3);
}
}
}
}
}
}
.assistant-disabled,
.no-approval-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 16px;
text-align: center;
color: var(--color-text-3);
p {
margin: 0 0 16px 0;
}
}
}
</style>

View File

@ -0,0 +1,736 @@
<template>
<div class="notification-center">
<!-- 消息中心图标和徽章 -->
<div class="notification-trigger" @click="toggleDropdown">
<a-badge :count="unreadCount" :dot="hasUrgentNotifications">
<a-button type="text" class="notification-btn" title="消息中心">
<template #icon>
<IconNotification />
</template>
<span class="notification-text">消息中心</span>
</a-button>
</a-badge>
</div>
<!-- 消息中心弹窗 -->
<a-modal
v-model:visible="modalVisible"
title="消息中心"
width="800px"
:footer="false"
:mask-closable="true"
:closable="true"
:destroy-on-close="false"
:z-index="999999"
class="notification-modal"
>
<!-- 消息中心头部 -->
<div class="notification-header">
<div class="header-left">
<h3>消息中心</h3>
<span class="notification-count">{{ unreadCount }} 条未读</span>
</div>
<div class="header-right">
<a-button type="text" size="small" @click="markAllAsRead">
全部已读
</a-button>
<a-button type="text" size="small" @click="clearRead">
清空已读
</a-button>
</div>
</div>
<!-- 消息类型标签 -->
<div class="notification-tabs">
<a-tabs v-model:active-key="activeTab" size="small">
<a-tab-pane key="all" title="全部">
<template #title>
<span>全部 ({{ totalCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="pending" title="待审批">
<template #title>
<span>待审批 ({{ pendingCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="equipment" title="设备">
<template #title>
<span>设备 ({{ equipmentCount }})</span>
</template>
</a-tab-pane>
<a-tab-pane key="urgent" title="紧急">
<template #title>
<span>紧急 ({{ urgentCount }})</span>
</template>
</a-tab-pane>
</a-tabs>
</div>
<!-- 消息列表 -->
<div class="notification-list">
<div v-if="filteredNotifications.length === 0" class="empty-state">
<IconInfo style="font-size: 48px; color: #d9d9d9; margin-bottom: 16px;" />
<p>暂无消息</p>
</div>
<div
v-for="notification in filteredNotifications"
:key="notification.id"
class="notification-item"
:class="{
'unread': !notification.read,
'urgent': notification.priority === 'URGENT',
'high': notification.priority === 'HIGH'
}"
@click="handleNotificationClick(notification)"
>
<!-- 消息图标 -->
<div class="notification-icon">
<component :is="getNotificationIcon(notification.type)" />
</div>
<!-- 消息内容 -->
<div class="notification-content">
<div class="notification-title">
{{ notification.title }}
<a-tag
v-if="notification.actionRequired"
size="small"
color="red"
>
需操作
</a-tag>
<a-tag
v-if="notification.reminderType"
size="small"
color="blue"
>
{{ getReminderTypeText(notification.reminderType) }}
</a-tag>
</div>
<div class="notification-message">{{ notification.content }}</div>
<div class="notification-meta">
<span class="notification-time">{{ formatTime(notification.createTime) }}</span>
<span class="notification-category">{{ notification.category }}</span>
<span v-if="notification.source" class="notification-source">{{ notification.source }}</span>
</div>
</div>
<!-- 消息操作 -->
<div class="notification-actions">
<a-button
type="text"
size="small"
@click.stop="toggleReminder(notification)"
:title="notification.reminderTime ? '取消提醒' : '设置提醒'"
>
<IconClockCircle v-if="!notification.reminderTime" />
<IconClose v-else />
</a-button>
<a-button
type="text"
size="small"
@click.stop="removeNotification(notification.id)"
title="删除消息"
>
<IconDelete />
</a-button>
</div>
</div>
</div>
<!-- 消息底部 -->
<div class="notification-footer">
<a-button type="text" size="small" @click="viewAllNotifications">
查看全部消息
</a-button>
<a-button type="text" size="small" @click="exportNotifications">
导出消息
</a-button>
</div>
</a-modal>
<!-- 提醒设置弹窗 -->
<a-modal
v-model:visible="reminderModalVisible"
title="设置消息提醒"
width="400px"
@ok="saveReminder"
@cancel="cancelReminder"
>
<a-form :model="reminderForm" layout="vertical">
<a-form-item label="提醒类型">
<a-radio-group v-model="reminderForm.type">
<a-radio value="IMMEDIATE">立即提醒</a-radio>
<a-radio value="DELAYED">延迟提醒</a-radio>
<a-radio value="RECURRING">重复提醒</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="reminderForm.type === 'DELAYED'" label="提醒时间">
<a-date-picker
v-model="reminderForm.time"
show-time
placeholder="选择提醒时间"
style="width: 100%"
/>
</a-form-item>
<a-form-item v-if="reminderForm.type === 'RECURRING'" label="重复间隔">
<a-input-number
v-model="reminderForm.interval"
:min="1"
:max="1440"
placeholder="间隔分钟"
style="width: 100%"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
IconNotification,
IconInfo,
IconClockCircle,
IconClose,
IconDelete,
IconCheckCircle,
IconClockCircle as IconPending,
IconApps,
IconExclamationCircle,
IconExclamationCircle as IconWarning,
IconSettings
} from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import notificationService from '@/services/notificationService'
import websocketService from '@/services/websocketService'
defineOptions({ name: 'NotificationCenter' })
const router = useRouter()
//
const modalVisible = ref(false)
const activeTab = ref('all')
const reminderModalVisible = ref(false)
const currentNotification = ref<any>(null)
//
const reminderForm = ref({
type: 'IMMEDIATE' as 'IMMEDIATE' | 'DELAYED' | 'RECURRING',
time: null as Date | null,
interval: 30
})
//
const notifications = computed(() => notificationService.getAllNotifications())
const unreadCount = computed(() => notificationService.unreadCount.value)
const totalCount = computed(() => notifications.value.length)
const pendingCount = computed(() => notificationService.pendingCount.value)
const equipmentCount = computed(() =>
notificationService.equipmentBorrowCount.value +
notificationService.equipmentReturnCount.value +
notificationService.equipmentMaintenanceCount.value +
notificationService.equipmentAlertCount.value
)
const urgentCount = computed(() => notificationService.urgentCount.value)
const hasUrgentNotifications = computed(() => urgentCount.value > 0)
//
const filteredNotifications = computed(() => {
let filtered = notifications.value
switch (activeTab.value) {
case 'pending':
filtered = filtered.filter(n => n.type === 'PENDING')
break
case 'equipment':
filtered = filtered.filter(n =>
['EQUIPMENT_BORROW', 'EQUIPMENT_RETURN', 'EQUIPMENT_MAINTENANCE', 'EQUIPMENT_ALERT'].includes(n.type)
)
break
case 'urgent':
filtered = filtered.filter(n => n.priority === 'URGENT' || n.priority === 'HIGH')
break
}
//
return filtered.sort((a, b) => {
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
const aPriority = priorityOrder[a.priority || 'NORMAL'] || 2
const bPriority = priorityOrder[b.priority || 'NORMAL'] || 2
if (aPriority !== bPriority) {
return bPriority - aPriority
}
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
}).slice(0, 20) // 20
})
//
const toggleDropdown = () => {
console.log('打开消息中心弹窗')
modalVisible.value = true
}
const markAllAsRead = () => {
notificationService.markAllAsRead()
message.success('已标记所有消息为已读')
}
const clearRead = () => {
notificationService.clearRead()
message.success('已清空已读消息')
}
const handleNotificationClick = (notification: any) => {
console.log('点击消息:', notification)
//
notificationService.markAsRead(notification.id)
//
let targetUrl = notification.targetUrl
// targetUrl
if (!targetUrl) {
targetUrl = buildTargetUrl(notification)
}
console.log('构建的目标URL:', targetUrl)
// URL
if (targetUrl) {
try {
router.push(targetUrl)
modalVisible.value = false
message.success('正在跳转到相关页面...')
} catch (error) {
console.error('路由跳转失败:', error)
message.error('页面跳转失败,请手动导航')
}
} else {
console.warn('无法构建跳转路径,消息数据:', notification)
message.warning('该消息暂无相关操作页面')
}
}
//
const buildTargetUrl = (notification: any): string | null => {
const { type, relatedId, metadata, category } = notification
console.log('构建跳转路径,消息类型:', type, '相关ID:', relatedId, '元数据:', metadata)
switch (type) {
case 'PROCUREMENT':
case 'PENDING':
// -
return '/asset-management/device-management/approval'
case 'APPROVAL':
// -
return '/asset-management/device-management/approval'
case 'EQUIPMENT_BORROW':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_RETURN':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_MAINTENANCE':
// -
return '/asset-management/device-management/device-center'
case 'EQUIPMENT_ALERT':
// -
return '/asset-management/device-management/device-center'
case 'WORKFLOW':
// -
if (metadata?.workflowType === 'PROJECT') {
return '/project-management/project-template/project-aproval'
}
return '/asset-management/device-management/approval'
case 'SYSTEM':
// -
return null
default:
//
return '/asset-management/device-management/approval'
}
}
const getNotificationIcon = (type: string) => {
const iconMap: Record<string, any> = {
'APPROVAL': IconCheckCircle,
'PENDING': IconPending,
'PROCUREMENT': IconApps,
'EQUIPMENT_BORROW': IconApps,
'EQUIPMENT_RETURN': IconApps,
'EQUIPMENT_MAINTENANCE': IconSettings,
'EQUIPMENT_ALERT': IconWarning,
'WORKFLOW': IconSettings,
'SYSTEM': IconExclamationCircle
}
return iconMap[type] || IconNotification
}
const getReminderTypeText = (type: string) => {
const typeMap: Record<string, string> = {
'IMMEDIATE': '立即',
'DELAYED': '延迟',
'RECURRING': '重复'
}
return typeMap[type] || type
}
const formatTime = (time: string) => {
const date = new Date(time)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 2592000000) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString()
}
const toggleReminder = (notification: any) => {
if (notification.reminderTime) {
//
notificationService.cancelNotificationReminder(notification.id)
message.success('已取消提醒')
} else {
//
currentNotification.value = notification
reminderModalVisible.value = true
}
}
const saveReminder = () => {
if (currentNotification.value) {
const reminderTime = reminderForm.value.type === 'DELAYED' && reminderForm.value.time
? reminderForm.value.time.toISOString()
: new Date().toISOString()
notificationService.setNotificationReminder(
currentNotification.value.id,
reminderTime,
reminderForm.value.type as any,
reminderForm.value.type === 'RECURRING' ? reminderForm.value.interval : undefined
)
message.success('提醒设置成功')
reminderModalVisible.value = false
resetReminderForm()
}
}
const cancelReminder = () => {
reminderModalVisible.value = false
resetReminderForm()
}
const resetReminderForm = () => {
reminderForm.value = {
type: 'IMMEDIATE',
time: null,
interval: 30
}
currentNotification.value = null
}
const removeNotification = (id: string) => {
notificationService.removeNotification(id)
message.success('消息已删除')
}
const viewAllNotifications = () => {
router.push('/notifications')
modalVisible.value = false
}
const exportNotifications = () => {
notificationService.exportNotifications()
message.success('消息导出成功')
}
// WebSocket
const setupWebSocketListeners = () => {
websocketService.on('message', (message) => {
//
console.log('收到WebSocket消息:', message)
})
websocketService.on('approvalStatusChanged', (data) => {
console.log('审批状态变更:', data)
})
websocketService.on('equipmentStatusChanged', (data) => {
console.log('设备状态变更:', data)
})
}
//
let reminderCheckInterval: NodeJS.Timeout | null = null
const startReminderCheck = () => {
reminderCheckInterval = setInterval(() => {
const reminderNotifications = notificationService.getReminderNotifications()
if (reminderNotifications.length > 0) {
//
reminderNotifications.forEach(notification => {
message.info(`${notification.title}: ${notification.content}`)
notificationService.markReminderProcessed(notification.id)
})
}
}, 60000) //
}
//
onMounted(() => {
setupWebSocketListeners()
startReminderCheck()
})
onUnmounted(() => {
if (reminderCheckInterval) {
clearInterval(reminderCheckInterval)
}
})
</script>
<style scoped lang="scss">
.notification-center {
position: relative;
z-index: 999999;
.notification-trigger {
cursor: pointer;
.notification-btn {
color: var(--color-text-1);
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: var(--color-primary);
}
.notification-text {
font-size: 14px;
margin-left: 4px;
}
}
}
}
//
.notification-modal {
:deep(.arco-modal) {
z-index: 999999 !important;
}
:deep(.arco-modal-mask) {
z-index: 999998 !important;
}
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
}
}
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 16px;
.header-left {
h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-1);
}
.notification-count {
font-size: 13px;
color: var(--color-text-3);
}
}
.header-right {
display: flex;
gap: 8px;
}
}
.notification-tabs {
margin-bottom: 16px;
:deep(.arco-tabs-nav) {
margin-bottom: 0;
}
}
.notification-list {
max-height: 400px;
overflow-y: auto;
.empty-state {
text-align: center;
padding: 40px 0;
color: var(--color-text-3);
}
.notification-item {
display: flex;
align-items: flex-start;
padding: 16px;
border: 1px solid var(--color-border-2);
border-radius: 8px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--color-fill-2);
border-color: var(--color-primary-light-3);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.unread {
background-color: var(--color-primary-light-1);
border-color: var(--color-primary-light-3);
&:hover {
background-color: var(--color-primary-light-2);
}
}
&.urgent {
border-left: 4px solid var(--color-danger);
background-color: var(--color-danger-light-1);
}
&.high {
border-left: 4px solid var(--color-warning);
background-color: var(--color-warning-light-1);
}
.notification-icon {
margin-right: 16px;
margin-top: 2px;
color: var(--color-text-2);
font-size: 18px;
}
.notification-content {
flex: 1;
min-width: 0;
.notification-title {
font-weight: 600;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-1);
font-size: 15px;
}
.notification-message {
font-size: 14px;
color: var(--color-text-2);
margin-bottom: 12px;
line-height: 1.5;
}
.notification-meta {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--color-text-3);
.notification-time {
color: var(--color-text-2);
font-weight: 500;
}
}
}
.notification-actions {
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s;
}
&:hover .notification-actions {
opacity: 1;
}
}
}
.notification-footer {
display: flex;
justify-content: space-between;
padding: 16px 0;
border-top: 1px solid var(--color-border);
margin-top: 16px;
background-color: var(--color-fill-1);
border-radius: 8px;
padding: 16px;
}
//
:deep(.arco-modal) {
z-index: 999999 !important;
}
:deep(.arco-modal-mask) {
z-index: 999998 !important;
}
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
}
// Arco Design v2
:deep(.arco-overlay) {
z-index: 999999 !important;
}
:deep(.arco-overlay-container) {
z-index: 999999 !important;
}
//
:deep(.arco-modal) {
z-index: 999999 !important;
position: relative !important;
}
//
:deep(.arco-modal-wrapper) {
z-index: 999999 !important;
position: relative !important;
}
</style>

View File

@ -12,24 +12,8 @@
</a-button> </a-button>
</a-tooltip> </a-tooltip>
<!-- 消息通知 --> <!-- 消息通知中心 -->
<a-popover <NotificationCenter ref="notificationCenterRef" />
position="bottom"
trigger="click"
:content-style="{ marginTop: '-5px', padding: 0, border: 'none' }"
:arrow-style="{ width: 0, height: 0 }"
>
<a-badge :count="unreadMessageCount" dot>
<a-button size="mini" class="gi_hover_btn">
<template #icon>
<icon-notification :size="18" />
</template>
</a-button>
</a-badge>
<template #content>
<Message @readall-success="getMessageCount" />
</template>
</a-popover>
<!-- 全屏切换组件 --> <!-- 全屏切换组件 -->
<a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom"> <a-tooltip v-if="!['xs', 'sm'].includes(breakpoint)" content="全屏切换" position="bottom">
@ -74,13 +58,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Modal } from '@arco-design/web-vue' import { Modal, Notification } from '@arco-design/web-vue'
import { useFullscreen } from '@vueuse/core' import { useFullscreen } from '@vueuse/core'
import { onMounted, ref, nextTick } from 'vue' import { onMounted, ref, nextTick, onBeforeUnmount } from 'vue'
import Message from './Message.vue' import NotificationCenter from '@/components/NotificationCenter/index.vue'
import SettingDrawer from './SettingDrawer.vue' import SettingDrawer from './SettingDrawer.vue'
import Search from './Search.vue' import Search from './Search.vue'
import { getUnreadMessageCount } from '@/apis'
import { useUserStore } from '@/stores' import { useUserStore } from '@/stores'
import { getToken } from '@/utils/auth' import { getToken } from '@/utils/auth'
import { useBreakpoint, useDevice } from '@/hooks' import { useBreakpoint, useDevice } from '@/hooks'
@ -89,6 +73,7 @@ defineOptions({ name: 'HeaderRight' })
const { isDesktop } = useDevice() const { isDesktop } = useDevice()
const { breakpoint } = useBreakpoint() const { breakpoint } = useBreakpoint()
const notificationCenterRef = ref()
let socket: WebSocket | null = null let socket: WebSocket | null = null
// //
@ -97,10 +82,148 @@ onBeforeUnmount(() => {
socket.close() socket.close()
socket = null socket = null
} }
//
if (titleFlashInterval) {
clearInterval(titleFlashInterval)
titleFlashInterval = null
}
}) })
const unreadMessageCount = ref(0) const unreadMessageCount = ref(0)
//
const playNotificationSound = () => {
try {
//
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
//
oscillator.frequency.setValueAtTime(800, audioContext.currentTime) // 800Hz
oscillator.type = 'sine'
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5)
//
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.5)
console.log('播放语音提示')
} catch (error) {
console.error('播放语音提示失败:', error)
}
}
//
let titleFlashInterval: NodeJS.Timeout | null = null
const flashPageTitle = () => {
const originalTitle = document.title
let flashCount = 0
const maxFlashes = 6 // 3-----
//
if (titleFlashInterval) {
clearInterval(titleFlashInterval)
}
titleFlashInterval = setInterval(() => {
if (flashCount >= maxFlashes) {
document.title = originalTitle
if (titleFlashInterval) {
clearInterval(titleFlashInterval)
titleFlashInterval = null
}
return
}
document.title = flashCount % 2 === 0 ? '🔔 新的采购申请' : originalTitle
flashCount++
}, 500)
}
// 便
if (typeof window !== 'undefined') {
(window as any).testNotification = {
playSound: playNotificationSound,
flashTitle: flashPageTitle,
showNotification: () => {
Notification.info({
title: '测试通知',
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
duration: 5000,
closable: true,
position: 'topRight'
})
unreadMessageCount.value++
},
testAll: () => {
playNotificationSound()
flashPageTitle()
Notification.info({
title: '测试通知',
content: '这是一个测试通知,用于验证通知功能是否正常工作。',
duration: 5000,
closable: true,
position: 'topRight'
})
unreadMessageCount.value++
},
//
debugWebSocket: () => {
console.log('=== WebSocket 调试信息 ===')
console.log('Socket对象:', socket)
console.log('Socket状态:', socket ? socket.readyState : '未连接')
console.log('Token:', getToken())
console.log('环境变量:', import.meta.env.VITE_API_WS_URL)
console.log('未读消息计数:', unreadMessageCount.value)
console.log('用户Token:', userStore.token)
},
// WebSocket
simulateWebSocketMessage: () => {
const mockMessage = {
type: "PROCUREMENT_APPLICATION",
title: "新的采购申请",
content: "收到来自 测试用户 的设备采购申请:测试设备"
}
const event = new MessageEvent('message', {
data: JSON.stringify(mockMessage)
})
if (socket && socket.onmessage) {
console.log('模拟WebSocket消息:', mockMessage)
socket.onmessage(event)
} else {
console.error('WebSocket连接不存在或onmessage未设置')
}
},
// WebSocket
reconnectWebSocket: () => {
console.log('强制重新连接WebSocket')
const token = getToken()
if (token) {
if (socket) {
socket.close()
socket = null
}
initWebSocket(token)
} else {
console.error('Token不存在无法重新连接')
}
}
}
// socket便
;(window as any).socket = socket
;(window as any).unreadMessageCount = unreadMessageCount
}
// WebSocket - 使 // WebSocket - 使
let initTimer: NodeJS.Timeout | null = null let initTimer: NodeJS.Timeout | null = null
const initWebSocket = (token: string) => { const initWebSocket = (token: string) => {
@ -116,29 +239,70 @@ const initWebSocket = (token: string) => {
} }
try { try {
socket = new WebSocket(`${import.meta.env.VITE_API_WS_URL}/websocket?token=${token}`) // WebSocket URL使
const wsUrl = import.meta.env.VITE_API_WS_URL || 'ws://localhost:8888'
const wsEndpoint = wsUrl.replace('8000', '8888') // 使8888
console.log('正在连接WebSocket:', `${wsEndpoint}/websocket?token=${token}`)
socket = new WebSocket(`${wsEndpoint}/websocket?token=${token}`)
socket.onopen = () => { socket.onopen = () => {
// console.log('WebSocket connection opened') console.log('WebSocket连接成功')
} }
socket.onmessage = (event) => { socket.onmessage = (event) => {
console.log('收到WebSocket消息:', event.data)
try {
const data = JSON.parse(event.data)
//
if (data.type && data.title && data.content) {
console.log('处理通知消息:', data)
//
playNotificationSound()
//
Notification.info({
title: data.title,
content: data.content,
duration: 5000,
closable: true,
position: 'topRight'
})
//
unreadMessageCount.value++
//
flashPageTitle()
} else {
//
const count = Number.parseInt(event.data)
if (!isNaN(count)) {
unreadMessageCount.value = count
}
}
} catch (error) {
console.error('解析WebSocket消息失败:', error)
//
const count = Number.parseInt(event.data) const count = Number.parseInt(event.data)
if (!isNaN(count)) { if (!isNaN(count)) {
unreadMessageCount.value = count unreadMessageCount.value = count
} }
} }
socket.onerror = () => {
// console.error('WebSocket error:', error)
} }
socket.onclose = () => { socket.onerror = (error) => {
// console.log('WebSocket connection closed') console.error('WebSocket连接错误:', error)
}
socket.onclose = (event) => {
console.log('WebSocket连接关闭:', event.code, event.reason)
socket = null socket = null
} }
} catch (error) { } catch (error) {
console.error('Failed to create WebSocket connection:', error) console.error('创建WebSocket连接失败:', error)
} }
initTimer = null initTimer = null
@ -149,10 +313,17 @@ const initWebSocket = (token: string) => {
const getMessageCount = async () => { const getMessageCount = async () => {
try { try {
const token = getToken() const token = getToken()
console.log('获取到token:', token ? '存在' : '不存在')
if (token && !socket) { if (token && !socket) {
console.log('准备初始化WebSocket连接')
nextTick(() => { nextTick(() => {
initWebSocket(token) initWebSocket(token)
}) })
} else if (!token) {
console.warn('Token不存在无法建立WebSocket连接')
} else if (socket) {
console.log('WebSocket连接已存在')
} }
} catch (error) { } catch (error) {
console.error('Failed to get message count:', error) console.error('Failed to get message count:', error)
@ -186,9 +357,32 @@ const logout = () => {
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
// getMessageCount() // WebSocket
getMessageCount()
// 1
setTimeout(() => {
if (!socket) {
console.log('首次连接失败重试WebSocket连接')
getMessageCount()
}
}, 1000)
}) })
}) })
//
watch(() => userStore.token, (newToken, oldToken) => {
console.log('Token变化:', { oldToken: oldToken ? '存在' : '不存在', newToken: newToken ? '存在' : '不存在' })
if (newToken && !socket) {
console.log('用户登录初始化WebSocket连接')
getMessageCount()
} else if (!newToken && socket) {
console.log('用户登出关闭WebSocket连接')
socket.close()
socket = null
}
}, { immediate: true })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@ -210,4 +404,56 @@ onMounted(() => {
margin-left: 2px; margin-left: 2px;
} }
} }
//
.notification-badge {
.arco-badge-dot {
background-color: #f53f3f;
box-shadow: 0 0 0 2px rgba(245, 63, 63, 0.2);
animation: pulse 2s infinite;
}
.arco-badge-count {
background-color: #f53f3f;
font-weight: bold;
animation: bounce 0.6s ease-in-out;
}
}
.notification-btn {
transition: all 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
//
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(245, 63, 63, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(245, 63, 63, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(245, 63, 63, 0);
}
}
//
@keyframes bounce {
0%, 20%, 53%, 80%, 100% {
transform: translate3d(0, 0, 0);
}
40%, 43% {
transform: translate3d(0, -8px, 0);
}
70% {
transform: translate3d(0, -4px, 0);
}
90% {
transform: translate3d(0, -2px, 0);
}
}
</style> </style>

View File

@ -11,8 +11,7 @@
> >
<template #icon> <template #icon>
<MenuIcon <MenuIcon
:svg-icon="onlyOneChild?.meta?.svgIcon || item?.meta?.svgIcon" :icon="onlyOneChild?.meta?.svgIcon || onlyOneChild?.meta?.icon || item?.meta?.svgIcon || item?.meta?.icon"
:icon="onlyOneChild?.meta?.icon || item?.meta?.icon"
/> />
</template> </template>
<a-tooltip :content="onlyOneChild?.meta?.title" position="right"> <a-tooltip :content="onlyOneChild?.meta?.title" position="right">
@ -22,7 +21,7 @@
<a-sub-menu v-else v-bind="attrs" :key="item.path" :title="item?.meta?.title"> <a-sub-menu v-else v-bind="attrs" :key="item.path" :title="item?.meta?.title">
<template #icon> <template #icon>
<MenuIcon :icon="item?.meta?.icon" /> <MenuIcon :icon="item?.meta?.svgIcon || item?.meta?.icon" />
</template> </template>
<MenuItem v-for="child in item.children" :key="child.path" :item="child"></MenuItem> <MenuItem v-for="child in item.children" :key="child.path" :item="child"></MenuItem>
</a-sub-menu> </a-sub-menu>

View File

@ -324,73 +324,7 @@ export const systemRoutes: RouteRecordRaw[] = [
// }, // },
// ], // ],
// }, // },
{
path: '/asset-management',
name: 'AssetManagement',
component: Layout,
redirect: '/asset-management/device/inventory',
meta: { title: '资产管理', icon: 'property-safety', hidden: false, sort: 3 },
children: [
{
path: '/asset-management/intellectual-property1',
name: 'IntellectualProperty1',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '设备管理', icon: 'copyright', hidden: false },
children: [
{
path: '/asset-management/intellectual-property1',
name: 'IntellectualProperty11',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '库存管理', hidden: false },
},
{
path: '/asset-management/intellectual-property1',
name: 'IntellectualProperty12',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '设备采购', hidden: false },
},
{
path: '/asset-management/intellectual-property1',
name: 'IntellectualProperty13',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '在线管理', hidden: false },
children: [
{
path: '/asset-management/intellectual-property11',
name: 'IntellectualProperty14',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '无人机', hidden: false },
},
{
path: '/asset-management/intellectual-property12',
name: 'IntellectualProperty15',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '机巢', hidden: false },
},
{
path: '/asset-management/intellectual-property13',
name: 'IntellectualProperty16',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '其他智能终端', hidden: false },
},
{
path: '/asset-management/intellectual-property14',
name: 'IntellectualProperty17',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '车辆管理', hidden: false },
},
],
},
],
},
{
path: '/asset-management/intellectual-property',
name: 'IntellectualProperty',
component: () => import('@/views/system-resource/information-system/software-management/index.vue'),
meta: { title: '其他资产', icon: 'copyright', hidden: false },
},
],
},
{ {
path: '/products-services', path: '/products-services',
name: 'ProductsServices', name: 'ProductsServices',
@ -852,26 +786,7 @@ export const systemRoutes: RouteRecordRaw[] = [
], ],
}, },
// 添加商务知识库
{
path: '/bussiness-knowledge',
name: 'bussinesskonwledge',
component: Layout,
redirect: '/bussiness-knowledge/data',
meta: { title: '商务资料知识库', icon: 'message', hidden: false, sort: 6 },
children: [
{
path: '/bussiness-konwledge/data',
name: 'bussiness-knowledge',
component: () => import('@/views/bussiness-data/bussiness.vue'),
meta: {
title: '商务数据库信息',
icon: 'info-circle',
hidden: false,
},
},
],
},
{ {
path: 'project-management/project-implementation/', path: 'project-management/project-implementation/',
name: 'Project-Implementation', name: 'Project-Implementation',
@ -915,6 +830,29 @@ export const systemRoutes: RouteRecordRaw[] = [
}, },
], ],
}, },
//项目中控台
{
path: '/project-management/projects/personnel-dispatch',
name: 'PersonnelDispatch',
component: () => import('@/views/project-management/personnel-dispatch/index.vue'),
meta: {
title: '项目中控台',
icon: 'user-group',
hidden: false,
},
},
{
path: '/project-management/personnel-dispatch/construction-personnel',
name: 'ConstructionPersonnel',
component: () => import('@/views/project-management/personnel-dispatch/construction-personnel.vue'),
meta: {
title: '团队成员管理',
icon: 'user',
hidden: true,
},
},
], ],
}, },
@ -1166,6 +1104,26 @@ export const systemRoutes: RouteRecordRaw[] = [
}, },
// ], // ],
// }, // },
// 商务数据库信息模块
{
path: '/bussiness-knowledge',
name: 'bussinesskonwledge',
component: Layout,
redirect: '/bussiness-knowledge/data',
meta: { title: '商务资料知识库', icon: 'database', hidden: false, sort: 5.5 },
children: [
{
path: '/bussiness-konwledge/data',
name: 'bussiness-knowledge',
component: () => import('@/views/bussiness-data/bussiness.vue'),
meta: {
title: '商务数据库信息',
icon: 'info-circle',
hidden: false,
},
},
],
},
{ {
path: '/chat-platform', path: '/chat-platform',
name: 'ChatPlatform', name: 'ChatPlatform',
@ -1326,7 +1284,7 @@ export const systemRoutes: RouteRecordRaw[] = [
}, },
{ {
path: '/system-resource/device-management/online', path: '/system-resource/device-management/online',
name: 'DeviceOnline', name: 'SystemResourceDeviceOnline',
component: () => import('@/components/ParentView/index.vue'), component: () => import('@/components/ParentView/index.vue'),
redirect: '/system-resource/device-management/online/drone', redirect: '/system-resource/device-management/online/drone',
meta: { meta: {
@ -1337,7 +1295,7 @@ export const systemRoutes: RouteRecordRaw[] = [
children: [ children: [
{ {
path: '/system-resource/device-management/online/drone', path: '/system-resource/device-management/online/drone',
name: 'DeviceDrone', name: 'SystemResourceDeviceDrone',
component: () => import('@/views/system-resource/device-management/index.vue'), component: () => import('@/views/system-resource/device-management/index.vue'),
meta: { meta: {
title: '无人机', title: '无人机',
@ -1347,7 +1305,7 @@ export const systemRoutes: RouteRecordRaw[] = [
}, },
{ {
path: '/system-resource/device-management/online/nest', path: '/system-resource/device-management/online/nest',
name: 'DeviceNest', name: 'SystemResourceDeviceNest',
component: () => import('@/views/system-resource/device-management/index.vue'), component: () => import('@/views/system-resource/device-management/index.vue'),
meta: { meta: {
title: '机巢', title: '机巢',
@ -1357,7 +1315,7 @@ export const systemRoutes: RouteRecordRaw[] = [
}, },
{ {
path: '/system-resource/device-management/online/smart-terminal', path: '/system-resource/device-management/online/smart-terminal',
name: 'DeviceSmartTerminal', name: 'SystemResourceDeviceSmartTerminal',
component: () => import('@/views/system-resource/device-management/index.vue'), component: () => import('@/views/system-resource/device-management/index.vue'),
meta: { meta: {
title: '其他智能终端', title: '其他智能终端',

View File

@ -0,0 +1,696 @@
import { ref, computed } from 'vue'
export interface NotificationItem {
id: string
type: 'APPROVAL' | 'PENDING' | 'SYSTEM' | 'PROCUREMENT' | 'EQUIPMENT_BORROW' | 'EQUIPMENT_RETURN' | 'EQUIPMENT_MAINTENANCE' | 'EQUIPMENT_ALERT' | 'WORKFLOW'
title: string
content: string
createTime: string
read: boolean
targetUrl?: string
priority?: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'
category?: string
metadata?: Record<string, any>
// 新增字段
source?: string // 消息来源
actionRequired?: boolean // 是否需要操作
expiresAt?: string // 过期时间
relatedId?: string // 关联的业务ID
// 新增智能提醒字段
reminderTime?: string // 提醒时间
reminderType?: 'IMMEDIATE' | 'DELAYED' | 'RECURRING' // 提醒类型
reminderInterval?: number // 重复提醒间隔(分钟)
lastReminderTime?: string // 上次提醒时间
}
class NotificationService {
private notifications = ref<NotificationItem[]>([])
// 计算属性
public readonly unreadCount = computed(() =>
this.notifications.value.filter(n => !n.read).length
)
public readonly pendingCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'PENDING' && !n.read
).length
)
public readonly approvalCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'APPROVAL' && !n.read
).length
)
public readonly procurementCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'PROCUREMENT' && !n.read
).length
)
// 新增:设备借用通知数量
public readonly equipmentBorrowCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'EQUIPMENT_BORROW' && !n.read
).length
)
// 新增:设备归还通知数量
public readonly equipmentReturnCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'EQUIPMENT_RETURN' && !n.read
).length
)
// 新增:设备维护通知数量
public readonly equipmentMaintenanceCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'EQUIPMENT_MAINTENANCE' && !n.read
).length
)
// 新增:设备告警通知数量
public readonly equipmentAlertCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'EQUIPMENT_ALERT' && !n.read
).length
)
// 新增:工作流通知数量
public readonly workflowCount = computed(() =>
this.notifications.value.filter(n =>
n.type === 'WORKFLOW' && !n.read
).length
)
// 新增:需要操作的通知数量
public readonly actionRequiredCount = computed(() =>
this.notifications.value.filter(n =>
n.actionRequired && !n.read
).length
)
// 新增:紧急通知数量
public readonly urgentCount = computed(() =>
this.notifications.value.filter(n =>
n.priority === 'URGENT' && !n.read
).length
)
// 新增:今日通知数量
public readonly todayCount = computed(() => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return this.notifications.value.filter(n => {
const createDate = new Date(n.createTime)
return createDate >= today
}).length
})
// 获取所有通知
public getAllNotifications() {
return this.notifications.value
}
// 获取未读通知
public getUnreadNotifications() {
return this.notifications.value.filter(n => !n.read)
}
// 获取特定类型的通知
public getNotificationsByType(type: NotificationItem['type']) {
return this.notifications.value.filter(n => n.type === type)
}
// 新增:获取需要操作的通知
public getActionRequiredNotifications() {
return this.notifications.value.filter(n => n.actionRequired && !n.read)
}
// 新增:获取紧急通知
public getUrgentNotifications() {
return this.notifications.value.filter(n => n.priority === 'URGENT' && !n.read)
}
// 新增:获取今日通知
public getTodayNotifications() {
const today = new Date()
today.setHours(0, 0, 0, 0)
return this.notifications.value.filter(n => {
const createDate = new Date(n.createTime)
return createDate >= today
})
}
// 新增:获取相关通知
public getRelatedNotifications(relatedId: string) {
return this.notifications.value.filter(n => n.relatedId === relatedId)
}
// 新增:获取需要提醒的通知
public getReminderNotifications() {
const now = new Date()
return this.notifications.value.filter(n => {
if (!n.reminderTime) return false
const reminderDate = new Date(n.reminderTime)
if (reminderDate <= now) {
// 检查重复提醒
if (n.reminderType === 'RECURRING' && n.reminderInterval) {
const lastReminder = n.lastReminderTime ? new Date(n.lastReminderTime) : reminderDate
const nextReminder = new Date(lastReminder.getTime() + n.reminderInterval * 60 * 1000)
return nextReminder <= now
}
return true
}
return false
})
}
// 新增:设置通知提醒
public setNotificationReminder(notificationId: string, reminderTime: string, reminderType: 'IMMEDIATE' | 'DELAYED' | 'RECURRING' = 'IMMEDIATE', reminderInterval?: number) {
const notification = this.notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.reminderTime = reminderTime
notification.reminderType = reminderType
notification.reminderInterval = reminderInterval
this.saveToStorage()
}
}
// 新增:取消通知提醒
public cancelNotificationReminder(notificationId: string) {
const notification = this.notifications.value.find(n => n.id === notificationId)
if (notification) {
notification.reminderTime = undefined
notification.reminderType = undefined
notification.reminderInterval = undefined
notification.lastReminderTime = undefined
this.saveToStorage()
}
}
// 新增:标记提醒已处理
public markReminderProcessed(notificationId: string) {
const notification = this.notifications.value.find(n => n.id === notificationId)
if (notification && notification.reminderType === 'RECURRING') {
notification.lastReminderTime = new Date().toISOString()
this.saveToStorage()
}
}
// 添加通知
public addNotification(notification: Omit<NotificationItem, 'id' | 'createTime' | 'read'>) {
const newNotification: NotificationItem = {
...notification,
id: this.generateId(),
createTime: new Date().toISOString(),
read: false,
// 设置默认值
priority: notification.priority || 'NORMAL',
actionRequired: notification.actionRequired || false,
source: notification.source || 'SYSTEM'
}
this.notifications.value.unshift(newNotification)
// 限制通知数量,避免内存泄漏
if (this.notifications.value.length > 200) {
this.notifications.value = this.notifications.value.slice(0, 200)
}
// 触发事件
this.emitNotificationEvent('add', newNotification)
// 自动保存
this.saveToStorage()
// 检查是否需要播放提示音
if (newNotification.priority === 'URGENT' || newNotification.priority === 'HIGH') {
this.playNotificationSound()
}
return newNotification
}
// 标记通知为已读
public markAsRead(id: string) {
const notification = this.notifications.value.find(n => n.id === id)
if (notification) {
notification.read = true
this.emitNotificationEvent('read', notification)
this.saveToStorage()
}
}
// 标记所有通知为已读
public markAllAsRead() {
this.notifications.value.forEach(n => n.read = true)
this.emitNotificationEvent('readAll', null)
this.saveToStorage()
}
// 标记特定类型通知为已读
public markTypeAsRead(type: NotificationItem['type']) {
this.notifications.value
.filter(n => n.type === type)
.forEach(n => n.read = true)
this.emitNotificationEvent('readType', { type })
this.saveToStorage()
}
// 新增:标记相关通知为已读
public markRelatedAsRead(relatedId: string) {
this.notifications.value
.filter(n => n.relatedId === relatedId)
.forEach(n => n.read = true)
this.emitNotificationEvent('readRelated', { relatedId })
this.saveToStorage()
}
// 删除通知
public removeNotification(id: string) {
const index = this.notifications.value.findIndex(n => n.id === id)
if (index > -1) {
const notification = this.notifications.value[index]
this.notifications.value.splice(index, 1)
this.emitNotificationEvent('remove', notification)
this.saveToStorage()
}
}
// 清空所有通知
public clearAll() {
this.notifications.value = []
this.emitNotificationEvent('clear', null)
this.saveToStorage()
}
// 清空已读通知
public clearRead() {
this.notifications.value = this.notifications.value.filter(n => !n.read)
this.emitNotificationEvent('clearRead', null)
this.saveToStorage()
}
// 新增:清空过期通知
public clearExpired() {
const now = new Date()
const beforeCount = this.notifications.value.length
this.notifications.value = this.notifications.value.filter(n => {
if (!n.expiresAt) return true
const expireDate = new Date(n.expiresAt)
return expireDate > now
})
const afterCount = this.notifications.value.length
if (beforeCount !== afterCount) {
this.emitNotificationEvent('clearExpired', { clearedCount: beforeCount - afterCount })
this.saveToStorage()
}
}
// 获取通知统计
public getStats() {
const total = this.notifications.value.length
const unread = this.unreadCount.value
const pending = this.pendingCount.value
const approval = this.approvalCount.value
const procurement = this.procurementCount.value
const actionRequired = this.actionRequiredCount.value
const urgent = this.urgentCount.value
const today = this.todayCount.value
return {
total,
unread,
pending,
approval,
procurement,
actionRequired,
urgent,
today,
read: total - unread
}
}
// 搜索通知
public searchNotifications(keyword: string) {
if (!keyword.trim()) {
return this.notifications.value
}
const lowerKeyword = keyword.toLowerCase()
return this.notifications.value.filter(n =>
n.title.toLowerCase().includes(lowerKeyword) ||
n.content.toLowerCase().includes(lowerKeyword) ||
n.category?.toLowerCase().includes(lowerKeyword) ||
n.source?.toLowerCase().includes(lowerKeyword)
)
}
// 按优先级排序
public sortByPriority() {
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
this.notifications.value.sort((a, b) => {
const aPriority = priorityOrder[a.priority || 'NORMAL'] || 2
const bPriority = priorityOrder[b.priority || 'NORMAL'] || 2
if (aPriority !== bPriority) {
return bPriority - aPriority
}
// 优先级相同时,按时间倒序
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
})
}
// 新增:按时间排序
public sortByTime() {
this.notifications.value.sort((a, b) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
)
}
// 新增:按类型分组
public groupByType() {
const groups: Record<string, NotificationItem[]> = {}
this.notifications.value.forEach(notification => {
const type = notification.type
if (!groups[type]) {
groups[type] = []
}
groups[type].push(notification)
})
return groups
}
// 添加采购申请通知
public addProcurementNotification(equipmentName: string, applicantName: string) {
return this.addNotification({
type: 'PROCUREMENT',
title: '新的设备采购申请',
content: `收到来自 ${applicantName} 的设备采购申请:${equipmentName}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '设备采购',
actionRequired: true,
source: 'PROCUREMENT_SYSTEM',
metadata: {
equipmentName,
applicantName,
timestamp: Date.now()
}
})
}
// 添加审批结果通知
public addApprovalNotification(equipmentName: string, result: 'APPROVED' | 'REJECTED', approverName: string) {
const isApproved = result === 'APPROVED'
return this.addNotification({
type: 'APPROVAL',
title: `采购申请${isApproved ? '已通过' : '已拒绝'}`,
content: `您的设备采购申请"${equipmentName}"${isApproved ? '已通过' : '被拒绝'},审批人:${approverName}`,
targetUrl: '/asset-management/device-management/procurement',
priority: isApproved ? 'NORMAL' : 'HIGH',
category: '审批结果',
actionRequired: !isApproved, // 被拒绝时需要操作
source: 'APPROVAL_SYSTEM',
metadata: {
equipmentName,
result,
approverName,
timestamp: Date.now()
}
})
}
// 添加系统通知
public addSystemNotification(title: string, content: string, priority: NotificationItem['priority'] = 'NORMAL') {
return this.addNotification({
type: 'SYSTEM',
title,
content,
priority,
category: '系统通知',
source: 'SYSTEM',
actionRequired: false
})
}
// 新增:添加待审批通知
public addPendingApprovalNotification(equipmentName: string, applicantName: string, businessType: string) {
return this.addNotification({
type: 'PENDING',
title: '新的审批申请',
content: `收到来自 ${applicantName}${businessType}申请:${equipmentName}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '审批申请',
actionRequired: true,
source: 'APPROVAL_SYSTEM',
metadata: {
equipmentName,
applicantName,
businessType,
timestamp: Date.now()
}
})
}
// 新增:添加设备借用通知
public addEquipmentBorrowNotification(equipmentName: string, borrowerName: string, borrowReason: string) {
return this.addNotification({
type: 'EQUIPMENT_BORROW',
title: '新的设备借用申请',
content: `收到来自 ${borrowerName} 的设备借用申请:${equipmentName},借用原因:${borrowReason}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'HIGH',
category: '设备借用',
actionRequired: true,
source: 'EQUIPMENT_SYSTEM',
metadata: {
equipmentName,
borrowerName,
borrowReason,
timestamp: Date.now()
}
})
}
// 新增:添加设备归还通知
public addEquipmentReturnNotification(equipmentName: string, returnerName: string, returnCondition: string) {
return this.addNotification({
type: 'EQUIPMENT_RETURN',
title: '设备归还申请',
content: `收到来自 ${returnerName} 的设备归还申请:${equipmentName},归还状态:${returnCondition}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备归还',
actionRequired: false,
source: 'EQUIPMENT_SYSTEM',
metadata: {
equipmentName,
returnerName,
returnCondition,
timestamp: Date.now()
}
})
}
// 新增:添加设备维护通知
public addEquipmentMaintenanceNotification(equipmentName: string, maintenanceType: string, estimatedDuration: string) {
return this.addNotification({
type: 'EQUIPMENT_MAINTENANCE',
title: '设备维护申请',
content: `设备 ${equipmentName} 申请${maintenanceType}维护,预计时长:${estimatedDuration}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备维护',
actionRequired: true,
source: 'EQUIPMENT_SYSTEM',
metadata: {
equipmentName,
maintenanceType,
estimatedDuration,
timestamp: Date.now()
}
})
}
// 新增:添加工作流通知
public addWorkflowNotification(workflowName: string, currentNode: string, actionRequired: string) {
return this.addNotification({
type: 'WORKFLOW',
title: '工作流待处理',
content: `工作流"${workflowName}"当前节点:${currentNode},需要操作:${actionRequired}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '工作流',
actionRequired: true,
source: 'WORKFLOW_SYSTEM',
metadata: {
workflowName,
currentNode,
actionRequired,
timestamp: Date.now()
}
})
}
// 新增:播放通知提示音
private playNotificationSound() {
try {
// 创建音频上下文
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
const oscillator = audioContext.createOscillator()
const gainNode = audioContext.createGain()
oscillator.connect(gainNode)
gainNode.connect(audioContext.destination)
oscillator.frequency.setValueAtTime(800, audioContext.currentTime)
oscillator.frequency.setValueAtTime(600, audioContext.currentTime + 0.1)
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3)
oscillator.start(audioContext.currentTime)
oscillator.stop(audioContext.currentTime + 0.3)
} catch (error) {
console.warn('无法播放通知提示音:', error)
}
}
// 事件系统
private eventListeners: Map<string, Function[]> = new Map()
public on(event: string, callback: Function) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, [])
}
this.eventListeners.get(event)!.push(callback)
}
public off(event: string, callback: Function) {
const listeners = this.eventListeners.get(event)
if (listeners) {
const index = listeners.indexOf(callback)
if (index > -1) {
listeners.splice(index, 1)
}
}
}
private emitNotificationEvent(event: string, data: any) {
const listeners = this.eventListeners.get(event)
if (listeners) {
listeners.forEach(callback => {
try {
callback(data)
} catch (error) {
console.error('通知事件回调执行失败:', error)
}
})
}
}
// 生成唯一ID
private generateId(): string {
return `notification_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 持久化存储
public saveToStorage() {
try {
localStorage.setItem('notifications', JSON.stringify(this.notifications.value))
} catch (error) {
console.error('保存通知到本地存储失败:', error)
}
}
public loadFromStorage() {
try {
const stored = localStorage.getItem('notifications')
if (stored) {
this.notifications.value = JSON.parse(stored)
// 清理过期通知
this.clearExpired()
// 重新排序
this.sortByPriority()
}
} catch (error) {
console.error('从本地存储加载通知失败:', error)
}
}
// 自动保存
public enableAutoSave() {
// 监听通知变化,自动保存
this.on('add', () => this.saveToStorage())
this.on('read', () => this.saveToStorage())
this.on('remove', () => this.saveToStorage())
this.on('clear', () => this.saveToStorage())
}
// 新增:定期清理过期通知
public enableAutoCleanup() {
// 每小时清理一次过期通知
setInterval(() => {
this.clearExpired()
}, 60 * 60 * 1000)
}
// 新增:导出通知数据
public exportNotifications() {
try {
const data = JSON.stringify(this.notifications.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `notifications_${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('导出通知失败:', error)
}
}
// 新增:导入通知数据
public importNotifications(data: string) {
try {
const notifications = JSON.parse(data)
if (Array.isArray(notifications)) {
this.notifications.value = [...this.notifications.value, ...notifications]
this.saveToStorage()
return true
}
return false
} catch (error) {
console.error('导入通知失败:', error)
return false
}
}
}
// 创建单例实例
const notificationService = new NotificationService()
// 启用自动保存
notificationService.enableAutoSave()
// 启用自动清理
notificationService.enableAutoCleanup()
// 从本地存储加载通知
notificationService.loadFromStorage()
export default notificationService

View File

@ -0,0 +1,492 @@
import { ref, computed } from 'vue'
import notificationService from './notificationService'
import websocketService from './websocketService'
export interface ProcurementItem {
id: string
equipmentName: string
applicantName: string
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'PROCESSING' | 'COMPLETED' | 'CANCELLED'
createTime: string
updateTime: string
priority: 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'
category: string
description?: string
budget?: number
quantity: number
unit: string
expectedDelivery?: string
approvalHistory: ApprovalRecord[]
metadata?: Record<string, any>
}
export interface ApprovalRecord {
id: string
approverName: string
action: 'APPROVE' | 'REJECT' | 'COMMENT'
comment?: string
timestamp: string
status: 'PENDING' | 'COMPLETED'
}
class ProcurementSyncService {
private procurements = ref<ProcurementItem[]>([])
private isInitialized = ref(false)
// 计算属性
public readonly pendingCount = computed(() =>
this.procurements.value.filter(p => p.status === 'PENDING').length
)
public readonly approvedCount = computed(() =>
this.procurements.value.filter(p => p.status === 'APPROVED').length
)
public readonly rejectedCount = computed(() =>
this.procurements.value.filter(p => p.status === 'REJECTED').length
)
public readonly processingCount = computed(() =>
this.procurements.value.filter(p => p.status === 'PROCESSING').length
)
public readonly completedCount = computed(() =>
this.procurements.value.filter(p => p.status === 'COMPLETED').length
)
public readonly urgentCount = computed(() =>
this.procurements.value.filter(p => p.priority === 'URGENT' && p.status === 'PENDING').length
)
public readonly highPriorityCount = computed(() =>
this.procurements.value.filter(p =>
(p.priority === 'HIGH' || p.priority === 'URGENT') &&
p.status === 'PENDING'
).length
)
// 获取所有采购项目
public getAllProcurements() {
return this.procurements.value
}
// 获取特定状态的采购项目
public getProcurementsByStatus(status: ProcurementItem['status']) {
return this.procurements.value.filter(p => p.status === status)
}
// 获取特定优先级的采购项目
public getProcurementsByPriority(priority: ProcurementItem['priority']) {
return this.procurements.value.filter(p => p.priority === priority)
}
// 获取待审批的采购项目
public getPendingProcurements() {
return this.procurements.value.filter(p => p.status === 'PENDING')
}
// 获取紧急采购项目
public getUrgentProcurements() {
return this.procurements.value.filter(p =>
p.priority === 'URGENT' && p.status === 'PENDING'
)
}
// 根据ID获取采购项目
public getProcurementById(id: string) {
return this.procurements.value.find(p => p.id === id)
}
// 添加采购申请
public addProcurement(procurement: Omit<ProcurementItem, 'id' | 'createTime' | 'updateTime' | 'approvalHistory'>) {
const newProcurement: ProcurementItem = {
...procurement,
id: this.generateId(),
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
approvalHistory: []
}
this.procurements.value.unshift(newProcurement)
// 限制数量,避免内存泄漏
if (this.procurements.value.length > 500) {
this.procurements.value = this.procurements.value.slice(0, 500)
}
// 添加通知
notificationService.addProcurementNotification(
newProcurement.equipmentName,
newProcurement.applicantName
)
// 触发WebSocket更新
this.broadcastProcurementUpdate('ADD', newProcurement)
// 保存到本地存储
this.saveToStorage()
return newProcurement
}
// 更新采购状态
public updateProcurementStatus(id: string, status: ProcurementItem['status'], approverName?: string, comment?: string) {
const procurement = this.procurements.value.find(p => p.id === id)
if (!procurement) {
console.warn(`采购项目 ${id} 不存在`)
return false
}
const oldStatus = procurement.status
procurement.status = status
procurement.updateTime = new Date().toISOString()
// 添加审批记录
if (approverName) {
const approvalRecord: ApprovalRecord = {
id: this.generateId(),
approverName,
action: status === 'APPROVED' ? 'APPROVE' : 'REJECT',
comment,
timestamp: new Date().toISOString(),
status: 'COMPLETED'
}
procurement.approvalHistory.push(approvalRecord)
}
// 添加通知
if (status === 'APPROVED' || status === 'REJECTED') {
notificationService.addApprovalNotification(
procurement.equipmentName,
status,
approverName || '系统'
)
}
// 触发WebSocket更新
this.broadcastProcurementUpdate('UPDATE', procurement)
// 保存到本地存储
this.saveToStorage()
console.log(`采购项目 ${id} 状态从 ${oldStatus} 更新为 ${status}`)
return true
}
// 批量更新状态
public batchUpdateStatus(ids: string[], status: ProcurementItem['status'], approverName: string, comment?: string) {
const results = ids.map(id => this.updateProcurementStatus(id, status, approverName, comment))
return results.every(result => result)
}
// 删除采购项目
public removeProcurement(id: string) {
const index = this.procurements.value.findIndex(p => p.id === id)
if (index > -1) {
const procurement = this.procurements.value[index]
this.procurements.value.splice(index, 1)
// 触发WebSocket更新
this.broadcastProcurementUpdate('REMOVE', { id })
// 保存到本地存储
this.saveToStorage()
return true
}
return false
}
// 搜索采购项目
public searchProcurements(keyword: string) {
if (!keyword.trim()) {
return this.procurements.value
}
const lowerKeyword = keyword.toLowerCase()
return this.procurements.value.filter(p =>
p.equipmentName.toLowerCase().includes(lowerKeyword) ||
p.applicantName.toLowerCase().includes(lowerKeyword) ||
p.description?.toLowerCase().includes(lowerKeyword) ||
p.category.toLowerCase().includes(lowerKeyword)
)
}
// 按优先级排序
public sortByPriority() {
const priorityOrder = { 'URGENT': 4, 'HIGH': 3, 'NORMAL': 2, 'LOW': 1 }
this.procurements.value.sort((a, b) => {
const aPriority = priorityOrder[a.priority] || 2
const bPriority = priorityOrder[b.priority] || 2
if (aPriority !== bPriority) {
return bPriority - aPriority
}
// 优先级相同时,按时间倒序
return new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
})
}
// 按时间排序
public sortByTime() {
this.procurements.value.sort((a, b) =>
new Date(b.createTime).getTime() - new Date(a.createTime).getTime()
)
}
// 按状态分组
public groupByStatus() {
const groups: Record<string, ProcurementItem[]> = {}
this.procurements.value.forEach(procurement => {
const status = procurement.status
if (!groups[status]) {
groups[status] = []
}
groups[status].push(procurement)
})
return groups
}
// 获取统计信息
public getStats() {
const total = this.procurements.value.length
const pending = this.pendingCount.value
const approved = this.approvedCount.value
const rejected = this.rejectedCount.value
const processing = this.processingCount.value
const completed = this.completedCount.value
const urgent = this.urgentCount.value
const highPriority = this.highPriorityCount.value
return {
total,
pending,
approved,
rejected,
processing,
completed,
urgent,
highPriority,
approvalRate: total > 0 ? ((approved + completed) / total * 100).toFixed(1) : '0.0'
}
}
// 导出采购数据
public exportProcurements() {
try {
const data = JSON.stringify(this.procurements.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `procurements_${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
} catch (error) {
console.error('导出采购数据失败:', error)
}
}
// 导入采购数据
public importProcurements(data: string) {
try {
const procurements = JSON.parse(data)
if (Array.isArray(procurements)) {
this.procurements.value = [...this.procurements.value, ...procurements]
this.saveToStorage()
return true
}
return false
} catch (error) {
console.error('导入采购数据失败:', error)
return false
}
}
// 清空所有数据
public clearAll() {
this.procurements.value = []
this.saveToStorage()
}
// 初始化服务
public async initialize() {
if (this.isInitialized.value) {
return
}
try {
// 从本地存储加载数据
this.loadFromStorage()
// 设置WebSocket监听
this.setupWebSocketListeners()
// 设置定期同步
this.setupPeriodicSync()
this.isInitialized.value = true
console.log('采购同步服务初始化完成')
} catch (error) {
console.error('采购同步服务初始化失败:', error)
}
}
// 设置WebSocket监听
private setupWebSocketListeners() {
// 监听采购更新消息
websocketService.on('PROCUREMENT_UPDATE', (data: any) => {
this.handleWebSocketMessage(data)
})
// 监听审批更新消息
websocketService.on('APPROVAL_UPDATE', (data: any) => {
this.handleApprovalUpdate(data)
})
// 监听设备状态更新
websocketService.on('EQUIPMENT_STATUS_UPDATE', (data: any) => {
this.handleEquipmentStatusUpdate(data)
})
}
// 处理WebSocket消息
private handleWebSocketMessage(data: any) {
try {
const { action, procurement } = data
switch (action) {
case 'ADD':
if (procurement && !this.procurements.value.find(p => p.id === procurement.id)) {
this.procurements.value.unshift(procurement)
}
break
case 'UPDATE':
if (procurement) {
const index = this.procurements.value.findIndex(p => p.id === procurement.id)
if (index > -1) {
this.procurements.value[index] = procurement
}
}
break
case 'REMOVE':
if (data.id) {
const index = this.procurements.value.findIndex(p => p.id === data.id)
if (index > -1) {
this.procurements.value.splice(index, 1)
}
}
break
}
// 保存到本地存储
this.saveToStorage()
} catch (error) {
console.error('处理WebSocket采购消息失败:', error)
}
}
// 处理审批更新
private handleApprovalUpdate(data: any) {
try {
const { procurementId, status, approverName, comment } = data
this.updateProcurementStatus(procurementId, status, approverName, comment)
} catch (error) {
console.error('处理审批更新失败:', error)
}
}
// 处理设备状态更新
private handleEquipmentStatusUpdate(data: any) {
try {
const { procurementId, equipmentStatus } = data
const procurement = this.procurements.value.find(p => p.id === procurementId)
if (procurement) {
// 根据设备状态更新采购状态
if (equipmentStatus === 'INSTALLED') {
this.updateProcurementStatus(procurementId, 'COMPLETED')
} else if (equipmentStatus === 'IN_TRANSIT') {
this.updateProcurementStatus(procurementId, 'PROCESSING')
}
}
} catch (error) {
console.error('处理设备状态更新失败:', error)
}
}
// 设置定期同步
private setupPeriodicSync() {
// 每5分钟同步一次状态
setInterval(() => {
this.syncProcurementStatus()
}, 5 * 60 * 1000)
}
// 同步采购状态
private async syncProcurementStatus() {
try {
// 这里可以调用后端API同步最新状态
// const response = await api.getProcurementStatus()
// 更新本地状态
console.log('定期同步采购状态...')
} catch (error) {
console.error('同步采购状态失败:', error)
}
}
// 广播采购更新
private broadcastProcurementUpdate(action: string, data: any) {
if (websocketService.connected.value) {
websocketService.send({
type: 'PROCUREMENT_UPDATE',
data: { action, ...data },
timestamp: Date.now()
})
}
}
// 生成唯一ID
private generateId(): string {
return `procurement_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
// 持久化存储
public saveToStorage() {
try {
localStorage.setItem('procurements', JSON.stringify(this.procurements.value))
} catch (error) {
console.error('保存采购数据到本地存储失败:', error)
}
}
public loadFromStorage() {
try {
const stored = localStorage.getItem('procurements')
if (stored) {
this.procurements.value = JSON.parse(stored)
// 重新排序
this.sortByPriority()
}
} catch (error) {
console.error('从本地存储加载采购数据失败:', error)
}
}
// 获取初始化状态
public getInitializationStatus() {
return this.isInitialized.value
}
}
// 创建单例实例
const procurementSyncService = new ProcurementSyncService()
export default procurementSyncService

View File

@ -0,0 +1,787 @@
import { ref, onMounted, onUnmounted } from 'vue'
import notificationService from './notificationService'
interface WebSocketMessage {
type: 'NOTIFICATION' | 'APPROVAL_UPDATE' | 'PROCUREMENT_UPDATE' | 'SYSTEM' | 'EQUIPMENT_STATUS_UPDATE' | 'HEARTBEAT' | 'EQUIPMENT_BORROW_UPDATE' | 'EQUIPMENT_RETURN_UPDATE' | 'EQUIPMENT_MAINTENANCE_UPDATE' | 'EQUIPMENT_ALERT' | 'WORKFLOW_UPDATE'
data: any
timestamp: number
messageId?: string
}
class WebSocketService {
private ws: WebSocket | null = null
private reconnectAttempts = 0
private maxReconnectAttempts = 5
private reconnectInterval = 3000 // 3秒
private heartbeatInterval: NodeJS.Timeout | null = null
private isConnected = ref(false)
private isConnecting = ref(false)
// 连接状态
public readonly connected = this.isConnected
public readonly connecting = this.isConnecting
// 连接配置
private wsUrl = import.meta.env.VITE_WS_URL || 'ws://localhost:8080/ws'
private token = localStorage.getItem('token') || ''
// 事件监听器
private eventListeners: Map<string, Function[]> = new Map()
// 消息队列 - 用于离线时缓存消息
private messageQueue: WebSocketMessage[] = []
private maxQueueSize = 100
// 消息去重 - 避免重复处理
private processedMessages = new Set<string>()
private maxProcessedMessages = 1000
constructor() {
this.setupEventListeners()
this.loadMessageQueue()
}
// 连接WebSocket
public connect() {
if (this.isConnecting.value || this.isConnected.value) {
return
}
this.isConnecting.value = true
try {
// 构建WebSocket URL包含认证token
const url = `${this.wsUrl}?token=${encodeURIComponent(this.token)}`
this.ws = new WebSocket(url)
this.ws.onopen = this.handleOpen.bind(this)
this.ws.onmessage = this.handleMessage.bind(this)
this.ws.onclose = this.handleClose.bind(this)
this.ws.onerror = this.handleError.bind(this)
} catch (error) {
console.error('WebSocket连接失败:', error)
this.isConnecting.value = false
this.scheduleReconnect()
}
}
// 断开连接
public disconnect() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
if (this.ws) {
this.ws.close()
this.ws = null
}
this.isConnected.value = false
this.isConnecting.value = false
}
// 发送消息
public send(message: any) {
if (this.ws && this.isConnected.value) {
try {
this.ws.send(JSON.stringify(message))
} catch (error) {
console.error('发送WebSocket消息失败:', error)
// 发送失败时,将消息加入队列
this.addToMessageQueue(message)
}
} else {
console.warn('WebSocket未连接将消息加入队列')
this.addToMessageQueue(message)
}
}
// 发送心跳
public sendHeartbeat() {
this.send({
type: 'HEARTBEAT',
data: { timestamp: Date.now() },
timestamp: Date.now()
})
}
// 处理连接打开
private handleOpen() {
console.log('WebSocket连接已建立')
this.isConnected.value = true
this.isConnecting.value = false
this.reconnectAttempts = 0
// 启动心跳
this.startHeartbeat()
// 发送认证消息
this.send({
type: 'AUTH',
data: { token: this.token },
timestamp: Date.now()
})
// 处理离线期间的消息队列
this.processMessageQueue()
// 触发连接事件
this.emit('connected', null)
}
// 处理消息接收
private handleMessage(event: MessageEvent) {
try {
const message: WebSocketMessage = JSON.parse(event.data)
this.processMessage(message)
} catch (error) {
console.error('解析WebSocket消息失败:', error)
}
}
// 处理连接关闭
private handleClose(event: CloseEvent) {
console.log('WebSocket连接已关闭:', event.code, event.reason)
this.isConnected.value = false
this.isConnecting.value = false
// 停止心跳
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
this.heartbeatInterval = null
}
// 触发断开连接事件
this.emit('disconnected', { code: event.code, reason: event.reason })
// 如果不是主动关闭,尝试重连
if (event.code !== 1000) {
this.scheduleReconnect()
}
}
// 处理连接错误
private handleError(error: Event) {
console.error('WebSocket连接错误:', error)
this.isConnecting.value = false
// 触发错误事件
this.emit('error', error)
// 尝试重连
this.scheduleReconnect()
}
// 处理接收到的消息
private processMessage(message: WebSocketMessage) {
console.log('收到WebSocket消息:', message)
// 消息去重检查
if (message.messageId && this.processedMessages.has(message.messageId)) {
console.log('消息已处理,跳过:', message.messageId)
return
}
// 添加到已处理消息集合
if (message.messageId) {
this.processedMessages.add(message.messageId)
// 限制已处理消息数量
if (this.processedMessages.size > this.maxProcessedMessages) {
const firstKey = this.processedMessages.keys().next().value
if (firstKey) {
this.processedMessages.delete(firstKey)
}
}
}
switch (message.type) {
case 'NOTIFICATION':
this.handleNotificationMessage(message.data)
break
case 'APPROVAL_UPDATE':
this.handleApprovalUpdateMessage(message.data)
break
case 'PROCUREMENT_UPDATE':
this.handleProcurementUpdateMessage(message.data)
break
case 'EQUIPMENT_STATUS_UPDATE':
this.handleEquipmentStatusUpdateMessage(message.data)
break
case 'EQUIPMENT_BORROW_UPDATE':
this.handleEquipmentBorrowUpdateMessage(message.data)
break
case 'EQUIPMENT_RETURN_UPDATE':
this.handleEquipmentReturnUpdateMessage(message.data)
break
case 'EQUIPMENT_MAINTENANCE_UPDATE':
this.handleEquipmentMaintenanceUpdateMessage(message.data)
break
case 'EQUIPMENT_ALERT':
this.handleEquipmentAlertMessage(message.data)
break
case 'WORKFLOW_UPDATE':
this.handleWorkflowUpdateMessage(message.data)
break
case 'SYSTEM':
this.handleSystemMessage(message.data)
break
case 'HEARTBEAT':
// 心跳响应,不需要特殊处理
break
default:
console.warn('未知的WebSocket消息类型:', message.type)
}
// 触发消息接收事件
this.emit('message', message)
// 保存消息到本地存储
this.saveMessageToStorage(message)
}
// 处理通知消息
private handleNotificationMessage(data: any) {
if (data.notification) {
notificationService.addNotification({
type: data.notification.type || 'SYSTEM',
title: data.notification.title || '新通知',
content: data.notification.content || '',
priority: data.notification.priority || 'NORMAL',
category: data.notification.category,
targetUrl: data.notification.targetUrl,
metadata: data.notification.metadata
})
}
}
// 处理审批更新消息
private handleApprovalUpdateMessage(data: any) {
if (data.approval) {
const approval = data.approval
// 根据审批状态添加相应通知
if (approval.status === 'APPROVED') {
notificationService.addApprovalNotification(
approval.equipmentName,
'APPROVED',
approval.approverName || '审批人'
)
// 触发审批状态更新事件
this.emit('approvalStatusChanged', {
type: 'APPROVED',
approvalId: approval.approvalId,
equipmentId: approval.equipmentId,
status: approval.status
})
} else if (approval.status === 'REJECTED') {
notificationService.addApprovalNotification(
approval.equipmentName,
'REJECTED',
approval.approverName || '审批人'
)
// 触发审批状态更新事件
this.emit('approvalStatusChanged', {
type: 'REJECTED',
approvalId: approval.approvalId,
equipmentId: approval.equipmentId,
status: approval.status
})
} else if (approval.status === 'SUBMITTED') {
// 新增待审批通知
notificationService.addNotification({
type: 'PENDING',
title: '新的审批申请',
content: `收到来自 ${approval.applicantName}${approval.businessType || '设备'}申请:${approval.equipmentName}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'HIGH',
category: '审批申请',
metadata: {
approvalId: approval.approvalId,
equipmentName: approval.equipmentName,
applicantName: approval.applicantName,
businessType: approval.businessType,
timestamp: Date.now()
}
})
// 触发新审批申请事件
this.emit('newApprovalRequest', approval)
}
}
}
// 处理采购更新消息
private handleProcurementUpdateMessage(data: any) {
if (data.procurement) {
const procurement = data.procurement
if (procurement.status === 'SUBMITTED') {
notificationService.addProcurementNotification(
procurement.equipmentName,
procurement.applicantName || '申请人'
)
// 触发采购状态更新事件
this.emit('procurementStatusChanged', {
type: 'SUBMITTED',
procurementId: procurement.procurementId,
equipmentId: procurement.equipmentId,
status: procurement.status
})
} else if (procurement.status === 'APPROVED') {
// 采购申请被批准
notificationService.addNotification({
type: 'PROCUREMENT',
title: '采购申请已批准',
content: `您的设备采购申请"${procurement.equipmentName}"已获得批准`,
targetUrl: '/asset-management/device-management/procurement',
priority: 'NORMAL',
category: '采购审批',
metadata: {
equipmentName: procurement.equipmentName,
status: procurement.status,
timestamp: Date.now()
}
})
// 触发采购状态更新事件
this.emit('procurementStatusChanged', {
type: 'APPROVED',
procurementId: procurement.procurementId,
equipmentId: procurement.equipmentId,
status: procurement.status
})
}
}
}
// 处理设备状态更新消息
private handleEquipmentStatusUpdateMessage(data: any) {
if (data.equipment) {
const equipment = data.equipment
// 触发设备状态更新事件
this.emit('equipmentStatusChanged', {
equipmentId: equipment.equipmentId,
oldStatus: equipment.oldStatus,
newStatus: equipment.newStatus,
updateTime: equipment.updateTime
})
// 如果状态变化涉及采购,添加相应通知
if (equipment.statusChangeType === 'PROCUREMENT') {
notificationService.addNotification({
type: 'PROCUREMENT',
title: '设备采购状态更新',
content: `设备"${equipment.equipmentName}"的采购状态已更新为:${equipment.newStatus}`,
targetUrl: '/asset-management/device-management/procurement',
priority: 'NORMAL',
category: '设备状态',
metadata: {
equipmentId: equipment.equipmentId,
equipmentName: equipment.equipmentName,
oldStatus: equipment.oldStatus,
newStatus: equipment.newStatus,
timestamp: Date.now()
}
})
}
}
}
// 处理设备借用更新消息
private handleEquipmentBorrowUpdateMessage(data: any) {
if (data.borrow) {
const borrow = data.borrow
// 触发设备借用状态更新事件
this.emit('equipmentBorrowChanged', {
equipmentId: borrow.equipmentId,
borrowId: borrow.borrowId,
oldStatus: borrow.oldStatus,
newStatus: borrow.newStatus,
updateTime: borrow.updateTime
})
// 添加借用状态更新通知
notificationService.addNotification({
type: 'EQUIPMENT_BORROW',
title: '设备借用状态更新',
content: `设备"${borrow.equipmentName}"的借用状态已更新为:${borrow.newStatus}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备借用',
metadata: {
equipmentId: borrow.equipmentId,
equipmentName: borrow.equipmentName,
borrowId: borrow.borrowId,
oldStatus: borrow.oldStatus,
newStatus: borrow.newStatus,
timestamp: Date.now()
}
})
}
}
// 处理设备归还更新消息
private handleEquipmentReturnUpdateMessage(data: any) {
if (data.return) {
const returnData = data.return
// 触发设备归还状态更新事件
this.emit('equipmentReturnChanged', {
equipmentId: returnData.equipmentId,
returnId: returnData.returnId,
oldStatus: returnData.oldStatus,
newStatus: returnData.newStatus,
updateTime: returnData.updateTime
})
// 添加归还状态更新通知
notificationService.addNotification({
type: 'EQUIPMENT_RETURN',
title: '设备归还状态更新',
content: `设备"${returnData.equipmentName}"的归还状态已更新为:${returnData.newStatus}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备归还',
metadata: {
equipmentId: returnData.equipmentId,
equipmentName: returnData.equipmentName,
returnId: returnData.returnId,
oldStatus: returnData.oldStatus,
newStatus: returnData.newStatus,
timestamp: Date.now()
}
})
}
}
// 处理设备维护更新消息
private handleEquipmentMaintenanceUpdateMessage(data: any) {
if (data.maintenance) {
const maintenance = data.maintenance
// 触发设备维护状态更新事件
this.emit('equipmentMaintenanceChanged', {
equipmentId: maintenance.equipmentId,
maintenanceId: maintenance.maintenanceId,
oldStatus: maintenance.oldStatus,
newStatus: maintenance.newStatus,
updateTime: maintenance.updateTime
})
// 添加维护状态更新通知
notificationService.addNotification({
type: 'EQUIPMENT_MAINTENANCE',
title: '设备维护状态更新',
content: `设备"${maintenance.equipmentName}"的维护状态已更新为:${maintenance.newStatus}`,
targetUrl: '/asset-management/device-management/device-center',
priority: 'NORMAL',
category: '设备维护',
metadata: {
equipmentId: maintenance.equipmentId,
equipmentName: maintenance.equipmentName,
maintenanceId: maintenance.maintenanceId,
oldStatus: maintenance.oldStatus,
newStatus: maintenance.newStatus,
timestamp: Date.now()
}
})
}
}
// 处理设备告警消息
private handleEquipmentAlertMessage(data: any) {
if (data.alert) {
const alert = data.alert
// 触发设备告警事件
this.emit('equipmentAlert', {
equipmentId: alert.equipmentId,
alertId: alert.alertId,
alertType: alert.alertType,
alertLevel: alert.alertLevel,
message: alert.message,
timestamp: alert.timestamp
})
// 添加设备告警通知
notificationService.addNotification({
type: 'EQUIPMENT_ALERT',
title: `设备告警 - ${alert.alertType}`,
content: `设备"${alert.equipmentName}"发生告警:${alert.message}`,
targetUrl: '/asset-management/device-management/device-center',
priority: alert.alertLevel === 'CRITICAL' ? 'URGENT' : 'HIGH',
category: '设备告警',
actionRequired: true,
metadata: {
equipmentId: alert.equipmentId,
equipmentName: alert.equipmentName,
alertId: alert.alertId,
alertType: alert.alertType,
alertLevel: alert.alertLevel,
message: alert.message,
timestamp: Date.now()
}
})
}
}
// 处理工作流更新消息
private handleWorkflowUpdateMessage(data: any) {
if (data.workflow) {
const workflow = data.workflow
// 触发工作流状态更新事件
this.emit('workflowStatusChanged', {
workflowId: workflow.workflowId,
oldStatus: workflow.oldStatus,
newStatus: workflow.newStatus,
currentNode: workflow.currentNode,
updateTime: workflow.updateTime
})
// 添加工作流状态更新通知
notificationService.addNotification({
type: 'WORKFLOW',
title: '工作流状态更新',
content: `工作流"${workflow.workflowName}"状态已更新为:${workflow.newStatus}`,
targetUrl: '/asset-management/device-management/approval',
priority: 'NORMAL',
category: '工作流',
metadata: {
workflowId: workflow.workflowId,
workflowName: workflow.workflowName,
oldStatus: workflow.oldStatus,
newStatus: workflow.newStatus,
currentNode: workflow.currentNode,
timestamp: Date.now()
}
})
}
}
// 处理系统消息
private handleSystemMessage(data: any) {
if (data.system) {
const system = data.system
notificationService.addSystemNotification(
system.title || '系统通知',
system.content || '',
system.priority || 'NORMAL'
)
}
}
// 启动心跳
private startHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval)
}
this.heartbeatInterval = setInterval(() => {
if (this.isConnected.value) {
this.sendHeartbeat()
}
}, 30000) // 30秒发送一次心跳
}
// 安排重连
private scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('WebSocket重连次数已达上限停止重连')
return
}
this.reconnectAttempts++
console.log(`WebSocket将在 ${this.reconnectInterval / 1000} 秒后尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
setTimeout(() => {
this.connect()
}, this.reconnectInterval)
// 递增重连间隔
this.reconnectInterval = Math.min(this.reconnectInterval * 1.5, 30000)
}
// 消息队列管理
private addToMessageQueue(message: any) {
this.messageQueue.push(message)
if (this.messageQueue.length > this.maxQueueSize) {
this.messageQueue.shift() // 移除最旧的消息
}
this.saveMessageQueue()
}
private processMessageQueue() {
if (this.messageQueue.length > 0) {
console.log(`处理离线期间的消息队列,共 ${this.messageQueue.length} 条消息`)
const messages = [...this.messageQueue]
this.messageQueue = []
messages.forEach(message => {
this.processMessage(message)
})
this.saveMessageQueue()
}
}
private saveMessageQueue() {
try {
localStorage.setItem('websocket_message_queue', JSON.stringify(this.messageQueue))
} catch (error) {
console.error('保存消息队列失败:', error)
}
}
private loadMessageQueue() {
try {
const stored = localStorage.getItem('websocket_message_queue')
if (stored) {
this.messageQueue = JSON.parse(stored)
}
} catch (error) {
console.error('加载消息队列失败:', error)
}
}
// 消息存储管理
private saveMessageToStorage(message: WebSocketMessage) {
try {
const messages = this.getStoredMessages()
messages.unshift(message)
// 限制存储的消息数量
if (messages.length > 200) {
messages.splice(200)
}
localStorage.setItem('websocket_messages', JSON.stringify(messages))
} catch (error) {
console.error('保存消息到本地存储失败:', error)
}
}
private getStoredMessages(): WebSocketMessage[] {
try {
const stored = localStorage.getItem('websocket_messages')
return stored ? JSON.parse(stored) : []
} catch (error) {
console.error('获取存储的消息失败:', error)
return []
}
}
// 设置事件监听器
private setupEventListeners() {
// 监听token变化重新连接
window.addEventListener('storage', (event) => {
if (event.key === 'token' && event.newValue !== this.token) {
this.token = event.newValue || ''
if (this.isConnected.value) {
this.disconnect()
this.connect()
}
}
})
}
// 事件系统
public on(event: string, callback: Function) {
if (!this.eventListeners.has(event)) {
this.eventListeners.set(event, [])
}
this.eventListeners.get(event)!.push(callback)
}
public off(event: string, callback: Function) {
const listeners = this.eventListeners.get(event)
if (listeners) {
const index = listeners.indexOf(callback)
if (index > -1) {
listeners.splice(index, 1)
}
}
}
private emit(event: string, data: any) {
const listeners = this.eventListeners.get(event)
if (listeners) {
listeners.forEach(callback => {
try {
callback(data)
} catch (error) {
console.error('WebSocket事件回调执行失败:', error)
}
})
}
}
// 获取连接状态
public getStatus() {
return {
connected: this.isConnected.value,
connecting: this.isConnecting.value,
reconnectAttempts: this.reconnectAttempts,
maxReconnectAttempts: this.maxReconnectAttempts,
messageQueueSize: this.messageQueue.length,
processedMessagesCount: this.processedMessages.size
}
}
// 更新认证token
public updateToken(newToken: string) {
this.token = newToken
if (this.isConnected.value) {
// 重新认证
this.send({
type: 'AUTH',
data: { token: this.token },
timestamp: Date.now()
})
}
}
// 获取存储的消息
public getMessages() {
return this.getStoredMessages()
}
// 清空存储的消息
public clearMessages() {
try {
localStorage.removeItem('websocket_messages')
localStorage.removeItem('websocket_message_queue')
this.messageQueue = []
this.processedMessages.clear()
} catch (error) {
console.error('清空消息失败:', error)
}
}
}
// 创建单例实例
const websocketService = new WebSocketService()
// 自动连接
websocketService.connect()
export default websocketService

View File

@ -164,6 +164,155 @@ const storeSetup = () => {
sort: 4, sort: 4,
}, },
], ],
},
{
id: 2000,
parentId: 0,
title: '资产管理',
type: 1,
path: '/asset-management',
name: 'AssetManagement',
component: 'Layout',
redirect: '/asset-management/device-management/device-center',
icon: 'desktop',
isExternal: false,
isCache: false,
isHidden: false,
sort: 2,
children: [
{
id: 2010,
parentId: 2000,
title: '设备管理',
type: 1,
path: '/asset-management/device-management',
name: 'DeviceManagement',
component: 'Layout',
redirect: '/asset-management/device-management/device-center',
icon: 'desktop',
isExternal: false,
isCache: false,
isHidden: false,
sort: 1,
children: [
{
id: 2011,
parentId: 2010,
title: '设备中心',
type: 2,
path: '/asset-management/device-management/device-center',
name: 'DeviceCenter',
component: 'system-resource/device-management/index',
icon: 'desktop',
isExternal: false,
isCache: false,
isHidden: false,
sort: 1,
},
{
id: 2012,
parentId: 2010,
title: '设备采购',
type: 2,
path: '/asset-management/device-management/procurement',
name: 'DeviceProcurement',
component: 'system-resource/device-management/procurement/index',
icon: 'shopping-cart',
isExternal: false,
isCache: false,
isHidden: false,
sort: 2,
},
{
id: 2013,
parentId: 2010,
title: '审批台',
type: 2,
path: '/asset-management/device-management/approval',
name: 'DeviceApproval',
component: 'system-resource/device-management/approval/index',
icon: 'check-circle',
isExternal: false,
isCache: false,
isHidden: false,
sort: 3,
},
{
id: 2014,
parentId: 2010,
title: '在线管理',
type: 1,
path: '/asset-management/device-management/online',
name: 'DeviceOnline',
component: 'Layout',
redirect: '/asset-management/device-management/online/drone',
icon: 'cloud',
isExternal: false,
isCache: false,
isHidden: false,
sort: 4,
children: [
{
id: 20141,
parentId: 2014,
title: '无人机',
type: 2,
path: '/asset-management/device-management/online/drone',
name: 'DeviceDrone',
component: 'system-resource/device-management/index',
icon: 'drone',
isExternal: false,
isCache: false,
isHidden: false,
sort: 1,
},
{
id: 20142,
parentId: 2014,
title: '机巢',
type: 2,
path: '/asset-management/device-management/online/nest',
name: 'DeviceNest',
component: 'system-resource/device-management/index',
icon: 'nest',
isExternal: false,
isCache: false,
isHidden: false,
sort: 2,
},
{
id: 20143,
parentId: 2014,
title: '其他智能终端',
type: 2,
path: '/asset-management/device-management/online/smart-terminal',
name: 'DeviceSmartTerminal',
component: 'system-resource/device-management/index',
icon: 'terminal',
isExternal: false,
isCache: false,
isHidden: false,
sort: 3,
},
],
},
{
id: 2015,
parentId: 2010,
title: '设备详情',
type: 2,
path: '/asset-management/device-management/device-detail/:id',
name: 'DeviceDetail',
component: 'system-resource/device-management/detail',
icon: 'file-text',
isExternal: false,
isCache: false,
isHidden: true,
sort: 5,
},
],
},
],
}] }]
// 使用已转换的数据生成路由 // 使用已转换的数据生成路由
const asyncRoutes = formatAsyncRoutes(data as unknown as RouteItem[]) const asyncRoutes = formatAsyncRoutes(data as unknown as RouteItem[])

View File

@ -1,4 +1,4 @@
@import './var.scss'; @use './var.scss' as *;
body { body {
--margin: 14px; // 通用外边距 --margin: 14px; // 通用外边距

View File

@ -1,5 +1,5 @@
/* 全局样式 */ /* 全局样式 */
@import './var.scss'; @use './var.scss' as *;
.w-full { .w-full {
width: 100%; width: 100%;
@ -33,6 +33,15 @@
padding: $padding; padding: $padding;
} }
// 高z-index类用于确保组件在最上层显示
.gi_z_index_high {
z-index: 10001 !important;
}
.gi_z_index_highest {
z-index: 100001 !important;
}
.gi_relative { .gi_relative {
position: relative; position: relative;
} }

View File

@ -1,17 +1,17 @@
// 基础样式 // 基础样式
@import './base.scss'; @use './base.scss';
// 全局类名样式 // 全局类名样式
@import './global.scss'; @use './global.scss';
// 自定义原生滚动条样式 // 自定义原生滚动条样式
@import './scrollbar-reset.scss'; @use './scrollbar-reset.scss';
// 自定义 nprogress 插件进度条颜色 // 自定义 nprogress 插件进度条颜色
@import './nprogress.scss'; @use './nprogress.scss';
// 富文本的css主题颜色变量 // 富文本的css主题颜色变量
@import './editor.scss'; @use './editor.scss';
// 动画类名 // 动画类名
@import './animated.scss'; @use './animated.scss';

41
src/test-console.vue Normal file
View File

@ -0,0 +1,41 @@
<template>
<div>
<h2>Console.log 测试</h2>
<button @click="testConsole">测试 Console.log</button>
<p>{{ message }}</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const message = ref('')
const testConsole = () => {
console.log('测试 console.log 是否正常工作')
console.warn('测试 console.warn')
console.error('测试 console.error')
message.value = '请查看浏览器控制台,应该能看到上述日志信息'
}
</script>
<style scoped>
div {
padding: 20px;
text-align: center;
}
button {
padding: 10px 20px;
margin: 10px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #40a9ff;
}
</style>

View File

@ -70,6 +70,6 @@ declare global {
// for type re-export // for type re-export
declare global { declare global {
// @ts-ignore // @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue' export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue') import('vue')
} }

View File

@ -7,66 +7,8 @@ export {}
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
Avatar: typeof import('./../components/Avatar/index.vue')['default'] NotificationCenter: typeof import('./../components/NotificationCenter/index.vue')['default']
Breadcrumb: typeof import('./../components/Breadcrumb/index.vue')['default']
CellCopy: typeof import('./../components/CellCopy/index.vue')['default']
Chart: typeof import('./../components/Chart/index.vue')['default']
ColumnSetting: typeof import('./../components/GiTable/src/components/ColumnSetting.vue')['default']
CronForm: typeof import('./../components/GenCron/CronForm/index.vue')['default']
CronModal: typeof import('./../components/GenCron/CronModal/index.vue')['default']
DateRangePicker: typeof import('./../components/DateRangePicker/index.vue')['default']
DayForm: typeof import('./../components/GenCron/CronForm/component/day-form.vue')['default']
FilePreview: typeof import('./../components/FilePreview/index.vue')['default']
GiCellAvatar: typeof import('./../components/GiCell/GiCellAvatar.vue')['default']
GiCellGender: typeof import('./../components/GiCell/GiCellGender.vue')['default']
GiCellStatus: typeof import('./../components/GiCell/GiCellStatus.vue')['default']
GiCellTag: typeof import('./../components/GiCell/GiCellTag.vue')['default']
GiCellTags: typeof import('./../components/GiCell/GiCellTags.vue')['default']
GiCodeView: typeof import('./../components/GiCodeView/index.vue')['default']
GiDot: typeof import('./../components/GiDot/index.tsx')['default']
GiEditTable: typeof import('./../components/GiEditTable/GiEditTable.vue')['default']
GiFooter: typeof import('./../components/GiFooter/index.vue')['default']
GiForm: typeof import('./../components/GiForm/src/GiForm.vue')['default']
GiIconBox: typeof import('./../components/GiIconBox/index.vue')['default']
GiIconSelector: typeof import('./../components/GiIconSelector/index.vue')['default']
GiIframe: typeof import('./../components/GiIframe/index.vue')['default']
GiOption: typeof import('./../components/GiOption/index.vue')['default']
GiOptionItem: typeof import('./../components/GiOptionItem/index.vue')['default']
GiPageLayout: typeof import('./../components/GiPageLayout/index.vue')['default']
GiSpace: typeof import('./../components/GiSpace/index.vue')['default']
GiSplitButton: typeof import('./../components/GiSplitButton/index.vue')['default']
GiSplitPane: typeof import('./../components/GiSplitPane/index.vue')['default']
GiSplitPaneFlexibleBox: typeof import('./../components/GiSplitPane/components/GiSplitPaneFlexibleBox.vue')['default']
GiSvgIcon: typeof import('./../components/GiSvgIcon/index.vue')['default']
GiTable: typeof import('./../components/GiTable/src/GiTable.vue')['default']
GiTag: typeof import('./../components/GiTag/index.tsx')['default']
GiThemeBtn: typeof import('./../components/GiThemeBtn/index.vue')['default']
HourForm: typeof import('./../components/GenCron/CronForm/component/hour-form.vue')['default']
Icon403: typeof import('./../components/icons/Icon403.vue')['default']
Icon404: typeof import('./../components/icons/Icon404.vue')['default']
Icon500: typeof import('./../components/icons/Icon500.vue')['default']
IconBorders: typeof import('./../components/icons/IconBorders.vue')['default']
IconTableSize: typeof import('./../components/icons/IconTableSize.vue')['default']
IconTreeAdd: typeof import('./../components/icons/IconTreeAdd.vue')['default']
IconTreeReduce: typeof import('./../components/icons/IconTreeReduce.vue')['default']
ImageImport: typeof import('./../components/ImageImport/index.vue')['default']
ImageImportWizard: typeof import('./../components/ImageImportWizard/index.vue')['default']
IndustrialImageList: typeof import('./../components/IndustrialImageList/index.vue')['default']
JsonPretty: typeof import('./../components/JsonPretty/index.vue')['default']
MinuteForm: typeof import('./../components/GenCron/CronForm/component/minute-form.vue')['default']
MonthForm: typeof import('./../components/GenCron/CronForm/component/month-form.vue')['default']
ParentView: typeof import('./../components/ParentView/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
SecondForm: typeof import('./../components/GenCron/CronForm/component/second-form.vue')['default']
SplitPanel: typeof import('./../components/SplitPanel/index.vue')['default']
TextCopy: typeof import('./../components/TextCopy/index.vue')['default']
TurbineGrid: typeof import('./../components/TurbineGrid/index.vue')['default']
UserSelect: typeof import('./../components/UserSelect/index.vue')['default']
Verify: typeof import('./../components/Verify/index.vue')['default']
VerifyPoints: typeof import('./../components/Verify/Verify/VerifyPoints.vue')['default']
VerifySlide: typeof import('./../components/Verify/Verify/VerifySlide.vue')['default']
WeekForm: typeof import('./../components/GenCron/CronForm/component/week-form.vue')['default']
YearForm: typeof import('./../components/GenCron/CronForm/component/year-form.vue')['default']
} }
} }

1
src/types/env.d.ts vendored
View File

@ -4,6 +4,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_PREFIX: string readonly VITE_API_PREFIX: string
readonly VITE_API_BASE_URL: string readonly VITE_API_BASE_URL: string
readonly VITE_API_WS_URL: string
readonly VITE_BASE: string readonly VITE_BASE: string
readonly VITE_APP_SETTING: string readonly VITE_APP_SETTING: string
readonly VITE_CLIENT_ID: string readonly VITE_CLIENT_ID: string

71
src/types/training.d.ts vendored Normal file
View File

@ -0,0 +1,71 @@
export interface TrainingPlanPageQuery {
planName?: string
trainingType?: string
trainingLevel?: string
status?: string
trainer?: string
startTime?: string
endTime?: string
page?: number
pageSize?: number
}
export interface TrainingPlanReq {
planName: string
trainingType: string
trainingLevel: string
trainingContent?: string
trainer?: string
trainingLocation?: string
startTime: string
endTime: string
status?: string
maxParticipants?: number
requirements?: string
remark?: string
}
export interface TrainingPlanResp {
planId: string
planName: string
trainingType: string
trainingLevel: string
trainingContent?: string
trainer?: string
trainingLocation?: string
startTime: string
endTime: string
status: string
maxParticipants?: number
currentParticipants?: number
requirements?: string
remark?: string
createTime: string
createBy: string
materials?: TrainingMaterialResp[]
records?: TrainingRecordResp[]
}
export interface TrainingMaterialResp {
materialId: string
materialName: string
materialType: string
materialPath?: string
materialSize?: number
description?: string
sortOrder?: number
}
export interface TrainingRecordResp {
recordId: string
userId: string
userName: string
deptId?: string
deptName?: string
attendanceStatus: string
signInTime?: string
signOutTime?: string
score?: number
feedback?: string
certificateId?: string
}

View File

@ -161,6 +161,32 @@ const requestNative = async <T = unknown>(config: AxiosRequestConfig): Promise<A
.catch((err: { msg: string }) => Promise.reject(err)) .catch((err: { msg: string }) => Promise.reject(err))
} }
// 完全绕过拦截器的请求方法
const requestRaw = async <T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse> => {
// 创建一个新的axios实例不包含拦截器
const rawAxios = axios.create({
baseURL: import.meta.env.VITE_API_PREFIX ?? import.meta.env.VITE_API_BASE_URL,
timeout: 30 * 1000,
})
// 只添加请求拦截器来设置token不添加响应拦截器
rawAxios.interceptors.request.use(
(config: AxiosRequestConfig) => {
const token = getToken()
if (token) {
if (!config.headers) {
config.headers = {}
}
config.headers.Authorization = `${token}`
}
return config
},
(error) => Promise.reject(error),
)
return rawAxios.request<T>(config)
}
const createRequest = (method: string) => { const createRequest = (method: string) => {
return <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => { return <T = any>(url: string, params?: object, config?: AxiosRequestConfig): Promise<ApiRes<T>> => {
return request({ return request({
@ -196,5 +222,6 @@ export default {
del: createRequest('delete'), del: createRequest('delete'),
request, request,
requestNative, requestNative,
requestRaw,
download, download,
} }

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,125 @@
<template>
<div>
<a-tab-pane key="props" tap="形变" title="形变原数据">
<div class="tab-content">
<raw-data>
</raw-data>
</div>
</a-tab-pane>
</div>
</template>
<script setup>
import rawData from './raw-data.vue';
</script>
<style lang="scss" scoped>
.data-storage-container {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.page-header {
margin-bottom: 16px;
.page-title {
font-size: 20px;
font-weight: 500;
color: #262626;
margin: 0;
}
}
.tabs-section {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.tab-content {
margin-top: 16px;
}
.filter-section {
margin-bottom: 16px;
padding: 12px;
background: #fafafa;
border-radius: 4px;
.filter-item {
display: inline-flex;
align-items: center;
.filter-label {
font-size: 14px;
color: #595959;
margin-right: 8px;
}
}
}
.uploaded-files-section {
.section-title {
font-size: 16px;
font-weight: 500;
color: #262626;
margin: 0 0 16px 0;
}
}
.preview-container {
text-align: center;
.image-preview,
.video-preview {
max-height: 500px;
overflow: hidden;
}
.file-info {
text-align: left;
p {
margin: 8px 0;
font-size: 14px;
color: #595959;
}
}
}
:deep(.arco-tabs-nav) {
margin-bottom: 0;
}
:deep(.arco-tabs-tab) {
font-size: 14px;
padding: 8px 16px;
}
:deep(.arco-table-th) {
background-color: #fafafa;
color: #8c8c8c;
font-weight: 500;
}
:deep(.arco-table-td) {
padding: 12px 16px;
}
:deep(.arco-tag) {
border-radius: 4px;
font-size: 12px;
}
:deep(.arco-btn-size-small) {
padding: 2px 8px;
font-size: 12px;
}
:deep(.arco-upload-drag:hover) {
border-color: #1890ff;
}
</style>

View File

@ -0,0 +1,590 @@
<template>
<GiPageLayout>
<div class="raw-data-container">
<!-- <div class="page-header">
<div class="page-title">原始数据管理</div>
<div class="page-subtitle">管理和分析原始视频数据</div>
</div> -->
<div class="action-bar">
<div class="action-buttons">
<a-button type="primary" @click="showUploadModal = true">
<template #icon>
<IconUpload />
</template>
上传视频
</a-button>
<a-button type="primary" @click="handleBatchAnalysis">
<template #icon>
<IconPlayCircle />
</template>
批量分析
</a-button>
<a-button type="primary" @click="handleExportData">
<template #icon>
<IconDownload />
</template>
导出数据
</a-button>
</div>
<div class="filter-section">
<a-form :model="filterForm" layout="inline">
<a-form-item label="项目">
<a-select v-model="filterForm.projectId" placeholder="请选择项目">
<a-option value="project-1">风电场A区</a-option>
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item>
<a-form-item label="机组号">
<a-input v-model="filterForm.unitNumber" placeholder="请输入机组号" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model="filterForm.status" placeholder="请选择状态">
<a-option value="completed">已完成</a-option>
<a-option value="pending">待分析</a-option>
<a-option value="analyzing">分析中</a-option>
<a-option value="failed">失败</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="handleFilterChange">查询</a-button>
</a-form-item>
</a-form>
</div>
</div>
<div class="project-sections">
<div v-for="project in filteredProjects" :key="project.id" class="project-section">
<div class="project-header">
<div class="project-title">{{ project.name }}</div>
<div class="project-stats">
<div class="stat-item">
<IconVideoCamera />
{{ project.totalVideos }} 个视频
</div>
<div class="stat-item">
<IconCheckCircle />
{{ project.completedCount }} 个已完成
</div>
<div class="stat-item">
<IconClockCircle />
{{ project.pendingCount }} 个待分析
</div>
</div>
</div>
<div class="units-grid">
<div v-for="unit in project.units" :key="unit.id" class="unit-card">
<div class="unit-header">
<div class="unit-title">{{ unit.number }}</div>
<div class="unit-actions">
<a-button type="primary" size="small" @click="handleViewUnitVideos(unit)">查看全部</a-button>
<a-button type="primary" size="small" @click="handleAnalyzeUnit(unit)">{{
getAnalysisButtonText(unit.status)
}}</a-button>
</div>
</div>
<div class="videos-list">
<div v-for="video in unit.videos" :key="video.id" class="video-item">
<div class="video-thumbnail">
<img :src="video.thumbnail" alt="Video Thumbnail" />
<div class="video-overlay" @click="handlePlayVideo(video)">
<IconPlayArrowFill style="font-size: 24px; color: #fff;" />
</div>
</div>
<div class="video-info">
<div class="video-name">{{ video.name }}</div>
<div class="video-meta">
<span>{{ video.duration }}</span>
<span>{{ video.angle }}°</span>
</div>
<div class="video-status">
<a-tag :color="getStatusColor(video.status)">{{ getStatusText(video.status) }}</a-tag>
</div>
</div>
</div>
</div>
<div class="analysis-progress">
<div class="progress-info">
<span>分析进度</span>
<span>{{ unit.progress }}%</span>
</div>
<a-progress :percent="unit.progress" :show-text="false" status="active" />
</div>
</div>
</div>
</div>
</div>
<!-- 视频播放模态框 -->
<a-modal v-model:visible="videoModalVisible" title="原始视频播放" width="900px" @ok="videoModalVisible = false"
@cancel="videoModalVisible = false">
<video v-if="selectedVideo" :src="selectedVideo.url" controls
style="width: 100%; height: 480px; border-radius: 8px; background: #000;"></video>
<div v-if="selectedVideo" class="video-meta-info">
<p>项目{{ selectedVideo.projectName }}</p>
<p>机组号{{ selectedVideo.unitNumber }}</p>
<p>采集人{{ selectedVideo.collector }}</p>
<p>风速{{ selectedVideo.windSpeed }} m/s</p>
<p>转速{{ selectedVideo.rpm }} rpm</p>
<p>采集时间{{ selectedVideo.time }}</p>
<p>角度{{ selectedVideo.angle }}°</p>
</div>
</a-modal>
<!-- 上传视频模态框 -->
<a-modal v-model:visible="showUploadModal" title="上传原始视频" width="600px" @ok="handleUpload"
@cancel="showUploadModal = false">
<a-form :model="uploadForm" layout="vertical">
<a-form-item label="项目" required>
<a-select v-model="uploadForm.projectId" placeholder="请选择项目">
<a-option value="project-1">风电场A区</a-option>
<a-option value="project-2">风电场B区</a-option>
<a-option value="project-3">风电场C区</a-option>
</a-select>
</a-form-item>
<a-form-item label="机组号" required>
<a-input v-model="uploadForm.unitNumber" placeholder="请输入机组号" />
</a-form-item>
<a-form-item label="采集人" required>
<a-input v-model="uploadForm.collector" placeholder="请输入采集人姓名" />
</a-form-item>
<a-form-item label="风速 (m/s)">
<a-input-number v-model="uploadForm.windSpeed" :min="0" />
</a-form-item>
<a-form-item label="转速 (rpm)">
<a-input-number v-model="uploadForm.rpm" :min="0" />
</a-form-item>
<a-form-item label="采集时间" required>
<a-date-picker v-model="uploadForm.time" show-time format="YYYY-MM-DD HH:mm" style="width: 100%;" />
</a-form-item>
<a-form-item label="视频文件可多选建议3个角度" required>
<a-upload v-model:file-list="uploadForm.fileList" :multiple="true" :limit="3" accept="video/*"
:auto-upload="false" list-type="picture-card">
<template #upload-button>
<a-button>选择视频</a-button>
</template>
</a-upload>
</a-form-item>
</a-form>
</a-modal>
</div>
</GiPageLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { Message } from '@arco-design/web-vue'
import {
IconUpload,
IconPlayCircle,
IconDownload,
IconVideoCamera,
IconCheckCircle,
IconClockCircle,
IconPlayArrowFill
} from '@arco-design/web-vue/es/icon'
const showUploadModal = ref(false)
const videoModalVisible = ref(false)
const selectedVideo = ref<any>(null)
const filterForm = reactive({
projectId: '',
unitNumber: '',
status: ''
})
const uploadForm = reactive({
projectId: '',
unitNumber: '',
collector: '',
windSpeed: null,
rpm: null,
time: '',
fileList: []
})
//
const projects = ref([
{
id: 'project-1',
name: '风电场A区',
totalVideos: 6,
completedCount: 4,
pendingCount: 2,
units: [
{
id: 'A-001',
number: 'A-001',
status: 'completed',
progress: 100,
videos: [
{
id: 'v1',
name: 'A-001-正面',
url: '/videos/A-001-front.mp4',
thumbnail: '/images/A-001-front.jpg',
angle: 0,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v2',
name: 'A-001-侧面',
url: '/videos/A-001-side.mp4',
thumbnail: '/images/A-001-side.jpg',
angle: 90,
duration: '00:30',
status: 'completed',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
},
{
id: 'v3',
name: 'A-001-背面',
url: '/videos/A-001-back.mp4',
thumbnail: '/images/A-001-back.jpg',
angle: 180,
duration: '00:30',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-001',
collector: '张三',
windSpeed: 8.2,
rpm: 15,
time: '2023-11-05 08:00'
}
]
},
{
id: 'A-002',
number: 'A-002',
status: 'analyzing',
progress: 60,
videos: [
{
id: 'v4',
name: 'A-002-正面',
url: '/videos/A-002-front.mp4',
thumbnail: '/images/A-002-front.jpg',
angle: 0,
duration: '00:28',
status: 'analyzing',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v5',
name: 'A-002-侧面',
url: '/videos/A-002-side.mp4',
thumbnail: '/images/A-002-side.jpg',
angle: 90,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
},
{
id: 'v6',
name: 'A-002-背面',
url: '/videos/A-002-back.mp4',
thumbnail: '/images/A-002-back.jpg',
angle: 180,
duration: '00:28',
status: 'pending',
projectName: '风电场A区',
unitNumber: 'A-002',
collector: '李四',
windSpeed: 7.9,
rpm: 14,
time: '2023-11-05 12:00'
}
]
}
]
}
// ...
])
const filteredProjects = computed(() => {
//
return projects.value
.filter(p => !filterForm.projectId || p.id === filterForm.projectId)
.map(project => ({
...project,
units: project.units
.filter(u => !filterForm.unitNumber || u.number === filterForm.unitNumber)
.map(unit => ({
...unit,
videos: unit.videos.filter(v => !filterForm.status || v.status === filterForm.status)
}))
.filter(u => u.videos.length > 0)
}))
.filter(p => p.units.length > 0)
})
function handleFilterChange() {
// API
}
function handlePlayVideo(video: any) {
selectedVideo.value = video
videoModalVisible.value = true
}
function handleViewUnitVideos(unit: any) {
//
Message.info(`查看机组 ${unit.number} 的所有视频`)
}
function handleAnalyzeUnit(unit: any) {
//
Message.success(`已提交机组 ${unit.number} 的分析任务`)
// API
}
function getStatusColor(status: string) {
switch (status) {
case 'completed': return 'green'
case 'pending': return 'gray'
case 'analyzing': return 'blue'
case 'failed': return 'red'
default: return 'gray'
}
}
function getStatusText(status: string) {
switch (status) {
case 'completed': return '已完成'
case 'pending': return '待分析'
case 'analyzing': return '分析中'
case 'failed': return '失败'
default: return '未知'
}
}
function getAnalysisButtonText(status: string) {
switch (status) {
case 'completed': return '重新分析'
case 'pending': return '分析'
case 'analyzing': return '分析中...'
case 'failed': return '重新分析'
default: return '分析'
}
}
function handleBatchAnalysis() {
Message.success('批量分析任务已提交')
}
function handleExportData() {
Message.success('数据导出成功')
}
function handleUpload() {
Message.success('上传成功')
showUploadModal.value = false
}
</script>
<style scoped lang="scss">
.raw-data-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 28px;
font-weight: 600;
margin: 0 0 8px 0;
color: #1d2129;
}
.page-subtitle {
font-size: 14px;
color: #86909c;
margin: 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.filter-section {
margin-left: 24px;
}
}
.project-sections {
.project-section {
margin-bottom: 32px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px #f0f1f2;
padding: 20px;
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
.project-title {
font-size: 20px;
font-weight: 600;
}
.project-stats {
display: flex;
gap: 16px;
.stat-item {
display: flex;
align-items: center;
gap: 4px;
color: #86909c;
}
}
}
.units-grid {
display: flex;
gap: 24px;
flex-wrap: wrap;
.unit-card {
background: #fafbfc;
border-radius: 8px;
padding: 16px;
width: 360px;
margin-bottom: 16px;
.unit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.unit-title {
font-size: 16px;
font-weight: 600;
}
.unit-actions {
display: flex;
gap: 8px;
}
}
.videos-list {
display: flex;
gap: 12px;
margin-bottom: 8px;
.video-item {
width: 100px;
.video-thumbnail {
position: relative;
width: 100px;
height: 60px;
border-radius: 6px;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.2);
opacity: 0;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}
}
.video-info {
margin-top: 4px;
.video-name {
font-size: 12px;
font-weight: 500;
color: #1d2129;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.video-meta {
font-size: 11px;
color: #86909c;
display: flex;
gap: 4px;
}
.video-status {
margin-top: 2px;
}
}
}
}
.analysis-progress {
margin-top: 8px;
.progress-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #86909c;
margin-bottom: 2px;
}
}
}
}
}
}
.video-meta-info {
margin-top: 16px;
font-size: 13px;
color: #4e5969;
p {
margin: 2px 0;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<GiPageLayout> <GiPageLayout>
<!-- 页面标题 --> <!-- 页面标题 -->
<!-- <div class="page-header"> <!-- <div class="page-header">
<h2 class="page-title">图像音频关联查看</h2> <h2 class="page-title">图像音频关联查看</h2>
</div>--> </div>-->
@ -17,62 +17,32 @@
<!-- 项目选择 --> <!-- 项目选择 -->
<div class="filter-item"> <div class="filter-item">
<span class="filter-label">项目</span> <span class="filter-label">项目</span>
<a-select <a-select v-model="filterParams.project" placeholder="请选择项目" :options="projectOptions" allow-search
v-model="filterParams.project" allow-clear :loading="loading.project" style="width: 200px" @change="handleFilterChange" />
placeholder="请选择项目"
:options="projectOptions"
allow-search
allow-clear
:loading="loading.project"
style="width: 200px"
@change="handleFilterChange"
/>
</div> </div>
<!-- 机组选择 --> <!-- 机组选择 -->
<div class="filter-item"> <div class="filter-item">
<span class="filter-label">机组</span> <span class="filter-label">机组</span>
<a-select <a-select v-model="filterParams.unit" placeholder="请先选择项目" :options="unitOptions" allow-search
v-model="filterParams.unit" allow-clear :disabled="!filterParams.project" :loading="loading.unit" style="width: 200px"
placeholder="请先选择项目" @change="handleFilterChange" />
:options="unitOptions"
allow-search
allow-clear
:disabled="!filterParams.project"
:loading="loading.unit"
style="width: 200px"
@change="handleFilterChange"
/>
</div> </div>
<!-- 部件选择 --> <!-- 部件选择 -->
<div class="filter-item"> <div class="filter-item">
<span class="filter-label">部件</span> <span class="filter-label">部件</span>
<a-select <a-select v-model="filterParams.component" placeholder="请先选择机组" :options="componentOptions"
v-model="filterParams.component" allow-search allow-clear :disabled="!filterParams.unit" :loading="loading.component"
placeholder="请先选择机组" style="width: 200px" @change="handleFilterChange" />
:options="componentOptions"
allow-search
allow-clear
:disabled="!filterParams.unit"
:loading="loading.component"
style="width: 200px"
@change="handleFilterChange"
/>
</div> </div>
</a-space> </a-space>
</div> </div>
<!-- 已上传数据列表 --> <!-- 已上传数据列表 -->
<div class="uploaded-files-section"> <div class="uploaded-files-section">
<a-table <a-table :columns="fileColumns" :data="imageList" :pagination="false"
:columns="fileColumns" :scroll="{ x: '100%', y: 'calc(100vh - 380px)' }" :loading="loading.image" class="scrollable-table">
:data="imageList"
:pagination="false"
:scroll="{ x: '100%', y: 'calc(100vh - 380px)' }"
:loading="loading.image"
class="scrollable-table"
>
<!-- 文件类型 --> <!-- 文件类型 -->
<template #type="{ record }"> <template #type="{ record }">
<a-tag :color="getFileTypeColor(record.type)" size="small"> <a-tag :color="getFileTypeColor(record.type)" size="small">
@ -82,7 +52,7 @@
<!-- 文件大小 --> <!-- 文件大小 -->
<template #size="{ record }"> <template #size="{ record }">
<span>{{ record.imageTypeLabel}}</span> <span>{{ record.imageTypeLabel }}</span>
</template> </template>
<!-- 状态 --> <!-- 状态 -->
@ -103,20 +73,27 @@
</div> </div>
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="props" tap="形变" title="形变原数据">
<div class="tab-content">
<raw-data>
</raw-data>
</div>
</a-tab-pane>
</a-tabs> </a-tabs>
</div> </div>
<!-- 文件预览模态框 --> <!-- 文件预览模态框待重新设计 -->
<PreviewModal ref="previewModal" /> <!-- <PreviewModal ref="previewModal" /> -->
</GiPageLayout> </GiPageLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, computed,onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue' import type { TableColumnData } from '@arco-design/web-vue'
import PreviewModal from './components/PreviewModal.vue' import PreviewModal from './components/PreviewModal.vue'
import rawData from './components/raw-data.vue'
import { import {
getProjectList, getProjectList,
getTurbineList, getTurbineList,
@ -128,8 +105,10 @@ import {
batchUploadImages, batchUploadImages,
uploadImageToPartV2 uploadImageToPartV2
} from '@/apis/industrial-image' } from '@/apis/industrial-image'
import DeformationTap from './components/DeformationTap.vue'
const previewModal = ref() //
// const previewModal = ref()
// //
const activeTab = ref('image') const activeTab = ref('image')
@ -141,9 +120,9 @@ const filterParams = reactive({
}) })
// //
const projectOptions = ref<Array<{label: string, value: string}>>([]) const projectOptions = ref<Array<{ label: string, value: string }>>([])
const unitOptions = ref<Array<{label: string, value: string}>>([]) const unitOptions = ref<Array<{ label: string, value: string }>>([])
const componentOptions = ref<Array<{label: string, value: string}>>([]) const componentOptions = ref<Array<{ label: string, value: string }>>([])
// //
const imageList = ref<Array<{ const imageList = ref<Array<{
@ -255,33 +234,28 @@ const handleFilterChange = async () => {
loading.image = true loading.image = true
try { try {
let params = { const params: any = {
projectId: filterParams.project
}
if(filterParams.unit){
params = {
projectId: filterParams.project, projectId: filterParams.project,
turbineId: filterParams.unit
} }
if (filterParams.unit) {
params.turbineId = filterParams.unit
} }
if(filterParams.component){
params = { if (filterParams.component) {
projectId: filterParams.project, params.partId = filterParams.component
turbineId: filterParams.unit,
partId: filterParams.component
}
} }
const res = await getImageList(params) const res = await getImageList(params)
imageList.value = res.data.map((item: any) => ({ imageList.value = res.data.map((item: any) => ({
id: item.imageId, id: item.imageId,
name: item.imageName, name: item.imageName,
type: item.imageType?item.imageType:"未指定类型", type: item.imageType ? item.imageType : "未指定类型",
imageTypeLabel: item.imageTypeLabel, imageTypeLabel: item.imageTypeLabel,
shootingTime: item.shootingTime, shootingTime: item.shootingTime,
preTreatment: item.preTreatment?"已审核":"未审核", preTreatment: item.preTreatment ? "已审核" : "未审核",
imagePath: item.imagePath, imagePath: item.imagePath,
audioList:item.audioList audioList: item.audioList
})) }))
Message.success(`获取到 ${imageList.value.length} 条图像数据`) Message.success(`获取到 ${imageList.value.length} 条图像数据`)
} catch (error) { } catch (error) {
@ -313,14 +287,6 @@ const fileColumns: TableColumnData[] = [
{ title: '操作', slotName: 'action', width: 150, fixed: 'right' } { title: '操作', slotName: 'action', width: 150, fixed: 'right' }
] ]
//
const filteredFiles = computed(() => {
return uploadedFiles.value.filter(file =>
(filterParams.project === null || file.project === filterParams.project) &&
(filterParams.unit === null || file.unit === filterParams.unit) &&
(filterParams.component === null || file.component === filterParams.component)
)
})
// //
const getFileTypeColor = (type: string) => { const getFileTypeColor = (type: string) => {
@ -358,28 +324,25 @@ const getImageUrl = (imagePath: string): string => {
return `${baseUrl}${imagePath}` return `${baseUrl}${imagePath}`
} }
// //
const previewFile = (file: any) => { const previewFile = (file: any) => {
/* previewFileData.value = file /* previewFileData.value = file
previewModalVisible.value = true*/ previewModalVisible.value = true*/
const fileObj ={ const fileObj = {
id: file.id, id: file.id,
name: file.name, name: file.name,
url: getImageUrl(file.imagePath), url: getImageUrl(file.imagePath)
audios: file.audioList
} }
previewModal.value.openPreview(fileObj) // TODO:
// previewModal.value.openPreview(fileObj)
console.log('预览文件:', fileObj)
//
} }
// //
const deleteFile = (file: any) => { const deleteFile = (file: any) => {
console.log(index);
const index = uploadedFiles.value.findIndex(f => f.id === file.id)
if (index > -1) {
uploadedFiles.value.splice(index, 1)
Message.success('文件已删除')
}
} }
</script> </script>

View File

@ -0,0 +1,155 @@
<template>
<a-spin :loading="loading">
<div v-if="contractDetail">
<a-descriptions
:column="1"
size="medium"
:label-style="{ width: '120px' }"
>
<a-descriptions-item label="合同编号">
{{ contractDetail.code }}
</a-descriptions-item>
<a-descriptions-item label="项目名称">
{{ contractDetail.projectName }}
</a-descriptions-item>
<a-descriptions-item label="客户名称">
{{ contractDetail.customer }}
</a-descriptions-item>
<a-descriptions-item label="合同金额">
<span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="已收款金额">
<span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="未收款金额">
<span class="font-medium text-orange-600">{{ (contractDetail.pendingAmount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="签署日期">
{{ contractDetail.signDate }}
</a-descriptions-item>
<a-descriptions-item label="履约期限">
{{ contractDetail.performanceDeadline }}
</a-descriptions-item>
<a-descriptions-item label="付款日期">
{{ contractDetail.paymentDate }}
</a-descriptions-item>
<a-descriptions-item label="合同状态">
<a-tag :color="getStatusColor(contractDetail.contractStatus)">
{{ getStatusText(contractDetail.contractStatusLabel || contractDetail.contractStatus) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="销售人员">
{{ contractDetail.salespersonName }}
</a-descriptions-item>
<a-descriptions-item label="销售部门">
{{ contractDetail.salespersonDeptName }}
</a-descriptions-item>
<a-descriptions-item label="产品服务">
{{ contractDetail.productService }}
</a-descriptions-item>
<a-descriptions-item label="备注">
{{ contractDetail.notes }}
</a-descriptions-item>
</a-descriptions>
</div>
<div v-else-if="!loading" class="empty-container">
<a-empty description="暂无信息" />
</div>
</a-spin>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import http from '@/utils/http'
import { Message } from '@arco-design/web-vue'
interface ContractDetail {
contractId: string
customer: string
code: string
projectId: string
type: string
productService: string
paymentDate: string | null
performanceDeadline: string | null
paymentAddress: string
amount: number
accountNumber: string
notes: string
contractStatus: string
contractText: string | null
projectName: string
salespersonName: string | null
salespersonDeptName: string
settlementAmount: number | null
receivedAmount: number | null
contractStatusLabel: string | null
createBy: string | null
updateBy: string | null
createTime: string
updateTime: string
page: number
pageSize: number
signDate: string
duration: string
pendingAmount?: number
}
const props = defineProps({
contractId: {
type: String,
required: true
}
})
const contractDetail = ref<ContractDetail | null>(null)
const loading = ref(false)
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
未确认: 'gray',
待审批: 'orange',
已签署: 'blue',
执行中: 'cyan',
已完成: 'green',
已终止: 'red'
}
return colorMap[status] || 'gray'
}
const getStatusText = (status: string) => {
return status || '未知状态'
}
const fetchContractDetail = async () => {
try {
loading.value = true
const response = await http.get(`/contract/${props.contractId}`)
if (response.code === 200) {
contractDetail.value = response.data
//
if (contractDetail.value) {
contractDetail.value.pendingAmount = (contractDetail.value.amount || 0) - (contractDetail.value.receivedAmount || 0)
}
} else {
Message.error(response.msg || '获取合同详情失败')
}
} catch (error) {
console.error('获取合同详情失败:', error)
Message.error('获取合同详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchContractDetail()
})
</script>
<style scoped>
.empty-container {
text-align: center;
padding: 40px 0;
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<a-form :model="contractData" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="code" label="合同编号">
<a-input v-model="contractData.code" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="projectName" label="项目名称">
<a-input v-model="contractData.projectName" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="customer" label="客户名称">
<a-input v-model="contractData.customer" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="amount" label="合同金额">
<a-input-number v-model="contractData.amount" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="accountNumber" label="收款账号">
<a-input v-model="contractData.accountNumber" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="contractStatus" label="合同状态">
<a-select v-model="contractData.contractStatus">
<a-option value="未确认">未确认</a-option>
<a-option value="待审批">待审批</a-option>
<a-option value="已签署">已签署</a-option>
<a-option value="执行中">执行中</a-option>
<a-option value="已完成">已完成</a-option>
<a-option value="已终止">已终止</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="signDate" label="签订日期">
<a-date-picker v-model="contractData.signDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="performanceDeadline" label="履约期限">
<a-date-picker v-model="contractData.performanceDeadline" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="paymentDate" label="付款日期">
<a-date-picker v-model="contractData.paymentDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="productService" label="产品或服务">
<a-input v-model="contractData.productService" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="paymentAddress" label="付款地址">
<a-input v-model="contractData.paymentAddress" />
</a-form-item>
<a-form-item field="notes" label="备注">
<a-textarea v-model="contractData.notes" />
</a-form-item>
<a-form-item field="contractText" label="合同内容">
<a-textarea v-model="contractData.contractText" />
</a-form-item>
</a-form>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ContractItem } from './index.vue'
const props = defineProps<{
contractData: ContractItem
}>()
const emit = defineEmits<{
(e: 'update:contractData', data: ContractItem): void
}>()
const contractData = ref({ ...props.contractData })
// props
watch(
() => props.contractData,
(newVal) => {
if (newVal) {
contractData.value = { ...newVal }
}
},
{ immediate: true },
)
//
watch(
contractData,
(newVal) => {
emit('update:contractData', newVal)
},
{ deep: true },
)
</script>

View File

@ -38,48 +38,110 @@
<!-- 合同状态 --> <!-- 合同状态 -->
<template #status="{ record }"> <template #status="{ record }">
<a-tag :color="getStatusColor(record.status)"> <a-tag :color="getStatusColor(record.contractStatus)">
{{ getStatusText(record.status) }} {{ getStatusText(record.contractStatusLabel || record.contractStatus) }}
</a-tag> </a-tag>
</template> </template>
<!-- 合同金额 --> <!-- 合同金额 -->
<template #contractAmount="{ record }"> <template #contractAmount="{ record }">
<span class="font-medium text-red-600">{{ record.contractAmount.toLocaleString() }}</span> <span class="font-medium text-green-600">{{ (record.amount || 0).toLocaleString() }}</span>
</template> </template>
<!-- 款金额 --> <!-- 款金额 -->
<template #paidAmount="{ record }"> <template #receivedAmount="{ record }">
<span class="font-medium text-orange-600">{{ record.paidAmount.toLocaleString() }}</span> <span class="font-medium text-blue-600">{{ (record.receivedAmount || 0).toLocaleString() }}</span>
</template> </template>
<!-- 操作列 -->
<template #action="{ record }"> <template #action="{ record }">
<a-space> <a-space>
<a-link @click="viewDetail(record)">详情</a-link> <a-link @click="viewDetail(record)">详情</a-link>
<a-link @click="editRecord(record)" v-if="record.status === 'draft'">编辑</a-link> <a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link>
<a-link @click="approveContract(record)" v-if="record.status === 'pending'">审批</a-link> <a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link>
<a-link @click="viewPayment(record)">付款记录</a-link> <a-link @click="viewPayment(record)">收款记录</a-link>
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
</a-space> </a-space>
</template> </template>
</GiTable> </GiTable>
<!-- 合同详情弹窗 -->
<a-modal
v-model:visible="showDetailModal"
title="合同详情"
:width="800"
:footer="false"
@cancel="closeDetailModal"
>
<ContractDetail v-if="showDetailModal" :contract-id="selectedContractId" />
</a-modal>
<!-- 合同编辑弹窗 -->
<a-modal
v-model:visible="showEditModal"
title="编辑合同"
:width="800"
@cancel="closeEditModal"
@before-ok="handleEditSubmit"
>
<ContractEdit
v-if="showEditModal"
:contract-data="selectedContractData"
@update:contract-data="handleContractDataUpdate"
/>
</a-modal>
</GiPageLayout> </GiPageLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue' import type { TableColumnData } from '@arco-design/web-vue'
import ContractEdit from './ContractEdit.vue'
import ContractDetail from './ContractDetail.vue'
import http from '@/utils/http'
//
interface ContractItem {
contractId: string
customer: string
code: string
projectId: string
type: string
productService: string
paymentDate: string | null
performanceDeadline: string | null
paymentAddress: string
amount: number
accountNumber: string
notes: string
contractStatus: string
contractText: string | null
projectName: string
salespersonName: string | null
salespersonDeptName: string
settlementAmount: number | null
receivedAmount: number | null
contractStatusLabel: string | null
createBy: string | null
updateBy: string | null
createTime: string
updateTime: string
page: number
pageSize: number
signDate: string
duration: string
}
// //
let searchForm = reactive({ const searchForm = reactive({
contractName: '', contractName: '',
contractCode: '', contractCode: '',
supplier: '', client: '',
status: '', status: '',
signDate: '', signDate: '',
page: 1, page: 1,
size: 10 size: 10,
}) })
// //
@ -89,16 +151,16 @@ const queryFormColumns = [
label: '合同名称', label: '合同名称',
type: 'input' as const, type: 'input' as const,
props: { props: {
placeholder: '请输入合同名称' placeholder: '请输入合同名称',
} },
}, },
{ {
field: 'supplier', field: 'client',
label: '供应商', label: '客户',
type: 'input' as const, type: 'input' as const,
props: { props: {
placeholder: '请输入供应商名称' placeholder: '请输入客户名称',
} },
}, },
{ {
field: 'status', field: 'status',
@ -107,147 +169,121 @@ const queryFormColumns = [
props: { props: {
placeholder: '请选择合同状态', placeholder: '请选择合同状态',
options: [ options: [
{ label: '草稿', value: 'draft' }, { label: '未确认', value: '未确认' },
{ label: '待审批', value: 'pending' }, { label: '待审批', value: '待审批' },
{ label: '已签署', value: 'signed' }, { label: '已签署', value: '已签署' },
{ label: '执行中', value: 'executing' }, { label: '执行中', value: '执行中' },
{ label: '已完成', value: 'completed' }, { label: '已完成', value: '已完成' },
{ label: '已终止', value: 'terminated' } { label: '已终止', value: '已终止' },
] ],
} },
} },
] ]
// //
const tableColumns: TableColumnData[] = [ const tableColumns: TableColumnData[] = [
{ title: '合同编号', dataIndex: 'contractCode', width: 150 }, { title: '合同编号', dataIndex: 'code', width: 150 },
{ title: '合同名称', dataIndex: 'contractName', width: 250, ellipsis: true, tooltip: true }, { title: '项目名称', dataIndex: 'projectName', width: 250, ellipsis: true, tooltip: true },
{ title: '供应商名称', dataIndex: 'supplier', width: 200, ellipsis: true, tooltip: true }, { title: '客户名称', dataIndex: 'customer', width: 200, ellipsis: true, tooltip: true },
{ title: '合同类型', dataIndex: 'contractType', width: 120 }, { title: '合同金额', dataIndex: 'amount', slotName: 'contractAmount', width: 120 },
{ title: '合同金额', dataIndex: 'contractAmount', slotName: 'contractAmount', width: 120 }, { title: '已收款金额', dataIndex: 'receivedAmount', slotName: 'receivedAmount', width: 120 },
{ title: '已付款金额', dataIndex: 'paidAmount', slotName: 'paidAmount', width: 120 }, { title: '未收款金额', dataIndex: 'pendingAmount', width: 120 },
{ title: '未付款金额', dataIndex: 'unpaidAmount', width: 120 },
{ title: '签署日期', dataIndex: 'signDate', width: 120 }, { title: '签署日期', dataIndex: 'signDate', width: 120 },
{ title: '开始日期', dataIndex: 'startDate', width: 120 }, { title: '履约期限', dataIndex: 'performanceDeadline', width: 120 },
{ title: '结束日期', dataIndex: 'endDate', width: 120 }, { title: '付款日期', dataIndex: 'paymentDate', width: 120 },
{ title: '合同状态', dataIndex: 'status', slotName: 'status', width: 100 }, { title: '合同状态', dataIndex: 'contractStatus', slotName: 'status', width: 100 },
{ title: '项目关联', dataIndex: 'relatedProject', width: 200, ellipsis: true, tooltip: true }, { title: '销售人员', dataIndex: 'salespersonName', width: 100 },
{ title: '采购负责人', dataIndex: 'purchaseManager', width: 100 }, { title: '销售部门', dataIndex: 'salespersonDeptName', width: 100 },
{ title: '付款方式', dataIndex: 'paymentMethod', width: 100 }, { title: '产品服务', dataIndex: 'productService', width: 120, ellipsis: true, tooltip: true },
{ title: '备注', dataIndex: 'remark', width: 200, ellipsis: true, tooltip: true }, { title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'action', width: 200, fixed: 'right' } { title: '操作', slotName: 'action', width: 200, fixed: 'right' },
] ]
// //
const loading = ref(false) const loading = ref(false)
const dataList = ref([ const dataList = ref<ContractItem[]>([])
{
id: 1, // API
contractCode: 'EC2024001', const fetchContractList = async () => {
contractName: '风电检测设备采购合同', try {
supplier: '深圳市智能检测设备有限公司', loading.value = true
contractType: '设备采购', const params = {
contractAmount: 120, page: searchForm.page,
paidAmount: 60, pageSize: searchForm.size,
unpaidAmount: 60, contractName: searchForm.contractName,
signDate: '2024-02-25', code: searchForm.contractCode,
startDate: '2024-03-01', customer: searchForm.client,
endDate: '2024-03-31', contractStatus: searchForm.status,
status: 'executing', signDate: searchForm.signDate,
relatedProject: '华能新能源风电场叶片检测服务项目',
purchaseManager: '李采购经理',
paymentMethod: '银行转账',
remark: '按合同约定分期付款'
},
{
id: 2,
contractCode: 'EC2024002',
contractName: '无人机检测服务外包合同',
supplier: '北京航天无人机技术有限公司',
contractType: '服务外包',
contractAmount: 85,
paidAmount: 25.5,
unpaidAmount: 59.5,
signDate: '2024-03-02',
startDate: '2024-03-05',
endDate: '2024-04-05',
status: 'executing',
relatedProject: '大唐风电场防雷检测项目',
purchaseManager: '王采购经理',
paymentMethod: '分期付款',
remark: '服务外包,按进度付款'
},
{
id: 3,
contractCode: 'EC2024003',
contractName: '检测车辆租赁合同',
supplier: '上海专业车辆租赁有限公司',
contractType: '车辆租赁',
contractAmount: 15,
paidAmount: 15,
unpaidAmount: 0,
signDate: '2024-01-20',
startDate: '2024-01-25',
endDate: '2024-02-25',
status: 'completed',
relatedProject: '中广核风电场设备维护服务项目',
purchaseManager: '刘采购经理',
paymentMethod: '月付',
remark: '租赁合同已完成'
} }
])
const response = await http.get('/contract/list', params)
if (response.code === 200) {
// ""
const allContracts = response.rows || []
const revenueContracts = allContracts.filter((item: ContractItem) => item.type === '支出合同')
//
dataList.value = revenueContracts.map((item: ContractItem) => ({
...item,
pendingAmount: (item.amount || 0) - (item.receivedAmount || 0),
}))
pagination.total = Number.parseInt(response.total) || 0
} else {
Message.error(response.msg || '获取合同列表失败')
dataList.value = []
}
} catch (error) {
console.error('获取合同列表失败:', error)
Message.error('获取合同列表失败')
dataList.value = []
} finally {
loading.value = false
}
}
const pagination = reactive({ const pagination = reactive({
current: 1, current: 1,
pageSize: 10, pageSize: 10,
total: 3, total: 0,
showTotal: true, showTotal: true,
showPageSize: true showPageSize: true,
}) })
// //
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
'draft': 'gray', 未确认: 'gray',
'pending': 'orange', 待审批: 'orange',
'signed': 'blue', 已签署: 'blue',
'executing': 'cyan', 执行中: 'cyan',
'completed': 'green', 已完成: 'green',
'terminated': 'red' 已终止: 'red',
} }
return colorMap[status] || 'gray' return colorMap[status] || 'gray'
} }
// //
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
const textMap: Record<string, string> = { // contractStatusLabel使使contractStatus
'draft': '草稿', return status || '未知状态'
'pending': '待审批',
'signed': '已签署',
'executing': '执行中',
'completed': '已完成',
'terminated': '已终止'
}
return textMap[status] || status
} }
// //
const search = async () => { const search = async () => {
loading.value = true await fetchContractList()
setTimeout(() => {
loading.value = false
}, 1000)
} }
const reset = () => { const reset = () => {
Object.assign(searchForm, { Object.assign(searchForm, {
contractName: '', contractName: '',
contractCode: '', contractCode: '',
supplier: '', client: '',
status: '', status: '',
signDate: '', signDate: '',
page: 1, page: 1,
size: 10 size: 10,
}) })
pagination.current = 1 pagination.current = 1
search() search()
@ -277,23 +313,131 @@ const exportContract = () => {
Message.info('导出合同功能开发中...') Message.info('导出合同功能开发中...')
} }
const viewDetail = (record: any) => { //
Message.info(`查看合同详情: ${record.contractName}`) const showEditModal = ref(false)
const selectedContractData = ref<ContractItem | null>(null)
const editedContractData = ref<ContractItem | null>(null)
const editRecord = (record: ContractItem) => {
//
const completeRecord = {
...record,
amount: record.amount || 0,
projectId: record.projectId || '',
type: record.type || '收入合同',
contractStatus: record.contractStatus || '未确认',
}
selectedContractData.value = completeRecord
showEditModal.value = true
} }
const editRecord = (record: any) => { const closeEditModal = () => {
Message.info(`编辑合同: ${record.contractName}`) showEditModal.value = false
selectedContractData.value = null
editedContractData.value = null
} }
const approveContract = (record: any) => { const handleContractDataUpdate = (data: ContractItem) => {
Message.info(`审批合同: ${record.contractName}`) editedContractData.value = data
} }
const viewPayment = (record: any) => { const handleEditSubmit = async () => {
Message.info(`查看付款记录: ${record.contractName}`) if (!editedContractData.value) return false;
try {
const requestData = {
...editedContractData.value,
accountNumber: editedContractData.value.accountNumber || '',
amount: editedContractData.value.amount || 0,
code: editedContractData.value.code || '',
contractId: editedContractData.value.contractId,
contractStatus: editedContractData.value.contractStatus || '',
contractText: editedContractData.value.contractText || '',
customer: editedContractData.value.customer || '',
departmentId: editedContractData.value.departmentId || '',
duration: editedContractData.value.duration || '',
notes: editedContractData.value.notes || '',
paymentAddress: editedContractData.value.paymentAddress || '',
paymentDate: editedContractData.value.paymentDate || null,
performanceDeadline: editedContractData.value.performanceDeadline || null,
productService: editedContractData.value.productService || '',
projectId: editedContractData.value.projectId || '',
salespersonId: editedContractData.value.salespersonId || '',
signDate: editedContractData.value.signDate || null,
type: editedContractData.value.type || '',
};
console.log('Edited Contract Data:', requestData); // 便
// /contract PUT
const response = await http.put('/contract', requestData);
//
if (response.status === 200 && response.code === 200) {
Message.success('合同编辑成功');
closeEditModal();
search(); //
return true;
} else {
Message.error(response.msg || '合同编辑失败');
return false;
}
} catch (error) {
console.error('合同编辑失败:', error);
Message.error('合同编辑失败: ' + (error.message || '请稍后再试'));
return false;
}
}
//
const deleteContract = async (record: ContractItem) => {
try {
await Modal.confirm({
title: '确认删除',
content: `确定要删除合同 "${record.projectName}" 吗?`,
})
const response = await http.delete(`/contract/${record.contractId}`)
if (response.code === 200) {
Message.success('合同删除成功')
search() //
} else {
Message.error(response.msg || '合同删除失败')
}
} catch (error) {
//
if (error !== 'cancel') {
console.error('合同删除失败:', error)
Message.error('合同删除失败')
}
}
}
//
const showDetailModal = ref(false)
const selectedContractId = ref<string | null>(null)
const viewDetail = (record: ContractItem) => {
selectedContractId.value = record.contractId
showDetailModal.value = true
}
const closeDetailModal = () => {
showDetailModal.value = false
selectedContractId.value = null
}
const approveContract = (record: ContractItem) => {
Message.info(`审批合同: ${record.projectName}`)
}
const viewPayment = (record: ContractItem) => {
Message.info(`查看收款记录: ${record.projectName}`)
} }
onMounted(() => { onMounted(() => {
search() fetchContractList()
}) })
</script> </script>

View File

@ -0,0 +1,155 @@
<template>
<a-spin :loading="loading">
<div v-if="contractDetail">
<a-descriptions
:column="1"
size="medium"
:label-style="{ width: '120px' }"
>
<a-descriptions-item label="合同编号">
{{ contractDetail.code }}
</a-descriptions-item>
<a-descriptions-item label="项目名称">
{{ contractDetail.projectName }}
</a-descriptions-item>
<a-descriptions-item label="客户名称">
{{ contractDetail.customer }}
</a-descriptions-item>
<a-descriptions-item label="合同金额">
<span class="font-medium text-green-600">{{ (contractDetail.amount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="已收款金额">
<span class="font-medium text-blue-600">{{ (contractDetail.receivedAmount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="未收款金额">
<span class="font-medium text-orange-600">{{ (contractDetail.pendingAmount || 0).toLocaleString() }}</span>
</a-descriptions-item>
<a-descriptions-item label="签署日期">
{{ contractDetail.signDate }}
</a-descriptions-item>
<a-descriptions-item label="履约期限">
{{ contractDetail.performanceDeadline }}
</a-descriptions-item>
<a-descriptions-item label="付款日期">
{{ contractDetail.paymentDate }}
</a-descriptions-item>
<a-descriptions-item label="合同状态">
<a-tag :color="getStatusColor(contractDetail.contractStatus)">
{{ getStatusText(contractDetail.contractStatusLabel || contractDetail.contractStatus) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="销售人员">
{{ contractDetail.salespersonName }}
</a-descriptions-item>
<a-descriptions-item label="销售部门">
{{ contractDetail.salespersonDeptName }}
</a-descriptions-item>
<a-descriptions-item label="产品服务">
{{ contractDetail.productService }}
</a-descriptions-item>
<a-descriptions-item label="备注">
{{ contractDetail.notes }}
</a-descriptions-item>
</a-descriptions>
</div>
<div v-else-if="!loading" class="empty-container">
<a-empty description="暂无信息" />
</div>
</a-spin>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import http from '@/utils/http'
import { Message } from '@arco-design/web-vue'
interface ContractDetail {
contractId: string
customer: string
code: string
projectId: string
type: string
productService: string
paymentDate: string | null
performanceDeadline: string | null
paymentAddress: string
amount: number
accountNumber: string
notes: string
contractStatus: string
contractText: string | null
projectName: string
salespersonName: string | null
salespersonDeptName: string
settlementAmount: number | null
receivedAmount: number | null
contractStatusLabel: string | null
createBy: string | null
updateBy: string | null
createTime: string
updateTime: string
page: number
pageSize: number
signDate: string
duration: string
pendingAmount?: number
}
const props = defineProps({
contractId: {
type: String,
required: true
}
})
const contractDetail = ref<ContractDetail | null>(null)
const loading = ref(false)
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
未确认: 'gray',
待审批: 'orange',
已签署: 'blue',
执行中: 'cyan',
已完成: 'green',
已终止: 'red'
}
return colorMap[status] || 'gray'
}
const getStatusText = (status: string) => {
return status || '未知状态'
}
const fetchContractDetail = async () => {
try {
loading.value = true
const response = await http.get(`/contract/${props.contractId}`)
if (response.code === 200) {
contractDetail.value = response.data
//
if (contractDetail.value) {
contractDetail.value.pendingAmount = (contractDetail.value.amount || 0) - (contractDetail.value.receivedAmount || 0)
}
} else {
Message.error(response.msg || '获取合同详情失败')
}
} catch (error) {
console.error('获取合同详情失败:', error)
Message.error('获取合同详情失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchContractDetail()
})
</script>
<style scoped>
.empty-container {
text-align: center;
padding: 40px 0;
}
</style>

View File

@ -0,0 +1,122 @@
<template>
<a-form :model="contractData" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="code" label="合同编号">
<a-input v-model="contractData.code" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="projectName" label="项目名称">
<a-input v-model="contractData.projectName" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="customer" label="客户名称">
<a-input v-model="contractData.customer" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="amount" label="合同金额">
<a-input-number v-model="contractData.amount" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="accountNumber" label="收款账号">
<a-input v-model="contractData.accountNumber" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="contractStatus" label="合同状态">
<a-select v-model="contractData.contractStatus">
<a-option value="未确认">未确认</a-option>
<a-option value="待审批">待审批</a-option>
<a-option value="已签署">已签署</a-option>
<a-option value="执行中">执行中</a-option>
<a-option value="已完成">已完成</a-option>
<a-option value="已终止">已终止</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="signDate" label="签订日期">
<a-date-picker v-model="contractData.signDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="performanceDeadline" label="履约期限">
<a-date-picker v-model="contractData.performanceDeadline" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="paymentDate" label="付款日期">
<a-date-picker v-model="contractData.paymentDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="productService" label="产品或服务">
<a-input v-model="contractData.productService" />
</a-form-item>
</a-col>
</a-row>
<a-form-item field="paymentAddress" label="付款地址">
<a-input v-model="contractData.paymentAddress" />
</a-form-item>
<a-form-item field="notes" label="备注">
<a-textarea v-model="contractData.notes" />
</a-form-item>
<a-form-item field="contractText" label="合同内容">
<a-textarea v-model="contractData.contractText" />
</a-form-item>
</a-form>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { ContractItem } from './index.vue'
const props = defineProps<{
contractData: ContractItem
}>()
const emit = defineEmits<{
(e: 'update:contractData', data: ContractItem): void
}>()
const contractData = ref({ ...props.contractData })
// props
watch(
() => props.contractData,
(newVal) => {
if (newVal) {
contractData.value = { ...newVal }
}
},
{ immediate: true },
)
//
watch(
contractData,
(newVal) => {
emit('update:contractData', newVal)
},
{ deep: true },
)
</script>

View File

@ -38,48 +38,110 @@
<!-- 合同状态 --> <!-- 合同状态 -->
<template #status="{ record }"> <template #status="{ record }">
<a-tag :color="getStatusColor(record.status)"> <a-tag :color="getStatusColor(record.contractStatus)">
{{ getStatusText(record.status) }} {{ getStatusText(record.contractStatusLabel || record.contractStatus) }}
</a-tag> </a-tag>
</template> </template>
<!-- 合同金额 --> <!-- 合同金额 -->
<template #contractAmount="{ record }"> <template #contractAmount="{ record }">
<span class="font-medium text-green-600">{{ record.contractAmount.toLocaleString() }}</span> <span class="font-medium text-green-600">{{ (record.amount || 0).toLocaleString() }}</span>
</template> </template>
<!-- 已收款金额 --> <!-- 已收款金额 -->
<template #receivedAmount="{ record }"> <template #receivedAmount="{ record }">
<span class="font-medium text-blue-600">{{ record.receivedAmount.toLocaleString() }}</span> <span class="font-medium text-blue-600">{{ (record.receivedAmount || 0).toLocaleString() }}</span>
</template> </template>
<!-- 操作列 -->
<template #action="{ record }"> <template #action="{ record }">
<a-space> <a-space>
<a-link @click="viewDetail(record)">详情</a-link> <a-link @click="viewDetail(record)">详情</a-link>
<a-link @click="editRecord(record)" v-if="record.status === 'draft'">编辑</a-link> <a-link v-if="record.contractStatus === '未确认'" @click="editRecord(record)">编辑</a-link>
<a-link @click="approveContract(record)" v-if="record.status === 'pending'">审批</a-link> <a-link v-if="record.contractStatus === '待审批'" @click="approveContract(record)">审批</a-link>
<a-link @click="viewPayment(record)">收款记录</a-link> <a-link @click="viewPayment(record)">收款记录</a-link>
<a-link v-if="record.contractStatus !== '已签署' && record.contractStatus !== '已完成'" @click="deleteContract(record)">删除</a-link>
</a-space> </a-space>
</template> </template>
</GiTable> </GiTable>
<!-- 合同详情弹窗 -->
<a-modal
v-model:visible="showDetailModal"
title="合同详情"
:width="800"
:footer="false"
@cancel="closeDetailModal"
>
<ContractDetail v-if="showDetailModal" :contract-id="selectedContractId" />
</a-modal>
<!-- 合同编辑弹窗 -->
<a-modal
v-model:visible="showEditModal"
title="编辑合同"
:width="800"
@cancel="closeEditModal"
@before-ok="handleEditSubmit"
>
<ContractEdit
v-if="showEditModal"
:contract-data="selectedContractData"
@update:contract-data="handleContractDataUpdate"
/>
</a-modal>
</GiPageLayout> </GiPageLayout>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue' import { Message, Modal } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue' import type { TableColumnData } from '@arco-design/web-vue'
import ContractEdit from './ContractEdit.vue'
import ContractDetail from './ContractDetail.vue'
import http from '@/utils/http'
//
interface ContractItem {
contractId: string
customer: string
code: string
projectId: string
type: string
productService: string
paymentDate: string | null
performanceDeadline: string | null
paymentAddress: string
amount: number
accountNumber: string
notes: string
contractStatus: string
contractText: string | null
projectName: string
salespersonName: string | null
salespersonDeptName: string
settlementAmount: number | null
receivedAmount: number | null
contractStatusLabel: string | null
createBy: string | null
updateBy: string | null
createTime: string
updateTime: string
page: number
pageSize: number
signDate: string
duration: string
}
// //
let searchForm = reactive({ const searchForm = reactive({
contractName: '', contractName: '',
contractCode: '', contractCode: '',
client: '', client: '',
status: '', status: '',
signDate: '', signDate: '',
page: 1, page: 1,
size: 10 size: 10,
}) })
// //
@ -89,16 +151,16 @@ const queryFormColumns = [
label: '合同名称', label: '合同名称',
type: 'input' as const, type: 'input' as const,
props: { props: {
placeholder: '请输入合同名称' placeholder: '请输入合同名称',
} },
}, },
{ {
field: 'client', field: 'client',
label: '客户', label: '客户',
type: 'input' as const, type: 'input' as const,
props: { props: {
placeholder: '请输入客户名称' placeholder: '请输入客户名称',
} },
}, },
{ {
field: 'status', field: 'status',
@ -107,132 +169,110 @@ const queryFormColumns = [
props: { props: {
placeholder: '请选择合同状态', placeholder: '请选择合同状态',
options: [ options: [
{ label: '草稿', value: 'draft' }, { label: '未确认', value: '未确认' },
{ label: '待审批', value: 'pending' }, { label: '待审批', value: '待审批' },
{ label: '已签署', value: 'signed' }, { label: '已签署', value: '已签署' },
{ label: '执行中', value: 'executing' }, { label: '执行中', value: '执行中' },
{ label: '已完成', value: 'completed' }, { label: '已完成', value: '已完成' },
{ label: '已终止', value: 'terminated' } { label: '已终止', value: '已终止' },
] ],
} },
} },
] ]
// //
const tableColumns: TableColumnData[] = [ const tableColumns: TableColumnData[] = [
{ title: '合同编号', dataIndex: 'contractCode', width: 150 }, { title: '合同编号', dataIndex: 'code', width: 150 },
{ title: '合同名称', dataIndex: 'contractName', width: 250, ellipsis: true, tooltip: true }, { title: '项目名称', dataIndex: 'projectName', width: 250, ellipsis: true, tooltip: true },
{ title: '客户名称', dataIndex: 'client', width: 200, ellipsis: true, tooltip: true }, { title: '客户名称', dataIndex: 'customer', width: 200, ellipsis: true, tooltip: true },
{ title: '合同金额', dataIndex: 'contractAmount', slotName: 'contractAmount', width: 120 }, { title: '合同金额', dataIndex: 'amount', slotName: 'contractAmount', width: 120 },
{ title: '已收款金额', dataIndex: 'receivedAmount', slotName: 'receivedAmount', width: 120 }, { title: '已收款金额', dataIndex: 'receivedAmount', slotName: 'receivedAmount', width: 120 },
{ title: '未收款金额', dataIndex: 'pendingAmount', width: 120 }, { title: '未收款金额', dataIndex: 'pendingAmount', width: 120 },
{ title: '签署日期', dataIndex: 'signDate', width: 120 }, { title: '签署日期', dataIndex: 'signDate', width: 120 },
{ title: '开始日期', dataIndex: 'startDate', width: 120 }, { title: '履约期限', dataIndex: 'performanceDeadline', width: 120 },
{ title: '结束日期', dataIndex: 'endDate', width: 120 }, { title: '付款日期', dataIndex: 'paymentDate', width: 120 },
{ title: '合同状态', dataIndex: 'status', slotName: 'status', width: 100 }, { title: '合同状态', dataIndex: 'contractStatus', slotName: 'status', width: 100 },
{ title: '项目经理', dataIndex: 'projectManager', width: 100 }, { title: '销售人员', dataIndex: 'salespersonName', width: 100 },
{ title: '销售经理', dataIndex: 'salesManager', width: 100 }, { title: '销售部门', dataIndex: 'salespersonDeptName', width: 100 },
{ title: '完成进度', dataIndex: 'progress', width: 100 }, { title: '产品服务', dataIndex: 'productService', width: 120, ellipsis: true, tooltip: true },
{ title: '备注', dataIndex: 'remark', width: 200, ellipsis: true, tooltip: true }, { title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, tooltip: true },
{ title: '操作', slotName: 'action', width: 200, fixed: 'right' } { title: '操作', slotName: 'action', width: 200, fixed: 'right' },
] ]
// //
const loading = ref(false) const loading = ref(false)
const dataList = ref([ const dataList = ref<ContractItem[]>([])
{
id: 1, // API
contractCode: 'RC2024001', const fetchContractList = async () => {
contractName: '华能新能源风电场叶片检测服务合同', try {
client: '华能新能源股份有限公司', loading.value = true
contractAmount: 320, const params = {
receivedAmount: 192, page: searchForm.page,
pendingAmount: 128, pageSize: searchForm.size,
signDate: '2024-02-20', contractName: searchForm.contractName,
startDate: '2024-03-01', code: searchForm.contractCode,
endDate: '2024-04-30', customer: searchForm.client,
status: 'executing', contractStatus: searchForm.status,
projectManager: '张项目经理', signDate: searchForm.signDate,
salesManager: '李销售经理',
progress: '60%',
remark: '项目进展顺利,客户满意度高'
},
{
id: 2,
contractCode: 'RC2024002',
contractName: '大唐风电场防雷检测项目合同',
client: '大唐新能源股份有限公司',
contractAmount: 268,
receivedAmount: 134,
pendingAmount: 134,
signDate: '2024-02-25',
startDate: '2024-03-05',
endDate: '2024-04-20',
status: 'executing',
projectManager: '王项目经理',
salesManager: '赵销售经理',
progress: '45%',
remark: '按计划执行中'
},
{
id: 3,
contractCode: 'RC2024003',
contractName: '中广核风电场设备维护服务合同',
client: '中广核新能源投资有限公司',
contractAmount: 450,
receivedAmount: 450,
pendingAmount: 0,
signDate: '2024-01-15',
startDate: '2024-01-20',
endDate: '2024-01-31',
status: 'completed',
projectManager: '刘项目经理',
salesManager: '孙销售经理',
progress: '100%',
remark: '项目已完成,客户验收通过'
} }
])
const response = await http.get('/contract/list', params)
if (response.code === 200) {
// ""
const allContracts = response.rows || []
const revenueContracts = allContracts.filter((item: ContractItem) => item.type === '收入合同')
//
dataList.value = revenueContracts.map((item: ContractItem) => ({
...item,
pendingAmount: (item.amount || 0) - (item.receivedAmount || 0),
}))
pagination.total = Number.parseInt(response.total) || 0
} else {
Message.error(response.msg || '获取合同列表失败')
dataList.value = []
}
} catch (error) {
console.error('获取合同列表失败:', error)
Message.error('获取合同列表失败')
dataList.value = []
} finally {
loading.value = false
}
}
const pagination = reactive({ const pagination = reactive({
current: 1, current: 1,
pageSize: 10, pageSize: 10,
total: 3, total: 0,
showTotal: true, showTotal: true,
showPageSize: true showPageSize: true,
}) })
// //
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = { const colorMap: Record<string, string> = {
'draft': 'gray', 未确认: 'gray',
'pending': 'orange', 待审批: 'orange',
'signed': 'blue', 已签署: 'blue',
'executing': 'cyan', 执行中: 'cyan',
'completed': 'green', 已完成: 'green',
'terminated': 'red' 已终止: 'red',
} }
return colorMap[status] || 'gray' return colorMap[status] || 'gray'
} }
// //
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
const textMap: Record<string, string> = { // contractStatusLabel使使contractStatus
'draft': '草稿', return status || '未知状态'
'pending': '待审批',
'signed': '已签署',
'executing': '执行中',
'completed': '已完成',
'terminated': '已终止'
}
return textMap[status] || status
} }
// //
const search = async () => { const search = async () => {
loading.value = true await fetchContractList()
setTimeout(() => {
loading.value = false
}, 1000)
} }
const reset = () => { const reset = () => {
@ -243,7 +283,7 @@ const reset = () => {
status: '', status: '',
signDate: '', signDate: '',
page: 1, page: 1,
size: 10 size: 10,
}) })
pagination.current = 1 pagination.current = 1
search() search()
@ -273,23 +313,131 @@ const exportContract = () => {
Message.info('导出合同功能开发中...') Message.info('导出合同功能开发中...')
} }
const viewDetail = (record: any) => { //
Message.info(`查看合同详情: ${record.contractName}`) const showEditModal = ref(false)
const selectedContractData = ref<ContractItem | null>(null)
const editedContractData = ref<ContractItem | null>(null)
const editRecord = (record: ContractItem) => {
//
const completeRecord = {
...record,
amount: record.amount || 0,
projectId: record.projectId || '',
type: record.type || '收入合同',
contractStatus: record.contractStatus || '未确认',
}
selectedContractData.value = completeRecord
showEditModal.value = true
} }
const editRecord = (record: any) => { const closeEditModal = () => {
Message.info(`编辑合同: ${record.contractName}`) showEditModal.value = false
selectedContractData.value = null
editedContractData.value = null
} }
const approveContract = (record: any) => { const handleContractDataUpdate = (data: ContractItem) => {
Message.info(`审批合同: ${record.contractName}`) editedContractData.value = data
} }
const viewPayment = (record: any) => { const handleEditSubmit = async () => {
Message.info(`查看收款记录: ${record.contractName}`) if (!editedContractData.value) return false;
try {
const requestData = {
...editedContractData.value,
accountNumber: editedContractData.value.accountNumber || '',
amount: editedContractData.value.amount || 0,
code: editedContractData.value.code || '',
contractId: editedContractData.value.contractId,
contractStatus: editedContractData.value.contractStatus || '',
contractText: editedContractData.value.contractText || '',
customer: editedContractData.value.customer || '',
departmentId: editedContractData.value.departmentId || '',
duration: editedContractData.value.duration || '',
notes: editedContractData.value.notes || '',
paymentAddress: editedContractData.value.paymentAddress || '',
paymentDate: editedContractData.value.paymentDate || null,
performanceDeadline: editedContractData.value.performanceDeadline || null,
productService: editedContractData.value.productService || '',
projectId: editedContractData.value.projectId || '',
salespersonId: editedContractData.value.salespersonId || '',
signDate: editedContractData.value.signDate || null,
type: editedContractData.value.type || '',
};
console.log('Edited Contract Data:', requestData); // 便
// /contract PUT
const response = await http.put('/contract', requestData);
//
if (response.status === 200 && response.code === 200) {
Message.success('合同编辑成功');
closeEditModal();
search(); //
return true;
} else {
Message.error(response.msg || '合同编辑失败');
return false;
}
} catch (error) {
console.error('合同编辑失败:', error);
Message.error('合同编辑失败: ' + (error.message || '请稍后再试'));
return false;
}
}
//
const deleteContract = async (record: ContractItem) => {
try {
await Modal.confirm({
title: '确认删除',
content: `确定要删除合同 "${record.projectName}" 吗?`,
})
const response = await http.delete(`/contract/${record.contractId}`)
if (response.code === 200) {
Message.success('合同删除成功')
search() //
} else {
Message.error(response.msg || '合同删除失败')
}
} catch (error) {
//
if (error !== 'cancel') {
console.error('合同删除失败:', error)
Message.error('合同删除失败')
}
}
}
//
const showDetailModal = ref(false)
const selectedContractId = ref<string | null>(null)
const viewDetail = (record: ContractItem) => {
selectedContractId.value = record.contractId
showDetailModal.value = true
}
const closeDetailModal = () => {
showDetailModal.value = false
selectedContractId.value = null
}
const approveContract = (record: ContractItem) => {
Message.info(`审批合同: ${record.projectName}`)
}
const viewPayment = (record: ContractItem) => {
Message.info(`查看收款记录: ${record.projectName}`)
} }
onMounted(() => { onMounted(() => {
search() fetchContractList()
}) })
</script> </script>

View File

@ -0,0 +1,981 @@
<!--
团队成员管理页面
功能特性:
1. 团队成员列表展示
2. 新增团队成员
3. 编辑团队成员信息
4. 删除团队成员
5. 导入导出团队成员数据
-->
<template>
<GiPageLayout class="construction-personnel-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
<a-button @click="$router.back()" class="back-btn">
<template #icon><icon-left /></template>
返回
</a-button>
<div class="header-title">
<icon-user-group class="title-icon" />
<h1>团队成员管理</h1>
<span v-if="projectId" class="project-info">项目ID: {{ projectId }}</span>
</div>
</div>
<div class="header-actions">
<a-button type="primary" @click="openAddModal">
<template #icon><icon-plus /></template>
新增成员
</a-button>
<a-button @click="openImportModal">
<template #icon><icon-upload /></template>
导入
</a-button>
<a-button @click="exportData">
<template #icon><icon-download /></template>
导出
</a-button>
</div>
</div>
<!-- 简洁搜索区域 -->
<div class="search-section">
<a-card :bordered="false" class="search-card">
<a-form layout="inline" :model="searchForm">
<a-form-item label="姓名">
<a-input
v-model="searchForm.name"
placeholder="请输入姓名"
style="width: 180px"
allow-clear
@press-enter="handleSearch"
/>
</a-form-item>
<a-form-item label="岗位">
<a-select
v-model="searchForm.position"
placeholder="请选择岗位"
style="width: 150px"
allow-clear
>
<a-option
v-for="option in POSITION_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model="searchForm.status"
placeholder="请选择状态"
style="width: 120px"
allow-clear
>
<a-option
v-for="option in STATUS_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="handleSearch">
<template #icon><icon-search /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><icon-refresh /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-card>
</div>
<!-- 数据表格 -->
<GiTable
row-key="id"
:data="dataList"
:columns="tableColumns"
:loading="loading"
:scroll="{ x: '100%', minWidth: 1200 }"
:pagination="pagination"
:disabled-tools="['size']"
@page-change="onPageChange"
@page-size-change="onPageSizeChange"
@refresh="loadData"
>
<!-- 状态列 -->
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status || 'offline')">
{{ getStatusText(record.status || 'offline') }}
</a-tag>
</template>
<!-- 备注列 -->
<template #remark="{ record }">
<div class="remark-content" v-if="record.remark">
{{ record.remark }}
</div>
<span v-else class="no-remark">暂无备注</span>
</template>
<!-- 操作列 -->
<template #action="{ record }">
<a-space>
<a-link title="编辑" @click="openEditModal(record)">编辑</a-link>
<a-link title="状态调整" @click="openStatusModal(record)">状态</a-link>
<a-link status="danger" title="删除" @click="confirmDelete(record)">删除</a-link>
</a-space>
</template>
</GiTable>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:visible="memberModalVisible"
:title="isEdit ? '编辑团队成员' : '新增团队成员'"
width="600px"
@ok="saveMember"
@cancel="cancelMember"
>
<div class="member-form">
<div class="form-row">
<div class="form-item">
<label>姓名 <span class="required">*</span></label>
<a-input v-model="memberForm.name" placeholder="请输入姓名" />
</div>
<div class="form-item">
<label>联系电话 <span class="required">*</span></label>
<a-input v-model="memberForm.phone" placeholder="请输入联系电话" />
</div>
</div>
<div class="form-row">
<div class="form-item">
<label>岗位 <span class="required">*</span></label>
<a-select v-model="memberForm.position" placeholder="请选择岗位">
<a-option
v-for="option in POSITION_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
<div class="form-item">
<label>状态</label>
<a-select v-model="memberForm.status" placeholder="请选择状态">
<a-option
v-for="option in STATUS_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
<div class="form-row">
<div class="form-item">
<label>邮箱</label>
<a-input v-model="memberForm.email" placeholder="请输入邮箱" />
</div>
<div class="form-item">
<label>入职日期</label>
<a-date-picker v-model="memberForm.joinDate" placeholder="请选择入职日期" />
</div>
</div>
<div class="form-item">
<label>备注信息</label>
<a-textarea
v-model="memberForm.remark"
placeholder="请输入备注信息,格式:主要负责:项目经理,次要负责:安全员等"
:rows="3"
/>
</div>
</div>
</a-modal>
<!-- 状态调整弹窗 -->
<a-modal
v-model:visible="statusModalVisible"
title="调整状态"
width="400px"
@ok="saveStatus"
@cancel="cancelStatus"
>
<div class="status-form">
<div class="form-item">
<label>成员姓名:</label>
<span>{{ statusForm.name }}</span>
</div>
<div class="form-item">
<label>当前状态:</label>
<span>{{ getStatusText(statusForm.currentStatus) }}</span>
</div>
<div class="form-item">
<label>新状态:</label>
<a-select v-model="statusForm.newStatus" placeholder="请选择新状态">
<a-option
v-for="option in STATUS_OPTIONS"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
</div>
</div>
</a-modal>
<!-- 导入弹窗 -->
<a-modal
v-model:visible="importModalVisible"
title="导入团队成员"
width="500px"
@ok="confirmImport"
@cancel="cancelImport"
>
<div class="import-form">
<div class="form-item">
<label>选择文件:</label>
<a-upload
v-model:file-list="fileList"
:custom-request="customUpload"
:show-file-list="true"
accept=".xlsx,.xls,.csv"
:limit="1"
>
<a-button>
<template #icon><icon-upload /></template>
选择文件
</a-button>
</a-upload>
</div>
<div class="form-item">
<label>下载模板:</label>
<a-button type="text" @click="downloadTemplate">
<template #icon><icon-download /></template>
下载导入模板
</a-button>
</div>
</div>
</a-modal>
</GiPageLayout>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { useRoute } from 'vue-router'
import type { TeamMemberResp, TeamMemberQuery, TeamMemberExportQuery, CreateTeamMemberForm, UpdateTeamMemberForm, BackendTeamMemberResp } from '@/apis/project/type'
import {
getProjectTeamMembers,
createTeamMember,
updateTeamMember,
deleteTeamMembers,
importTeamMembers,
exportTeamMembers,
downloadImportTemplate
} from '@/apis/project/personnel-dispatch'
//
const route = useRoute()
const projectId = route.query.projectId as string
//
const POSITION_OPTIONS = [
{ label: '项目经理', value: '项目经理' },
{ label: '技术负责人', value: '技术负责人' },
{ label: '安全员', value: '安全员' },
{ label: '质量员', value: '质量员' },
{ label: '施工员', value: '施工员' },
{ label: '材料员', value: '材料员' },
{ label: '资料员', value: '资料员' },
{ label: '实习生', value: '实习生' },
{ label: '技术工人', value: '技术工人' },
{ label: '普通工人', value: '普通工人' }
]
const STATUS_OPTIONS = [
{ label: '可用', value: 'available' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' }
]
//
const loading = ref(false)
const dataList = ref<TeamMemberResp[]>([])
const pagination = reactive({
current: 1,
pageSize: 10,
total: 0,
showTotal: true,
showJumper: true,
showPageSize: true
})
//
const searchForm = reactive<{
name: string
position: string
status: string
}>({
name: '',
position: '',
status: ''
})
//
const tableColumns = [
{ title: '姓名', dataIndex: 'name', width: 100, fixed: 'left' },
{ title: '联系电话', dataIndex: 'phone', width: 120 },
{ title: '邮箱', dataIndex: 'email', width: 150 },
{ title: '项目岗位', dataIndex: 'position', width: 120 },
{ title: '状态', dataIndex: 'status', width: 100, slotName: 'status' },
{ title: '入职日期', dataIndex: 'joinDate', width: 120 },
{ title: '备注', dataIndex: 'remark', width: 200, slotName: 'remark' },
{ title: '操作', dataIndex: 'action', width: 180, fixed: 'right', slotName: 'action' }
]
//
const memberModalVisible = ref(false)
const statusModalVisible = ref(false)
const importModalVisible = ref(false)
const isEdit = ref(false)
//
const memberForm = reactive<CreateTeamMemberForm & { id?: string | number }>({
projectId: projectId,
name: '',
phone: '',
email: '',
position: '',
status: 'available',
joinDate: '',
remark: ''
})
const statusForm = reactive<{
id: string | number
name: string
currentStatus: string
newStatus: 'available' | 'busy' | 'offline'
}>({
id: '',
name: '',
currentStatus: '',
newStatus: 'available'
})
const fileList = ref<any[]>([])
//
const loadData = async () => {
if (!projectId) {
console.warn('未获取到项目ID无法加载团队成员数据')
Message.warning('未获取到项目信息,请从项目详情页面进入')
return
}
loading.value = true
try {
console.log('正在加载项目团队成员数据项目ID:', projectId)
//
const queryParams: TeamMemberQuery = {
projectId: projectId,
page: pagination.current,
pageSize: pagination.pageSize,
name: searchForm.name || undefined,
position: searchForm.position || undefined,
status: searchForm.status || undefined
}
const response = await getProjectTeamMembers(queryParams)
console.log('API响应数据:', response.data)
// response.data
const rawData = Array.isArray(response.data) ? response.data : [response.data]
console.log('处理后的原始数据:', rawData)
//
const mappedData = rawData.map((item: BackendTeamMemberResp) => {
const mappedItem: TeamMemberResp = {
id: item.memberId,
name: item.name || '',
phone: item.phone || '',
email: item.email || '',
position: item.position || '',
status: (item.status === 'ACTIVE' ? 'available' : item.status === 'BUSY' ? 'busy' : 'offline') as 'available' | 'busy' | 'offline',
joinDate: item.joinDate || '',
remark: item.remark || '',
avatar: item.userAvatar || ''
}
console.log('映射后的数据项:', mappedItem)
return mappedItem
})
dataList.value = mappedData
pagination.total = mappedData.length
console.log('团队成员数据加载完成,显示数据:', dataList.value.length, '条,总计:', pagination.total, '条')
} catch (error) {
console.error('团队成员数据加载失败:', error)
Message.error('团队成员数据加载失败')
} finally {
loading.value = false
}
}
const handleSearch = async () => {
pagination.current = 1
await loadData()
}
const handleReset = () => {
console.log('重置搜索表单')
Object.assign(searchForm, {
name: '',
position: '',
status: ''
})
pagination.current = 1
loadData()
}
const onPageChange = (page: number) => {
pagination.current = page
loadData()
}
const onPageSizeChange = (pageSize: number) => {
pagination.pageSize = pageSize
pagination.current = 1
loadData()
}
const openAddModal = () => {
isEdit.value = false
resetMemberForm()
memberModalVisible.value = true
}
const openEditModal = (record: TeamMemberResp) => {
isEdit.value = true
Object.assign(memberForm, {
id: record.id,
projectId: projectId,
name: record.name,
phone: record.phone || '',
email: record.email || '',
position: record.position,
status: record.status || 'available',
joinDate: record.joinDate || '',
remark: record.remark || ''
})
memberModalVisible.value = true
}
const saveMember = async () => {
if (!memberForm.name || !memberForm.phone || !memberForm.position) {
Message.error('请填写必填项')
return
}
try {
if (isEdit.value && memberForm.id) {
//
const updateData: UpdateTeamMemberForm = {
name: memberForm.name,
phone: memberForm.phone,
email: memberForm.email,
position: memberForm.position,
status: memberForm.status,
joinDate: memberForm.joinDate,
remark: memberForm.remark
}
await updateTeamMember(memberForm.id, updateData)
Message.success('更新成功')
} else {
//
const createData: CreateTeamMemberForm = {
projectId: projectId,
name: memberForm.name,
phone: memberForm.phone,
email: memberForm.email,
position: memberForm.position,
status: memberForm.status,
joinDate: memberForm.joinDate,
remark: memberForm.remark
}
await createTeamMember(createData)
Message.success('添加成功')
}
memberModalVisible.value = false
loadData()
} catch (error) {
console.error('保存团队成员失败:', error)
Message.error(isEdit.value ? '更新失败' : '添加失败')
}
}
const cancelMember = () => {
memberModalVisible.value = false
resetMemberForm()
}
const resetMemberForm = () => {
Object.assign(memberForm, {
id: undefined,
projectId: projectId,
name: '',
phone: '',
email: '',
position: '',
status: 'available',
joinDate: '',
remark: ''
})
}
const openStatusModal = (record: TeamMemberResp) => {
Object.assign(statusForm, {
id: record.id,
name: record.name,
currentStatus: record.status || 'available',
newStatus: record.status || 'available'
})
statusModalVisible.value = true
}
const saveStatus = async () => {
try {
await updateTeamMember(statusForm.id, {
status: statusForm.newStatus as 'available' | 'busy' | 'offline'
})
Message.success('状态更新成功')
statusModalVisible.value = false
loadData()
} catch (error) {
console.error('状态更新失败:', error)
Message.error('状态更新失败')
}
}
const cancelStatus = () => {
statusModalVisible.value = false
}
const confirmDelete = (record: TeamMemberResp) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除团队成员"${record.name}"吗?`,
onOk: async () => {
try {
await deleteTeamMembers(record.id)
Message.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
Message.error('删除失败')
}
}
})
}
const openImportModal = () => {
importModalVisible.value = true
}
const customUpload = (options: any) => {
const { file } = options
fileList.value = [file]
}
const confirmImport = async () => {
if (fileList.value.length === 0) {
Message.error('请选择要导入的文件')
return
}
try {
const file = fileList.value[0].originFileObj
await importTeamMembers(projectId, file)
Message.success('导入成功')
importModalVisible.value = false
fileList.value = []
loadData()
} catch (error) {
console.error('导入失败:', error)
Message.error('导入失败')
}
}
const cancelImport = () => {
importModalVisible.value = false
fileList.value = []
}
const downloadTemplate = async () => {
try {
const response = await downloadImportTemplate()
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '团队成员导入模板.xlsx'
link.click()
window.URL.revokeObjectURL(url)
Message.success('模板下载成功')
} catch (error) {
console.error('模板下载失败:', error)
Message.error('模板下载失败')
}
}
const exportData = async () => {
try {
const queryParams: TeamMemberExportQuery = {
projectId: projectId,
name: searchForm.name || undefined,
position: searchForm.position || undefined,
status: searchForm.status || undefined
}
const response = await exportTeamMembers(queryParams)
const blob = new Blob([response.data], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `团队成员数据_${new Date().toISOString().split('T')[0]}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
Message.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
Message.error('导出失败')
}
}
//
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
available: 'green',
busy: 'orange',
offline: 'gray'
}
return colorMap[status] || 'gray'
}
const getStatusText = (status: string) => {
const textMap: Record<string, string> = {
available: '可用',
busy: '忙碌',
offline: '离线'
}
return textMap[status] || '未知'
}
//
onMounted(() => {
console.log('团队成员管理页面加载项目ID:', projectId)
if (projectId) {
loadData()
} else {
console.warn('未获取到项目ID无法加载团队成员数据')
Message.warning('未获取到项目信息,请从项目详情页面进入')
}
})
</script>
<style lang="scss" scoped>
//
:deep(.gi-page-layout) {
height: auto !important;
min-height: 100vh;
overflow-y: visible;
}
:deep(.gi-page-layout__body) {
height: auto !important;
overflow-y: visible;
}
//
:deep(body), :deep(html) {
overflow-y: auto !important;
height: auto !important;
}
//
:deep(.app-main), :deep(.main-content), :deep(.layout-content) {
overflow-y: auto !important;
height: auto !important;
}
//
:deep(.arco-table-container) {
overflow-x: auto;
overflow-y: visible;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.header-left {
display: flex;
align-items: center;
gap: 16px;
.back-btn {
border: none;
background: #f7f8fa;
color: #4e5969;
&:hover {
background: #e5e6eb;
color: #1d2129;
}
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
.title-icon {
font-size: 24px;
color: #667eea;
}
h1 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: #1d2129;
}
.project-info {
font-size: 14px;
color: #969fa8;
}
}
}
.header-actions {
display: flex;
gap: 12px;
}
}
.search-section {
margin-bottom: 16px;
.search-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
:deep(.arco-card-body) {
padding: 20px;
}
:deep(.arco-form-item) {
margin-bottom: 0;
margin-right: 16px;
}
:deep(.arco-form-item-label) {
font-weight: 500;
color: #4e5969;
}
:deep(.arco-input),
:deep(.arco-select) {
width: 100%;
}
}
}
//
:deep(.gi-table) {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 24px;
max-height: calc(100vh - 300px);
overflow: hidden;
.arco-table-container {
overflow-x: auto;
overflow-y: auto;
max-height: calc(100vh - 350px);
}
.arco-table {
overflow: visible;
}
.arco-table-body {
overflow-y: auto;
}
}
.remark-content {
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #4e5969;
}
.no-remark {
color: #c9cdd4;
font-style: italic;
}
.member-form {
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.form-item {
margin-bottom: 16px;
label {
display: block;
font-weight: 500;
color: #4e5969;
margin-bottom: 6px;
.required {
color: #f53f3f;
}
}
.arco-input,
.arco-select,
.arco-textarea,
.arco-date-picker {
width: 100%;
}
}
}
.status-form {
.form-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
label {
font-weight: 500;
color: #4e5969;
min-width: 80px;
}
.arco-select {
flex: 1;
}
}
}
.import-form {
.form-item {
margin-bottom: 16px;
label {
display: block;
font-weight: 500;
color: #4e5969;
margin-bottom: 6px;
}
}
}
//
.construction-personnel-page {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
:deep(.gi-page-layout) {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
:deep(.gi-page-layout-content) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
}
//
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
align-items: stretch;
.header-actions {
justify-content: center;
}
}
.search-section {
.search-card {
:deep(.arco-form) {
flex-direction: column;
align-items: stretch;
}
:deep(.arco-form-item) {
margin-right: 0;
margin-bottom: 16px;
}
}
}
.member-form {
.form-row {
grid-template-columns: 1fr;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -63,6 +63,10 @@
共找到 <strong>{{ pagination.total }}</strong> 条记录 共找到 <strong>{{ pagination.total }}</strong> 条记录
</span> </span>
</template> </template>
<template #regulationType="{ record }">
{{ getRegulationTypeName(record.regulationType) }}
</template>
<template #status="{ record }"> <template #status="{ record }">
<a-tag :color="getStatusColor(record.status)"> <a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }} {{ getStatusText(record.status) }}
@ -105,7 +109,7 @@
<h3>{{ currentProposal.title }}</h3> <h3>{{ currentProposal.title }}</h3>
<div class="detail-meta"> <div class="detail-meta">
<span>提案人: {{ currentProposal.createByName }}</span> <span>提案人: {{ currentProposal.createByName }}</span>
<span>提案类型: {{ currentProposal.regulationType }}</span> <span>提案类型: {{ getRegulationTypeName(currentProposal.regulationType) }}</span>
<span>适用范围: {{ currentProposal.scope }}</span> <span>适用范围: {{ currentProposal.scope }}</span>
<span>级别: <a-tag :color="getLevelColor(currentProposal.level)">{{ getLevelText(currentProposal.level) }}</a-tag></span> <span>级别: <a-tag :color="getLevelColor(currentProposal.level)">{{ getLevelText(currentProposal.level) }}</a-tag></span>
<span>创建时间: {{ formatDate(currentProposal.createTime) }}</span> <span>创建时间: {{ formatDate(currentProposal.createTime) }}</span>
@ -157,7 +161,7 @@ defineOptions({ name: 'ProcessManagement' })
const columns = [ const columns = [
{ title: '提案标题', dataIndex: 'title', key: 'title', width: 200 }, { title: '提案标题', dataIndex: 'title', key: 'title', width: 200 },
{ title: '提案人', dataIndex: 'createByName', key: 'createByName', width: 120 }, { title: '提案人', dataIndex: 'createByName', key: 'createByName', width: 120 },
{ title: '提案类型', dataIndex: 'regulationType', key: 'regulationType', width: 120 }, { title: '提案类型', dataIndex: 'regulationType', key: 'regulationType', slotName: 'regulationType', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', slotName: 'status', width: 100 }, { title: '状态', dataIndex: 'status', key: 'status', slotName: 'status', width: 100 },
{ title: '级别', dataIndex: 'level', key: 'level', slotName: 'level', width: 80 }, { title: '级别', dataIndex: 'level', key: 'level', slotName: 'level', width: 80 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 }, { title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 180 },
@ -194,6 +198,9 @@ const currentUser = ref('管理者') // 从用户认证系统获取当前用户I
// store // store
const regulationStore = useRegulationStore() const regulationStore = useRegulationStore()
//
const regulationTypes = ref<RegulationType[]>([])
// //
const getStatusColor = (status: RegulationStatus) => { const getStatusColor = (status: RegulationStatus) => {
const colors = { const colors = {
@ -245,6 +252,24 @@ const getLevelText = (level: RegulationLevel) => {
return texts[level] || '中' return texts[level] || '中'
} }
// ID
const getRegulationTypeName = (typeId: string) => {
const type = regulationTypes.value.find(t => t.typeId === typeId)
return type ? type.typeName : typeId
}
//
const getRegulationTypes = async () => {
try {
const response = await regulationApi.searchRegulationTypes()
if (response.status === 200) {
regulationTypes.value = response.data.records || response.data
}
} catch (error) {
console.error('获取制度类型列表失败:', error)
}
}
// - 使 // - 使
const getTableData = async () => { const getTableData = async () => {
loading.value = true loading.value = true
@ -345,6 +370,7 @@ const handlePageSizeChange = (pageSize: number) => {
onMounted(() => { onMounted(() => {
getTableData() getTableData()
getRegulationTypes()
}) })
</script> </script>

View File

@ -41,6 +41,7 @@
<a-option value="">全部</a-option> <a-option value="">全部</a-option>
<a-option value="DRAFT">草稿</a-option> <a-option value="DRAFT">草稿</a-option>
<a-option value="PUBLISHED">已公告</a-option> <a-option value="PUBLISHED">已公告</a-option>
<a-option value="APPROVED">已公示</a-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
@ -78,6 +79,10 @@
</a-tag> </a-tag>
</template> </template>
<template #regulationType="{ record }">
{{ getRegulationTypeName(record.regulationType) }}
</template>
<template #level="{ record }"> <template #level="{ record }">
<a-tag :color="getLevelColor(record.level)"> <a-tag :color="getLevelColor(record.level)">
{{ getLevelText(record.level) }} {{ getLevelText(record.level) }}
@ -165,7 +170,7 @@
<a-option <a-option
v-for="type in regulationTypes" v-for="type in regulationTypes"
:key="type.typeId" :key="type.typeId"
:value="type.typeName" :value="type.typeId"
:disabled="type.isEnabled === '0'" :disabled="type.isEnabled === '0'"
> >
{{ type.typeName }} {{ type.typeName }}
@ -236,7 +241,7 @@
<h3>{{ currentProposal.title }}</h3> <h3>{{ currentProposal.title }}</h3>
<div class="detail-meta"> <div class="detail-meta">
<span>提案人: {{ currentProposal.createByName }}</span> <span>提案人: {{ currentProposal.createByName }}</span>
<span>提案类型: {{ currentProposal.regulationType }}</span> <span>提案类型: {{ getRegulationTypeName(currentProposal.regulationType) }}</span>
<span>适用范围: {{ currentProposal.scope }}</span> <span>适用范围: {{ currentProposal.scope }}</span>
<span>级别: <a-tag :color="getLevelColor(currentProposal.level)">{{ getLevelText(currentProposal.level) }}</a-tag></span> <span>级别: <a-tag :color="getLevelColor(currentProposal.level)">{{ getLevelText(currentProposal.level) }}</a-tag></span>
<span>创建时间: {{ formatDate(currentProposal.createTime) }}</span> <span>创建时间: {{ formatDate(currentProposal.createTime) }}</span>
@ -326,7 +331,7 @@ defineOptions({ name: 'RegulationProposal' })
const columns = [ const columns = [
{ title: '提案标题', dataIndex: 'title', key: 'title', width: 220 }, { title: '提案标题', dataIndex: 'title', key: 'title', width: 220 },
{ title: '提案人', dataIndex: 'createByName', key: 'createByName', width: 130 }, { title: '提案人', dataIndex: 'createByName', key: 'createByName', width: 130 },
{ title: '提案类型', dataIndex: 'regulationType', key: 'regulationType', width: 130 }, { title: '提案类型', dataIndex: 'regulationType', key: 'regulationType', slotName: 'regulationType', width: 130 },
{ title: '状态', dataIndex: 'status', key: 'status', slotName: 'status', width: 110 }, { title: '状态', dataIndex: 'status', key: 'status', slotName: 'status', width: 110 },
{ title: '级别', dataIndex: 'level', key: 'level', slotName: 'level', width: 90 }, { title: '级别', dataIndex: 'level', key: 'level', slotName: 'level', width: 90 },
{ title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 200 }, { title: '创建时间', dataIndex: 'createTime', key: 'createTime', width: 200 },
@ -447,6 +452,12 @@ const getLevelText = (level: RegulationLevel) => {
return texts[level] || '中' return texts[level] || '中'
} }
// ID
const getRegulationTypeName = (typeId: string) => {
const type = regulationTypes.value.find(t => t.typeId === typeId)
return type ? type.typeName : typeId
}
// //
const getRegulationTypes = async () => { const getRegulationTypes = async () => {
try { try {
@ -606,6 +617,7 @@ const handleSubmit = async () => {
scope: formData.scope, scope: formData.scope,
level: formData.level, level: formData.level,
remark: formData.remark, remark: formData.remark,
createBy: currentUser.value,
createByName: currentUser.value createByName: currentUser.value
} }
console.log('新增提案数据:', createData) console.log('新增提案数据:', createData)

View File

@ -63,6 +63,10 @@
共找到 <strong>{{ pagination.total }}</strong> 条记录 共找到 <strong>{{ pagination.total }}</strong> 条记录
</span> </span>
</template> </template>
<template #regulationType="{ record }">
{{ getRegulationTypeName(record.regulationType) }}
</template>
<template #confirmStatus="{ record }"> <template #confirmStatus="{ record }">
<a-tag :color="record.confirmStatus === 'CONFIRMED' ? 'green' : 'orange'"> <a-tag :color="record.confirmStatus === 'CONFIRMED' ? 'green' : 'orange'">
{{ record.confirmStatus === 'CONFIRMED' ? '已确认' : '待确认' }} {{ record.confirmStatus === 'CONFIRMED' ? '已确认' : '待确认' }}
@ -170,7 +174,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import type { Regulation } from '@/apis/regulation/type' import type { Regulation, RegulationType } from '@/apis/regulation/type'
import { Message } from '@arco-design/web-vue' import { Message } from '@arco-design/web-vue'
import { IconSearch, IconRefresh } from '@arco-design/web-vue/es/icon' import { IconSearch, IconRefresh } from '@arco-design/web-vue/es/icon'
import { useRegulationStore } from '@/stores/modules/regulation' import { useRegulationStore } from '@/stores/modules/regulation'
@ -182,7 +186,7 @@ defineOptions({ name: 'SystemRegulation' })
// //
const columns = [ const columns = [
{ title: '制度名称', dataIndex: 'title', key: 'title' }, { title: '制度名称', dataIndex: 'title', key: 'title' },
{ title: '制度类型', dataIndex: 'regulationType', key: 'regulationType' }, { title: '制度类型', dataIndex: 'regulationType', key: 'regulationType', slotName: 'regulationType' },
{ title: '公示人', dataIndex: 'createByName', key: 'createByName' }, { title: '公示人', dataIndex: 'createByName', key: 'createByName' },
{ title: '公示时间', dataIndex: 'publishTime', key: 'publishTime' }, { title: '公示时间', dataIndex: 'publishTime', key: 'publishTime' },
{ title: '生效日期', dataIndex: 'effectiveTime', key: 'effectiveTime' }, { title: '生效日期', dataIndex: 'effectiveTime', key: 'effectiveTime' },
@ -218,6 +222,27 @@ const agreeTerms = ref(false)
// store // store
const regulationStore = useRegulationStore() const regulationStore = useRegulationStore()
//
const regulationTypes = ref<RegulationType[]>([])
// ID
const getRegulationTypeName = (typeId: string) => {
const type = regulationTypes.value.find(t => t.typeId === typeId)
return type ? type.typeName : typeId
}
//
const getRegulationTypes = async () => {
try {
const response = await regulationApi.searchRegulationTypes()
if (response.status === 200) {
regulationTypes.value = response.data.records || response.data
}
} catch (error) {
console.error('获取制度类型列表失败:', error)
}
}
// - 使 // - 使
const getTableData = async () => { const getTableData = async () => {
loading.value = true loading.value = true
@ -356,6 +381,7 @@ const submitConfirm = async () => {
onMounted(() => { onMounted(() => {
getTableData() getTableData()
getRegulationTypes()
}) })
</script> </script>

View File

@ -0,0 +1,354 @@
<template>
<a-modal
v-model:visible="visible"
:title="modalTitle"
width="600px"
@ok="handleSubmit"
@cancel="handleCancel"
:confirm-loading="loading"
>
<div class="approval-action" v-if="approvalData">
<!-- 审批信息预览 -->
<div class="preview-section">
<h4 class="preview-title">
<IconInfoCircle style="margin-right: 8px; color: var(--color-primary);" />
审批信息预览
</h4>
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="设备名称">
{{ approvalData.equipmentName }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
{{ approvalData.equipmentType }}
</a-descriptions-item>
<a-descriptions-item label="设备型号">
{{ approvalData.equipmentModel }}
</a-descriptions-item>
<a-descriptions-item label="申请人">
{{ approvalData.applicantName }}
</a-descriptions-item>
<a-descriptions-item label="申请时间">
{{ formatDateTime(approvalData.applyTime) }}
</a-descriptions-item>
<a-descriptions-item label="总价">
<span v-if="approvalData.totalPrice" class="price-text">
¥{{ formatPrice(approvalData.totalPrice) }}
</span>
<span v-else class="no-data">-</span>
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 审批表单 -->
<div class="form-section">
<h4 class="form-title">
<IconEdit style="margin-right: 8px; color: var(--color-warning);" />
审批意见
</h4>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
>
<a-form-item field="approvalComment" label="审批意见" class="form-item">
<a-textarea
v-model="formData.approvalComment"
:placeholder="actionType === 'approve' ? '请输入审批通过的意见(可选)' : '请输入审批拒绝的原因(必填)'"
:rows="4"
class="form-textarea"
allow-clear
/>
</a-form-item>
</a-form>
</div>
<!-- 确认提示 -->
<div class="confirm-section">
<a-alert
:type="actionType === 'approve' ? 'success' : 'error'"
:title="actionType === 'approve' ? '审批通过确认' : '审批拒绝确认'"
:description="actionType === 'approve' ? '确认通过此设备采购申请吗?通过后该申请将进入采购流程。' : '确认拒绝此设备采购申请吗?拒绝后申请人将收到通知。'"
show-icon
/>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'
import { IconInfoCircle, IconEdit } from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import type { EquipmentApprovalResp, EquipmentApprovalReq } from '@/apis/equipment/type'
import { equipmentApprovalApi } from '@/apis/equipment/approval'
import notificationService from '@/services/notificationService'
defineOptions({ name: 'ApprovalActionModal' })
// Props
interface Props {
visible: boolean
approvalData: EquipmentApprovalResp | null
actionType: 'approve' | 'reject'
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
//
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
const modalTitle = computed(() => {
return props.actionType === 'approve' ? '审批通过' : '审批拒绝'
})
//
const formRef = ref()
const loading = ref(false)
const formData = reactive<EquipmentApprovalReq>({
approvalComment: '',
approvalResult: props.actionType === 'approve' ? 'APPROVED' : 'REJECTED',
approverName: '当前用户', //
approverId: 'current-user-id' //
})
//
const rules = computed(() => ({
approvalComment: props.actionType === 'reject' ? [
{ required: true, message: '请输入审批拒绝的原因' }
] : []
}))
//
const formatPrice = (price: number) => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return '-'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
//
const handleSubmit = async () => {
try {
await formRef.value.validate()
if (!props.approvalData) {
message.error('审批数据不存在')
return
}
loading.value = true
const approvalId = props.approvalData.approvalId
const submitData: EquipmentApprovalReq = {
approvalComment: formData.approvalComment,
approvalResult: props.actionType === 'approve' ? 'APPROVED' : 'REJECTED',
approverName: formData.approverName,
approverId: formData.approverId
}
console.log('提交审批数据:', submitData)
if (props.actionType === 'approve') {
await equipmentApprovalApi.approve(approvalId, submitData)
message.success('审批通过成功')
//
notificationService.addApprovalNotification(
props.approvalData.equipmentName,
'APPROVED',
formData.approverName || '审批人'
)
} else {
await equipmentApprovalApi.reject(approvalId, submitData)
message.success('审批拒绝成功')
//
notificationService.addApprovalNotification(
props.approvalData.equipmentName,
'REJECTED',
formData.approverName || '审批人'
)
}
emit('success')
visible.value = false
resetForm()
} catch (error: any) {
console.error('审批操作失败:', error)
message.error(error?.message || '审批操作失败')
} finally {
loading.value = false
}
}
//
const handleCancel = () => {
visible.value = false
resetForm()
}
//
const resetForm = () => {
Object.assign(formData, {
approvalComment: '',
approvalResult: props.actionType === 'approve' ? 'APPROVED' : 'REJECTED',
approverName: '当前用户',
approverId: 'current-user-id'
})
formRef.value?.resetFields()
}
//
watch(() => props.visible, (newVal) => {
if (newVal) {
resetForm()
}
})
</script>
<style scoped lang="scss">
.approval-action {
.preview-section {
margin-bottom: 24px;
.preview-title {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.arco-descriptions {
.arco-descriptions-item-label {
font-weight: 500;
color: var(--color-text-1);
background-color: var(--color-fill-1);
}
.arco-descriptions-item-value {
color: var(--color-text-2);
}
}
}
.form-section {
margin-bottom: 24px;
.form-title {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
}
.form-item {
.arco-form-item-label {
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
}
.form-textarea {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary-light-3);
}
&:focus,
&.arco-textarea-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.1);
}
.arco-textarea-inner {
padding: 12px;
font-size: 14px;
line-height: 1.6;
}
}
}
}
.confirm-section {
.arco-alert {
border-radius: 6px;
.arco-alert-title {
font-weight: 600;
}
.arco-alert-description {
color: var(--color-text-2);
line-height: 1.5;
}
}
}
.price-text {
color: #f56c6c;
font-weight: 500;
}
.no-data {
color: var(--color-text-4);
font-style: italic;
}
}
//
@media (max-width: 768px) {
.approval-action {
.preview-section,
.form-section {
.arco-descriptions {
.arco-descriptions-item {
display: block;
.arco-descriptions-item-label {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
}
.arco-descriptions-item-value {
display: block;
width: 100%;
padding: 8px 12px;
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,362 @@
<template>
<a-modal
v-model:visible="visible"
title="审批详情"
width="800px"
:footer="false"
@cancel="handleCancel"
>
<div class="approval-detail" v-if="approvalData">
<!-- 基本信息 -->
<div class="detail-section">
<h3 class="section-title">
<IconInfoCircle style="margin-right: 8px; color: var(--color-primary);" />
基本信息
</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="设备名称">
{{ approvalData.equipmentName }}
</a-descriptions-item>
<a-descriptions-item label="设备类型">
{{ approvalData.equipmentType }}
</a-descriptions-item>
<a-descriptions-item label="业务类型">
<a-tag :color="getBusinessTypeColor(approvalData.businessType)">
{{ getBusinessTypeText(approvalData.businessType) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="设备型号">
{{ approvalData.equipmentModel }}
</a-descriptions-item>
<a-descriptions-item label="品牌">
{{ approvalData.brand || '-' }}
</a-descriptions-item>
<a-descriptions-item label="供应商">
{{ approvalData.supplierName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="数量">
{{ approvalData.quantity || '-' }}
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 价格信息 -->
<div class="detail-section">
<h3 class="section-title">
<IconFile style="margin-right: 8px; color: var(--color-success);" />
价格信息
</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="采购价格">
<span v-if="approvalData.purchasePrice" class="price-text">
¥{{ formatPrice(approvalData.purchasePrice) }}
</span>
<span v-else class="no-data">-</span>
</a-descriptions-item>
<a-descriptions-item label="总价">
<span v-if="approvalData.totalPrice" class="price-text">
¥{{ formatPrice(approvalData.totalPrice) }}
</span>
<span v-else class="no-data">-</span>
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 申请信息 -->
<div class="detail-section">
<h3 class="section-title">
<IconUser style="margin-right: 8px; color: var(--color-warning);" />
申请信息
</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="申请人">
{{ approvalData.applicantName }}
</a-descriptions-item>
<a-descriptions-item label="申请时间">
{{ formatDateTime(approvalData.applyTime) }}
</a-descriptions-item>
<a-descriptions-item label="申请原因" :span="2">
{{ approvalData.applyReason || '暂无申请原因' }}
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 审批信息 -->
<div class="detail-section" v-if="approvalData.approvalStatus !== ApprovalStatus.PENDING">
<h3 class="section-title">
<IconCheckCircle style="margin-right: 8px; color: var(--color-success);" />
审批信息
</h3>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="审批状态">
<a-tag :color="getApprovalStatusColor(approvalData.approvalStatus)">
{{ getApprovalStatusText(approvalData.approvalStatus) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="审批人">
{{ approvalData.approverName || '-' }}
</a-descriptions-item>
<a-descriptions-item label="审批时间">
{{ formatDateTime(approvalData.approvalTime) }}
</a-descriptions-item>
<a-descriptions-item label="审批意见" :span="2">
{{ approvalData.approvalComment || '暂无审批意见' }}
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 审批状态 -->
<div class="detail-section" v-else>
<h3 class="section-title">
<IconClockCircle style="margin-right: 8px; color: var(--color-warning);" />
审批状态
</h3>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="当前状态">
<a-tag :color="getApprovalStatusColor(approvalData.approvalStatus)">
{{ getApprovalStatusText(approvalData.approvalStatus) }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</div>
<!-- 操作按钮 -->
<div class="detail-footer">
<a-space>
<a-button
v-if="approvalData.approvalStatus === ApprovalStatus.PENDING"
type="primary"
@click="handleApprove"
>
<template #icon><IconCheckCircle /></template>
审批通过
</a-button>
<a-button
v-if="approvalData.approvalStatus === ApprovalStatus.PENDING"
status="danger"
@click="handleReject"
>
<template #icon><IconCloseCircle /></template>
审批拒绝
</a-button>
<a-button @click="handleCancel">
关闭
</a-button>
</a-space>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import {
IconInfoCircle,
IconFile,
IconUser,
IconCheckCircle,
IconCloseCircle,
IconClockCircle
} from '@arco-design/web-vue/es/icon'
import type { EquipmentApprovalResp } from '@/apis/equipment/type'
import { ApprovalStatus, BusinessType } from '@/apis/equipment/type'
defineOptions({ name: 'ApprovalDetailModal' })
// Props
interface Props {
visible: boolean
approvalData: EquipmentApprovalResp | null
}
const props = defineProps<Props>()
// Emits
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
approve: [data: EquipmentApprovalResp]
reject: [data: EquipmentApprovalResp]
}>()
//
const visible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value)
})
//
const formatPrice = (price: number) => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return '-'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
//
const getBusinessTypeColor = (type: BusinessType) => {
const colorMap: Record<string, string> = {
[BusinessType.PROCUREMENT]: 'blue',
[BusinessType.BORROW]: 'green',
[BusinessType.RETURN]: 'orange',
}
return colorMap[type] || 'gray'
}
//
const getBusinessTypeText = (type: BusinessType) => {
const textMap: Record<string, string> = {
[BusinessType.PROCUREMENT]: '采购',
[BusinessType.BORROW]: '借用',
[BusinessType.RETURN]: '归还',
}
return textMap[type] || '未知'
}
//
const getApprovalStatusColor = (status: ApprovalStatus) => {
const colorMap: Record<string, string> = {
[ApprovalStatus.PENDING]: 'orange',
[ApprovalStatus.APPROVED]: 'green',
[ApprovalStatus.REJECTED]: 'red',
}
return colorMap[status] || 'blue'
}
//
const getApprovalStatusText = (status: ApprovalStatus) => {
const textMap: Record<string, string> = {
[ApprovalStatus.PENDING]: '待审批',
[ApprovalStatus.APPROVED]: '已通过',
[ApprovalStatus.REJECTED]: '已拒绝',
}
return textMap[status] || '未知'
}
//
const handleApprove = () => {
if (props.approvalData) {
emit('approve', props.approvalData)
}
}
//
const handleReject = () => {
if (props.approvalData) {
emit('reject', props.approvalData)
}
}
//
const handleCancel = () => {
visible.value = false
}
</script>
<style scoped lang="scss">
.approval-detail {
.detail-section {
margin-bottom: 24px;
.section-title {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
border-bottom: 2px solid var(--color-border);
padding-bottom: 8px;
}
.arco-descriptions {
.arco-descriptions-item-label {
font-weight: 500;
color: var(--color-text-1);
background-color: var(--color-fill-1);
}
.arco-descriptions-item-value {
color: var(--color-text-2);
}
}
}
.price-text {
color: #f56c6c;
font-weight: 500;
}
.no-data {
color: var(--color-text-4);
font-style: italic;
}
.detail-footer {
display: flex;
justify-content: center;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--color-border);
.arco-btn {
border-radius: 6px;
font-weight: 500;
}
}
}
//
@media (max-width: 768px) {
.approval-detail {
.detail-section {
.arco-descriptions {
.arco-descriptions-item {
display: block;
.arco-descriptions-item-label {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
}
.arco-descriptions-item-value {
display: block;
width: 100%;
padding: 8px 12px;
}
}
}
}
.detail-footer {
flex-direction: column;
align-items: center;
.arco-space {
flex-direction: column;
width: 100%;
.arco-btn {
width: 100%;
margin-bottom: 8px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,292 @@
<template>
<div class="approval-search-container">
<a-form layout="inline" :model="searchForm" class="search-form">
<a-form-item label="设备名称">
<a-input
v-model="searchForm.equipmentName"
placeholder="请输入设备名称"
allow-clear
style="width: 200px"
@input="debouncedSearch"
/>
</a-form-item>
<a-form-item label="申请人">
<a-input
v-model="searchForm.applicantName"
placeholder="请输入申请人"
allow-clear
style="width: 150px"
@input="debouncedSearch"
/>
</a-form-item>
<a-form-item label="业务类型">
<a-select
v-model="searchForm.businessType"
placeholder="请选择业务类型"
allow-clear
style="width: 150px"
@change="debouncedSearch"
>
<a-option value="">全部</a-option>
<a-option :value="BusinessType.PROCUREMENT">采购</a-option>
<a-option :value="BusinessType.BORROW">借用</a-option>
<a-option :value="BusinessType.RETURN">归还</a-option>
</a-select>
</a-form-item>
<a-form-item label="审批状态">
<a-select
v-model="searchForm.approvalStatus"
placeholder="请选择状态"
allow-clear
style="width: 150px"
@change="debouncedSearch"
>
<a-option value="">全部</a-option>
<a-option :value="ApprovalStatus.PENDING">待审批</a-option>
<a-option :value="ApprovalStatus.APPROVED">已通过</a-option>
<a-option :value="ApprovalStatus.REJECTED">已拒绝</a-option>
</a-select>
</a-form-item>
<a-form-item label="申请时间">
<a-range-picker
v-model="dateRange"
style="width: 240px"
@change="handleDateChange"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" @click="search" :loading="loading">
<template #icon><icon-search /></template>
搜索
</a-button>
<a-button @click="reset" :loading="loading">
<template #icon><icon-refresh /></template>
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, watch } from 'vue'
import { IconSearch, IconRefresh } from '@arco-design/web-vue/es/icon'
import type { EquipmentApprovalListReq } from '@/apis/equipment/type'
import { ApprovalStatus, BusinessType } from '@/apis/equipment/type'
//
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout
return (...args: any[]) => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => func.apply(null, args), delay)
}
}
defineOptions({ name: 'ApprovalSearch' })
// Props
interface Props {
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false
})
// Emits
const emit = defineEmits<{
search: [params: EquipmentApprovalListReq]
reset: []
}>()
//
const searchForm = reactive<EquipmentApprovalListReq>({
equipmentName: '',
applicantName: '',
businessType: undefined,
approvalStatus: undefined,
applyTimeStart: '',
applyTimeEnd: ''
})
//
const dateRange = ref<[string, string] | null>(null)
//
const debouncedSearch = debounce(() => {
search()
}, 300)
//
const search = () => {
console.log('🔍 ApprovalSearch - 搜索按钮被点击')
console.log('🔍 ApprovalSearch - 搜索表单数据:', searchForm)
// -
const searchParams: EquipmentApprovalListReq = {
equipmentName: searchForm.equipmentName || undefined,
applicantName: searchForm.applicantName || undefined,
businessType: searchForm.businessType || undefined,
approvalStatus: searchForm.approvalStatus || undefined,
applyTimeStart: searchForm.applyTimeStart || undefined,
applyTimeEnd: searchForm.applyTimeEnd || undefined,
}
console.log('🔍 ApprovalSearch - 发送的搜索参数:', searchParams)
emit('search', searchParams)
}
//
const reset = () => {
console.log('🔄 ApprovalSearch - 重置操作')
//
Object.assign(searchForm, {
equipmentName: '',
applicantName: '',
businessType: undefined,
approvalStatus: undefined,
applyTimeStart: '',
applyTimeEnd: ''
})
//
dateRange.value = null
emit('reset')
}
//
const handleDateChange = (dates: [string, string] | null) => {
if (dates && dates.length === 2) {
searchForm.applyTimeStart = dates[0]
searchForm.applyTimeEnd = dates[1]
} else {
searchForm.applyTimeStart = ''
searchForm.applyTimeEnd = ''
}
debouncedSearch()
}
// loading
watch(() => props.loading, (newVal) => {
console.log('🔄 ApprovalSearch - loading状态变化:', newVal)
})
</script>
<style scoped lang="scss">
.approval-search-container {
//
background: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
border: 1px solid #e8e8e8;
.search-form {
.arco-form-item {
margin-bottom: 0;
margin-right: 16px;
.arco-form-item-label {
font-weight: 500;
color: var(--color-text-1);
margin-right: 8px;
}
}
.arco-input,
.arco-select,
.arco-picker {
border-radius: 4px;
//
.arco-input-inner {
color: #333 !important;
font-weight: 500;
}
//
.arco-input-inner::placeholder {
color: #666 !important;
font-weight: 400;
}
//
.arco-select-view-input {
color: #333 !important;
font-weight: 500;
}
//
.arco-select-view-placeholder {
color: #666 !important;
font-weight: 400;
}
//
.arco-picker-input {
color: #333 !important;
font-weight: 500;
}
//
.arco-picker-input::placeholder {
color: #666 !important;
font-weight: 400;
}
//
&:focus-within {
.arco-input-inner,
.arco-select-view-input,
.arco-picker-input {
color: #000 !important;
font-weight: 600;
}
}
//
&:hover {
.arco-input-inner,
.arco-select-view-input,
.arco-picker-input {
color: #000 !important;
}
}
}
.arco-btn {
border-radius: 4px;
}
}
}
//
@media (max-width: 768px) {
.approval-search-container {
.search-form {
.arco-form-item {
margin-bottom: 12px;
margin-right: 0;
width: 100%;
.arco-input,
.arco-select,
.arco-picker {
width: 100% !important;
}
}
}
}
}
</style>

View File

@ -0,0 +1,829 @@
<template>
<div class="equipment-approval-container">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="page-title">
<IconCheckCircle style="font-size: 24px; margin-right: 12px; color: var(--color-primary);" />
<h1>设备审批控制台</h1>
</div>
<div class="page-description">
管理设备采购申请的审批流程包括待审批已审批等状态管理
</div>
</div>
<div class="header-right">
<a-space>
<ApprovalSearch
:loading="loading"
@search="handleSearch"
@reset="handleReset"
/>
<a-button type="primary" size="large" @click="refreshData">
<template #icon>
<IconRefresh />
</template>
刷新
</a-button>
</a-space>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<IconClockCircle />
</div>
<div class="stat-info">
<div class="stat-number">{{ getPendingCount() }}</div>
<div class="stat-label">待审批</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<IconCheckCircle />
</div>
<div class="stat-info">
<div class="stat-number">{{ getApprovedCount() }}</div>
<div class="stat-label">已通过</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<IconCloseCircle />
</div>
<div class="stat-info">
<div class="stat-number">{{ getRejectedCount() }}</div>
<div class="stat-label">已拒绝</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<IconApps />
</div>
<div class="stat-info">
<div class="stat-number">¥{{ getTotalAmount() }}</div>
<div class="stat-label">审批总额</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 标签页 -->
<a-card class="table-card" :bordered="false">
<template #title>
<div class="card-title">
<a-tabs v-model:active-key="activeTab" @change="handleTabChange">
<a-tab-pane key="pending" title="待审批">
<template #title>
<span>
<IconClockCircle style="margin-right: 4px;" />
待审批 ({{ getPendingCount() }})
</span>
</template>
</a-tab-pane>
<a-tab-pane key="approved" title="已审批">
<template #title>
<span>
<IconCheckCircle style="margin-right: 4px;" />
已审批 ({{ getApprovedCount() + getRejectedCount() }})
</span>
</template>
</a-tab-pane>
</a-tabs>
</div>
</template>
<a-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
row-key="approvalId"
@change="handleTableChange"
>
<!-- 业务类型 -->
<template #businessType="{ record }">
<a-tag :color="getBusinessTypeColor(record.businessType)">
{{ getBusinessTypeText(record.businessType) }}
</a-tag>
</template>
<!-- 审批状态 -->
<template #approvalStatus="{ record }">
<a-tag :color="getApprovalStatusColor(record.approvalStatus)">
{{ getApprovalStatusText(record.approvalStatus) }}
</a-tag>
</template>
<!-- 采购价格 -->
<template #purchasePrice="{ record }">
<span v-if="record.purchasePrice" class="price-text">
¥{{ formatPrice(record.purchasePrice) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 总价 -->
<template #totalPrice="{ record }">
<span v-if="record.totalPrice" class="price-text">
¥{{ formatPrice(record.totalPrice) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 申请时间 -->
<template #applyTime="{ record }">
<span v-if="record.applyTime" class="time-text">
{{ formatDateTime(record.applyTime) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 审批时间 -->
<template #approvalTime="{ record }">
<span v-if="record.approvalTime" class="time-text">
{{ formatDateTime(record.approvalTime) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 操作 -->
<template #action="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleView(record)">
查看详情
</a-button>
<a-button
v-if="record.approvalStatus === ApprovalStatus.PENDING"
type="primary"
size="small"
@click="handleApprove(record)"
>
审批通过
</a-button>
<a-button
v-if="record.approvalStatus === ApprovalStatus.PENDING"
type="text"
size="small"
status="danger"
@click="handleReject(record)"
>
审批拒绝
</a-button>
</a-space>
</template>
</a-table>
</a-card>
<!-- 审批详情弹窗 -->
<ApprovalDetailModal
v-model:visible="detailModalVisible"
:approval-data="currentApproval"
@success="handleModalSuccess"
@approve="handleApproveFromDetail"
@reject="handleRejectFromDetail"
/>
<!-- 审批操作弹窗 -->
<ApprovalActionModal
v-model:visible="actionModalVisible"
:approval-data="currentApproval"
:action-type="actionType"
@success="handleModalSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Modal } from '@arco-design/web-vue'
import {
IconApps,
IconCheckCircle,
IconClockCircle,
IconCloseCircle,
IconRefresh,
} from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import ApprovalDetailModal from './components/ApprovalDetailModal.vue'
import ApprovalActionModal from './components/ApprovalActionModal.vue'
import ApprovalSearch from './components/ApprovalSearch.vue'
import { equipmentApprovalApi } from '@/apis/equipment/approval'
import type { EquipmentApprovalListReq, EquipmentApprovalResp } from '@/apis/equipment/type'
import { ApprovalStatus, BusinessType } from '@/apis/equipment/type'
defineOptions({ name: 'EquipmentApproval' })
//
const currentSearchParams = ref<EquipmentApprovalListReq>({})
//
const tableData = ref<EquipmentApprovalResp[]>([])
const loading = ref(false)
//
const pagination = reactive<any>({
current: 1,
pageSize: 10,
total: 0,
showPageSize: true,
showJumper: true,
showTotal: (total: number) => `${total} 条记录`,
})
//
const detailModalVisible = ref(false)
const actionModalVisible = ref(false)
const currentApproval = ref<EquipmentApprovalResp | null>(null)
const actionType = ref<'approve' | 'reject'>('approve')
//
const activeTab = ref('pending')
//
const columns = [
{
title: '设备名称',
dataIndex: 'equipmentName',
key: 'equipmentName',
width: 150,
},
{
title: '设备类型',
dataIndex: 'equipmentType',
key: 'equipmentType',
width: 120,
},
{
title: '业务类型',
dataIndex: 'businessType',
key: 'businessType',
slotName: 'businessType',
width: 100,
},
{
title: '设备型号',
dataIndex: 'equipmentModel',
key: 'equipmentModel',
width: 120,
},
{
title: '品牌',
dataIndex: 'brand',
key: 'brand',
width: 100,
},
{
title: '供应商',
dataIndex: 'supplierName',
key: 'supplierName',
width: 150,
},
{
title: '采购价格',
dataIndex: 'purchasePrice',
key: 'purchasePrice',
slotName: 'purchasePrice',
width: 120,
},
{
title: '总价',
dataIndex: 'totalPrice',
key: 'totalPrice',
slotName: 'totalPrice',
width: 120,
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
width: 80,
},
{
title: '申请人',
dataIndex: 'applicantName',
key: 'applicantName',
width: 120,
},
{
title: '申请时间',
dataIndex: 'applyTime',
key: 'applyTime',
slotName: 'applyTime',
width: 160,
},
{
title: '审批状态',
dataIndex: 'approvalStatus',
key: 'approvalStatus',
slotName: 'approvalStatus',
width: 120,
},
{
title: '审批人',
dataIndex: 'approverName',
key: 'approverName',
width: 120,
},
{
title: '审批时间',
dataIndex: 'approvalTime',
key: 'approvalTime',
slotName: 'approvalTime',
width: 160,
},
{
title: '操作',
key: 'action',
slotName: 'action',
width: 200,
fixed: 'right',
},
]
//
const getApprovalStatusColor = (status: ApprovalStatus) => {
const colorMap: Record<string, string> = {
[ApprovalStatus.PENDING]: 'orange',
[ApprovalStatus.APPROVED]: 'green',
[ApprovalStatus.REJECTED]: 'red',
}
return colorMap[status] || 'blue'
}
//
const getBusinessTypeColor = (type: BusinessType) => {
const colorMap: Record<string, string> = {
[BusinessType.PROCUREMENT]: 'blue',
[BusinessType.BORROW]: 'green',
[BusinessType.RETURN]: 'orange',
}
return colorMap[type] || 'gray'
}
//
const getBusinessTypeText = (type: BusinessType) => {
const textMap: Record<string, string> = {
[BusinessType.PROCUREMENT]: '采购',
[BusinessType.BORROW]: '借用',
[BusinessType.RETURN]: '归还',
}
return textMap[type] || '未知'
}
//
const getApprovalStatusText = (status: ApprovalStatus) => {
const textMap: Record<string, string> = {
[ApprovalStatus.PENDING]: '待审批',
[ApprovalStatus.APPROVED]: '已通过',
[ApprovalStatus.REJECTED]: '已拒绝',
}
return textMap[status] || '未知'
}
//
const formatPrice = (price: number) => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return '-'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
//
const transformBackendData = (data: any[]): EquipmentApprovalResp[] => {
return data.map((item: any) => ({
approvalId: item.approvalId || item.id,
equipmentId: item.equipmentId,
equipmentName: item.equipmentName,
equipmentType: item.equipmentType,
equipmentModel: item.equipmentModel,
brand: item.brand,
supplierName: item.supplierName,
purchasePrice: item.purchasePrice,
totalPrice: item.totalPrice,
quantity: item.quantity,
applicantName: item.applicantName,
applicantId: item.applicantId,
applyTime: item.applyTime,
applyReason: item.applyReason,
businessType: item.businessType || BusinessType.PROCUREMENT,
approvalStatus: item.approvalStatus,
approverName: item.approverName,
approverId: item.approverId,
approvalTime: item.approvalTime,
approvalComment: item.approvalComment,
createTime: item.createTime,
updateTime: item.updateTime,
}))
}
//
const loadData = async (searchParams?: EquipmentApprovalListReq) => {
console.log('📊 loadData - 开始加载数据')
console.log('📊 loadData - 接收到的搜索参数:', searchParams)
console.log('📊 loadData - 当前标签页:', activeTab.value)
loading.value = true
try {
// -
const params: EquipmentApprovalListReq = {
pageSize: pagination.pageSize,
page: pagination.current,
...(searchParams || {}),
}
console.log('📊 loadData - 构建的完整请求参数:', params)
let res: any
if (activeTab.value === 'pending') {
console.log('📊 loadData - 调用待审批API')
res = await equipmentApprovalApi.getPendingApprovals(params)
} else {
console.log('📊 loadData - 调用已审批API')
res = await equipmentApprovalApi.getApprovedApprovals(params)
}
console.log('API响应:', res)
//
let dataList: any[] = []
let total = 0
if (res && res.data) {
if (Array.isArray(res.data)) {
dataList = res.data
total = res.data.length
} else if (res.data && Array.isArray((res.data as any).records)) {
dataList = (res.data as any).records
total = (res.data as any).total || 0
} else if (res.data && Array.isArray((res.data as any).list)) {
dataList = (res.data as any).list
total = (res.data as any).total || 0
} else if (res.data && Array.isArray((res.data as any).rows)) {
dataList = (res.data as any).rows
total = (res.data as any).total || 0
} else if (res.data && Array.isArray((res.data as any).data)) {
dataList = (res.data as any).data
total = (res.data as any).total || 0
}
} else if (Array.isArray(res)) {
dataList = res
total = res.length
}
console.log('处理后的数据列表:', dataList)
console.log('总数:', total)
if (dataList.length > 0) {
const transformedData = transformBackendData(dataList)
console.log('转换后的数据:', transformedData)
tableData.value = transformedData
} else {
console.log('没有数据,设置空数组')
tableData.value = []
}
pagination.total = total
console.log('设置分页总数:', pagination.total)
} catch (error: any) {
console.error('加载数据失败:', error)
message.error(error?.message || '加载数据失败')
tableData.value = []
pagination.total = 0
} finally {
loading.value = false
console.log('📊 loadData - 加载完成')
}
}
//
const handleSearch = (searchParams: EquipmentApprovalListReq) => {
console.log('🔍 主组件 - 接收到的搜索参数:', searchParams)
pagination.current = 1
currentSearchParams.value = { ...searchParams }
loadData(searchParams)
}
//
const handleReset = () => {
console.log('🔄 主组件 - 重置操作')
pagination.current = 1
currentSearchParams.value = {}
loadData({}) // undefined
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData(currentSearchParams.value || {})
}
//
const handleTabChange = (key: string) => {
activeTab.value = key
pagination.current = 1
loadData(currentSearchParams.value || {})
}
//
const handleView = (record: EquipmentApprovalResp) => {
currentApproval.value = { ...record }
detailModalVisible.value = true
}
//
const handleApprove = (record: EquipmentApprovalResp) => {
currentApproval.value = { ...record }
actionType.value = 'approve'
actionModalVisible.value = true
}
//
const handleReject = (record: EquipmentApprovalResp) => {
currentApproval.value = { ...record }
actionType.value = 'reject'
actionModalVisible.value = true
}
//
const handleApproveFromDetail = (record: EquipmentApprovalResp) => {
currentApproval.value = { ...record }
actionType.value = 'approve'
actionModalVisible.value = true
}
//
const handleRejectFromDetail = (record: EquipmentApprovalResp) => {
currentApproval.value = { ...record }
actionType.value = 'reject'
actionModalVisible.value = true
}
//
const handleModalSuccess = () => {
detailModalVisible.value = false
actionModalVisible.value = false
loadData(currentSearchParams.value || {})
}
//
const refreshData = () => {
loadData(currentSearchParams.value || {})
}
//
const getPendingCount = () => {
return tableData.value.filter(item =>
item.approvalStatus === ApprovalStatus.PENDING
).length
}
const getApprovedCount = () => {
return tableData.value.filter(item =>
item.approvalStatus === ApprovalStatus.APPROVED
).length
}
const getRejectedCount = () => {
return tableData.value.filter(item =>
item.approvalStatus === ApprovalStatus.REJECTED
).length
}
const getTotalAmount = () => {
const total = tableData.value.reduce((sum, item) => {
return sum + (item.totalPrice || 0)
}, 0)
return formatPrice(total)
}
onMounted(() => {
loadData({}) // undefined
})
</script>
<style scoped lang="scss">
.equipment-approval-container {
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.page-title {
display: flex;
align-items: center;
margin-bottom: 8px;
h1 {
margin: 0;
color: white;
font-size: 28px;
font-weight: 600;
background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.page-description {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
}
.header-right {
.arco-btn {
border-radius: 8px;
font-weight: 500;
}
}
}
}
.stats-container {
margin-bottom: 24px;
.stat-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
.arco-icon {
font-size: 24px;
color: white;
}
}
.stat-info {
flex: 1;
.stat-number {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--color-text-3);
}
}
}
}
}
.table-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.card-title {
.arco-tabs {
.arco-tabs-nav {
background: transparent;
.arco-tabs-tab {
border-radius: 8px;
margin-right: 8px;
&.arco-tabs-tab-active {
background: var(--color-primary-light-1);
color: var(--color-primary);
}
}
}
}
}
.arco-table {
.arco-table-th {
background-color: var(--color-fill-2);
font-weight: 600;
}
.arco-table-tr:hover {
background-color: var(--color-fill-1);
}
}
}
.price-text {
color: #f56c6c;
font-weight: 500;
}
.time-text {
color: var(--color-text-2);
font-size: 12px;
}
.no-data {
color: var(--color-text-4);
font-style: italic;
}
//
.approval-search-container {
background: #ffffff;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
.search-form {
.arco-form-item-label {
color: #333 !important;
font-weight: 600;
}
}
}
}
//
@media (max-width: 768px) {
.equipment-approval-container {
.page-header {
.header-content {
flex-direction: column;
align-items: flex-start;
.header-right {
margin-top: 16px;
width: 100%;
.arco-space {
width: 100%;
justify-content: space-between;
}
}
}
}
.stats-container {
.arco-col {
margin-bottom: 16px;
}
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,496 @@
<template>
<div class="equipment-search-container">
<!-- 搜索按钮 -->
<div class="search-trigger">
<a-button type="primary" @click="showSearchModal = true">
<template #icon>
<IconSearch />
</template>
搜索设备
</a-button>
</div>
<!-- 搜索弹窗 -->
<a-modal
v-model:visible="showSearchModal"
title="设备搜索"
width="1000px"
:footer="false"
@cancel="handleCancel"
class="search-modal"
>
<div class="search-content">
<a-form layout="vertical" :model="searchForm" class="search-form">
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="设备名称">
<a-input
v-model="searchForm.equipmentName"
placeholder="请输入设备名称"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备类型">
<a-select
v-model="searchForm.equipmentType"
:options="equipmentTypeOptions"
placeholder="请选择设备类型"
allow-clear
@change="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备状态">
<a-select
v-model="searchForm.equipmentStatus"
:options="equipmentStatusOptions"
placeholder="请选择设备状态"
allow-clear
@change="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="位置状态">
<a-select
v-model="searchForm.locationStatus"
:options="locationStatusOptions"
placeholder="请选择位置状态"
allow-clear
@change="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="健康状态">
<a-select
v-model="searchForm.healthStatus"
:options="healthStatusOptions"
placeholder="请选择健康状态"
allow-clear
@change="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="使用状态">
<a-select
v-model="searchForm.useStatus"
:options="useStatusOptions"
placeholder="请选择使用状态"
allow-clear
@change="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="使用部门/人">
<a-input
v-model="searchForm.usingDepartment"
placeholder="请输入使用部门/人"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="负责人">
<a-input
v-model="searchForm.responsiblePerson"
placeholder="请输入负责人"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="维护人员">
<a-input
v-model="searchForm.maintenancePerson"
placeholder="请输入维护人员"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="序列号">
<a-input
v-model="searchForm.equipmentSn"
placeholder="请输入序列号"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="资产编号">
<a-input
v-model="searchForm.assetCode"
placeholder="请输入资产编号"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="品牌">
<a-input
v-model="searchForm.brand"
placeholder="请输入品牌"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="设备型号">
<a-input
v-model="searchForm.equipmentModel"
placeholder="请输入设备型号"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="物理位置">
<a-input
v-model="searchForm.physicalLocation"
placeholder="请输入物理位置"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="供应商">
<a-input
v-model="searchForm.supplierName"
placeholder="请输入供应商"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="发票">
<a-input
v-model="searchForm.invoice"
placeholder="请输入发票"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="条码">
<a-input
v-model="searchForm.barcode"
placeholder="请输入条码"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="库存条码">
<a-input
v-model="searchForm.inventoryBarcode"
placeholder="请输入库存条码"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="规格型号">
<a-input
v-model="searchForm.specification"
placeholder="请输入规格型号"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="资产备注">
<a-input
v-model="searchForm.assetRemark"
placeholder="请输入资产备注"
allow-clear
@input="debouncedSearch"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 搜索操作按钮 -->
<div class="search-actions">
<a-space>
<a-button type="primary" @click="handleSearch" :loading="loading">
<template #icon>
<IconSearch />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<IconRefresh />
</template>
重置
</a-button>
<a-button @click="handleCancel">
取消
</a-button>
</a-space>
</div>
</a-form>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue'
import { IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import type { EquipmentPageQuery } from '@/types/equipment.d'
interface Props {
loading?: boolean
}
// eslint-disable-next-line unused-imports/no-unused-vars
const props = withDefaults(defineProps<Props>(), {
loading: false,
})
const emit = defineEmits<{
search: [params: EquipmentPageQuery]
reset: []
}>()
//
const showSearchModal = ref(false)
//
// eslint-disable-next-line ts/no-unsafe-function-type
const debounce = (func: Function, delay: number) => {
let timeoutId: NodeJS.Timeout
return (...args: any[]) => {
clearTimeout(timeoutId)
// eslint-disable-next-line prefer-spread
timeoutId = setTimeout(() => func.apply(null, args), delay)
}
}
const searchForm = reactive<EquipmentPageQuery>({
equipmentName: '',
equipmentType: '',
equipmentStatus: '',
locationStatus: '',
healthStatus: '',
usingDepartment: '',
invoice: '',
barcode: '',
importer: '',
equipmentSn: '',
assetCode: '',
brand: '',
responsiblePerson: '',
useStatus: '',
equipmentModel: '',
specification: '',
physicalLocation: '',
supplierName: '',
maintenancePerson: '',
inventoryBarcode: '',
assetRemark: '',
})
const equipmentTypeOptions = [
{ label: '计算机设备', value: 'computer' },
{ label: '网络设备', value: 'network' },
{ label: '存储设备', value: 'storage' },
{ label: '安全设备', value: 'security' },
{ label: '办公设备', value: 'office' },
{ label: '其他设备', value: 'other' },
]
const equipmentStatusOptions = [
{ label: '正常', value: 'normal' },
{ label: '维修中', value: 'repair' },
{ label: '报废', value: 'scrap' },
{ label: '闲置', value: 'idle' },
]
const locationStatusOptions = [
{ label: '在库', value: 'in_stock' },
{ label: '已分配', value: 'allocated' },
{ label: '外借中', value: 'borrowed' },
{ label: '维修中', value: 'repair' },
{ label: '已报废', value: 'scrapped' },
]
const healthStatusOptions = [
{ label: '良好', value: 'good' },
{ label: '一般', value: 'normal' },
{ label: '较差', value: 'poor' },
{ label: '故障', value: 'fault' },
]
const useStatusOptions = [
{ label: '空闲中', value: '0' },
{ label: '使用中', value: '1' },
]
//
const debouncedSearch = debounce(() => {
console.log('🔍 EquipmentSearch - 防抖搜索触发')
console.log('🔍 EquipmentSearch - 搜索表单数据:', searchForm)
emit('search', { ...searchForm })
}, 300)
const handleSearch = () => {
console.log('🔍 EquipmentSearch - 搜索按钮被点击')
console.log('🔍 EquipmentSearch - 搜索表单数据:', searchForm)
console.log('🔍 EquipmentSearch - 发送的搜索参数:', { ...searchForm })
emit('search', { ...searchForm })
showSearchModal.value = false
}
const handleReset = () => {
console.log('🔄 EquipmentSearch - 重置按钮被点击')
console.log('🔄 EquipmentSearch - 重置前的表单数据:', { ...searchForm })
Object.keys(searchForm).forEach((key) => {
searchForm[key as keyof EquipmentPageQuery] = '' as any
})
console.log('🔄 EquipmentSearch - 重置后的表单数据:', { ...searchForm })
emit('reset')
}
const handleCancel = () => {
showSearchModal.value = false
}
defineExpose({
reset: handleReset,
getSearchForm: () => ({ ...searchForm }),
showModal: () => { showSearchModal.value = true },
})
</script>
<style lang="scss" scoped>
.equipment-search-container {
.search-trigger {
margin-bottom: 16px;
}
}
.search-modal {
.arco-modal-header {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--color-border);
.arco-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--color-text-1);
}
}
.arco-modal-body {
padding: 24px;
}
}
.search-content {
.search-form {
.arco-form-item {
margin-bottom: 20px;
.arco-form-item-label {
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
font-size: 14px;
}
}
.arco-input,
.arco-select {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary-light-3);
}
&:focus,
&.arco-input-focus,
&.arco-select-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.1);
}
}
.arco-input-inner {
padding: 8px 12px;
font-size: 14px;
}
.arco-select-view {
padding: 8px 12px;
font-size: 14px;
}
.arco-select-arrow {
color: var(--color-text-3);
}
.arco-input-inner::placeholder {
color: var(--color-text-3);
font-size: 14px;
}
.arco-input-clear-btn {
color: var(--color-text-3);
&:hover {
color: var(--color-text-2);
}
}
}
.search-actions {
display: flex;
justify-content: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--color-border);
}
}
</style>

View File

@ -54,7 +54,7 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { IconUpload } from '@arco-design/web-vue/es/icon' import { IconUpload } from '@arco-design/web-vue/es/icon'
import { getEquipmentDetail } from '@/apis/equipment' import { EquipmentAPI } from '@/apis'
import type { EquipmentResp } from '@/types/equipment.d' import type { EquipmentResp } from '@/types/equipment.d'
defineOptions({ name: 'DeviceDetail' }) defineOptions({ name: 'DeviceDetail' })
@ -245,7 +245,7 @@ const getHealthStatusText = (status: string) => {
// //
const loadDeviceDetail = async () => { const loadDeviceDetail = async () => {
try { try {
const res = await getEquipmentDetail(deviceId) const res = await EquipmentAPI.getEquipmentDetail(deviceId)
const device = res.data as EquipmentResp const device = res.data as EquipmentResp
// //

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,350 @@
<template>
<a-modal
:visible="visible"
:title="modalTitle"
width="800px"
@ok="handleSubmit"
@cancel="handleCancel"
@update:visible="(value) => emit('update:visible', value)"
:confirm-loading="submitLoading"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
class="procurement-application-form"
>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备名称" field="equipmentName">
<a-input
v-model="formData.equipmentName"
placeholder="请输入设备名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="设备类型" field="equipmentType">
<a-select
v-model="formData.equipmentType"
placeholder="请选择设备类型"
allow-clear
>
<a-option value="服务器">服务器</a-option>
<a-option value="网络设备">网络设备</a-option>
<a-option value="办公设备">办公设备</a-option>
<a-option value="计算机">计算机</a-option>
<a-option value="安防设备">安防设备</a-option>
<a-option value="显示设备">显示设备</a-option>
<a-option value="测试设备">测试设备</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="设备型号" field="equipmentModel">
<a-input
v-model="formData.equipmentModel"
placeholder="请输入设备型号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="品牌" field="brand">
<a-input
v-model="formData.brand"
placeholder="请输入品牌"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="供应商名称" field="supplierName">
<a-input
v-model="formData.supplierName"
placeholder="请输入供应商名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="数量" field="quantity">
<a-input-number
v-model="formData.quantity"
placeholder="请输入数量"
:min="1"
:precision="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="预算金额" field="budgetAmount">
<a-input-number
v-model="formData.budgetAmount"
placeholder="请输入预算金额"
:min="0"
:precision="2"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="紧急程度" field="urgencyLevel">
<a-select
v-model="formData.urgencyLevel"
placeholder="请选择紧急程度"
allow-clear
>
<a-option value="LOW"></a-option>
<a-option value="NORMAL">普通</a-option>
<a-option value="HIGH"></a-option>
<a-option value="URGENT">紧急</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="采购类型" field="procurementType">
<a-select
v-model="formData.procurementType"
placeholder="请选择采购类型"
allow-clear
>
<a-option value="NEW_PURCHASE">新采购</a-option>
<a-option value="REPLACEMENT">更换</a-option>
<a-option value="UPGRADE">升级</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="预期交付日期" field="expectedDeliveryDate">
<a-date-picker
v-model="formData.expectedDeliveryDate"
placeholder="请选择预期交付日期"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="申请原因" field="applyReason">
<a-textarea
v-model="formData.applyReason"
placeholder="请详细说明采购原因"
:rows="4"
allow-clear
/>
</a-form-item>
<a-form-item label="技术要求" field="technicalRequirements">
<a-textarea
v-model="formData.technicalRequirements"
placeholder="请描述设备的技术要求(可选)"
:rows="3"
allow-clear
/>
</a-form-item>
<a-form-item label="业务合理性说明" field="businessJustification">
<a-textarea
v-model="formData.businessJustification"
placeholder="请说明采购的业务合理性(可选)"
:rows="3"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { equipmentApprovalApi } from '@/apis/equipment/approval'
import notificationService from '@/services/notificationService'
interface Props {
visible: boolean
equipmentData?: any
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const modalTitle = ref('申请采购')
const submitLoading = ref(false)
const formRef = ref()
//
const formData = reactive({
equipmentId: '', // ID
equipmentName: '',
equipmentType: '',
equipmentModel: '',
brand: '',
supplierName: '',
quantity: 1,
budgetAmount: undefined,
urgencyLevel: 'NORMAL',
procurementType: 'NEW_PURCHASE',
expectedDeliveryDate: null,
applyReason: '',
technicalRequirements: '',
businessJustification: ''
})
//
const rules = {
equipmentId: [{ required: true, message: '设备ID不能为空' }],
equipmentName: [{ required: true, message: '请输入设备名称' }],
equipmentType: [{ required: true, message: '请选择设备类型' }],
quantity: [{ required: true, message: '请输入数量' }],
budgetAmount: [{ required: true, message: '请输入预算金额' }],
urgencyLevel: [{ required: true, message: '请选择紧急程度' }],
procurementType: [{ required: true, message: '请选择采购类型' }],
applyReason: [
{ required: true, message: '请输入申请原因' },
{
validator: (value: string) => {
if (!value || value.trim() === '') {
return new Error('申请原因不能为空')
}
return true
}
}
]
}
//
watch(() => props.equipmentData, (newData) => {
if (newData) {
//
Object.assign(formData, {
equipmentId: newData.equipmentId || '', // ID
equipmentName: newData.equipmentName || '',
equipmentType: newData.equipmentType || '',
equipmentModel: newData.equipmentModel || '',
brand: newData.brand || '',
supplierName: newData.supplierName || '',
quantity: newData.quantity || 1,
budgetAmount: newData.unitPrice || undefined,
urgencyLevel: 'NORMAL',
procurementType: 'NEW_PURCHASE',
expectedDeliveryDate: null,
applyReason: '', //
technicalRequirements: '',
businessJustification: ''
})
}
}, { immediate: true })
//
watch(() => props.visible, (visible) => {
if (visible) {
//
if (props.equipmentData) {
// watch
} else {
//
formRef.value?.resetFields()
}
}
})
//
const handleSubmit = async () => {
try {
// applyReason
if (!formData.applyReason || formData.applyReason.trim() === '') {
Message.error('请输入申请原因')
return
}
await formRef.value.validate()
submitLoading.value = true
const submitData = {
...formData,
applyReason: formData.applyReason.trim(), //
expectedDeliveryDate: formData.expectedDeliveryDate ?
(formData.expectedDeliveryDate as any).format('YYYY-MM-DD') : null
}
//
console.log('提交的表单数据:', submitData)
console.log('applyReason 值:', submitData.applyReason)
console.log('applyReason 类型:', typeof submitData.applyReason)
console.log('applyReason 长度:', submitData.applyReason?.length)
await equipmentApprovalApi.submitProcurementApplication(submitData)
//
const currentUser = JSON.parse(localStorage.getItem('userInfo') || '{}')
const applicantName = currentUser.nickname || currentUser.username || '未知用户'
notificationService.addProcurementNotification(
submitData.equipmentName,
applicantName
)
Message.success('采购申请提交成功')
emit('success')
handleCancel()
} catch (error: any) {
console.error('提交采购申请失败:', error)
Message.error(error?.message || '提交失败')
} finally {
submitLoading.value = false
}
}
//
const handleCancel = () => {
emit('update:visible', false)
//
formData.equipmentId = ''
formData.applyReason = ''
formData.technicalRequirements = ''
formData.businessJustification = ''
formData.expectedDeliveryDate = null
formRef.value?.resetFields()
}
</script>
<style scoped lang="scss">
.procurement-application-form {
.arco-form-item {
margin-bottom: 16px;
}
.arco-input,
.arco-select,
.arco-input-number,
.arco-date-picker {
border-radius: 6px;
}
.arco-textarea {
border-radius: 6px;
}
}
</style>

View File

@ -0,0 +1,491 @@
<template>
<a-button type="primary" @click="showSearchModal = true">
<template #icon>
<IconSearch />
</template>
搜索采购
</a-button>
<!-- 搜索弹窗 -->
<a-modal
v-model:visible="showSearchModal"
title="采购记录搜索"
width="1000px"
>
<a-form
ref="formRef"
:model="searchForm"
layout="vertical"
class="search-form"
>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="设备名称">
<a-input
v-model="searchForm.equipmentName"
placeholder="请输入设备名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备型号">
<a-input
v-model="searchForm.equipmentModel"
placeholder="请输入设备型号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备类型">
<a-select
v-model="searchForm.equipmentType"
placeholder="请选择设备类型"
allow-clear
>
<a-option value="detection">检测设备</a-option>
<a-option value="security">安防设备</a-option>
<a-option value="office">办公设备</a-option>
<a-option value="car">车辆</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="品牌">
<a-input
v-model="searchForm.brand"
placeholder="请输入品牌"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="供应商">
<a-input
v-model="searchForm.supplierName"
placeholder="请输入供应商名称"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="采购订单">
<a-input
v-model="searchForm.purchaseOrder"
placeholder="请输入采购订单号"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="设备状态">
<a-select
v-model="searchForm.equipmentStatus"
placeholder="请选择设备状态"
allow-clear
>
<a-option value="normal">正常</a-option>
<a-option value="repair">维修中</a-option>
<a-option value="maintain">保养中</a-option>
<a-option value="scrap">报废</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="位置状态">
<a-select
v-model="searchForm.locationStatus"
placeholder="请选择位置状态"
allow-clear
>
<a-option value="not_in_stock">未入库</a-option>
<a-option value="in_stock">库存中</a-option>
<a-option value="allocated">已分配</a-option>
<a-option value="repair">维修中</a-option>
<a-option value="scrap">待报废</a-option>
<a-option value="scrapped">已报废</a-option>
<a-option value="borrowed">外借中</a-option>
<a-option value="lost">丢失</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="健康状态">
<a-select
v-model="searchForm.healthStatus"
placeholder="请选择健康状态"
allow-clear
>
<a-option value="excellent">优秀</a-option>
<a-option value="good">良好</a-option>
<a-option value="normal">一般</a-option>
<a-option value="poor">较差</a-option>
<a-option value="critical">危险</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="负责人">
<a-input
v-model="searchForm.responsiblePerson"
placeholder="请输入负责人"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="维护人员">
<a-input
v-model="searchForm.maintenancePerson"
placeholder="请输入维护人员"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="物理位置">
<a-input
v-model="searchForm.physicalLocation"
placeholder="请输入物理位置"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="资产编号">
<a-input
v-model="searchForm.assetCode"
placeholder="请输入资产编号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备序列号">
<a-input
v-model="searchForm.equipmentSn"
placeholder="请输入设备序列号"
allow-clear
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="库存条码">
<a-input
v-model="searchForm.inventoryBarcode"
placeholder="请输入库存条码"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="采购时间范围">
<a-range-picker
v-model="searchForm.purchaseTimeRange"
show-time
placeholder="['开始时间', '结束时间']"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="入库时间范围">
<a-range-picker
v-model="searchForm.inStockTimeRange"
show-time
placeholder="['开始时间', '结束时间']"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="启用时间范围">
<a-range-picker
v-model="searchForm.activationTimeRange"
show-time
placeholder="['开始时间', '结束时间']"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="价格范围">
<a-input-number
v-model="searchForm.minPrice"
placeholder="最低价格"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="至">
<a-input-number
v-model="searchForm.maxPrice"
placeholder="最高价格"
:min="0"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="配置规格">
<a-input
v-model="searchForm.specification"
placeholder="请输入配置规格关键词"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="备注">
<a-input
v-model="searchForm.assetRemark"
placeholder="请输入备注关键词"
allow-clear
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<template #footer>
<div class="search-actions">
<a-space>
<a-button type="primary" @click="handleSearch" :loading="loading">
<template #icon>
<IconSearch />
</template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon>
<IconRefresh />
</template>
重置
</a-button>
<a-button @click="showSearchModal = false">
取消
</a-button>
</a-space>
</div>
</template>
</a-modal>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { IconSearch, IconRefresh } from '@arco-design/web-vue/es/icon'
import type { EquipmentListReq } from '@/apis/equipment/type'
interface Props {
loading?: boolean
}
interface Emits {
(e: 'search', params: EquipmentListReq): void
(e: 'reset'): void
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
})
const emit = defineEmits<Emits>()
const showSearchModal = ref(false)
const formRef = ref()
//
const searchForm = reactive({
equipmentName: '',
equipmentModel: '',
equipmentType: '',
brand: '',
supplierName: '',
purchaseOrder: '',
equipmentStatus: '',
locationStatus: '',
healthStatus: '',
responsiblePerson: '',
maintenancePerson: '',
physicalLocation: '',
assetCode: '',
equipmentSn: '',
inventoryBarcode: '',
purchaseTimeRange: [],
inStockTimeRange: [],
activationTimeRange: [],
minPrice: undefined,
maxPrice: undefined,
specification: '',
assetRemark: '',
})
//
const handleSearch = () => {
const params: EquipmentListReq = {
equipmentName: searchForm.equipmentName || undefined,
equipmentModel: searchForm.equipmentModel || undefined,
equipmentType: searchForm.equipmentType || undefined,
brand: searchForm.brand || undefined,
supplierName: searchForm.supplierName || undefined,
purchaseOrder: searchForm.purchaseOrder || undefined,
equipmentStatus: searchForm.equipmentStatus || undefined,
locationStatus: searchForm.locationStatus || undefined,
healthStatus: searchForm.healthStatus || undefined,
responsiblePerson: searchForm.responsiblePerson || undefined,
maintenancePerson: searchForm.maintenancePerson || undefined,
physicalLocation: searchForm.physicalLocation || undefined,
assetCode: searchForm.assetCode || undefined,
equipmentSn: searchForm.equipmentSn || undefined,
inventoryBarcode: searchForm.inventoryBarcode || undefined,
specification: searchForm.specification || undefined,
assetRemark: searchForm.assetRemark || undefined,
}
//
if (searchForm.purchaseTimeRange && searchForm.purchaseTimeRange.length === 2) {
params.purchaseTimeStart = searchForm.purchaseTimeRange[0]
params.purchaseTimeEnd = searchForm.purchaseTimeRange[1]
}
if (searchForm.inStockTimeRange && searchForm.inStockTimeRange.length === 2) {
params.inStockTimeStart = searchForm.inStockTimeRange[0]
params.inStockTimeEnd = searchForm.inStockTimeRange[1]
}
if (searchForm.activationTimeRange && searchForm.activationTimeRange.length === 2) {
params.activationTimeStart = searchForm.activationTimeRange[0]
params.activationTimeEnd = searchForm.activationTimeRange[1]
}
//
if (searchForm.minPrice !== undefined) {
params.minPrice = searchForm.minPrice
}
if (searchForm.maxPrice !== undefined) {
params.maxPrice = searchForm.maxPrice
}
emit('search', params)
showSearchModal.value = false
}
//
const handleReset = () => {
Object.keys(searchForm).forEach(key => {
if (Array.isArray(searchForm[key])) {
searchForm[key] = []
} else {
searchForm[key] = ''
}
})
formRef.value?.resetFields()
emit('reset')
showSearchModal.value = false
}
</script>
<style scoped lang="scss">
.search-form {
.arco-form-item {
margin-bottom: 16px;
.arco-form-item-label {
font-weight: 500;
color: var(--color-text-1);
margin-bottom: 8px;
}
}
.arco-input,
.arco-select,
.arco-input-number {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary-light-3);
}
&:focus,
&.arco-input-focus,
&.arco-select-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.1);
}
}
.arco-range-picker {
border-radius: 6px;
border: 1px solid var(--color-border);
transition: all 0.2s ease;
&:hover {
border-color: var(--color-primary-light-3);
}
&:focus,
&.arco-range-picker-focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.1);
}
}
}
.search-actions {
display: flex;
justify-content: center;
padding-top: 16px;
border-top: 1px solid var(--color-border);
.arco-btn {
border-radius: 6px;
font-weight: 500;
min-width: 80px;
}
}
//
@media (max-width: 768px) {
.search-form {
.arco-row {
.arco-col {
margin-bottom: 8px;
}
}
}
.search-actions {
.arco-space {
width: 100%;
justify-content: space-between;
}
}
}
</style>

View File

@ -0,0 +1,924 @@
<template>
<div class="equipment-procurement-container">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<div class="page-title">
<IconDesktop style="font-size: 24px; margin-right: 12px; color: var(--color-primary);" />
<h1>设备采购管理</h1>
</div>
<div class="page-description">
管理企业设备采购流程包括采购申请订单管理供应商管理等
</div>
</div>
<div class="header-right">
<a-space>
<ProcurementSearch
:loading="loading"
@search="handleSearch"
@reset="handleReset"
/>
<a-button type="primary" @click="handleAdd" size="large">
<template #icon>
<IconPlus />
</template>
新增采购
</a-button>
</a-space>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-container">
<a-row :gutter="16">
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);">
<IconDesktop />
</div>
<div class="stat-info">
<div class="stat-number">{{ pagination.total }}</div>
<div class="stat-label">采购总数</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);">
<IconClockCircle />
</div>
<div class="stat-info">
<div class="stat-number">{{ getPendingCount() }}</div>
<div class="stat-label">待处理</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);">
<IconCheckCircle />
</div>
<div class="stat-info">
<div class="stat-number">{{ getCompletedCount() }}</div>
<div class="stat-label">已完成</div>
</div>
</div>
</a-card>
</a-col>
<a-col :span="6">
<a-card class="stat-card" :bordered="false">
<div class="stat-content">
<div class="stat-icon" style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);">
<IconApps />
</div>
<div class="stat-info">
<div class="stat-number">¥{{ getTotalAmount() }}</div>
<div class="stat-label">采购总额</div>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
<!-- 数据表格 -->
<a-card class="table-card" :bordered="false">
<template #title>
<div class="card-title">
<span>采购记录</span>
<div class="table-actions">
<a-space>
<a-button type="text" @click="refreshData">
<template #icon>
<IconRefresh />
</template>
刷新
</a-button>
<a-button type="text" @click="handleExport">
<template #icon>
<IconDownload />
</template>
导出
</a-button>
</a-space>
</div>
</div>
</template>
<a-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
row-key="equipmentId"
@change="handleTableChange"
>
<!-- 设备状态 -->
<template #equipmentStatus="{ record }">
<a-tag :color="getEquipmentStatusColor(record.equipmentStatus)">
{{ getEquipmentStatusText(record.equipmentStatus) }}
</a-tag>
</template>
<!-- 位置状态 -->
<template #locationStatus="{ record }">
<a-tag :color="getLocationStatusColor(record.locationStatus)">
{{ getLocationStatusText(record.locationStatus) }}
</a-tag>
</template>
<!-- 健康状态 -->
<template #healthStatus="{ record }">
<a-tag :color="getHealthStatusColor(record.healthStatus)">
{{ getHealthStatusText(record.healthStatus) }}
</a-tag>
</template>
<!-- 采购价格 -->
<template #purchasePrice="{ record }">
<span v-if="record.purchasePrice" class="price-text">
¥{{ formatPrice(record.purchasePrice) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 当前净值 -->
<template #currentNetValue="{ record }">
<span v-if="record.currentNetValue" class="price-text">
¥{{ formatPrice(record.currentNetValue) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 创建时间 -->
<template #createTime="{ record }">
<span v-if="record.createTime" class="time-text">
{{ formatDateTime(record.createTime) }}
</span>
<span v-else class="no-data">-</span>
</template>
<!-- 操作 -->
<template #action="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<!-- 申请采购按钮 -->
<a-button
v-if="canApplyProcurement(record)"
type="primary"
size="small"
@click="handleApplyProcurement(record)"
>
申请采购
</a-button>
<!-- 显示审批状态 -->
<a-tag v-if="record.approvalStatus" :color="getApprovalStatusColor(record.approvalStatus)">
{{ getApprovalStatusText(record.approvalStatus) }}
</a-tag>
<a-popconfirm
content="确定要删除这条采购记录吗?"
@ok="handleDelete(record)"
>
<a-button type="text" size="small" status="danger">
删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<ProcurementModal
v-model:visible="modalVisible"
:procurement-data="currentProcurement"
:mode="modalMode"
@success="handleModalSuccess"
/>
<!-- 采购申请弹窗 -->
<ProcurementApplicationModal
v-model:visible="applicationModalVisible"
:equipment-data="currentApplicationData"
@success="handleApplicationSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { Modal } from '@arco-design/web-vue'
import {
IconCheckCircle,
IconClockCircle,
IconDownload,
IconPlus,
IconRefresh,
IconDesktop,
IconApps
} from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import ProcurementModal from './components/ProcurementModal.vue'
import ProcurementSearch from './components/ProcurementSearch.vue'
import ProcurementApplicationModal from './components/ProcurementApplicationModal.vue'
import { equipmentProcurementApi } from '@/apis/equipment/procurement'
import { equipmentApprovalApi } from '@/apis/equipment/approval'
import type { EquipmentListReq, EquipmentResp } from '@/apis/equipment/type'
defineOptions({ name: 'EquipmentProcurement' })
//
const currentSearchParams = ref<EquipmentListReq>({
minPrice: undefined,
maxPrice: undefined
})
//
const tableData = ref<EquipmentResp[]>([])
const loading = ref(false)
//
const pagination = reactive<any>({
current: 1,
pageSize: 10,
total: 0,
showPageSize: true,
showJumper: true,
showTotal: (total: number) => `${total} 条记录`,
})
//
const modalVisible = ref(false)
const currentProcurement = ref<EquipmentResp | null>(null)
const modalMode = ref<'add' | 'edit' | 'view'>('add')
//
const applicationModalVisible = ref(false)
const currentApplicationData = ref<EquipmentResp | null>(null)
//
const selectedRowKeys = ref<string[]>([])
const rowSelection = reactive({
type: 'checkbox' as const,
showCheckedAll: true,
selectedRowKeys,
onChange: (keys: string[]) => {
selectedRowKeys.value = keys
},
})
//
const columns = [
{
title: '资产编号',
dataIndex: 'assetCode',
key: 'assetCode',
width: 120,
},
{
title: '设备名称',
dataIndex: 'equipmentName',
key: 'equipmentName',
width: 150,
},
{
title: '设备类型',
dataIndex: 'equipmentType',
key: 'equipmentType',
width: 120,
},
{
title: '设备型号',
dataIndex: 'equipmentModel',
key: 'equipmentModel',
width: 120,
},
{
title: '品牌',
dataIndex: 'brand',
key: 'brand',
width: 100,
},
{
title: '供应商',
dataIndex: 'supplierName',
key: 'supplierName',
width: 150,
},
{
title: '采购订单',
dataIndex: 'purchaseOrder',
key: 'purchaseOrder',
width: 120,
},
{
title: '采购价格',
dataIndex: 'purchasePrice',
key: 'purchasePrice',
slotName: 'purchasePrice',
width: 120,
},
{
title: '当前净值',
dataIndex: 'currentNetValue',
key: 'currentNetValue',
slotName: 'currentNetValue',
width: 120,
},
{
title: '设备状态',
dataIndex: 'equipmentStatus',
key: 'equipmentStatus',
slotName: 'equipmentStatus',
width: 120,
},
{
title: '位置状态',
dataIndex: 'locationStatus',
key: 'locationStatus',
slotName: 'locationStatus',
width: 120,
},
{
title: '健康状态',
dataIndex: 'healthStatus',
key: 'healthStatus',
slotName: 'healthStatus',
width: 100,
},
{
title: '负责人',
dataIndex: 'responsiblePerson',
key: 'responsiblePerson',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
slotName: 'createTime',
width: 160,
},
{
title: '操作',
key: 'action',
slotName: 'action',
width: 200,
fixed: 'right',
},
]
//
const getEquipmentStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
normal: 'green',
repair: 'orange',
maintain: 'blue',
scrap: 'red',
}
return colorMap[status] || 'blue'
}
//
const getEquipmentStatusText = (status: string) => {
const textMap: Record<string, string> = {
normal: '正常',
repair: '维修中',
maintain: '保养中',
scrap: '报废',
}
return textMap[status] || '未知'
}
//
const getLocationStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
not_in_stock: 'gray',
in_stock: 'blue',
allocated: 'green',
repair: 'orange',
scrap: 'red',
scrapped: 'red',
borrowed: 'purple',
lost: 'gray',
}
return colorMap[status] || 'blue'
}
//
const getLocationStatusText = (status: string) => {
const textMap: Record<string, string> = {
not_in_stock: '未入库',
in_stock: '库存中',
allocated: '已分配',
repair: '维修中',
scrap: '待报废',
scrapped: '已报废',
borrowed: '外借中',
lost: '丢失',
}
return textMap[status] || '未知'
}
//
const getHealthStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
excellent: 'green',
good: 'blue',
normal: 'orange',
poor: 'red',
critical: 'red',
}
return colorMap[status] || 'blue'
}
//
const getHealthStatusText = (status: string) => {
const textMap: Record<string, string> = {
excellent: '优秀',
good: '良好',
normal: '一般',
poor: '较差',
critical: '危险',
}
return textMap[status] || '未知'
}
//
const formatPrice = (price: number) => {
return price.toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
//
const formatDateTime = (dateTime: string) => {
if (!dateTime) return '-'
const date = new Date(dateTime)
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
//
const transformBackendData = (data: any[]): EquipmentResp[] => {
return data.map((item: any) => ({
equipmentId: item.equipmentId || item.id,
assetCode: item.assetCode,
equipmentName: item.equipmentName,
equipmentType: item.equipmentType,
equipmentTypeLabel: item.equipmentTypeLabel,
equipmentModel: item.equipmentModel,
equipmentSn: item.equipmentSn,
brand: item.brand,
specification: item.specification,
equipmentStatus: item.equipmentStatus,
equipmentStatusLabel: item.equipmentStatusLabel,
useStatus: item.useStatus,
locationStatus: item.locationStatus,
locationStatusLabel: item.locationStatusLabel,
physicalLocation: item.physicalLocation,
responsiblePerson: item.responsiblePerson,
healthStatus: item.healthStatus,
healthStatusLabel: item.healthStatusLabel,
purchaseTime: item.purchaseTime,
inStockTime: item.inStockTime,
activationTime: item.activationTime,
expectedScrapTime: item.expectedScrapTime,
actualScrapTime: item.actualScrapTime,
statusChangeTime: item.statusChangeTime,
purchaseOrder: item.purchaseOrder,
supplierName: item.supplierName,
purchasePrice: item.purchasePrice,
currentNetValue: item.currentNetValue,
depreciationMethod: item.depreciationMethod,
depreciationYears: item.depreciationYears,
salvageValue: item.salvageValue,
warrantyExpireDate: item.warrantyExpireDate,
lastMaintenanceDate: item.lastMaintenanceDate,
nextMaintenanceDate: item.nextMaintenanceDate,
maintenancePerson: item.maintenancePerson,
inventoryBarcode: item.inventoryBarcode,
assetRemark: item.assetRemark,
projectId: item.projectId,
projectName: item.projectName,
userId: item.userId,
name: item.name,
createTime: item.createTime,
updateTime: item.updateTime,
accountNumber: item.accountNumber,
quantity: item.quantity,
unitPrice: item.unitPrice,
totalPrice: item.totalPrice,
inventoryBasis: item.inventoryBasis,
dynamicRecord: item.dynamicRecord,
}))
}
//
const loadData = async (searchParams?: EquipmentListReq) => {
console.log('📊 loadData - 开始加载数据')
console.log('📊 loadData - 接收到的搜索参数:', searchParams)
loading.value = true
try {
const params: EquipmentListReq = {
pageSize: pagination.pageSize,
page: pagination.current,
minPrice: undefined,
maxPrice: undefined,
...(searchParams || {}),
}
console.log('📊 loadData - 构建的完整请求参数:', params)
const res = await equipmentProcurementApi.page(params)
console.log('API响应:', res)
if (res.success || res.status === 200 || res.code === 200) {
let dataList: any[] = []
if (Array.isArray(res.data)) {
dataList = res.data
} else if (res.data && Array.isArray((res.data as any).records)) {
dataList = (res.data as any).records
} else if (res.data && Array.isArray((res.data as any).list)) {
dataList = (res.data as any).list
} else if (res.data && Array.isArray((res.data as any).rows)) {
dataList = (res.data as any).rows
}
console.log('处理后的数据列表:', dataList)
if (dataList.length > 0) {
const transformedData = transformBackendData(dataList)
console.log('转换后的数据:', transformedData)
tableData.value = transformedData
} else {
tableData.value = []
}
pagination.total = (res.data as any)?.total || (res as any).total || dataList.length || 0
console.log('总数:', pagination.total)
} else {
message.error(res.msg || '加载数据失败')
}
} catch (error: any) {
console.error('加载数据失败:', error)
message.error(error?.message || '加载数据失败')
} finally {
loading.value = false
}
}
//
const handleSearch = (searchParams: EquipmentListReq) => {
console.log('🔍 主组件 - 接收到的搜索参数:', searchParams)
pagination.current = 1
currentSearchParams.value = { ...searchParams }
loadData(searchParams)
}
//
const handleReset = () => {
console.log('🔄 主组件 - 重置操作')
pagination.current = 1
currentSearchParams.value = {
minPrice: undefined,
maxPrice: undefined
}
loadData()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData(currentSearchParams.value)
}
//
const handleAdd = () => {
modalMode.value = 'add'
currentProcurement.value = null
modalVisible.value = true
}
//
const handleView = (record: EquipmentResp) => {
modalMode.value = 'view'
currentProcurement.value = { ...record }
modalVisible.value = true
}
//
const handleEdit = (record: EquipmentResp) => {
modalMode.value = 'edit'
currentProcurement.value = { ...record }
modalVisible.value = true
}
//
const handleApplyProcurement = (record: EquipmentResp) => {
currentApplicationData.value = { ...record }
applicationModalVisible.value = true
}
//
const handleDelete = async (record: EquipmentResp) => {
try {
await equipmentProcurementApi.delete(record.equipmentId)
message.success('删除成功')
loadData(currentSearchParams.value)
} catch (error: any) {
console.error('删除失败:', error)
message.error(error?.message || '删除失败')
}
}
//
const handleModalSuccess = () => {
modalVisible.value = false
loadData(currentSearchParams.value)
}
//
const handleApplicationSuccess = () => {
applicationModalVisible.value = false
loadData(currentSearchParams.value)
}
//
const refreshData = () => {
loadData(currentSearchParams.value)
}
//
const handleExport = async () => {
try {
const response = await equipmentProcurementApi.export(currentSearchParams.value)
const blob = response.data || response
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `设备采购记录_${new Date().toISOString().split('T')[0]}.xlsx`
link.click()
window.URL.revokeObjectURL(url)
message.success('导出成功')
} catch (error: any) {
console.error('导出失败:', error)
message.error(error?.message || '导出失败')
}
}
//
const getPendingCount = () => {
return tableData.value.filter(item =>
item.equipmentStatus === 'pending' ||
item.locationStatus === 'pending'
).length
}
const getCompletedCount = () => {
return tableData.value.filter(item =>
item.equipmentStatus === 'completed' ||
item.locationStatus === 'completed'
).length
}
const getTotalAmount = () => {
const total = tableData.value.reduce((sum, item) => {
return sum + (item.purchasePrice || 0)
}, 0)
return formatPrice(total)
}
//
const canApplyProcurement = (record: EquipmentResp) => {
//
return !record.approvalStatus || record.approvalStatus === 'PENDING_APPLICATION'
}
//
const getApprovalStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
'PENDING_APPLICATION': 'blue',
'PENDING': 'orange',
'APPROVED': 'green',
'REJECTED': 'red',
'WITHDRAWN': 'gray'
}
return colorMap[status] || 'blue'
}
//
const getApprovalStatusText = (status: string) => {
const textMap: Record<string, string> = {
'PENDING_APPLICATION': '待申请',
'PENDING': '待审批',
'APPROVED': '已通过',
'REJECTED': '已拒绝',
'WITHDRAWN': '已撤回'
}
return textMap[status] || '未知'
}
onMounted(() => {
loadData()
})
</script>
<style scoped lang="scss">
.equipment-procurement-container {
.page-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
.header-left {
.page-title {
display: flex;
align-items: center;
margin-bottom: 8px;
h1 {
margin: 0;
color: white;
font-size: 28px;
font-weight: 600;
background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.page-description {
color: rgba(255, 255, 255, 0.8);
font-size: 14px;
}
}
.header-right {
.arco-btn {
border-radius: 8px;
font-weight: 500;
}
}
}
}
.stats-container {
margin-bottom: 24px;
.stat-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
.stat-icon {
width: 60px;
height: 60px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
.arco-icon {
font-size: 24px;
color: white;
}
}
.stat-info {
flex: 1;
.stat-number {
font-size: 24px;
font-weight: 600;
color: var(--color-text-1);
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--color-text-3);
}
}
}
}
}
.table-card {
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
span {
font-size: 18px;
font-weight: 600;
color: var(--color-text-1);
}
.table-actions {
.arco-btn {
border-radius: 6px;
}
}
}
.arco-table {
.arco-table-th {
background-color: var(--color-fill-2);
font-weight: 600;
}
.arco-table-tr:hover {
background-color: var(--color-fill-1);
}
}
}
.price-text {
color: #f56c6c;
font-weight: 500;
}
.time-text {
color: var(--color-text-2);
font-size: 12px;
}
.no-data {
color: var(--color-text-4);
font-style: italic;
}
}
//
@media (max-width: 768px) {
.equipment-procurement-container {
.page-header {
.header-content {
flex-direction: column;
align-items: flex-start;
.header-right {
margin-top: 16px;
width: 100%;
.arco-space {
width: 100%;
justify-content: space-between;
}
}
}
}
.stats-container {
.arco-col {
margin-bottom: 16px;
}
}
}
}
</style>

View File

@ -0,0 +1,136 @@
<template>
<div class="procurement-test">
<a-card title="设备采购模块测试" :bordered="false">
<template #extra>
<a-space>
<a-button type="primary" @click="testApi">
测试API
</a-button>
<a-button @click="testSearch">
测试搜索
</a-button>
<a-button @click="testAdd">
测试新增
</a-button>
</a-space>
</template>
<a-divider />
<div class="test-results">
<h3>测试结果</h3>
<a-textarea
v-model="testResults"
:rows="10"
placeholder="测试结果将显示在这里..."
readonly
/>
</div>
<a-divider />
<div class="test-params">
<h3>测试参数</h3>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="设备名称">
<a-input v-model="testParams.equipmentName" placeholder="测试设备" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="设备型号">
<a-input v-model="testParams.equipmentModel" placeholder="测试型号" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="供应商">
<a-input v-model="testParams.supplierName" placeholder="测试供应商" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { Message } from '@arco-design/web-vue'
import { equipmentProcurementApi } from '@/apis/equipment/procurement'
defineOptions({ name: 'ProcurementTest' })
const testResults = ref('')
const testParams = reactive({
equipmentName: '测试设备',
equipmentModel: '测试型号',
supplierName: '测试供应商',
})
//
const addTestResult = (message: string) => {
const timestamp = new Date().toLocaleString()
testResults.value += `[${timestamp}] ${message}\n`
}
// API
const testApi = async () => {
try {
addTestResult('开始测试API...')
const params = {
page: 1,
pageSize: 10,
equipmentName: testParams.equipmentName,
equipmentModel: testParams.equipmentModel,
supplierName: testParams.supplierName,
}
addTestResult(`请求参数: ${JSON.stringify(params, null, 2)}`)
const response = await equipmentProcurementApi.page(params)
addTestResult(`API响应: ${JSON.stringify(response, null, 2)}`)
Message.success('API测试成功')
} catch (error: any) {
addTestResult(`API测试失败: ${error.message}`)
Message.error('API测试失败')
}
}
//
const testSearch = () => {
addTestResult('测试搜索功能...')
addTestResult(`搜索参数: ${JSON.stringify(testParams, null, 2)}`)
Message.info('搜索测试完成')
}
//
const testAdd = () => {
addTestResult('测试新增功能...')
addTestResult(`新增参数: ${JSON.stringify(testParams, null, 2)}`)
Message.info('新增测试完成')
}
</script>
<style scoped lang="scss">
.procurement-test {
.test-results {
margin-bottom: 24px;
h3 {
margin-bottom: 16px;
color: var(--color-text-1);
}
}
.test-params {
h3 {
margin-bottom: 16px;
color: var(--color-text-1);
}
}
}
</style>

View File

@ -0,0 +1,81 @@
<template>
<div class="training-detail-container">
<a-card title="培训基本信息">
<!-- 培训基本信息展示区 -->
</a-card>
<a-card title="互动与留痕" style="margin-top: 16px">
<a-tabs>
<a-tab-pane key="sign" tab="签到">
<div class="sign-in-section">
<a-button type="primary" :disabled="signedIn" @click="handleSignIn">
{{ signedIn ? '已签到' : '点击签到' }}
</a-button>
<div v-if="signedIn" class="sign-in-success">签到成功</div>
<a-divider />
<div class="sign-in-records">
<h4>签到记录</h4>
<a-table :data="signInRecords" :columns="signInColumns" row-key="id" size="small" />
</div>
</div>
</a-tab-pane>
<a-tab-pane key="quiz" tab="答题">
<!-- 答题互动区 -->
</a-tab-pane>
<a-tab-pane key="discussion" tab="讨论">
<!-- 讨论区 -->
</a-tab-pane>
<a-tab-pane key="score" tab="打分/评价">
<!-- 打分/评价区 -->
</a-tab-pane>
<a-tab-pane key="feedback" tab="反馈/留痕">
<!-- 反馈/留痕区 -->
</a-tab-pane>
</a-tabs>
</a-card>
<a-card title="案例/公开课关联" style="margin-top: 16px">
<!-- 关联案例/公开课展示区 -->
</a-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
//
const signedIn = ref(false)
//
const signInRecords = ref([
{ id: 1, name: '张三', time: '2024-06-01 09:00:00' },
{ id: 2, name: '李四', time: '2024-06-01 09:01:00' },
])
const signInColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '签到时间', dataIndex: 'time', key: 'time' },
]
//
function handleSignIn() {
signedIn.value = true
// API
}
</script>
<style scoped>
.training-detail-container {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
}
.sign-in-section {
padding: 16px 0;
}
.sign-in-success {
color: #52c41a;
margin-top: 8px;
}
.sign-in-records {
margin-top: 24px;
}
</style>

View File

@ -0,0 +1,576 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑培训计划' : '新增培训计划'"
width="800px"
:confirm-loading="loading"
:ok-button-props="{ disabled: !isFormValid }"
@cancel="handleCancel"
@ok="handleSubmit"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
auto-label-width
>
<a-form-item label="计划名称" field="planName">
<a-input
v-model="formData.planName"
placeholder="请输入计划名称"
:disabled="isView"
show-word-limit
:max-length="100"
/>
</a-form-item>
<a-form-item label="培训类型" field="trainingType">
<a-select
v-model="formData.trainingType"
:options="trainingTypeOptions"
placeholder="请选择培训类型"
:disabled="isView"
/>
</a-form-item>
<a-form-item label="培训级别" field="trainingLevel">
<a-select
v-model="formData.trainingLevel"
:options="trainingLevelOptions"
placeholder="请选择培训级别"
:disabled="isView"
/>
</a-form-item>
<a-form-item label="培训内容" field="trainingContent">
<a-textarea
v-model="formData.trainingContent"
placeholder="请输入培训内容"
:rows="4"
:disabled="isView"
show-word-limit
:max-length="500"
/>
</a-form-item>
<a-form-item label="培训讲师" field="trainer">
<a-input
v-model="formData.trainer"
placeholder="请输入培训讲师"
:disabled="isView"
/>
</a-form-item>
<a-form-item label="培训地点" field="trainingLocation">
<a-input
v-model="formData.trainingLocation"
placeholder="请输入培训地点"
:disabled="isView"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="开始时间" field="startTime">
<a-date-picker
v-model="formData.startTime"
show-time
placeholder="请选择开始时间"
style="width: 100%"
:disabled="isView"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="结束时间" field="endTime">
<a-date-picker
v-model="formData.endTime"
show-time
placeholder="请选择结束时间"
style="width: 100%"
:disabled="isView"
:disabled-date="(current) => current && current < dayjs().startOf('day')"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="最大参训人数" field="maxParticipants">
<a-input-number
v-model="formData.maxParticipants"
placeholder="请输入最大参训人数"
:min="1"
:max="1000"
style="width: 100%"
:disabled="isView"
/>
</a-form-item>
<a-form-item label="培训要求" field="requirements">
<a-textarea
v-model="formData.requirements"
placeholder="请输入培训要求"
:rows="3"
:disabled="isView"
show-word-limit
:max-length="300"
/>
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea
v-model="formData.remark"
placeholder="请输入备注"
:rows="3"
:disabled="isView"
show-word-limit
:max-length="200"
/>
</a-form-item>
<!-- 调试按钮 - 仅在开发环境显示 -->
<a-form-item v-if="isDev" label="调试">
<a-space>
<a-button type="dashed" size="small" @click="testFormData">
测试表单数据绑定
</a-button>
<a-button type="dashed" size="small" @click="fillTestData">
填充测试数据
</a-button>
<a-button size="small" type="dashed" @click="clearFormData">
清空表单数据
</a-button>
</a-space>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import dayjs from 'dayjs'
import { createTrainingPlan, updateTrainingPlan } from '@/apis/training'
import type { TrainingPlanReq, TrainingPlanResp } from '@/types/training.d'
defineOptions({ name: 'TrainingPlanModal' })
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
interface Props {
visible: boolean
planData?: TrainingPlanResp | null
}
interface Emits {
(e: 'update:visible', visible: boolean): void
(e: 'success'): void
}
//
const formRef = ref<FormInstance>()
const loading = ref(false)
//
const isDev = import.meta.env.DEV
//
const isEdit = computed(() => !!props.planData?.planId)
//
const isView = computed(() => !!props.planData?.planId && !isEdit.value)
//
interface FormDataType {
planName: string
trainingType: string
trainingLevel: string
trainingContent: string
trainer: string
trainingLocation: string
startTime: any // dayjsnull
endTime: any // dayjsnull
maxParticipants?: number
requirements: string
remark: string
status?: string
}
//
const formData = reactive<FormDataType>({
planName: '',
trainingType: '',
trainingLevel: '',
trainingContent: '',
trainer: '',
trainingLocation: '',
startTime: null,
endTime: null,
maxParticipants: undefined,
requirements: '',
remark: '',
status: 'DRAFT',
})
//
const rules = {
planName: [
{ required: true, message: '请输入计划名称', trigger: 'blur' },
{ min: 2, max: 100, message: '计划名称长度应在2-100个字符之间', trigger: 'blur' },
],
trainingType: [{ required: true, message: '请选择培训类型', trigger: 'change' }],
trainingLevel: [{ required: true, message: '请选择培训级别', trigger: 'change' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [
{ required: true, message: '请选择结束时间', trigger: 'change' },
{
validator: (value: any, callback: any) => {
if (value && formData.startTime && dayjs(value).isBefore(dayjs(formData.startTime))) {
callback('结束时间不能早于开始时间')
} else {
callback()
}
},
trigger: 'change',
},
],
maxParticipants: [
{
validator: (value: any, callback: any) => {
if (value !== undefined && value !== null) {
if (value < 1) {
callback('最大参训人数不能小于1')
} else if (value > 1000) {
callback('最大参训人数不能超过1000')
} else {
callback()
}
} else {
callback()
}
},
trigger: 'blur',
},
],
trainingContent: [
{ max: 500, message: '培训内容长度不能超过500个字符', trigger: 'blur' },
],
requirements: [
{ max: 300, message: '培训要求长度不能超过300个字符', trigger: 'blur' },
],
remark: [
{ max: 200, message: '备注长度不能超过200个字符', trigger: 'blur' },
],
}
//
const trainingTypeOptions = [
{ label: '安全教育', value: 'SAFETY' },
{ label: '技能培训', value: 'SKILL' },
{ label: '企业文化', value: 'CULTURE' },
]
//
const trainingLevelOptions = [
{ label: '现场级', value: 'SITE' },
{ label: '部门级', value: 'DEPARTMENT' },
{ label: '公司级', value: 'COMPANY' },
]
//
watch(
() => props.visible,
(visible) => {
if (visible) {
// eslint-disable-next-line ts/no-use-before-define
initFormData()
}
},
)
//
watch(
() => props.planData,
() => {
if (props.visible) {
// eslint-disable-next-line ts/no-use-before-define
initFormData()
}
},
)
//
watch(
formData,
(newData) => {
console.log('表单数据变化:', newData)
console.log('表单数据详情:', {
planName: newData.planName,
trainingType: newData.trainingType,
trainingLevel: newData.trainingLevel,
startTime: newData.startTime,
endTime: newData.endTime,
maxParticipants: newData.maxParticipants,
trainer: newData.trainer,
trainingLocation: newData.trainingLocation,
trainingContent: newData.trainingContent,
requirements: newData.requirements,
remark: newData.remark,
})
},
{ deep: true },
)
//
watch(
() => formData.startTime,
(newStartTime) => {
console.log('开始时间变化:', newStartTime)
if (newStartTime && formData.endTime && dayjs(formData.endTime).isBefore(dayjs(newStartTime))) {
//
formData.endTime = null
}
},
)
//
watch(
() => formData.endTime,
(newEndTime) => {
console.log('结束时间变化:', newEndTime)
},
)
//
const isFormValid = computed(() => {
return formData.planName.trim()
&& formData.trainingType
&& formData.trainingLevel
&& formData.startTime
&& formData.endTime
})
//
const initFormData = () => {
console.log('初始化表单数据props.planData:', props.planData)
if (props.planData) {
//
Object.assign(formData, {
planName: props.planData.planName || '',
trainingType: props.planData.trainingType || '',
trainingLevel: props.planData.trainingLevel || '',
trainingContent: props.planData.trainingContent || '',
trainer: props.planData.trainer || '',
trainingLocation: props.planData.trainingLocation || '',
startTime: props.planData.startTime ? dayjs(props.planData.startTime) : null,
endTime: props.planData.endTime ? dayjs(props.planData.endTime) : null,
maxParticipants: props.planData.maxParticipants,
requirements: props.planData.requirements || '',
remark: props.planData.remark || '',
status: props.planData.status || 'DRAFT',
})
} else {
//
// eslint-disable-next-line ts/no-use-before-define
resetFormData()
}
console.log('初始化后的表单数据:', formData)
}
//
const resetFormData = () => {
Object.assign(formData, {
planName: '',
trainingType: '',
trainingLevel: '',
trainingContent: '',
trainer: '',
trainingLocation: '',
startTime: null,
endTime: null,
maxParticipants: undefined,
requirements: '',
remark: '',
status: 'DRAFT',
})
}
//
const handleSubmit = async () => {
try {
console.log('开始提交表单,表单数据:', formData)
console.log('表单数据原始值:', {
planName: formData.planName,
trainingType: formData.trainingType,
trainingLevel: formData.trainingLevel,
startTime: formData.startTime,
endTime: formData.endTime,
maxParticipants: formData.maxParticipants,
trainer: formData.trainer,
trainingLocation: formData.trainingLocation,
trainingContent: formData.trainingContent,
requirements: formData.requirements,
remark: formData.remark,
})
//
await formRef.value?.validate()
loading.value = true
// -
if (!formData.planName?.trim()) {
throw new Error('计划名称不能为空')
}
if (!formData.trainingType) {
throw new Error('培训类型不能为空')
}
if (!formData.trainingLevel) {
throw new Error('培训级别不能为空')
}
if (!formData.startTime) {
throw new Error('开始时间不能为空')
}
if (!formData.endTime) {
throw new Error('结束时间不能为空')
}
//
const submitData: TrainingPlanReq = {
planName: formData.planName.trim(),
trainingType: formData.trainingType,
trainingLevel: formData.trainingLevel,
trainingContent: formData.trainingContent?.trim() || '',
trainer: formData.trainer?.trim() || '',
trainingLocation: formData.trainingLocation?.trim() || '',
startTime: formData.startTime ? dayjs(formData.startTime).format('YYYY-MM-DD HH:mm:ss') : '',
endTime: formData.endTime ? dayjs(formData.endTime).format('YYYY-MM-DD HH:mm:ss') : '',
status: formData.status || 'DRAFT',
maxParticipants: formData.maxParticipants || undefined,
requirements: formData.requirements?.trim() || '',
remark: formData.remark?.trim() || '',
}
console.log('提交的数据:', submitData)
console.log('提交的数据JSON:', JSON.stringify(submitData, null, 2))
if (isEdit.value && props.planData) {
console.log('执行更新操作')
await updateTrainingPlan(props.planData.planId, submitData)
Message.success('培训计划更新成功')
} else {
console.log('执行创建操作')
await createTrainingPlan(submitData)
Message.success('培训计划创建成功')
}
emit('success')
emit('update:visible', false)
} catch (error: any) {
console.error('提交失败:', error)
//
if (error?.response?.data?.message) {
Message.error(error.response.data.message)
} else if (error?.message) {
Message.error(error.message)
} else if (typeof error === 'string') {
Message.error(error)
} else {
Message.error('操作失败,请检查表单数据或稍后重试')
}
} finally {
loading.value = false
}
}
//
const handleCancel = () => {
//
formRef.value?.resetFields()
resetFormData()
emit('update:visible', false)
}
//
const testFormData = () => {
console.log('=== 表单数据绑定测试 ===')
console.log('当前表单数据:', formData)
console.log('当前表单数据详情:', {
planName: formData.planName,
trainingType: formData.trainingType,
trainingLevel: formData.trainingLevel,
startTime: formData.startTime,
endTime: formData.endTime,
maxParticipants: formData.maxParticipants,
trainer: formData.trainer,
trainingLocation: formData.trainingLocation,
trainingContent: formData.trainingContent,
requirements: formData.requirements,
remark: formData.remark,
})
//
formRef.value?.validate().then(() => {
console.log('✅ 表单验证通过')
}).catch((errors) => {
console.log('❌ 表单验证失败:', errors)
})
//
console.log('表单字段值测试:')
console.log('- planName:', formData.planName, typeof formData.planName)
console.log('- trainingType:', formData.trainingType, typeof formData.trainingType)
console.log('- trainingLevel:', formData.trainingLevel, typeof formData.trainingLevel)
console.log('- startTime:', formData.startTime, typeof formData.startTime)
console.log('- endTime:', formData.endTime, typeof formData.endTime)
}
//
const fillTestData = () => {
Object.assign(formData, {
planName: '测试计划名称',
trainingType: 'SAFETY',
trainingLevel: 'SITE',
trainingContent: '测试培训内容',
trainer: '测试讲师',
trainingLocation: '测试地点',
startTime: dayjs('2023-10-27 10:00'),
endTime: dayjs('2023-10-27 12:00'),
maxParticipants: 50,
requirements: '测试培训要求',
remark: '测试备注',
status: 'DRAFT',
})
console.log('已填充测试数据')
}
//
const clearFormData = () => {
resetFormData()
console.log('已清空表单数据')
}
//
const watchFormField = (fieldName: string) => {
watch(() => formData[fieldName as keyof typeof formData], (newValue, oldValue) => {
console.log(`字段 ${fieldName} 变化:`, { oldValue, newValue })
})
}
//
Object.keys(formData).forEach((fieldName) => {
watchFormField(fieldName)
})
</script>
<style lang="scss" scoped>
.ant-form-item {
margin-bottom: 16px;
}
</style>

View File

@ -0,0 +1,577 @@
<template>
<div class="training-plan-container">
<!-- 搜索表单 -->
<a-card class="search-card" :bordered="false">
<a-form layout="inline" :model="searchForm" @submit="handleSearch">
<a-form-item label="计划名称">
<a-input
v-model:value="searchForm.planName"
placeholder="请输入计划名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item label="培训类型">
<a-select
v-model:value="searchForm.trainingType"
:options="trainingTypeOptions"
placeholder="请选择培训类型"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="培训级别">
<a-select
v-model:value="searchForm.trainingLevel"
:options="trainingLevelOptions"
placeholder="请选择培训级别"
allow-clear
style="width: 150px"
/>
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchForm.status"
:options="statusOptions"
placeholder="请选择状态"
allow-clear
style="width: 120px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit" :loading="loading">
<template #icon>
<IconSearch />
</template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon>
<IconRefresh />
</template>
重置
</a-button>
</a-form-item>
</a-form>
</a-card>
<!-- 操作按钮 -->
<a-card class="table-card" :bordered="false">
<template #title>
<div class="card-title">
<span>培训计划列表</span>
<a-button type="primary" @click="handleAdd">
<template #icon>
<IconPlus />
</template>
新增计划
</a-button>
</div>
</template>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data="tableData"
:loading="loading"
:pagination="pagination"
row-key="planId"
@change="handleTableChange"
>
<template #trainingType="{ record }">
<a-tag :color="getTrainingTypeColor(record.trainingType)">
{{ getTrainingTypeText(record.trainingType) }}
</a-tag>
</template>
<template #trainingLevel="{ record }">
<a-tag :color="getTrainingLevelColor(record.trainingLevel)">
{{ getTrainingLevelText(record.trainingLevel) }}
</a-tag>
</template>
<template #status="{ record }">
<a-tag :color="getStatusColor(record.status)">
{{ getStatusText(record.status) }}
</a-tag>
</template>
<template #participants="{ record }">
<span>{{ record.currentParticipants || 0 }}/{{ record.maxParticipants || '-' }}</span>
</template>
<template #action="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleView(record)">
查看
</a-button>
<a-button
v-if="record.status === 'DRAFT'"
type="text"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-button
v-if="record.status === 'DRAFT'"
type="text"
size="small"
@click="handlePublish(record)"
>
发布
</a-button>
<a-button
v-if="['DRAFT', 'PUBLISHED', 'IN_PROGRESS'].includes(record.status)"
type="text"
size="small"
danger
@click="handleCancel(record)"
>
取消
</a-button>
<a-button
v-if="record.status === 'DRAFT'"
type="text"
size="small"
danger
@click="handleDelete(record)"
>
删除
</a-button>
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
</a-space>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<TrainingPlanModal
v-model:visible="modalVisible"
:plan-data="currentPlan"
@success="handleModalSuccess"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref, watch } from 'vue'
import { Modal } from '@arco-design/web-vue'
import { IconPlus, IconRefresh, IconSearch } from '@arco-design/web-vue/es/icon'
import message from '@arco-design/web-vue/es/message'
import TrainingPlanModal from './components/TrainingPlanModal.vue'
import {
cancelTrainingPlan,
deleteTrainingPlan,
pageTrainingPlan,
publishTrainingPlan,
} from '@/apis/training'
import type { TrainingPlanPageQuery, TrainingPlanResp } from '@/types/training.d'
import router from '@/router'
defineOptions({ name: 'TrainingPlan' })
//
const searchForm = reactive<TrainingPlanPageQuery>({
planName: '',
trainingType: '',
trainingLevel: '',
status: '',
})
//
const tableData = ref<TrainingPlanResp[]>([])
const loading = ref(false)
//
const pagination = reactive<any>({
current: 1,
pageSize: 10,
total: 0,
showPageSize: true,
showJumper: true,
showTotal: (total: number) => `${total} 条记录`,
})
//
const modalVisible = ref(false)
const currentPlan = ref<TrainingPlanResp | null>(null)
//
const columns = [
{
title: '计划名称',
dataIndex: 'planName',
key: 'planName',
width: 200,
},
{
title: '培训类型',
dataIndex: 'trainingType',
key: 'trainingType',
slotName: 'trainingType',
width: 120,
},
{
title: '培训级别',
dataIndex: 'trainingLevel',
key: 'trainingLevel',
slotName: 'trainingLevel',
width: 120,
},
{
title: '培训讲师',
dataIndex: 'trainer',
key: 'trainer',
width: 120,
},
{
title: '培训地点',
dataIndex: 'trainingLocation',
key: 'trainingLocation',
width: 150,
},
{
title: '开始时间',
dataIndex: 'startTime',
key: 'startTime',
width: 150,
},
{
title: '结束时间',
dataIndex: 'endTime',
key: 'endTime',
width: 150,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
slotName: 'status',
width: 100,
},
{
title: '参训人数',
dataIndex: 'participants',
key: 'participants',
slotName: 'participants',
width: 100,
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 150,
},
{
title: '操作',
key: 'action',
slotName: 'action',
width: 200,
fixed: 'right',
},
]
//
const trainingTypeOptions = [
{ label: '安全教育', value: 'SAFETY' },
{ label: '技能培训', value: 'SKILL' },
{ label: '企业文化', value: 'CULTURE' },
]
const trainingLevelOptions = [
{ label: '现场级', value: 'SITE' },
{ label: '部门级', value: 'DEPARTMENT' },
{ label: '公司级', value: 'COMPANY' },
]
const statusOptions = [
{ label: '草稿', value: 'DRAFT' },
{ label: '已发布', value: 'PUBLISHED' },
{ label: '进行中', value: 'IN_PROGRESS' },
{ label: '已完成', value: 'COMPLETED' },
{ label: '已取消', value: 'CANCELLED' },
]
//
const getTrainingTypeText = (type: string) => {
const typeMap: Record<string, string> = {
SAFETY: '安全教育',
SKILL: '技能培训',
CULTURE: '企业文化',
安全教育: '安全教育',
技能培训: '技能培训',
企业文化: '企业文化',
}
return typeMap[type] || type
}
//
const getTrainingTypeColor = (type: string) => {
const colorMap: Record<string, string> = {
SAFETY: 'red',
SKILL: 'blue',
CULTURE: 'green',
安全教育: 'red',
技能培训: 'blue',
企业文化: 'green',
}
return colorMap[type] || 'default'
}
//
const getTrainingLevelText = (level: string) => {
const levelMap: Record<string, string> = {
SITE: '现场级',
DEPARTMENT: '部门级',
COMPANY: '公司级',
现场级: '现场级',
部门级: '部门级',
公司级: '公司级',
}
return levelMap[level] || level
}
//
const getTrainingLevelColor = (level: string) => {
const colorMap: Record<string, string> = {
SITE: 'orange',
DEPARTMENT: 'purple',
COMPANY: 'cyan',
现场级: 'orange',
部门级: 'purple',
公司级: 'cyan',
}
return colorMap[level] || 'default'
}
//
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
DRAFT: '草稿',
PUBLISHED: '已发布',
IN_PROGRESS: '进行中',
COMPLETED: '已完成',
CANCELLED: '已取消',
}
return statusMap[status] || status
}
//
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
DRAFT: 'default',
PUBLISHED: 'blue',
IN_PROGRESS: 'processing',
COMPLETED: 'success',
CANCELLED: 'error',
}
return colorMap[status] || 'default'
}
//
const transformBackendData = (data: any[]) => {
console.log('转换前的数据:', data)
return data.map((item) => {
console.log('处理单个项目:', item)
return {
planId: item.planId || item.id,
planName: item.planName || item.name,
trainingType: item.trainingType || item.type,
trainingLevel: item.trainingLevel || item.level,
trainingContent: item.trainingContent || item.content,
trainer: item.trainer,
trainingLocation: item.trainingLocation || item.location,
startTime: item.startTime ? new Date(item.startTime).toLocaleString() : '',
endTime: item.endTime ? new Date(item.endTime).toLocaleString() : '',
status: item.status,
maxParticipants: item.maxParticipants || item.maxParticipantsNum,
currentParticipants: item.currentParticipants || item.currentParticipantsNum || 0,
requirements: item.requirements,
remark: item.remark,
createTime: item.createTime ? new Date(item.createTime).toLocaleString() : '',
createBy: item.createBy || item.createByUser,
}
})
}
//
const loadData = async () => {
loading.value = true
try {
const params = {
pageSize: pagination.pageSize,
page: pagination.current,
...searchForm, //
}
const res = await pageTrainingPlan(params)
console.log('API响应:', res)
//
if (res.success || res.status === 200 || res.code === 200) {
//
let dataList: any[] = []
//
if (Array.isArray(res.data)) {
dataList = res.data
} else if (res.data && Array.isArray((res as any).rows)) {
dataList = (res as any).rows
} else if (res.data && Array.isArray((res.data as any).records)) {
dataList = (res.data as any).records
} else if (res.data && Array.isArray((res.data as any).list)) {
dataList = (res.data as any).list
}
console.log('处理后的数据列表:', dataList)
if (dataList.length > 0) {
const transformedData = transformBackendData(dataList)
console.log('转换后的数据:', transformedData)
tableData.value = transformedData
} else {
tableData.value = []
}
//
pagination.total = (res as any).total || (res.data as any)?.total || dataList.length || 0
console.log('总数:', pagination.total)
} else {
message.error(res.msg || '加载数据失败')
}
} catch (error) {
console.error('加载数据失败:', error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
//
const handleSearch = () => {
pagination.current = 1
loadData()
}
//
const handleReset = () => {
Object.assign(searchForm, {
planName: '',
trainingType: '',
trainingLevel: '',
status: '',
})
pagination.current = 1
loadData()
}
//
const handleTableChange = (pag: any) => {
pagination.current = pag.current || 1
pagination.pageSize = pag.pageSize || 10
loadData()
}
//
const handleAdd = () => {
currentPlan.value = null
modalVisible.value = true
}
//
const handleView = (record: TrainingPlanResp) => {
currentPlan.value = record
modalVisible.value = true
}
//
const handleEdit = (record: TrainingPlanResp) => {
currentPlan.value = record
modalVisible.value = true
}
//
const handlePublish = async (record: TrainingPlanResp) => {
try {
await publishTrainingPlan(record.planId)
message.success('发布成功')
loadData()
} catch (error) {
console.error('发布失败:', error)
message.error('发布失败')
}
}
//
const handleCancel = async (record: TrainingPlanResp) => {
Modal.confirm({
title: '确认取消',
content: `确定要取消培训计划"${record.planName}"吗?`,
onOk: async () => {
try {
await cancelTrainingPlan(record.planId)
message.success('取消成功')
loadData()
} catch (error) {
console.error('取消失败:', error)
message.error('取消失败')
}
},
})
}
//
const handleDelete = async (record: TrainingPlanResp) => {
Modal.confirm({
title: '确认删除',
content: `确定要删除培训计划"${record.planName}"吗?`,
onOk: async () => {
try {
await deleteTrainingPlan(record.planId)
message.success('删除成功')
loadData()
} catch (error) {
console.error('删除失败:', error)
message.error('删除失败')
}
},
})
}
//
const handleDetail = (record: TrainingPlanResp) => {
// planId
router.push({ name: 'TrainingDetail', params: { id: record.planId } })
}
//
const handleModalSuccess = () => {
modalVisible.value = false
loadData()
}
//
watch(tableData, (_newData) => {
//
}, { deep: true })
//
onMounted(() => {
loadData()
})
</script>
<style lang="scss" scoped>
.training-plan-container {
padding: 16px;
background: #f5f5f5;
min-height: 100vh;
.search-card {
margin-bottom: 16px;
}
.table-card {
.card-title {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
</style>

View File

@ -104,11 +104,11 @@ import { useWindowSize } from '@vueuse/core'
import { import {
type MessageQuery, type MessageQuery,
type MessageResp, type MessageResp,
deleteMessage,
getUserMessage,
listMessage, listMessage,
readAllMessage, deleteMessage,
readMessage, readMessage,
readAllMessage,
getUserMessage,
} from '@/apis' } from '@/apis'
import { useTable } from '@/hooks' import { useTable } from '@/hooks'
import { useDict } from '@/hooks/app' import { useDict } from '@/hooks/app'

View File

@ -5,6 +5,11 @@ import createVitePlugins from './config/plugins'
export default defineConfig(({ command, mode }) => { export default defineConfig(({ command, mode }) => {
const env = loadEnv(mode, process.cwd()) as ImportMetaEnv const env = loadEnv(mode, process.cwd()) as ImportMetaEnv
// 设置默认的WebSocket URL
if (!env.VITE_API_WS_URL) {
env.VITE_API_WS_URL = 'ws://localhost:8888'
}
return { return {
// 开发或生产环境服务的公共基础路径 // 开发或生产环境服务的公共基础路径
base: env.VITE_BASE, base: env.VITE_BASE,