Merge remote-tracking branch 'origin/develop' into wanglei_branch

pull/61/head
wanglei 3 months ago
commit 2cd0e67a5f

@ -0,0 +1,91 @@
# 小组会议纪要-第12周
## 会议记录概要
**团队名称:** 软1-汪汪队
**指导老师:** 肖雄仁
**主 持 人:** 曹峻茂
**记录人员:** 张红卫
**会议主题:** 第12周任务规划与分工
**会议地点:** 宿舍
**会议时间:** 2025-12-8 12:30-13:30
**记录时间:** 2025-12-8 18:00
**参与人员:** 曹峻茂、张红卫、罗月航、周竞由、王磊
---
## 会议内容
### 1. 本周总体目标
完成前后端核心模块联调与问题闭环,推进云服务器与数据库环境部署,为项目最终交付做好技术准备与系统集成。
### 2. 后端任务安排
**后端1云服务器与数据库环境配置**
- **负责人:** 曹峻茂
- **内容:**
- 完成云服务器环境搭建与系统初始化
- 配置生产数据库环境,完成数据迁移与备份策略
- 部署后端服务并完成初步连通性测试
**后端2联调支持与接口问题修复**
- **负责人:** 王磊
- **内容:**
- 配合前端完成统计模块、设备管理模块接口联调
- 修复接口响应慢、数据不一致等遗留问题
- 协助优化核心业务接口性能
**后端3权限与告警模块联调支持**
- **负责人:** 周竞由
- **内容:**
- 完成权限控制模块前后端联调,确保权限逻辑一致
- 支持告警推送功能与前端展示联调
- 协助修复联调中发现的权限与推送相关Bug
### 3. 前端任务安排
**前端1APP端联调与功能验证**
- **负责人:** 罗月航
- **内容:**
- 完成APP端用水记录、工单详情页面与后端接口联调
- 进行功能验证与用户体验优化
- 配合后端修复数据展示与交互问题
**前端2Web端联调与性能优化**
- **负责人:** 张红卫
- **内容:**
- 完成Web端统计页面、设备管理页面与后端接口联调
- 实施性能优化方案(分页、虚拟滚动、缓存等)
- 验证权限控制与异常处理逻辑
### 4. 联调与整合任务
**前后端联调**
- **参与人员:** 王磊、周竞由、罗月航、张红卫
- **内容:**
- 集中解决第11周联调遗留问题
- 完成所有核心业务流程端到端验证
- 输出联调测试报告与问题修复清单
### 5. 团队协作要求
- 每日晨会同步进展,晚前提交当日工作小结
- 联调问题统一记录至共享表格,明确责任人与解决时间
- 环境配置与部署过程中保持沟通,确保各环节顺利衔接
- 重点关注系统稳定性与数据一致性,为交付做好准备
### 6. 预期交付物
- 云服务器与生产数据库环境部署完成
- 前后端联调问题修复清单与测试报告
- 所有核心功能模块完成联调并稳定运行
- 性能优化与权限控制验证通过记录
- 项目部署文档初稿
### 7. 支持需求
- 希望获得关于云环境部署与数据库性能调优的指导
- 期待老师在系统集成与交付前测试方面给予建议
- 如遇部署或联调难题,可向研究生学长请求技术支援
---
**会议确认:**
全体与会人员确认会议内容与分工无异议。

@ -0,0 +1,30 @@
# 小组周计划-第12周
## 团队名称和起止时间
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-8
**结束时间:** 2025-12-14
## 本周任务计划安排
| 序号 | 计划内容 | 执行人 | 情况说明 |
|----|--------------------------------------------------------|----------------|------------------------|
| 1 | 确定本周计划分工 | 全体组员 | 2023-12-8 开会确定计划以及团队分工 |
| 2 | 前后端联调 | 王磊,周竞由,罗月航,张红卫 | 前后端联调解决核心问题 |
|3|配置云服务器和数据库环境|曹峻茂|配置云服务器和数据库环境|
## 小结
1. **沟通协作:** 每日简短同步进度,遇到阻塞问题及时召开临时会议协商解决,可向指导老师及研究生学长寻求技术支持。
2. **学习安排:** 结合项目需求深入学习权限管理和接口性能优化相关知识,分享学习笔记。
3. **项目管理:** PM 每日跟踪任务进度,记录问题并推动解决,确保按计划推进。
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. PM综合本小组成员工作情况提交小组周计划、周总结报告按时上传至代码托管平台。
---

@ -0,0 +1,37 @@
# 小组周总结-第11周
## 团队名称和起止时间
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-1
**结束时间:** 2025-12-7
## 本周任务计划安排
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----|--------------|------|----------------------------|
| 1 | 确定本周计划分工 | 完成 | 2023-11-24 开会确定计划以及团队分工 |
| 2 | 继续完善mqtt数据生成和接收及存储 | 完成 | 完善mqtt报文与接口 |
| 3 | 开发工单管理接口,扫码用水接口,水质信息查询接口 | 完成 | 完善相关接口 |
| 4 | 开发学生端扫码模块,水质信息页面,维修端工单抢单 / 拒单功能,工单处理页面,扫码用水触发功能 | 完成 | 完善app相关页面 |
| 5 | 开发设备监控页面功能,告警列表页面,开发工单列表页面,与后端联调 | 完成 | 开发设备监控页面功能,告警列表页面,开发工单列表页面 |
| 6 |开发告警触发逻辑,完成登录接口| 完成 | 开发告警触发逻辑,完成登录接口,与前端对接 |
| 7 | 前后端联调 | 进行中 | 前后端联调核心流程 |
## 小结
1. **沟通协作:** 小组成员应积极主动沟通,遇到困难及时寻求帮助,也可以主动向指导老师及研究生学长寻求建议。
2. **学习安排:** 小组成员仍处于软件开发专业知识的初步学习阶段,应合理安排自主学习时间,以便后续开发的顺利进行。
3. **项目管理:** 代码框架、依赖版本和jdk版本管理混乱导致浪费大量时间需要改进。
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. PM综合本小组成员工作情况提交小组周计划、周总结报告按时上传至代码托管平台。
---

@ -0,0 +1,32 @@
# 个人周计划-第11周
## 姓名和起止时间
**姓  名:** 曹峻茂
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-8
**结束时间:** 2025-12-14
## 本周任务计划安排
| 序号 | 计划内容 | 协作人 | 情况说明 |
|----|--------------------|----|-----------------------|
| 1 | 确定分工 | 组员 | 2023-12-8 开会确定计划和团队分工 |
|3|配置云服务器和数据库环境| 个人 |配置云服务器和数据库环境|
## 小结
1. **知识储备:** 学习后续需要使用的知识,为后续的开发做准备;
2. **文档撰写:** 完成迭代开发计划撰写。
3. **项目管理** 管理项目环境和框架
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
1. 请将个人计划和总结提前发给负责人;
1. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
1. 所有组员都需提交个人周计划、周总结文档,按时上传至代码托管平台;
---

@ -0,0 +1,35 @@
# 个人周总结-第11周
## 姓名和起止时间
**姓  名:** 曹峻茂
**团队名称:** 1班-汪汪队
**开始时间:** 2025-12-1
**结束时间:** 2025-12-7
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|----| ------------------------------------------------ |------|--------------------------------------------------------------|
| 1 | 确定分工 | 完成 | 2023-11-24 开会确定计划和团队分工 |
| 2 | 继续完善mqtt数据生成和接收及存储 | 完成 | 优化模拟器支持手动触发异常数据如模拟漏水、TDS 超标),增加断网重连功能。 |
|3|管理项目环境| 进行中 |管理项目环境,协调开发|
## 对团队工作的建议
1. **互助学习:** 小组成员应该根据自身的技能长短开展互帮互助的活动,共同努力提高小组成员的专业水平;
2. **进度统一:** 团队成员尽量统一项目进度;
## 小结
1. **项目管理:** 协调开发进度和前后端同步
2. **团队协作**:与团队成员保持良好的沟通协作,确保设计方向与产品需求一致
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. 所有组员都需提交个人周计划、周总结文档,上传至代码托管平台;

@ -0,0 +1,30 @@
# 个人周计划-第12周
## 姓名和起止时间
**姓  名:** [罗月航]
**团队名称:** 1班-汪汪队
**开始时间:** 2025-12-08
**结束时间:** 2025-12-14
## 本周任务计划安排
| 序号 | 计划内容 | 协作人 | 情况说明 |
| ---- | -------- | ------ | -------- |
| 1 | 运维APP工单功能联调 | 后端开发 | 调试工单创建、抢单、处理、完成等完整业务流程接口 |
| 2 | 运维APP设备监控联调 | 后端开发 | 调试设备数据实时获取、告警推送、状态更新等接口 |
| 3 | 学生端扫码功能联调 | 后端开发 | 调试扫码用水、水质查询、设备识别等核心功能接口 |
| 4 | 学生端用水记录联调 | 后端开发 | 调试个人用水量统计、历史记录查询、数据可视化接口 |
| 5 | 联调问题记录与修复 | 后端开发 | 记录联调过程中的问题并协同修复,确保功能稳定 |
## 小结
1. **联调协调**:需要与后端团队协调联调时间和环境,确保联调效率;
2. **测试数据**:需要准备充分的测试数据覆盖各种业务场景;
3. **问题追踪**:需要建立有效的问题追踪机制,确保问题及时解决;
4. **性能关注**:在联调过程中需要关注接口响应时间和前端性能表现;
5. **错误处理**:需要测试各种异常情况下的错误处理和用户提示;
6. **移动端适配**:需要确保联调功能在不同移动设备上的兼容性;

@ -0,0 +1,36 @@
# 个人周总结-第11周
## 姓名和起止时间
**姓  名:** [罗月航]
**团队名称:** 1班-汪汪队
**开始时间:** 2025-12-01
**结束时间:** 2025-12-07
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
| ---- | -------- | -------- | -------- |
| 1 | 运维人员APP页面样式优化 | 完成 | 对所有运维端页面进行了视觉统一和交互优化,提升了用户体验 |
| 2 | 学生端页面样式优化 | 完成 | 优化了学生端所有页面的视觉效果和布局结构,确保界面美观易用 |
| 3 | 登录功能接口对接 | 完成 | 成功对接后端登录接口,实现用户认证、权限验证和会话管理 |
| 4 | 注册功能接口对接 | 完成 | 完成用户注册接口对接,支持新用户账号创建和信息验证 |
| 5 | 响应式设计完善 | 完成 | 确保所有页面在不同屏幕尺寸下的良好显示效果 |
## 对团队工作的建议
1. **样式规范统一**:建议建立团队统一的设计规范文档,包括颜色、字体、间距等;
2. **组件库建设**:建议逐步建立前端组件库,提高开发效率和一致性;
3. **接口错误处理**:建议规范接口错误码和错误信息,便于前端统一处理用户提示;
4. **性能监控**:建议引入前端性能监控工具,及时发现和解决性能问题。
## 小结
1. **视觉体验提升**通过系统的样式优化运维人员APP和学生端的视觉体验得到显著改善
2. **接口对接成功**登录注册功能与后端API成功对接用户认证流程完整可靠
3. **设计一致性**:统一了两端应用的设计语言,保持了产品体验的一致性;
4. **技术难点攻克**:解决了移动端适配、接口错误处理、加载状态管理等技术难点;
5. **团队协作**:与后端开发团队紧密协作,接口对接过程高效顺利;
6. **质量保障**:在样式优化的同时,确保了功能完整性和用户体验的平衡;
7. **后续重点**:接下来将重点进行功能测试和性能优化,确保系统稳定运行。

@ -0,0 +1,89 @@
# 个人周计划-第十二周
## 基本信息
**姓  名:** 张红卫
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-8
**结束时间:** 2025-12-14
---
## 本周任务计划安排
| 序号 | 计划内容 | 执行人 | 情况说明 |
|------|----------|--------|----------|
| 1 | Web端统计页面联调与优化 | 个人 | 与王磊协作完成统计接口联调,优化图表加载性能,完善筛选交互 |
| 2 | Web端设备管理页面联调与优化 | 个人 | 与周竞由协作完成设备管理接口联调,实现权限控制与批量操作功能 |
| 3 | 前后端联调支持 | 个人 + 后端团队 | 参与团队联调,修复接口调用异常、数据展示不一致等问题 |
| 4 | 前端性能优化实施 | 个人 | 实施分页加载、虚拟滚动方案,优化大数据场景下的页面渲染性能 |
| 5 | 权限控制与异常处理完善 | 个人 | 配合后端完成权限逻辑对齐,优化导航栏与页面异常跳转处理 |
---
## 技术学习与实施重点
### 1. 联调协作能力提升
- 掌握前后端接口调试与问题定位方法
- 学习使用Postman等工具进行接口测试与数据验证
### 2. 性能优化实战深化
- 实施虚拟滚动完整方案,优化大数据列表展示
- 探索前端缓存策略与懒加载机制
- 学习使用Chrome DevTools进行性能分析与优化
### 3. 权限系统集成实践
- 完成路由级与操作级权限控制
- 实现动态菜单与按钮权限控制
- 学习RBAC权限模型在前端的应用
### 4. 异常处理与用户体验
- 完善各类异常状态下的用户提示
- 优化页面加载失败的重试与降级机制
- 实现友好的错误页面和加载状态
---
## 交付物清单
- [ ] 联调完成的统计页面代码(含性能优化)
- [ ] 联调完成的设备管理页面代码(含权限控制)
- [ ] 前后端联调问题修复记录
- [ ] 性能优化实施方案文档
- [ ] 权限控制测试验证报告
- [ ] 每日工作进展记录
---
## 协作依赖与风险说明
### 协作依赖
1. **接口依赖**
- 依赖王磊提供稳定、性能优化的统计查询接口
- 依赖周竞由提供完整的权限控制接口规范
- 需要设备管理相关接口的稳定支持
2. **环境依赖**
- 需要曹峻茂部署的测试环境支持联调验证
- 需要稳定的开发环境和版本控制支持
3. **流程依赖**
- 需要罗月航在APP端配合进行跨端流程验证
- 需要团队成员及时沟通接口变更
---
## 小结与期望
### 本周目标
完成Web端所有核心页面的前后端联调实现性能优化与权限控制确保功能稳定可用。
### 团队协作建议
1. 建议每日固定时间进行联调进度同步
2. 建立接口变更通知机制,减少沟通成本
3. 统一问题记录与跟踪方式,提高解决效率
### 支持需求
1. 希望获得前端性能优化(特别是大数据渲染)的实战指导
2. 期待老师在系统集成测试方面提供方法指导
3. 如遇技术难题,可向研究生学长请求支持

@ -0,0 +1,77 @@
# 个人周总结-第十一周
## 基本信息
**姓  名:** 张红卫
**团队名称:** 软1-汪汪队
**起止时间:** 2025-12-1 至 2025-12-7
**核心职责:** 前端界面开发 + 前后端联调支持
---
## 本周任务完成情况
| 序号 | 计划内容 | 完成状态 | 详细说明 |
|------|------------------------------------------------|----------|------------------------------------------------------------------------------------------------------------------|
| 1 | 完成 Web 端统计页面功能开发与交互优化 | 进行中 | 已完成 ECharts 图表框架集成,实现用水量、告警次数等基础图表展示;筛选功能已初步实现,接口数据对接正在调试中 |
| 2 | 完成 Web 端设备管理页面功能开发与交互优化 | 进行中 | 页面结构与基础表单组件已完成,支持设备增删改查;批量操作功能正在开发中,接口调试同步进行 |
| 3 | 参与前后端联调工作 | 进行中 | 与王磊、周竞由等后端成员保持每日沟通,统计模块接口已部分联调完成,设备管理模块接口正在逐步对接 |
| 4 | 性能优化与异常处理完善 | 进行中 | 已引入分页加载机制优化大数据展示;权限控制逻辑与后端对齐中;导航栏异常跳转处理逻辑已优化并测试 |
---
## 本周总结
### 一、任务进展概述
本周主要工作集中在 **统计页面** 和 **设备管理页面** 的前后端联调阶段。目前两个页面的基础功能框架已搭建完成,核心接口正在有序对接中。性能优化与权限控制方案已初步实施,但整体进度仍处于联调阶段,尚未完全完成。
### 二、技术实现与收获
1. **性能优化实践**
- 引入分页加载机制,初步缓解大数据渲染压力
2. **权限控制集成**
- 配合后端权限接口,搭建前端路由权限控制基础框架
- 完善导航栏跳转与操作权限校验逻辑
3. **复杂业务处理**
- 实现设备管理中的复杂表单验证与状态管理
- 掌握批量操作的前端实现方式
### 三、遇到的问题与挑战
1. **接口响应延迟**
- 部分统计查询接口响应时间较长,影响页面加载体验
2. **权限逻辑不一致**
- 前端权限控制逻辑需与后端进一步对齐,部分页面权限拦截尚未生效
3. **性能瓶颈**
- 大数据量图表渲染仍存在轻微卡顿,需进一步优化
4. **联调协作效率**
- 接口变更沟通不够及时,影响前端调试进度
### 四、协作建议
1. **接口规范明确**
- 建议后端提供完整的接口文档与示例数据
- 建立接口变更的版本管理机制
2. **联调流程优化**
- 每周安排专门联调会议
- 建立联调问题跟踪清单
3. **性能监控机制**
- 共同建立接口性能监控体系
- 定期进行性能测试与优化
### 五、工作总结与展望
本周在前后端联调过程中,进一步深入理解了系统整体架构与业务流程。通过实际调试,不仅提升了问题排查能力,也对前端性能优化和权限控制有了更深刻的认识。虽然进度比预期稍慢,但通过持续的沟通协作,各项任务正在稳步推进。
**期待与建议:**
- 希望团队成员继续加强沟通,及时同步进展和问题
- 建议建立更规范的联调流程,提高协作效率
- 期待后端接口性能进一步提升,为前端优化提供更好基础
---
**提交时间:** 2025-12-07
**下周重点:** 完成所有接口联调,实现性能优化目标,完善权限控制系统

@ -0,0 +1,22 @@
# 个人周计划-第12周
## 姓名和起止时间
**姓  名:** 周竞由
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-8
**结束时间:** 2025-12-14
## 本周任务计划安排
| 序号 | 计划内容 | 协作人 | 情况说明 |
|----|-----------------------------|-----|-------------------------------------|
| 1 | 前后端接口联调:核心业务接口(权限/告警/工单)全量对接验证 | 前端协作人员 | 梳理接口文档并核对字段定义,逐一验证参数传递、数据返回格式、异常场景处理逻辑,修复接口调用异常问题 |
| 2 | 页面与后端逻辑联调:权限展示/告警推送/工单流转页面 | 前端协作人员 | 联调各角色权限对应的页面展示逻辑,验证告警弹窗/列表与后端数据实时联动,确保工单全流程页面数据同步更新 |
| 3 | 联调问题闭环与性能优化:接口响应/数据一致性/多端兼容 | 前端协作人员 | 汇总联调问题清单并逐一修复,完成回归测试;优化高频接口响应速度,验证多浏览器/移动设备兼容性 |
## 小结
1. **接口验证:** 完成核心业务接口全量联调,统一前后端字段定义与异常处理标准,解决接口调用类问题;
2. **页面联调:** 实现权限、告警、工单相关页面与后端逻辑的精准联动,保障页面数据实时性与准确性;
3. **问题闭环:** 梳理并修复联调全流程问题,通过回归测试验证问题解决效果,确保无遗留问题;
4. **体验优化:** 针对高频接口做性能调优,提升页面交互响应速度,保障多端访问的兼容性与稳定性。

@ -0,0 +1,39 @@
# 个人周总结-第11周
## 姓名和起止时间
**姓  名:** 周竞由
**团队名称:** 软1-汪汪队
**开始时间:** 2025-12-1
**结束时间:** 2025-12-7
**核心职责:** 权限模块细化开发 + 告警功能优化 + 全流程闭环测试
## 本周任务完成情况
| 序号 | 计划内容 | 完成情况 | 说明 |
|----|-----------------------------|------|------------------------------------------|
| 1 | 权限细化:接口级权限控制(学生/维修人员/管理员) | 已完成 | 完成三类角色12个核心接口的访问范围划分开发并调试权限中间件解决2处角色权限冲突问题通过接口测试验证权限拦截有效性 |
| 2 | 告警功能完善:告警分级、推送、历史查询接口 | 已完成 | 实现一般/紧急两级告警标识逻辑完成Web弹窗、APP消息推送联调开发告警历史查询接口支持按时间/级别筛选),验证多端推送兼容性 |
| 3 | 全流程测试:设备异常→告警→工单→维修→恢复 | 部分完成 | 模拟8种真实业务场景含3种异常场景验证各环节数据同步与流程衔接定位并修复3个逻辑断点工单状态更新延迟、告警与工单关联失败等实现全流程闭环 |
## 本周总结
1. **阶段成果:**
本周完成权限体系的精细化落地与告警功能的全场景覆盖,通过全流程测试验证了核心业务链路的完整性。权限控制精准度、告警响应及时性及流程衔接顺畅度均达到预期,为后续前后端联调奠定了坚实基础。
2. **技术提升:**
- 深入理解JWT权限认证与中间件拦截原理掌握接口级权限控制的实现方法
- 熟悉Web/APP多端告警推送的联调技巧解决了跨端消息格式兼容问题
- 提升复杂业务流程的测试场景设计能力,学会通过场景复现定位链路断点。
3. **存在问题:**
- 权限模块对“临时角色”“跨角色操作”等特殊场景考虑不足,暂无适配方案;
- 全流程测试依赖人工模拟,测试效率较低,未形成自动化测试脚本;
4. **改进方向:**
- 补充特殊角色权限场景的需求分析新增3-5条关键用例并完善代码逻辑
- 优化告警推送机制,引入消息队列降低并发压力,提升推送实时性;
- 基于Postman或JMeter编写全流程自动化测试脚本减少人工重复操作
- 对告警查询接口添加索引优化SQL查询语句提升大数据量下的响应速度。
5. **工作体会:**
本周工作让我深刻认识到“功能完整性”与“场景适配性”的重要性。权限控制不仅要划分清晰,更要考虑实际业务中的特殊情况;全流程测试不仅要验证“正常路径”,更要覆盖“异常场景”。通过与前端、产品的沟通,意识到功能开发需提前对齐需求细节,避免因理解偏差导致返工。同时,性能优化是功能落地后的关键环节,后续需在开发初期就融入性能考量,提升系统整体稳定性。

@ -12,7 +12,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@ -27,7 +26,7 @@ import java.util.Arrays;
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true) // 启用方法级权限控制
@EnableMethodSecurity() // 启用方法级权限控制
public class SecurityConfig {
private final UserDetailsServiceImpl userDetailsService;

@ -0,0 +1,54 @@
package com.campus.water.controller.web;
import com.campus.water.entity.Admin;
import com.campus.water.service.AdminService;
import com.campus.water.util.ResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/web/admin")
@RequiredArgsConstructor
@Tag(name = "管理员管理接口", description = "Web管理端管理员列表查询接口")
public class AdminController {
private final AdminService adminService;
/**
*
* @param role super_admin/area_admin/viewer
* @param status active/inactive
*/
@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')") // 仅管理员可访问
@Operation(summary = "获取管理员列表", description = "支持按角色和状态筛选管理员")
public ResponseEntity<ResultVO<List<Admin>>> getAdminList(
@RequestParam(required = false) String role,
@RequestParam(required = false) String status
) {
try {
// 转换参数为枚举类型
Admin.AdminRole adminRole = role != null ? Admin.AdminRole.valueOf(role) : null;
Admin.AdminStatus adminStatus = status != null ? Admin.AdminStatus.valueOf(status) : null;
// 调用服务层查询
List<Admin> adminList = adminService.getAdminList(adminRole, adminStatus);
return ResponseEntity.ok(ResultVO.success(adminList));
} catch (IllegalArgumentException e) {
// 处理枚举参数错误
return ResponseEntity.ok(ResultVO.error(400, "无效的角色或状态参数: " + e.getMessage()));
} catch (Exception e) {
// 处理其他异常
return ResponseEntity.ok(ResultVO.error(500, "查询管理员列表失败: " + e.getMessage()));
}
}
}

@ -102,17 +102,24 @@ public class DeviceStatusController {
@GetMapping("/by-status")
@Operation(summary = "按状态查询设备", description = "根据状态查询设备列表")
public ResponseEntity<ResultVO<List<Device>>> getDevicesByStatus(
@RequestParam String status,
@RequestParam(required = false) String areaId,
@RequestParam(required = false) String deviceType) {
try {
List<Device> devices = deviceStatusService.getDevicesByStatus(status, areaId, deviceType);
return ResponseEntity.ok(ResultVO.success(devices));
} catch (Exception e) {
return ResponseEntity.ok(ResultVO.error(500, "查询设备失败: " + e.getMessage()));
}
@RequestParam String status,
@RequestParam(required = false) String areaId,
@RequestParam(required = false) String deviceType) {
// 添加默认值处理
if (deviceType == null || deviceType.isEmpty()) {
deviceType = "water_maker"; // 默认值
}
try {
List<Device> devices = deviceStatusService.getDevicesByStatus(status, areaId, deviceType);
return ResponseEntity.ok(ResultVO.success(devices));
} catch (Exception e) {
return ResponseEntity.ok(ResultVO.error(500, "查询设备失败: " + e.getMessage()));
}
}
@GetMapping("/status-count")
@Operation(summary = "设备状态数量统计", description = "统计各状态设备数量")
public ResponseEntity<ResultVO<Map<String, Object>>> getDeviceStatusCount(

@ -0,0 +1,53 @@
package com.campus.water.controller.web;
import com.campus.water.entity.User;
import com.campus.water.service.UserService;
import com.campus.water.util.ResultVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/web/user")
@RequiredArgsConstructor
@Tag(name = "学生管理接口", description = "Web管理端学生列表查询接口")
public class UserController {
private final UserService userService;
/**
*
* @param studentName
* @param status active/inactive
*/
@GetMapping("/list")
@PreAuthorize("hasRole('ADMIN')") // 仅管理员可访问
@Operation(summary = "获取学生用户列表", description = "支持按姓名和状态筛选学生")
public ResponseEntity<ResultVO<List<User>>> getUserList(
@RequestParam(required = false) String studentName,
@RequestParam(required = false) String status
) {
try {
// 转换状态参数为枚举类型
User.UserStatus userStatus = status != null ? User.UserStatus.valueOf(status) : null;
// 调用服务层查询
List<User> userList = userService.getUserList(studentName, userStatus);
return ResponseEntity.ok(ResultVO.success(userList));
} catch (IllegalArgumentException e) {
// 处理枚举参数错误
return ResponseEntity.ok(ResultVO.error(400, "无效的状态参数: " + e.getMessage()));
} catch (Exception e) {
// 处理其他异常
return ResponseEntity.ok(ResultVO.error(500, "查询学生列表失败: " + e.getMessage()));
}
}
}

@ -0,0 +1,34 @@
package com.campus.water.service;
import com.campus.water.entity.Admin;
import com.campus.water.mapper.AdminRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AdminService {
private final AdminRepository adminRepository;
/**
*
*/
public List<Admin> getAdminList(Admin.AdminRole role, Admin.AdminStatus status) {
if (role != null && status != null) {
// 按角色和状态筛选
return adminRepository.findByRoleAndStatus(role, status);
} else if (role != null) {
// 仅按角色筛选
return adminRepository.findByRole(role);
} else if (status != null) {
// 仅按状态筛选
return adminRepository.findByStatus(status);
} else {
// 查询所有管理员
return adminRepository.findAll();
}
}
}

@ -12,6 +12,7 @@ import com.campus.water.entity.dto.request.LoginRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import java.util.UUID;
@ -32,22 +33,33 @@ public class LoginService {
return switch (userType) {
case "admin" -> handleAdminLogin(username, password);
case "user" -> handleUserLogin(username, password);
case "repairer" -> handleRepairerLogin(username, password);
case "repairman" -> handleRepairmanLogin(username, password);
default -> throw new RuntimeException("无效的用户类型:" + userType);
};
}
private LoginVO handleAdminLogin(String username, String password) {
Admin admin = adminRepository.findByAdminName(username)
.orElseThrow(() -> new RuntimeException("管理员不存在"));
if (!passwordEncoder.matches(password, admin.getPassword())) {
throw new RuntimeException("密码错误");
}
Admin admin = adminRepository.findByAdminName(username)
.orElseThrow(() -> new RuntimeException("管理员不存在"));
boolean matches;
// 临时支持 MD5 验证(仅用于测试环境)
if (admin.getPassword().startsWith("$2a$") || admin.getPassword().startsWith("$2y$")) {
// BCrypt 格式密码
matches = passwordEncoder.matches(password, admin.getPassword());
} else {
// MD5 格式密码
String md5Password = DigestUtils.md5DigestAsHex(password.getBytes());
matches = md5Password.equals(admin.getPassword());
}
return createLoginVO(admin.getAdminId(), username, "admin");
if (!matches) {
throw new RuntimeException("密码错误");
}
return createLoginVO(admin.getAdminId(), username, "admin");
}
private LoginVO handleUserLogin(String username, String password) {
// 改为查询User实体使用studentName字段匹配用户名
User user = userRepository.findByStudentName(username)
@ -62,7 +74,7 @@ public class LoginService {
return createLoginVO(user.getStudentId(), username, "user");
}
private LoginVO handleRepairerLogin(String username, String password) {
private LoginVO handleRepairmanLogin(String username, String password) {
// 此处将RepairerAuthPO改为RepairerAuth
RepairerAuth repairer = repairerAuthRepository.findByUsername(username)
.orElseThrow(() -> new RuntimeException("维修人员不存在"));
@ -71,7 +83,7 @@ public class LoginService {
throw new RuntimeException("密码错误");
}
return createLoginVO(repairer.getRepairmanId(), username, "repairer");
return createLoginVO(repairer.getRepairmanId(), username, "repairman");
}
private LoginVO createLoginVO(String userId, String username, String userType) {

@ -40,8 +40,8 @@ public class RegisterService {
case "user":
handleUserRegister(username, encryptedPwd, request);
break;
case "repairer":
handleRepairerRegister(username, encryptedPwd, request);
case "repairman":
handleRepairmanRegister(username, encryptedPwd, request);
break;
default:
throw new RuntimeException("无效的用户类型:" + userType);
@ -89,7 +89,7 @@ public class RegisterService {
}
// 维修人员注册逻辑保持不变
private void handleRepairerRegister(String username, String password, RegisterRequest request) {
private void handleRepairmanRegister(String username, String password, RegisterRequest request) {
if (repairerAuthRepository.existsByUsername(username)) {
throw new RuntimeException("维修人员用户名已存在");
}
@ -97,12 +97,12 @@ public class RegisterService {
throw new RuntimeException("维修人员ID已被注册");
}
RepairerAuth repairer = new RepairerAuth();
repairer.setUsername(username);
repairer.setPassword(password);
repairer.setRepairmanId(request.getRepairmanId());
repairer.setAccountStatus(RepairerAuth.AccountStatus.active);
RepairerAuth repairman = new RepairerAuth();
repairman.setUsername(username);
repairman.setPassword(password);
repairman.setRepairmanId(request.getRepairmanId());
repairman.setAccountStatus(RepairerAuth.AccountStatus.active);
repairerAuthRepository.save(repairer);
repairerAuthRepository.save(repairman);
}
}

@ -0,0 +1,34 @@
package com.campus.water.service;
import com.campus.water.entity.User;
import com.campus.water.mapper.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
/**
*
*/
public List<User> getUserList(String studentName, User.UserStatus status) {
if (studentName != null && status != null) {
// 按姓名模糊查询和状态筛选
return userRepository.findByStudentNameContainingAndStatus(studentName, status);
} else if (studentName != null) {
// 仅按姓名模糊查询
return userRepository.findByStudentNameContaining(studentName);
} else if (status != null) {
// 仅按状态筛选
return userRepository.findByStatus(status);
} else {
// 查询所有学生
return userRepository.findAll();
}
}
}

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer"
]
}

@ -0,0 +1,44 @@
# app2
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,28 @@
{
"name": "app2",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test:unit": "vitest"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"@vue/test-utils": "^2.4.6",
"jsdom": "^27.2.0",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5",
"vitest": "^4.0.14"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@ -0,0 +1,70 @@
<script setup>
import { RouterView } from 'vue-router'
import { onMounted } from 'vue'
onMounted(() => {
//
document.addEventListener('touchstart', function(event) {
if (event.touches.length > 1) {
event.preventDefault()
}
}, { passive: false })
//
let lastTouchEnd = 0
document.addEventListener('touchend', function(event) {
const now = Date.now()
if (now - lastTouchEnd <= 300) {
event.preventDefault()
}
lastTouchEnd = now
}, false)
})
</script>
<template>
<div class="app-container">
<div class="mobile-frame">
<RouterView />
</div>
</div>
</template>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #f0f2f5;
padding: 20px;
}
.mobile-frame {
width: 375px;
height: 667px;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
overflow: hidden;
position: relative;
border: 1px solid #e8e8e8;
}
/* 移动端适配 */
@media (max-width: 420px) {
.app-container {
padding: 0;
background: #f0f2f5;
}
.mobile-frame {
width: 100%;
height: 100vh;
border-radius: 0;
box-shadow: none;
border: none;
}
}
</style>

@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@ -0,0 +1,138 @@
/* 更新现有的 main.css */
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
/* 移动端优化 */
.no-scroll {
overflow: hidden;
position: fixed;
width: 100%;
height: 100%;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 移动端点击效果优化 */
@media (hover: none) and (pointer: coarse) {
* {
cursor: pointer;
}
input,
textarea,
select,
button {
font-size: 16px !important; /* 防止iOS缩放 */
}
a,
button {
-webkit-tap-highlight-color: transparent;
}
}
/* 修复移动端overflow-scrolling问题 */
.element

@ -0,0 +1,37 @@
/* 移动端全局样式 */
:root {
--mobile-width: 375px;
--mobile-height: 667px;
--primary-color: #1156b1;
--secondary-color: #81d3f8;
--success-color: #04d919;
--warning-color: #ff9800;
--danger-color: #f44336;
}
/* 移动端基础样式 */
#app {
width: 100%;
min-height: 100vh;
background: #f5f5f5;
overflow-x: hidden;
}
/* 移动端容器 */
.mobile-container {
max-width: var(--mobile-width);
min-height: var(--mobile-height);
margin: 0 auto;
background: white;
position: relative;
overflow: hidden;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
}
/* 安全区域适配 */
.safe-area {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}

@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>

@ -0,0 +1,95 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>

@ -0,0 +1,86 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>

@ -0,0 +1,11 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import HelloWorld from '../HelloWorld.vue'
describe('HelloWorld', () => {
it('renders properly', () => {
const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
expect(wrapper.text()).toContain('Hello Vitest')
})
})

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>

@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>

@ -0,0 +1,15 @@
import './assets/main.css'
import './assets/mobile.css' // 添加这行
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

@ -0,0 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'StudentLoginPage',
component: () => import('../views/StudentLoginPage.vue')
},
{
path: '/home',
name: 'HomePage',
component: () => import('../views/HomePage.vue')
},
{
path: '/water-quality',
name: 'WaterQuality',
component: () => import('../views/WaterQualityPage.vue')
},
{
path: '/scan',
name: 'ScanPage',
component: () => import('../views/ScanPage.vue')
},
{
path: '/profile',
name: 'ProfilePage',
component: () => import('../views/ProfilePage.vue')
},
{
path: '/history',
name: 'HistoryPage',
component: () => import('../views/HistoryPage.vue')
}
]
})
export default router

@ -0,0 +1,11 @@
// src/services/api.js
import axios from 'axios'
const apiClient = axios.create({
baseURL: 'http://localhost:8080', // Adjust to your backend URL
headers: {
'Content-Type': 'application/json'
}
})
export default apiClient

@ -0,0 +1,58 @@
// src/services/authServices.js
import api from './api'
export const authServices = {
// 通用登录接口
async login(loginData) {
try {
const response = await api.post('/api/common/login', loginData)
return response.data
} catch (error) {
throw error.response?.data || error.message
}
},
// 学生登录接口
async studentLogin(studentId, password) {
try {
const loginData = {
username: studentId,
password: password,
userType: 'user' // 注意:后端中用户类型是"user"
}
const response = await api.post('/api/common/login', loginData)
return response.data
} catch (error) {
throw error.response?.data || error.message
}
},
// 修改:学生注册接口 - 使用正确的路径和字段
async studentRegister(registerData) {
try {
// 构建符合RegisterRequest的数据
const requestData = {
username: registerData.name, // 用户名(学生姓名)
password: registerData.password, // 密码
userType: 'user', // 固定为用户类型
studentId: registerData.studentId, // 学号
studentName: registerData.name // 学生姓名
// 注意后端RegisterRequest目前只支持这些字段
// 手机号、邮箱等需要扩展User实体和RegisterRequest
}
console.log('发送注册数据到后端:', requestData)
const response = await api.post('/api/common/register', requestData)
console.log('后端注册响应:', response.data)
return response.data
} catch (error) {
console.error('注册API错误:', error)
// 返回更详细的错误信息
const errorData = error.response?.data || {
code: 500,
message: error.message || '网络错误'
}
throw errorData
}
}
}

@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

@ -0,0 +1,501 @@
<template>
<div class="history-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">历史记录</div>
<button class="back-btn" @click="goBack"></button>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 统计信息 -->
<div class="summary-info">
<div class="total-count">
{{ historyList.length }}条记录
</div>
</div>
<!-- 历史记录列表 -->
<div class="history-list">
<!-- 今日 -->
<div class="date-group" v-if="todayRecords.length > 0">
<div class="group-title">今日</div>
<div class="records-container">
<div
v-for="record in todayRecords"
:key="record.id"
class="record-card"
>
<div class="record-header">
<div class="device-name">{{ record.deviceName }}</div>
<div class="record-time">{{ record.time }}</div>
</div>
<div class="record-details">
<div class="water-amount">{{ record.amount }}</div>
<div class="device-id">ID: {{ record.deviceId }}</div>
</div>
</div>
</div>
</div>
<!-- 07-20 -->
<div class="date-group" v-if="dateGroupedRecords['07-20']?.length > 0">
<div class="group-title">07-20</div>
<div class="records-container">
<div
v-for="record in dateGroupedRecords['07-20']"
:key="record.id"
class="record-card"
>
<div class="record-header">
<div class="device-name">{{ record.deviceName }}</div>
<div class="record-time">{{ record.time }}</div>
</div>
<div class="record-details">
<div class="water-amount">{{ record.amount }}</div>
<div class="device-id">ID: {{ record.deviceId }}</div>
</div>
</div>
</div>
</div>
<!-- 07-19 -->
<div class="date-group" v-if="dateGroupedRecords['07-19']?.length > 0">
<div class="group-title">07-19</div>
<div class="records-container">
<div
v-for="record in dateGroupedRecords['07-19']"
:key="record.id"
class="record-card"
>
<div class="record-header">
<div class="device-name">{{ record.deviceName }}</div>
<div class="record-time">{{ record.time }}</div>
</div>
<div class="record-details">
<div class="water-amount">{{ record.amount }}</div>
<div class="device-id">ID: {{ record.deviceId }}</div>
</div>
</div>
</div>
</div>
<!-- 07-18 -->
<div class="date-group" v-if="dateGroupedRecords['07-18']?.length > 0">
<div class="group-title">07-18</div>
<div class="records-container">
<div
v-for="record in dateGroupedRecords['07-18']"
:key="record.id"
class="record-card"
>
<div class="record-header">
<div class="device-name">{{ record.deviceName }}</div>
<div class="record-time">{{ record.time }}</div>
</div>
<div class="record-details">
<div class="water-amount">{{ record.amount }}</div>
<div class="device-id">ID: {{ record.deviceId }}</div>
</div>
</div>
</div>
</div>
<!-- 没有记录时显示 -->
<div class="empty-state" v-if="historyList.length === 0">
<div class="empty-icon">📊</div>
<div class="empty-text">暂无历史记录</div>
<div class="empty-hint">开始取水后会在这里显示记录</div>
</div>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-button" @click="goToPage('home')">
<div class="nav-icon">🗺</div>
<div class="nav-text">地图</div>
</div>
<div class="nav-button" @click="goToPage('scan')">
<div class="nav-icon">📷</div>
<div class="nav-text">扫码</div>
</div>
<div class="nav-button" @click="goToPage('profile')">
<div class="nav-icon">👤</div>
<div class="nav-text">我的</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const historyList = ref([
{
id: 1,
date: '今日',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:08',
amount: '200ml'
},
{
id: 2,
date: '今日',
deviceName: '湖南大学信息楼2F饮水机',
deviceId: 'B301',
time: '09:28',
amount: '200ml'
},
{
id: 3,
date: '今日',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '08:30',
amount: '200ml'
},
{
id: 4,
date: '07-20',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:40',
amount: '200ml'
},
{
id: 5,
date: '07-19',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:08',
amount: '200ml'
},
{
id: 6,
date: '07-19',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:08',
amount: '200ml'
},
{
id: 7,
date: '07-18',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:08',
amount: '200ml'
},
{
id: 8,
date: '07-18',
deviceName: '湖南大学教学楼1F饮水机',
deviceId: 'A201',
time: '12:08',
amount: '200ml'
}
])
//
const dateGroupedRecords = computed(() => {
const grouped = {}
historyList.value.forEach(record => {
if (record.date !== '今日') {
if (!grouped[record.date]) {
grouped[record.date] = []
}
grouped[record.date].push(record)
}
})
return grouped
})
//
const todayRecords = computed(() => {
return historyList.value.filter(record => record.date === '今日')
})
//
const loadHistoryFromStorage = () => {
const savedHistory = localStorage.getItem('waterHistory')
if (savedHistory) {
try {
const parsedHistory = JSON.parse(savedHistory)
//
if (parsedHistory && parsedHistory.length > 0) {
historyList.value = [...parsedHistory, ...historyList.value]
}
} catch (error) {
console.error('加载历史记录失败:', error)
}
}
}
//
const goBack = () => {
router.back()
}
//
const goToPage = (page) => {
switch(page) {
case 'home':
router.push('/home')
break
case 'scan':
router.push('/scan')
break
case 'profile':
router.push('/profile')
break
}
}
onMounted(() => {
loadHistoryFromStorage()
})
</script>
<style scoped>
.history-page {
width: 375px;
height: 667px;
background: #f5f5f5;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 顶部标题栏 */
.header {
height: 40px;
background: linear-gradient(135deg, #1156b1 0%, #81d3f8 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: white;
letter-spacing: 1px;
}
.back-btn {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 20px 0;
overflow-y: auto;
}
/* 统计信息 */
.summary-info {
padding: 0 16px 16px;
}
.total-count {
font-size: 14px;
color: #666;
font-weight: 500;
}
/* 历史记录列表 */
.history-list {
padding: 0 16px;
}
.date-group {
margin-bottom: 20px;
}
.group-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-left: 8px;
border-left: 4px solid #1890ff;
}
.records-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-card {
background: white;
border-radius: 12px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e8e8e8;
transition: all 0.3s;
}
.record-card:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #1890ff;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.device-name {
font-size: 14px;
font-weight: 500;
color: #333;
flex: 1;
margin-right: 12px;
}
.record-time {
font-size: 12px;
color: #999;
flex-shrink: 0;
}
.record-details {
display: flex;
justify-content: space-between;
align-items: center;
}
.water-amount {
font-size: 14px;
font-weight: 600;
color: #1890ff;
}
.device-id {
font-size: 12px;
color: #666;
background: #f8f9fa;
padding: 4px 8px;
border-radius: 12px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-text {
font-size: 16px;
color: #666;
margin-bottom: 8px;
}
.empty-hint {
font-size: 14px;
color: #999;
}
/* 底部导航栏 */
.bottom-nav {
height: 60px;
background: white;
border-top: 1px solid #e8e8e8;
display: grid;
grid-template-columns: repeat(3, 1fr);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.nav-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
color: #666;
background: none;
border: none;
padding: 0;
}
.nav-button:hover {
background: #f8f9fa;
}
.nav-button.active {
color: #1890ff;
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
}
.nav-text {
font-size: 12px;
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 420px) {
.history-page {
width: 100%;
height: 100vh;
}
.main-content {
padding: 16px 0;
}
.record-card {
padding: 12px;
}
.device-name {
font-size: 13px;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,681 @@
<template>
<div class="profile-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">个人主页</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 用户信息卡片 -->
<div class="user-card">
<div class="avatar-section">
<div class="avatar-circle">
<div class="avatar-text">{{ userInfo.lastName }}</div>
</div>
<div class="user-name">{{ userInfo.fullName }}</div>
</div>
<div class="divider"></div>
<!-- 用户详细信息 -->
<div class="user-details">
<div class="detail-item">
<span class="detail-label">学号</span>
<span class="detail-value">{{ userInfo.studentId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">学院</span>
<span class="detail-value">{{ userInfo.college }}</span>
</div>
<div class="detail-item">
<span class="detail-label">班级</span>
<span class="detail-value">{{ userInfo.class }}</span>
</div>
</div>
</div>
<!-- 数据统计卡片 -->
<div class="stats-section">
<div class="stats-cards">
<div class="stat-card">
<div class="stat-value">{{ userStats.days }}</div>
<div class="stat-label">累计用水天数</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ userStats.todayWater }}</div>
<div class="stat-label">今日饮水量</div>
</div>
</div>
</div>
<!-- 时间切换 -->
<div class="time-switch">
<div
class="time-option"
:class="{ active: selectedPeriod === 'week' }"
@click="selectPeriod('week')"
>
本周
</div>
<div
class="time-option"
:class="{ active: selectedPeriod === 'month' }"
@click="selectPeriod('month')"
>
本月
</div>
</div>
<!-- 饮水数据图表 -->
<div class="chart-section">
<div class="chart-title">饮水统计图表</div>
<div class="chart-container">
<!-- 柱状图容器 -->
<div class="chart-placeholder" v-if="!showChart">
<div class="placeholder-text">数据加载中...</div>
</div>
<!-- 模拟柱状图 -->
<div class="mock-chart" v-else>
<div class="chart-axis">
<!-- Y轴 -->
<div class="y-axis">
<div class="y-label">800ml</div>
<div class="y-label">600ml</div>
<div class="y-label">400ml</div>
<div class="y-label">200ml</div>
<div class="y-label">0ml</div>
</div>
<!-- 柱状图 -->
<div class="bars-container">
<div
v-for="(item, index) in chartData"
:key="index"
class="bar-item"
>
<div
class="bar"
:style="{ height: item.value + 'px' }"
:class="{ active: item.active }"
>
<div class="bar-value">{{ item.value }}ml</div>
</div>
<div class="bar-label">{{ item.label }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 功能按钮 -->
<div class="action-section">
<button class="action-btn history-btn" @click="goToHistory">
<span class="btn-icon">📊</span>
<span class="btn-text">查看历史饮水记录</span>
<span class="btn-arrow"></span>
</button>
<button class="action-btn setting-btn" @click="goToSettings">
<span class="btn-icon"></span>
<span class="btn-text">设置</span>
<span class="btn-arrow"></span>
</button>
<button class="action-btn logout-btn" @click="handleLogout">
<span class="btn-icon">🚪</span>
<span class="btn-text">退出登录</span>
</button>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-button" @click="goToPage('home')">
<div class="nav-icon">🗺</div>
<div class="nav-text">地图</div>
</div>
<div class="nav-button" @click="goToPage('scan')">
<div class="nav-icon">📷</div>
<div class="nav-text">扫码</div>
</div>
<div class="nav-button active" @click="goToPage('profile')">
<div class="nav-icon">👤</div>
<div class="nav-text">我的</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const userInfo = reactive({
lastName: '张',
fullName: '张三',
studentId: '20212601212',
college: '信息科学与工程学院',
class: '软件2301班'
})
//
const userStats = reactive({
days: '26天',
todayWater: '500ml'
})
//
const selectedPeriod = ref('week')
const showChart = ref(false)
const chartData = ref([])
//
const initChartData = () => {
if (selectedPeriod.value === 'week') {
//
chartData.value = [
{ label: '周一', value: 450, active: false },
{ label: '周二', value: 620, active: false },
{ label: '周三', value: 380, active: false },
{ label: '周四', value: 540, active: true },
{ label: '周五', value: 280, active: false },
{ label: '周六', value: 720, active: false },
{ label: '周日', value: 400, active: false }
]
} else {
// 4
chartData.value = [
{ label: '第1周', value: 1800, active: false },
{ label: '第2周', value: 2200, active: false },
{ label: '第3周', value: 2500, active: true },
{ label: '第4周', value: 800, active: false }
]
}
}
//
const selectPeriod = (period) => {
selectedPeriod.value = period
initChartData()
}
//
const goToHistory = () => {
router.push('/history')
}
//
const goToSettings = () => {
alert('设置页面(待开发)')
}
// 退
const handleLogout = () => {
if (confirm('确定要退出登录吗?')) {
//
localStorage.removeItem('studentId')
localStorage.removeItem('studentName')
//
router.push('/')
}
}
//
const goToPage = (page) => {
switch(page) {
case 'home':
router.push('/home')
break
case 'scan':
router.push('/scan')
break
case 'profile':
//
break
}
}
onMounted(() => {
// API
const savedStudentId = localStorage.getItem('studentId')
const savedStudentName = localStorage.getItem('studentName')
if (savedStudentId) {
userInfo.studentId = savedStudentId
}
if (savedStudentName) {
userInfo.fullName = savedStudentName
userInfo.lastName = savedStudentName.charAt(0)
}
//
initChartData()
//
setTimeout(() => {
showChart.value = true
}, 500)
})
</script>
<style scoped>
.profile-page {
width: 375px;
height: 667px;
background: #f5f5f5;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 顶部标题栏 */
.header {
height: 40px;
background: linear-gradient(135deg, #1156b1 0%, #81d3f8 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: white;
letter-spacing: 1px;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 20px 16px;
overflow-y: auto;
}
/* 用户信息卡片 */
.user-card {
background: white;
border-radius: 16px;
padding: 24px 20px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.avatar-section {
text-align: center;
margin-bottom: 20px;
}
.avatar-circle {
width: 70px;
height: 70px;
background: linear-gradient(135deg, #409eff, #66b1ff);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 12px;
border: 3px solid #f2f2f2;
}
.avatar-text {
font-size: 28px;
font-weight: 600;
color: white;
}
.user-name {
font-size: 18px;
font-weight: 600;
color: #333;
}
/* 分割线 */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e8e8e8, transparent);
margin: 20px 0;
}
/* 用户详细信息 */
.user-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-item {
display: flex;
align-items: center;
font-size: 14px;
}
.detail-label {
color: #666;
min-width: 60px;
}
.detail-value {
color: #333;
font-weight: 500;
}
/* 数据统计卡片 */
.stats-section {
margin-bottom: 20px;
}
.stats-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 20px 16px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e8e8e8;
transition: all 0.3s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: #1890ff;
margin-bottom: 8px;
}
.stat-label {
font-size: 12px;
color: #666;
}
/* 时间切换 */
.time-switch {
display: flex;
background: white;
border-radius: 25px;
padding: 4px;
margin-bottom: 20px;
border: 1px solid #e8e8e8;
}
.time-option {
flex: 1;
text-align: center;
padding: 10px;
font-size: 14px;
color: #666;
cursor: pointer;
border-radius: 21px;
transition: all 0.3s;
}
.time-option.active {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
font-weight: 500;
}
/* 图表区域 */
.chart-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
}
.chart-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-left: 4px;
border-left: 4px solid #1890ff;
}
.chart-container {
min-height: 200px;
position: relative;
}
.chart-placeholder {
width: 100%;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
border-radius: 8px;
border: 1px dashed #e8e8e8;
}
.placeholder-text {
color: #999;
font-size: 14px;
}
/* 模拟柱状图 */
.mock-chart {
height: 200px;
position: relative;
}
.chart-axis {
display: flex;
height: 100%;
padding-left: 40px;
position: relative;
}
.y-axis {
position: absolute;
left: 0;
top: 0;
bottom: 20px;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
width: 40px;
}
.y-label {
font-size: 10px;
color: #999;
transform: translateY(50%);
}
.bars-container {
flex: 1;
display: flex;
justify-content: space-around;
align-items: flex-end;
height: 100%;
padding-bottom: 20px;
border-bottom: 1px solid #e8e8e8;
}
.bar-item {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 30px;
}
.bar {
width: 20px;
background: linear-gradient(to top, #409eff, #66b1ff);
border-radius: 4px 4px 0 0;
position: relative;
transition: height 0.5s ease;
margin-bottom: 4px;
}
.bar.active {
background: linear-gradient(to top, #ff6b6b, #ff8e8e);
}
.bar-value {
position: absolute;
top: -24px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #666;
white-space: nowrap;
}
.bar-label {
font-size: 10px;
color: #666;
margin-top: 4px;
}
/* 功能按钮区域 */
.action-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.action-btn {
background: white;
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 16px 20px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.3s;
}
.action-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.btn-icon {
font-size: 20px;
margin-right: 12px;
}
.btn-text {
flex: 1;
text-align: left;
font-size: 14px;
color: #333;
font-weight: 500;
}
.btn-arrow {
color: #999;
font-size: 18px;
}
.history-btn:hover {
border-color: #409eff;
background: #f0f5ff;
}
.setting-btn:hover {
border-color: #67c23a;
background: #f0f9eb;
}
.logout-btn:hover {
border-color: #f56c6c;
background: #fef0f0;
}
/* 底部导航栏 */
.bottom-nav {
height: 60px;
background: white;
border-top: 1px solid #e8e8e8;
display: grid;
grid-template-columns: repeat(3, 1fr);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.nav-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
color: #666;
background: none;
border: none;
padding: 0;
}
.nav-button:hover {
background: #f8f9fa;
}
.nav-button.active {
color: #1890ff;
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
}
.nav-text {
font-size: 12px;
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 420px) {
.profile-page {
width: 100%;
height: 100vh;
}
.main-content {
padding: 16px 12px;
}
.stats-cards {
grid-template-columns: 1fr;
}
.user-card {
padding: 20px 16px;
}
.bars-container {
gap: 4px;
}
.bar-item {
width: 25px;
}
}
</style>

@ -0,0 +1,821 @@
<template>
<div class="scan-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">扫码取水</div>
<button class="back-btn" @click="goBack"></button>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 扫描区域 -->
<div class="scan-section" v-if="!deviceInfo">
<div class="scan-area">
<!-- 扫描框 -->
<div class="scan-frame">
<div class="scan-lines">
<div class="scan-line"></div>
</div>
<div class="scan-corners">
<div class="corner top-left"></div>
<div class="corner top-right"></div>
<div class="corner bottom-left"></div>
<div class="corner bottom-right"></div>
</div>
</div>
<div class="scan-instruction">
请扫描设备二维码
</div>
<div class="scan-hint">
将二维码放入框内即可自动扫描
</div>
</div>
<!-- 手动输入选项 -->
<div class="manual-input" @click="showManualInput">
<span class="manual-icon">🔢</span>
<span class="manual-text">手动输入设备ID</span>
</div>
</div>
<!-- 设备信息区域扫描后显示 -->
<div class="device-section" v-else>
<div class="device-card">
<div class="device-header">
<div class="device-icon">🚰</div>
<div class="device-info">
<div class="device-name">{{ deviceInfo.name }}</div>
<div class="device-id">ID: {{ deviceInfo.id }}</div>
</div>
<div class="device-status" :class="deviceInfo.status">
{{ deviceInfo.statusText }}
</div>
</div>
<div class="divider"></div>
<!-- 取水量选择 -->
<div class="water-amount-section">
<div class="section-title">选择取水量</div>
<div class="amount-options">
<div
v-for="amount in waterAmounts"
:key="amount.value"
class="amount-option"
:class="{ selected: selectedAmount === amount.value }"
@click="selectAmount(amount.value)"
>
<div class="amount-value">{{ amount.value }}ml</div>
<div class="amount-price">{{ amount.price }}</div>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button class="action-btn primary" @click="confirmWater" :disabled="!selectedAmount">
确认取水
</button>
<button class="action-btn secondary" @click="viewWaterQuality">
查看水质
</button>
</div>
</div>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-button" @click="goToPage('home')">
<div class="nav-icon">🗺</div>
<div class="nav-text">地图</div>
</div>
<div class="nav-button active" @click="goToPage('scan')">
<div class="nav-icon">📷</div>
<div class="nav-text">扫码</div>
</div>
<div class="nav-button" @click="goToPage('profile')">
<div class="nav-icon">👤</div>
<div class="nav-text">我的</div>
</div>
</div>
<!-- 手动输入弹窗 -->
<div v-if="showManualDialog" class="dialog-overlay">
<div class="dialog-content">
<div class="dialog-header">
<h3>手动输入设备ID</h3>
<button class="close-btn" @click="closeManualDialog"></button>
</div>
<div class="dialog-body">
<input
type="text"
v-model="manualDeviceId"
placeholder="请输入设备IDA201"
class="device-input"
@keyup.enter="submitManualInput"
/>
<div class="input-hint">可在设备上找到二维码下方的ID</div>
</div>
<div class="dialog-footer">
<button class="dialog-btn secondary" @click="closeManualDialog">
取消
</button>
<button class="dialog-btn primary" @click="submitManualInput">
确定
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const deviceInfo = ref(null)
const showManualDialog = ref(false)
const manualDeviceId = ref('')
const selectedAmount = ref(null)
//
const waterAmounts = [
{ value: 150, price: '免费' },
{ value: 200, price: '免费' },
{ value: 250, price: '免费' }
]
//
const mockDevices = {
'A201': {
id: 'A201',
name: '湖南大学教学楼1F饮水机',
status: 'online',
statusText: '在线'
},
'B201': {
id: 'B201',
name: '天马学生公寓1F饮水机',
status: 'online',
statusText: '在线'
},
'C101': {
id: 'C101',
name: '图书馆2F饮水机',
status: 'offline',
statusText: '离线'
}
}
// 使API
const simulateScan = () => {
// A201
setTimeout(() => {
deviceInfo.value = mockDevices['A201']
}, 1000)
}
onMounted(() => {
//
//
simulateScan()
})
//
const selectAmount = (amount) => {
selectedAmount.value = amount
}
//
const confirmWater = () => {
if (!selectedAmount.value) {
alert('请选择取水量')
return
}
alert(`开始取水:${selectedAmount.value}ml`)
// API
}
//
const viewWaterQuality = () => {
if (deviceInfo.value) {
router.push({
path: '/water-quality',
query: { deviceId: deviceInfo.value.id }
})
}
}
//
const showManualInput = () => {
showManualDialog.value = true
}
//
const closeManualDialog = () => {
showManualDialog.value = false
manualDeviceId.value = ''
}
//
const submitManualInput = () => {
if (!manualDeviceId.value.trim()) {
alert('请输入设备ID')
return
}
const device = mockDevices[manualDeviceId.value.trim()]
if (device) {
deviceInfo.value = device
closeManualDialog()
} else {
alert('设备ID不存在请检查后重新输入')
}
}
//
const goBack = () => {
if (deviceInfo.value) {
//
deviceInfo.value = null
selectedAmount.value = null
} else {
//
router.push('/home')
}
}
//
const goToPage = (page) => {
switch(page) {
case 'home':
router.push('/home')
break
case 'scan':
//
break
case 'profile':
router.push('/profile')
break
}
}
</script>
<style scoped>
.scan-page {
width: 375px;
height: 667px;
background: #f5f5f5;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 顶部标题栏 */
.header {
height: 40px;
background: linear-gradient(135deg, #1156b1 0%, #81d3f8 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: white;
letter-spacing: 1px;
}
.back-btn {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
/* 扫描区域 */
.scan-section {
text-align: center;
}
.scan-area {
background: white;
border-radius: 12px;
padding: 30px 20px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
/* 扫描框 */
.scan-frame {
width: 250px;
height: 250px;
margin: 0 auto 20px;
position: relative;
border: 2px solid rgba(24, 144, 255, 0.3);
border-radius: 12px;
background: rgba(255, 255, 255, 0.8);
overflow: hidden;
}
/* 扫描线动画 */
.scan-lines {
position: absolute;
width: 100%;
height: 100%;
}
.scan-line {
position: absolute;
width: 100%;
height: 2px;
background: linear-gradient(90deg, transparent, #1890ff, transparent);
top: 20%;
animation: scanLine 2s infinite linear;
}
@keyframes scanLine {
0% {
top: 20%;
}
50% {
top: 80%;
}
100% {
top: 20%;
}
}
/* 扫描框四角 */
.scan-corners {
position: absolute;
width: 100%;
height: 100%;
}
.corner {
position: absolute;
width: 20px;
height: 20px;
border: 3px solid #1890ff;
}
.corner.top-left {
top: -3px;
left: -3px;
border-right: none;
border-bottom: none;
border-radius: 6px 0 0 0;
}
.corner.top-right {
top: -3px;
right: -3px;
border-left: none;
border-bottom: none;
border-radius: 0 6px 0 0;
}
.corner.bottom-left {
bottom: -3px;
left: -3px;
border-right: none;
border-top: none;
border-radius: 0 0 0 6px;
}
.corner.bottom-right {
bottom: -3px;
right: -3px;
border-left: none;
border-top: none;
border-radius: 0 0 6px 0;
}
.scan-instruction {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.scan-hint {
font-size: 14px;
color: #666;
}
/* 手动输入 */
.manual-input {
background: white;
border: 1px dashed #1890ff;
border-radius: 8px;
padding: 12px 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s;
}
.manual-input:hover {
background: #f0f5ff;
transform: translateY(-1px);
}
.manual-icon {
font-size: 18px;
}
.manual-text {
font-size: 14px;
color: #1890ff;
font-weight: 500;
}
/* 设备信息卡片 */
.device-section {
padding: 10px 0;
}
.device-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.device-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.device-icon {
font-size: 32px;
}
.device-info {
flex: 1;
}
.device-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.device-id {
font-size: 14px;
color: #1890ff;
font-weight: 500;
}
.device-status {
font-size: 12px;
font-weight: 600;
padding: 4px 12px;
border-radius: 12px;
}
.device-status.online {
background: rgba(4, 217, 25, 0.1);
color: #04d919;
}
.device-status.offline {
background: rgba(170, 170, 170, 0.1);
color: #aaaaaa;
}
/* 分割线 */
.divider {
height: 1px;
background: linear-gradient(90deg, transparent, #e8e8e8, transparent);
margin: 20px 0;
}
/* 取水量选择 */
.water-amount-section {
margin-bottom: 20px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-left: 4px;
border-left: 4px solid #1890ff;
}
.amount-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.amount-option {
background: #f8f9fa;
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 16px 8px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.amount-option:hover {
transform: translateY(-2px);
border-color: #1890ff;
}
.amount-option.selected {
background: #f0f5ff;
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
}
.amount-value {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.amount-price {
font-size: 12px;
color: #1890ff;
font-weight: 500;
}
/* 操作按钮 */
.action-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.action-btn {
padding: 14px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.action-btn.primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
}
.action-btn.secondary {
background: #f0f5ff;
color: #1890ff;
border: 1px solid #d6e4ff;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
/* 底部导航栏 */
.bottom-nav {
height: 60px;
background: white;
border-top: 1px solid #e8e8e8;
display: grid;
grid-template-columns: repeat(3, 1fr);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.nav-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
color: #666;
background: none;
border: none;
padding: 0;
}
.nav-button:hover {
background: #f8f9fa;
}
.nav-button.active {
color: #1890ff;
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
}
.nav-text {
font-size: 12px;
font-weight: 500;
}
/* 手动输入弹窗 */
.dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.dialog-content {
background: white;
border-radius: 16px;
width: 320px;
max-width: 90%;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 20px 16px;
border-bottom: 1px solid #e8e8e8;
}
.dialog-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.close-btn {
width: 28px;
height: 28px;
border: none;
background: #f5f5f5;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
color: #666;
}
.dialog-body {
padding: 20px;
}
.device-input {
width: 100%;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 6px;
font-size: 14px;
margin-bottom: 8px;
}
.device-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.input-hint {
font-size: 12px;
color: #666;
text-align: left;
}
.dialog-footer {
display: flex;
gap: 12px;
padding: 16px 20px 20px;
}
.dialog-btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.dialog-btn.primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
}
.dialog-btn.secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #e8e8e8;
}
.dialog-btn:hover {
transform: translateY(-1px);
}
.dialog-btn.primary:hover {
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* 响应式调整 */
@media (max-width: 420px) {
.scan-page {
width: 100%;
height: 100vh;
}
.main-content {
padding: 16px;
}
.scan-frame {
width: 200px;
height: 200px;
}
.amount-options {
grid-template-columns: 1fr;
}
.action-buttons {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,745 @@
<template>
<div class="student-login-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">学生服务平台</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 登录表单 -->
<div class="login-form" v-if="!showRegister">
<div class="welcome-text">
<h2>欢迎回来</h2>
<p>请登录您的账户</p>
</div>
<div class="form-group">
<label for="studentId">学号</label>
<input
type="text"
id="studentId"
v-model="loginForm.studentId"
placeholder="请输入学号"
@keyup.enter="handleLogin"
/>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="password-input">
<input
:type="showPassword ? 'text' : 'password'"
id="password"
v-model="loginForm.password"
placeholder="请输入密码"
@keyup.enter="handleLogin"
/>
<button
type="button"
class="password-toggle"
@click="showPassword = !showPassword"
>
<span v-if="showPassword"></span>
<span v-else></span>
</button>
</div>
</div>
<div class="form-options">
<label class="remember-me">
<input type="checkbox" v-model="loginForm.rememberMe" />
<span>记住我</span>
</label>
<button class="forgot-password" @click="handleForgotPassword">
忘记密码?
</button>
</div>
<button class="login-btn primary" @click="handleLogin" :disabled="loading">
<span v-if="loading">...</span>
<span v-else></span>
</button>
<div class="register-prompt">
还没有账户
<button class="register-link" @click="showRegister = true">
立即注册
</button>
</div>
</div>
<!-- 注册表单 -->
<div class="register-form" v-else>
<div class="welcome-text">
<h2>创建账户</h2>
<p>注册学生账户</p>
</div>
<div class="form-group">
<label for="regStudentId">学号</label>
<input
type="text"
id="regStudentId"
v-model="registerForm.studentId"
placeholder="请输入学号"
:class="{ 'has-error': errors.studentId }"
/>
<div v-if="errors.studentId" class="error-message">
{{ errors.studentId }}
</div>
</div>
<div class="form-group">
<label for="regName">姓名</label>
<input
type="text"
id="regName"
v-model="registerForm.name"
placeholder="请输入真实姓名"
:class="{ 'has-error': errors.name }"
/>
<div v-if="errors.name" class="error-message">
{{ errors.name }}
</div>
</div>
<div class="form-group">
<label for="regPhone">手机号</label>
<input
type="tel"
id="regPhone"
v-model="registerForm.phone"
placeholder="请输入手机号"
:class="{ 'has-error': errors.phone }"
/>
<div v-if="errors.phone" class="error-message">
{{ errors.phone }}
</div>
</div>
<div class="form-group">
<label for="regPassword">密码</label>
<div class="password-input">
<input
:type="showRegPassword ? 'text' : 'password'"
id="regPassword"
v-model="registerForm.password"
placeholder="请输入密码6-20位"
:class="{ 'has-error': errors.password }"
/>
<button
type="button"
class="password-toggle"
@click="showRegPassword = !showRegPassword"
>
<span v-if="showRegPassword"></span>
<span v-else></span>
</button>
</div>
<div v-if="errors.password" class="error-message">
{{ errors.password }}
</div>
</div>
<div class="form-group">
<label for="regConfirmPassword">确认密码</label>
<div class="password-input">
<input
:type="showConfirmPassword ? 'text' : 'password'"
id="regConfirmPassword"
v-model="registerForm.confirmPassword"
placeholder="请再次输入密码"
:class="{ 'has-error': errors.confirmPassword }"
/>
<button
type="button"
class="password-toggle"
@click="showConfirmPassword = !showConfirmPassword"
>
<span v-if="showConfirmPassword"></span>
<span v-else></span>
</button>
</div>
<div v-if="errors.confirmPassword" class="error-message">
{{ errors.confirmPassword }}
</div>
</div>
<div class="form-group">
<label for="regEmail">邮箱选填</label>
<input
type="email"
id="regEmail"
v-model="registerForm.email"
placeholder="请输入邮箱"
:class="{ 'has-error': errors.email }"
/>
<div v-if="errors.email" class="error-message">
{{ errors.email }}
</div>
</div>
<div class="agreement">
<label>
<input type="checkbox" v-model="registerForm.agreed" />
<span>我已阅读并同意</span>
<button class="agreement-link" @click="showAgreement">
用户协议
</button>
<button class="agreement-link" @click="showPrivacy">
隐私政策
</button>
</label>
<div v-if="errors.agreement" class="error-message">
{{ errors.agreement }}
</div>
</div>
<div class="action-buttons">
<button class="register-btn primary" @click="handleRegister" :disabled="loading">
<span v-if="loading">...</span>
<span v-else></span>
</button>
<button class="register-btn secondary" @click="showRegister = false">
返回登录
</button>
</div>
</div>
</div>
<!-- 底部信息 -->
<div class="footer">
<!-- 这里只保留空白没有文字内容 -->
</div>
</div>
</template>
<!-- 只修改 <script> 部分<template> <style> 保持不变 -->
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { authServices } from '@/services/authServices' //
const router = useRouter()
// /
const showRegister = ref(false)
//
const loginForm = reactive({
studentId: '',
password: '',
rememberMe: false
})
//
const registerForm = reactive({
studentId: '',
name: '',
phone: '',
password: '',
confirmPassword: '',
email: '',
agreed: false
})
//
const errors = reactive({
studentId: '',
name: '',
phone: '',
password: '',
confirmPassword: '',
email: '',
agreement: ''
})
//
const loading = ref(false)
const showPassword = ref(false)
const showRegPassword = ref(false)
const showConfirmPassword = ref(false)
//
const validateLogin = () => {
if (!loginForm.studentId.trim()) {
alert('请输入学号')
return false
}
if (!loginForm.password) {
alert('请输入密码')
return false
}
return true
}
const validateRegister = () => {
let isValid = true
Object.keys(errors).forEach(key => errors[key] = '')
if (!registerForm.studentId.trim()) {
errors.studentId = '请输入学号'
isValid = false
}
if (!registerForm.name.trim()) {
errors.name = '请输入姓名'
isValid = false
}
if (!registerForm.phone.trim()) {
errors.phone = '请输入手机号'
isValid = false
}
if (!registerForm.password) {
errors.password = '请输入密码'
isValid = false
} else if (registerForm.password.length < 6) {
errors.password = '密码长度至少6位'
isValid = false
}
if (registerForm.password !== registerForm.confirmPassword) {
errors.confirmPassword = '两次输入的密码不一致'
isValid = false
}
if (!registerForm.agreed) {
errors.agreement = '请阅读并同意用户协议和隐私政策'
isValid = false
}
return isValid
}
//
const handleLogin = async () => {
if (!validateLogin()) return
loading.value = true
try {
console.log('开始学生登录:', loginForm.studentId)
//
const result = await authServices.studentLogin(
loginForm.studentId,
loginForm.password
)
console.log('登录响应:', result)
if (result.code === 200) {
//
const loginData = result.data
//
localStorage.setItem('token', loginData.token)
localStorage.setItem('userId', loginData.userId)
localStorage.setItem('username', loginData.username)
localStorage.setItem('userType', loginData.userType)
localStorage.setItem('studentId', loginForm.studentId)
//
if (loginForm.rememberMe) {
localStorage.setItem('rememberedStudentId', loginForm.studentId)
} else {
localStorage.removeItem('rememberedStudentId')
}
alert('登录成功!')
//
router.push('/home')
} else {
//
alert('登录失败: ' + (result.message || '未知错误'))
}
} catch (error) {
console.error('登录过程异常:', error)
//
if (error.message && error.message.includes('Network Error')) {
alert('网络连接失败,请检查网络或服务器状态')
} else if (error.message && error.message.includes('timeout')) {
alert('请求超时,请稍后重试')
} else if (error.code === 500) {
alert('服务器内部错误,请联系管理员')
} else {
alert('登录失败: ' + (error.message || '未知错误'))
}
} finally {
loading.value = false
}
}
//
// <script setup> handleRegister
const handleRegister = async () => {
if (!validateRegister()) return
loading.value = true
try {
// RegisterRequest
const registerData = {
studentId: registerForm.studentId.trim(),
name: registerForm.name.trim(),
password: registerForm.password,
// RegisterRequestphoneemail
// RegisterRequestUser
}
console.log('前端注册数据:', registerData)
//
const result = await authServices.studentRegister(registerData)
console.log('注册完整响应:', result)
// ResultVO
if (result && result.code === 200) {
//
alert('注册成功!请使用您的学号和密码登录')
showRegister.value = false
//
Object.keys(registerForm).forEach(key => {
if (key !== 'agreed') {
registerForm[key] = ''
}
})
registerForm.agreed = false
//
Object.keys(errors).forEach(key => errors[key] = '')
} else {
//
const errorMsg = result?.message || '注册失败,请稍后重试'
alert('注册失败: ' + errorMsg)
}
} catch (error) {
console.error('注册过程异常:', error)
//
let errorMessage = '注册失败,请稍后重试'
if (error.code === 400) {
errorMessage = error.message || '请求参数错误,请检查填写的信息'
} else if (error.code === 409) {
errorMessage = error.message || '用户已存在'
} else if (error.message) {
// message
if (typeof error.message === 'object' && error.message.message) {
errorMessage = error.message.message
} else if (typeof error.message === 'string') {
errorMessage = error.message
}
}
alert('注册失败: ' + errorMessage)
} finally {
loading.value = false
}
}
//
const handleForgotPassword = () => {
alert('请联系管理员重置密码admin@example.com')
}
const showAgreement = () => {
alert('用户协议内容...')
}
const showPrivacy = () => {
alert('隐私政策内容...')
}
//
const initRememberedUser = () => {
const rememberedStudentId = localStorage.getItem('rememberedStudentId')
if (rememberedStudentId) {
loginForm.studentId = rememberedStudentId
loginForm.rememberMe = true
}
}
//
initRememberedUser()
</script>
<style scoped>
.student-login-page {
width: 375px;
height: 667px;
background: #f8f9fa;
display: flex;
flex-direction: column;
position: relative;
overflow-y: auto;
}
/* 顶部标题栏 */
.header {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
padding: 16px;
color: white;
text-align: center;
}
.header-title {
font-size: 18px;
font-weight: 600;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 24px 16px;
overflow-y: auto;
}
/* 欢迎文本 */
.welcome-text {
text-align: center;
margin-bottom: 32px;
}
.welcome-text h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.welcome-text p {
font-size: 14px;
color: #666;
}
/* 表单组 */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
background: white;
}
.form-group input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.form-group input.has-error {
border-color: #ff4d4f;
}
.form-group input::placeholder {
color: #999;
}
/* 密码输入框 */
.password-input {
position: relative;
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #1890ff;
font-size: 12px;
cursor: pointer;
padding: 4px;
}
/* 错误消息 */
.error-message {
color: #ff4d4f;
font-size: 12px;
margin-top: 4px;
}
/* 表单选项 */
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
font-size: 14px;
}
.remember-me {
display: flex;
align-items: center;
gap: 6px;
color: #666;
cursor: pointer;
}
.remember-me input {
width: 16px;
height: 16px;
}
.forgot-password {
background: none;
border: none;
color: #1890ff;
font-size: 14px;
cursor: pointer;
padding: 0;
}
/* 登录/注册按钮 */
.login-btn,
.register-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 16px;
}
.login-btn.primary,
.register-btn.primary {
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
}
.login-btn.secondary,
.register-btn.secondary {
background: white;
color: #1890ff;
border: 1px solid #1890ff;
}
.login-btn:disabled,
.register-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-btn:hover:not(:disabled),
.register-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* 注册提示 */
.register-prompt {
text-align: center;
font-size: 14px;
color: #666;
margin-top: 24px;
}
.register-link {
background: none;
border: none;
color: #1890ff;
font-size: 14px;
cursor: pointer;
padding: 0;
font-weight: 500;
}
/* 协议同意 */
.agreement {
margin: 24px 0;
font-size: 12px;
color: #666;
}
.agreement label {
display: flex;
align-items: flex-start;
gap: 6px;
cursor: pointer;
}
.agreement input {
margin-top: 2px;
flex-shrink: 0;
}
.agreement-link {
background: none;
border: none;
color: #1890ff;
padding: 0;
cursor: pointer;
}
/* 注册按钮组 */
.action-buttons {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 底部信息 - 保持布局但无内容 */
.footer {
padding: 8px;
text-align: center;
background: white;
border-top: 1px solid #e8e8e8;
}
/* 响应式调整 */
@media (max-width: 420px) {
.student-login-page {
width: 100%;
height: 100vh;
}
.main-content {
padding: 20px 12px;
}
.welcome-text {
margin-bottom: 24px;
}
.welcome-text h2 {
font-size: 22px;
}
.login-btn,
.register-btn {
padding: 12px;
}
.footer {
padding: 4px;
}
}
</style>

@ -0,0 +1,535 @@
<template>
<div class="water-quality-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">水质详情</div>
<button class="back-btn" @click="goBack"></button>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 设备信息 -->
<div class="device-info-section">
<h2 class="device-name">{{ deviceInfo.name }}</h2>
<div class="device-details">
<span class="distance">{{ deviceInfo.distance }}</span>
<span class="separator">|</span>
<span class="device-id">ID: {{ deviceInfo.id }}</span>
<span class="separator">|</span>
<span class="device-status" :class="deviceInfo.status">
{{ deviceInfo.statusText }}
</span>
</div>
</div>
<!-- 水质检测标题 -->
<div class="section-title">
<span>TDS水质检测</span>
</div>
<!-- TDS水质数据卡片 -->
<div class="quality-cards">
<div class="quality-card tap-water">
<div class="card-title">自来水TDS</div>
<div class="card-value">{{ deviceInfo.waterQuality.tapWater }}</div>
<div class="card-unit">mg/L</div>
</div>
<div class="quality-card pure-water">
<div class="card-title">纯净水TDS</div>
<div class="card-value">{{ deviceInfo.waterQuality.pureWater }}</div>
<div class="card-unit">mg/L</div>
</div>
<div class="quality-card mineral-water">
<div class="card-title">矿化水TDS</div>
<div class="card-value">{{ deviceInfo.waterQuality.mineralWater }}</div>
<div class="card-unit">mg/L</div>
</div>
</div>
<!-- 滤芯状态标题 -->
<div class="section-title">
<span>滤芯状态</span>
</div>
<!-- 滤芯状态 -->
<div class="filter-status-section">
<!-- 前置滤芯组 -->
<div class="filter-item">
<div class="filter-info">
<div class="filter-name">前置滤芯组</div>
<div class="filter-life">{{ deviceInfo.filters.preFilter.life }}</div>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: deviceInfo.filters.preFilter.percentage + '%' }"></div>
</div>
</div>
<!-- 纯水滤芯组 -->
<div class="filter-item">
<div class="filter-info">
<div class="filter-name">纯水滤芯组</div>
<div class="filter-life">{{ deviceInfo.filters.pureFilter.life }}</div>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: deviceInfo.filters.pureFilter.percentage + '%' }"></div>
</div>
</div>
<!-- 矿化滤芯组 -->
<div class="filter-item">
<div class="filter-info">
<div class="filter-name">矿化滤芯组</div>
<div class="filter-life">{{ deviceInfo.filters.mineralFilter.life }}</div>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: deviceInfo.filters.mineralFilter.percentage + '%' }"></div>
</div>
</div>
</div>
<!-- 水质说明 -->
<div class="quality-explanation">
<div class="explanation-title">水质说明</div>
<div class="explanation-content">
<div class="explanation-item">
<span class="explanation-label">自来水TDS</span>
<span class="explanation-value">{{ deviceInfo.waterQuality.tapWater }} mg/L</span>
<span class="explanation-status up">(偏高)</span>
</div>
<div class="explanation-item">
<span class="explanation-label">纯净水TDS</span>
<span class="explanation-value">{{ deviceInfo.waterQuality.pureWater }} mg/L</span>
<span class="explanation-status good">(优良)</span>
</div>
<div class="explanation-item">
<span class="explanation-label">矿化水TDS</span>
<span class="explanation-value">{{ deviceInfo.waterQuality.mineralWater }} mg/L</span>
<span class="explanation-status good">(优良)</span>
</div>
</div>
<div class="explanation-text">
TDS值越低水质越纯净纯净水TDS&lt;50为优良矿化水TDS 50-150为适宜范围
</div>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-button" @click="goToPage('home')">
<div class="nav-icon">🗺</div>
<div class="nav-text">地图</div>
</div>
<div class="nav-button" @click="goToPage('scan')">
<div class="nav-icon">📷</div>
<div class="nav-text">扫码</div>
</div>
<div class="nav-button" @click="goToPage('profile')">
<div class="nav-icon">👤</div>
<div class="nav-text">我的</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const deviceInfo = reactive({
name: '湖南大学教学楼1F饮水机',
distance: '128m',
id: 'A201',
status: 'online',
statusText: '在线',
waterQuality: {
tapWater: '285',
pureWater: '13',
mineralWater: '87'
},
filters: {
preFilter: {
name: '前置滤芯组',
life: '100%',
percentage: 100
},
pureFilter: {
name: '纯水滤芯组',
life: '80%',
percentage: 80
},
mineralFilter: {
name: '矿化滤芯组',
life: '70%',
percentage: 70
}
}
})
// ID
onMounted(() => {
//
const deviceId = router.currentRoute.value.query.deviceId || 'A201'
// deviceId
console.log('加载设备数据:', deviceId)
})
//
const goBack = () => {
router.back()
}
//
const goToPage = (page) => {
switch(page) {
case 'home':
router.push('/home')
break
case 'scan':
router.push('/scan')
break
case 'profile':
router.push('/profile')
break
}
}
</script>
<style scoped>
.water-quality-page {
width: 375px;
height: 667px;
background: #f5f5f5;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* 顶部标题栏 */
.header {
height: 40px;
background: linear-gradient(135deg, #1156b1 0%, #81d3f8 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: white;
letter-spacing: 1px;
}
.back-btn {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 20px 16px;
overflow-y: auto;
}
/* 设备信息 */
.device-info-section {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.device-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.device-details {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.distance {
color: #666;
}
.separator {
color: #ccc;
}
.device-id {
color: #1890ff;
font-weight: 500;
}
.device-status {
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.device-status.online {
background: rgba(4, 217, 25, 0.1);
color: #04d919;
}
.device-status.offline {
background: rgba(170, 170, 170, 0.1);
color: #aaaaaa;
}
/* 分区标题 */
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin: 20px 0 16px;
padding-left: 4px;
border-left: 4px solid #1890ff;
}
/* TDS水质卡片 */
.quality-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.quality-card {
background: white;
border-radius: 10px;
padding: 16px 12px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
border: 1px solid #e8e8e8;
transition: all 0.3s;
}
.quality-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-title {
font-size: 12px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.card-value {
font-size: 24px;
font-weight: bold;
color: #1890ff;
margin-bottom: 4px;
}
.card-unit {
font-size: 11px;
color: #999;
}
/* 滤芯状态 */
.filter-status-section {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.filter-item {
margin-bottom: 20px;
}
.filter-item:last-child {
margin-bottom: 0;
}
.filter-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.filter-name {
font-size: 14px;
font-weight: 500;
color: #333;
}
.filter-life {
font-size: 14px;
font-weight: 600;
color: #1890ff;
}
.progress-bar {
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #1890ff, #52c41a);
border-radius: 4px;
transition: width 0.5s ease;
}
/* 水质说明 */
.quality-explanation {
background: #f0f5ff;
border-radius: 8px;
padding: 16px;
margin-top: 20px;
border-left: 4px solid #1890ff;
}
.explanation-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.explanation-content {
margin-bottom: 12px;
}
.explanation-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
}
.explanation-label {
color: #666;
min-width: 80px;
}
.explanation-value {
color: #333;
font-weight: 500;
margin: 0 8px;
}
.explanation-status {
font-size: 12px;
padding: 2px 6px;
border-radius: 10px;
}
.explanation-status.good {
background: rgba(4, 217, 25, 0.1);
color: #04d919;
}
.explanation-status.up {
background: rgba(255, 152, 0, 0.1);
color: #ff9800;
}
.explanation-text {
font-size: 12px;
color: #666;
line-height: 1.4;
padding-top: 8px;
border-top: 1px solid rgba(24, 144, 255, 0.2);
}
/* 底部导航栏 */
.bottom-nav {
height: 60px;
background: white;
border-top: 1px solid #e8e8e8;
display: grid;
grid-template-columns: repeat(3, 1fr);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}
.nav-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
color: #666;
background: none;
border: none;
padding: 0;
}
.nav-button:hover {
background: #f8f9fa;
}
.nav-button.active {
color: #1890ff;
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
}
.nav-text {
font-size: 12px;
font-weight: 500;
}
/* 响应式调整 */
@media (max-width: 420px) {
.water-quality-page {
width: 100%;
height: 100vh;
}
.quality-cards {
grid-template-columns: 1fr;
}
.main-content {
padding: 16px 12px;
}
}
</style>

@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})

@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)

@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8080
VITE_APP_ORIGIN=http://localhost:5173

@ -1,58 +1,61 @@
import type { LoginRequest, LoginVO } from './types/auth'
// 替换原文件内容
import type { LoginRequest, LoginResponse, LoginVO } from './types/auth'
// 真实的登录API调用
export const realLoginApi = async (data: LoginRequest): Promise<LoginVO> => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
console.log('🌐 调用登录接口:', `${API_BASE_URL}/api/common/login`)
console.log('📤 请求数据:', data)
try {
const response = await fetch(`${API_BASE_URL}/api/common/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
console.log('📥 响应状态:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`网络请求失败: ${response.status} ${response.statusText}`)
export const realLoginApi = async (data: LoginRequest): Promise<LoginResponse> => {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
console.log('🌐 调用登录接口:', `${API_BASE_URL}/api/common/login`)
console.log('📤 请求数据:', data)
try {
const response = await fetch(`${API_BASE_URL}/api/common/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
console.log('📥 响应状态:', response.status, response.statusText)
if (!response.ok) {
const errorText = await response.text()
console.error('❌ 响应内容:', errorText)
throw new Error(`网络请求失败: ${response.status} ${response.statusText}`)
}
const result: LoginResponse = await response.json()
console.log('✅ 登录响应:', result)
return result
} catch (error: any) {
console.error('❌ 登录接口调用失败:', error)
throw new Error(`登录失败: ${error.message}`)
}
const result: LoginVO = await response.json()
console.log('✅ 登录响应:', result)
return result
} catch (error: any) {
console.error('❌ 登录接口调用失败:', error)
throw new Error(`登录失败: ${error.message}`)
}
}
// 备用模拟登录
export const mockLoginApi = async (data: LoginRequest): Promise<LoginVO> => {
await new Promise(resolve => setTimeout(resolve, 1000))
if (data.username === 'admin' && data.password === '123456') {
return {
code: 200,
message: '登录成功',
data: {
token: 'mock-jwt-token-' + Date.now(),
userInfo: {
id: 1,
username: 'admin',
realName: '张管理员',
role: 'admin',
avatar: ''
export const mockLoginApi = async (data: LoginRequest): Promise<LoginResponse> => {
await new Promise(resolve => setTimeout(resolve, 1000))
if (data.username === 'admin' && data.password === '123456') {
return {
code: 200,
message: '登录成功',
data: {
token: 'mock-jwt-token-' + Date.now(),
userInfo: {
id: 1,
username: 'admin',
realName: '张管理员',
role: 'admin',
avatar: ''
}
}
}
}
} else {
throw new Error('用户名或密码错误')
}
} else {
throw new Error('用户名或密码错误')
}
}

@ -0,0 +1,50 @@
// src/api/deviceStatus.ts
import axios from 'axios'
export const DeviceStatusApi = {
// 获取设备状态列表 - 修改为匹配后端实际接口
getDevicesByStatus: async (status: string, areaId?: string, deviceType?: string) => {
try {
const params: any = { status }
if (areaId) params.areaId = areaId
if (deviceType) params.deviceType = deviceType
const response = await axios.get('/api/web/device-status/by-status', { params })
return response.data
} catch (error) {
throw new Error(`获取设备列表失败: ${error}`)
}
},
// 标记设备在线
markDeviceOnline: async (deviceId: string) => {
try {
const response = await axios.post(`/api/web/device-status/${deviceId}/online`)
return response.data
} catch (error) {
throw new Error(`设置设备在线失败: ${error}`)
}
},
// 标记设备离线
markDeviceOffline: async (deviceId: string, reason?: string) => {
try {
const params = reason ? { reason } : {}
const response = await axios.post(`/api/web/device-status/${deviceId}/offline`, null, { params })
return response.data
} catch (error) {
throw new Error(`设置设备离线失败: ${error}`)
}
},
// 标记设备故障
markDeviceFault: async (deviceId: string, faultType: string, description: string) => {
try {
const params = { faultType, description }
const response = await axios.post(`/api/web/device-status/${deviceId}/fault`, null, { params })
return response.data
} catch (error) {
throw new Error(`设置设备故障失败: ${error}`)
}
}
}

@ -0,0 +1,15 @@
// src/api/modules/auth.ts
import { api } from '../request'
import type { LoginRequest, LoginVO, ResultVO } from '../types/auth'
class AuthApi {
/**
*
*/
async login(data: LoginRequest): Promise<ResultVO<LoginVO>> {
console.log('🚀 调用登录接口:', data)
return api.post<ResultVO<LoginVO>>('/api/common/login', data)
}
}
export const authApi = new AuthApi()

@ -0,0 +1,141 @@
// src/api/request.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
// 统一的 fetch 封装
export async function request<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
console.log(`🌐 发送请求: ${API_BASE_URL}${url}`, {
method: options.method || 'GET',
headers: options.headers,
body: options.body ? JSON.parse(options.body as string) : undefined,
})
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
}
// 确保登录请求不携带任何认证信息
const isLoginRequest = url.includes('/login')
if (isLoginRequest) {
console.log('🔐 这是登录请求,不携带认证头')
// 确保没有 Authorization header
const headers = new Headers(defaultOptions.headers)
headers.delete('Authorization')
headers.delete('authorization')
defaultOptions.headers = headers
} else {
// 非登录请求,从存储中获取 token
const token = localStorage.getItem('token') || sessionStorage.getItem('token')
if (token) {
const headers = new Headers(defaultOptions.headers)
headers.set('Authorization', `Bearer ${token}`)
defaultOptions.headers = headers
}
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...defaultOptions,
...options,
})
console.log('📥 响应状态:', response.status, response.statusText)
// 尝试读取响应文本(无论成功与否)
let responseText = ''
try {
responseText = await response.text()
console.log('📥 响应内容:', responseText)
} catch (e) {
console.log('📥 无法读取响应文本')
}
if (!response.ok) {
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
if (responseText) {
try {
const errorJson = JSON.parse(responseText)
errorMessage = errorJson.message || errorJson.error || errorMessage
} catch {
errorMessage = `${errorMessage}\n${responseText}`
}
}
console.error('❌ 请求失败:', errorMessage)
throw new Error(errorMessage)
}
// 尝试解析 JSON
if (responseText) {
try {
const data = JSON.parse(responseText)
console.log('✅ 解析成功的数据:', data)
return data
} catch (e) {
console.error('❌ JSON 解析失败:', e)
throw new Error(`响应不是有效的 JSON: ${responseText}`)
}
} else {
// 没有响应体的情况(如 204 No Content
return {} as T
}
} catch (error: any) {
console.error('❌ 请求异常:', error)
// 处理网络错误
if (error.name === 'TypeError' && error.message.includes('fetch')) {
throw new Error('网络连接失败,请检查网络设置和后端服务')
}
throw error
}
}
// 封装常用 HTTP 方法
export const api = {
get<T>(url: string) {
return request<T>(url, { method: 'GET' })
},
post<T>(url: string, data?: any) {
return request<T>(url, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
})
},
put<T>(url: string, data?: any) {
return request<T>(url, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
})
},
patch<T>(url: string, data?: any) {
return request<T>(url, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined,
})
},
delete<T>(url: string) {
return request<T>(url, { method: 'DELETE' })
},
upload<T>(url: string, formData: FormData) {
const headers = new Headers()
// 上传文件时不要设置 Content-Type浏览器会自动设置
headers.delete('Content-Type')
return request<T>(url, {
method: 'POST',
headers,
body: formData,
})
},
}

@ -1,22 +1,29 @@
// 与后端 LoginRequest 对应的类型
// src/api/types/auth.ts
// 登录请求参数 - 匹配后端的 LoginRequest
export interface LoginRequest {
username: string
password: string
rememberMe?: boolean
username: string
password: string
userType: string // 添加这个属性
rememberMe?: boolean
}
// 通用响应结构
export interface ResultVO<T = any> {
code: number
message: string
data: T
}
// 与后端 LoginVO 对应的类型
// 登录响应数据 - 匹配后端的 LoginVO
export interface LoginVO {
code: number
message: string
data: {
token: string
userInfo: {
id: number
username: string
realName: string
role: string
avatar?: string
id: number
username: string
realName?: string // 根据后端字段调整
role?: string // 根据后端字段调整
userType?: string // 添加这个字段
avatar?: string
}
}
}

@ -4,7 +4,7 @@
<h1 class="system-title">校园矿化水系统</h1>
</div>
<div class="header-right">
<div class="user-info">
<div class="user-info" @click="goToProfile">
<div class="user-avatar">
<img :src="userAvatar" alt="用户头像" />
</div>
@ -17,8 +17,15 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const userAvatar = ref('images/用户界面/u106.jpg') //
const router = useRouter()
//
const goToProfile = () => {
router.push('/home/profile')
}
</script>
<style scoped>

@ -29,15 +29,18 @@ interface MenuItem {
const route = useRoute()
const router = useRouter()
// ID
const activeItem = computed(() => {
const currentPath = route.path
//
const item = menuItems.find(item =>
item.route === currentPath ||
item.children?.some(child => child.route === currentPath)
)
return item?.id || 1
return item?.id || 1 //
})
//
const menuItems: MenuItem[] = [
{
id: 1,
@ -72,7 +75,7 @@ const menuItems: MenuItem[] = [
id: 4,
name: '人员管理',
icon: '👥',
route: '/home/personnel',
//
children: [
{ name: '管理员', route: '/home/personnel/admin' },
{ name: '维修人员', route: '/home/personnel/maintenance' },
@ -83,7 +86,7 @@ const menuItems: MenuItem[] = [
id: 5,
name: '片区',
icon: '🗺️',
route: '/home/area',
//
children: [
{ name: '市区', route: '/home/area/urban' },
{ name: '校区', route: '/home/area/campus' }
@ -97,6 +100,7 @@ const menuItems: MenuItem[] = [
}
]
//
const handleItemClick = (itemId: number) => {
const item = menuItems.find(m => m.id === itemId)
if (item?.route) {
@ -111,6 +115,8 @@ const handleItemClick = (itemId: number) => {
background: white;
border-right: 1px solid #e1e8ed;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.05);
height: calc(100vh - 60px); /* 减去头部高度 */
overflow-y: auto; /* 菜单过多时可滚动 */
}
.sidebar-nav {

@ -1,33 +1,251 @@
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory, type NavigationGuardNext, type RouteLocationNormalized } from 'vue-router'
import LoginView from '../views/LoginView.vue'
import MainLayout from '../components/layout/MainLayout.vue' // 导入布局组件
import MainLayout from '../components/layout/MainLayout.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'login',
component: LoginView
},
{
path: '/home',
component: MainLayout, // 使用 MainLayout 作为布局
children: [
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '',
name: 'home',
component: () => import('../views/Dashboard.vue') // Dashboard 作为子路由
}
]
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
},
],
path: '/',
name: 'login',
component: LoginView,
meta: {
title: '登录',
requiresAuth: false // 不需要认证
}
},
{
path: '/home',
component: MainLayout,
meta: {
requiresAuth: true // 需要认证
},
children: [
{
path: '',
name: 'home',
component: () => import('../views/Dashboard.vue'),
meta: {
title: '首页'
}
},
// 设备监控相关路由
{
path: 'equipment',
name: 'equipment',
component: () => import('../views/equipment/EquipmentView.vue'),
meta: {
title: '设备监控'
}
},
{
path: 'equipment/water-maker',
name: 'water-maker',
component: () => import('../views/equipment/WaterMaker.vue'),
meta: {
title: '制水设备'
}
},
{
path: 'equipment/water-supplier',
name: 'water-supplier',
component: () => import('../views/equipment/WaterSupplier.vue'),
meta: {
title: '供水设备'
}
},
// 工单管理相关路由
{
path: 'work-order',
name: 'work-order',
component: () => import('../views/workorder/WorkOrderView.vue'),
meta: {
title: '工单管理'
}
},
{
path: 'work-order/pending',
name: 'work-order-pending',
component: () => import('../views/workorder/Pending.vue'),
meta: {
title: '待处理工单'
}
},
{
path: 'work-order/timeout',
name: 'work-order-timeout',
component: () => import('../views/workorder/Timeout.vue'),
meta: {
title: '超时工单'
}
},
{
path: 'work-order/processing',
name: 'work-order-processing',
component: () => import('../views/workorder/Processing.vue'),
meta: {
title: '处理中工单'
}
},
{
path: 'work-order/review',
name: 'work-order-review',
component: () => import('../views/workorder/Review.vue'),
meta: {
title: '待审核工单'
}
},
{
path: 'work-order/review/:id',
name: 'ReviewDetail',
component: () => import('../views/workorder/ReviewDetail.vue'),
meta: {
title: '工单审核详情'
}
},
{
path: 'work-order/completed',
name: 'work-order-completed',
component: () => import('../views/workorder/Completed.vue'),
meta: {
title: '已完成工单'
}
},
{
path: '/home/work-order/completed/:id',
name: 'CompletedDetail',
component: () => import('@/views/workorder/CompletedDetail.vue'),
meta: {
title: '结单信息'
}
},
// 人员管理相关路由
{
path: 'personnel/admin',
name: 'personnel-admin',
component: () => import('../views/personnel/Admin.vue'),
meta: {
title: '管理员管理'
}
},
{
path: 'personnel/maintenance',
name: 'personnel-maintenance',
component: () => import('../views/personnel/Maintenance.vue'),
meta: {
title: '运维人员管理'
}
},
{
path: 'personnel/user',
name: 'personnel-user',
component: () => import('../views/personnel/User.vue'),
meta: {
title: '用户管理'
}
},
// 片区相关路由
{
path: 'area/urban',
name: 'area-urban',
component: () => import('../views/area/Urban.vue'),
meta: {
title: '城市片区'
}
},
{
path: 'equipment/water-maker/:id',
name: 'water-maker-detail',
component: () => import('../views/equipment/WaterMakerDetail.vue'),
meta: {
title: '制水设备详情'
}
},
{
path: 'area/campus',
name: 'area-campus',
component: () => import('../views/area/Campus.vue'),
meta: {
title: '校园片区'
}
},
// 个人信息路由
{
path: 'profile',
name: 'profile',
component: () => import('../views/Profile.vue'),
meta: {
title: '个人信息',
requiresAuth: true
}
}
]
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
meta: {
title: '关于',
requiresAuth: false
}
},
/* // 404
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../views/NotFound.vue'),
meta: {
title: '页面不存在',
requiresAuth: false
}
}*/
]
})
// 路由守卫
router.beforeEach(async (to: RouteLocationNormalized, from: RouteLocationNormalized, next: NavigationGuardNext) => {
// 设置页面标题
const title = to.meta?.title as string || '校园直饮水管理系统'
document.title = title
// 获取认证状态
const authStore = useAuthStore()
// 初始化登录状态
if (!authStore.isLoggedIn) {
authStore.initialize()
}
// 判断是否需要认证
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
// 如果路由需要认证但用户未登录
if (requiresAuth && !authStore.isLoggedIn) {
// 重定向到登录页面,并保存当前想要访问的路径
next({
name: 'login',
query: { redirect: to.fullPath }
})
}
// 如果用户已登录但访问登录页面
else if (to.name === 'login' && authStore.isLoggedIn) {
// 检查是否有重定向路径
const redirect = from.query.redirect as string || '/home'
next(redirect)
}
// 其他情况正常放行
else {
next()
}
})
// 路由后置守卫 - 可在这里添加一些统计或清理工作
router.afterEach((to: RouteLocationNormalized, from: RouteLocationNormalized) => {
// 可以在这里添加页面访问统计等
console.log(`路由跳转: ${from.fullPath} -> ${to.fullPath}`)
})
export default router

@ -1,71 +1,105 @@
// src/stores/auth.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { realLoginApi } from '@/api/auth'
import type { LoginVO } from '@/api/types/auth'
import { authApi } from '@/api/modules/auth'
import type { LoginRequest, LoginVO, ResultVO } from '@/api/types/auth'
interface UserInfo {
id: number
username: string
realName: string
role: string
avatar?: string
id: number
username: string
realName: string
role: string
avatar?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = ref(false)
const token = ref<string>('')
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = ref(false)
// 登录方法
const login = async (loginData: { username: string; password: string; rememberMe?: boolean }) => {
try {
const response: LoginVO = await realLoginApi(loginData)
if (response.code === 200) {
token.value = response.data.token
userInfo.value = response.data.userInfo
isLoggedIn.value = true
// 存储到localStorage
localStorage.setItem('token', response.data.token)
localStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
return response
} else {
throw new Error(response.message || '登录失败')
}
} catch (error: any) {
throw new Error(error.message || '登录失败,请检查网络连接')
// 真实登录接口调用
const login = async (loginData: LoginRequest) => {
try {
// 调用真实后端接口
const response: ResultVO<LoginVO> = await authApi.login(loginData)
// 检查响应状态
if (response.code !== 200) {
throw new Error(response.message || '登录失败')
}
// 保存 token 和用户信息
token.value = response.data.token
userInfo.value = response.data.userInfo
isLoggedIn.value = true
// 存储到 localStorage如果用户选择记住我
if (loginData.rememberMe) {
localStorage.setItem('token', response.data.token)
localStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
localStorage.setItem('rememberMe', 'true')
} else {
sessionStorage.setItem('token', response.data.token)
sessionStorage.setItem('userInfo', JSON.stringify(response.data.userInfo))
localStorage.removeItem('rememberMe')
}
return response
} catch (error: any) {
console.error('登录失败:', error)
throw error
}
}
// 退出登录
const logout = () => {
// 可以调用后端登出接口
// authApi.logout().catch(console.error)
// 清除本地存储
token.value = ''
userInfo.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
localStorage.removeItem('rememberMe')
sessionStorage.removeItem('token')
sessionStorage.removeItem('userInfo')
}
}
// 退出登录
const logout = () => {
token.value = ''
userInfo.value = null
isLoggedIn.value = false
localStorage.removeItem('token')
localStorage.removeItem('userInfo')
}
// 初始化时从存储恢复状态
const initialize = () => {
const rememberMe = localStorage.getItem('rememberMe')
const storage = rememberMe ? localStorage : sessionStorage
const savedToken = storage.getItem('token')
const savedUserInfo = storage.getItem('userInfo')
// 初始化时从localStorage恢复状态
const initialize = () => {
const savedToken = localStorage.getItem('token')
const savedUserInfo = localStorage.getItem('userInfo')
if (savedToken && savedUserInfo) {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
isLoggedIn.value = true
if (savedToken && savedUserInfo) {
try {
token.value = savedToken
userInfo.value = JSON.parse(savedUserInfo)
isLoggedIn.value = true
} catch (e) {
console.error('恢复登录状态失败:', e)
logout()
}
}
}
}
return {
token,
userInfo,
isLoggedIn,
login,
logout,
initialize
}
// 检查是否已登录(用于路由守卫)
const checkAuth = (): boolean => {
return isLoggedIn.value && !!token.value
}
return {
token,
userInfo,
isLoggedIn,
login,
logout,
initialize,
checkAuth,
}
})

@ -7,6 +7,22 @@
</div>
<form class="login-form" @submit.prevent="handleLogin">
<!-- 用户类型选择 -->
<div class="form-group">
<label for="userType">用户类型</label>
<select
id="userType"
v-model="loginForm.userType"
class="form-input"
:disabled="loading"
required
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="repairer">维修人员</option>
</select>
</div>
<div class="form-group">
<label for="username">用户名</label>
<input
@ -68,9 +84,11 @@ const debugInfo = ref('')
const loginForm = reactive({
username: '',
password: '',
userType: 'user', //
rememberMe: false
})
// handleLogin
const handleLogin = async () => {
if (!loginForm.username.trim() || !loginForm.password.trim()) {
alert('请输入用户名和密码')
@ -78,27 +96,34 @@ const handleLogin = async () => {
}
loading.value = true
debugInfo.value = '开始登录...'
try {
debugInfo.value = `调用接口: ${import.meta.env.VITE_API_BASE_URL}/api/common/login\n请求数据: ${JSON.stringify(loginForm, null, 2)}`
//
//
await authStore.login({
username: loginForm.username,
password: loginForm.password,
userType: loginForm.userType, //
rememberMe: loginForm.rememberMe
})
debugInfo.value += '\n✅ 登录成功,跳转到首页...'
//
router.push('/home')
//
const redirect = router.currentRoute.value.query.redirect as string
if (redirect) {
router.push(redirect)
} else {
router.push('/home')
}
} catch (error: any) {
console.error('登录失败:', error)
debugInfo.value += `\n❌ 登录失败: ${error.message}`
alert(error.message || '登录失败,请检查用户名和密码')
//
const errorMessage = error.message.includes('Network')
? '网络连接失败,请检查网络设置'
: error.message.includes('401')
? '用户名或密码错误'
: error.message || '登录失败,请稍后重试'
alert(errorMessage)
} finally {
loading.value = false
}
@ -242,4 +267,4 @@ const handleLogin = async () => {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>
</style>

@ -0,0 +1,629 @@
<!-- src/views/profile/Profile.vue -->
<template>
<div class="profile-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>个人信息</h2>
<div class="breadcrumb">校园矿化水平台 / 个人中心 / 个人信息</div>
</div>
<div class="profile-container">
<!-- 左侧头像和基本信息 -->
<div class="profile-sidebar">
<div class="avatar-container">
<img :src="userInfo.avatar || defaultAvatar" alt="用户头像" class="user-avatar">
<button class="change-avatar-btn" @click="triggerAvatarUpload"></button>
<input
type="file"
ref="avatarInput"
class="avatar-input"
accept="image/*"
@change="handleAvatarUpload"
>
</div>
<div class="basic-info">
<div class="info-item">
<span class="label">用户名</span>
<span class="value">{{ userInfo.username }}</span>
</div>
<div class="info-item">
<span class="label">真实姓名</span>
<span class="value">{{ userInfo.realName }}</span>
</div>
<div class="info-item">
<span class="label">身份</span>
<span class="value role-tag">{{ formatRole(userInfo.role) }}</span>
</div>
<div class="info-item">
<span class="label">联系电话</span>
<span class="value">{{ userInfo.phone || '未设置' }}</span>
</div>
<div class="info-item">
<span class="label">账号状态</span>
<span class="value status-tag" :class="userInfo.status">
{{ userInfo.status === 'active' ? '启用' : '禁用' }}
</span>
</div>
<div class="info-item">
<span class="label">最后登录</span>
<span class="value">{{ formatDate(userInfo.lastLoginTime) }}</span>
</div>
</div>
</div>
<!-- 右侧信息编辑和密码修改 -->
<div class="profile-content">
<!-- 个人信息编辑表单 -->
<div class="profile-card">
<div class="card-header">
<h3>编辑个人信息</h3>
</div>
<div class="card-body">
<form @submit.prevent="updateProfile">
<div class="form-row">
<div class="form-item">
<label>真实姓名</label>
<input
type="text"
v-model="profileForm.realName"
placeholder="请输入真实姓名"
required
>
</div>
<div class="form-item">
<label>联系电话</label>
<input
type="tel"
v-model="profileForm.phone"
placeholder="请输入联系电话"
pattern="^1[3-9]\d{9}$"
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>备注信息</label>
<textarea
v-model="profileForm.remark"
placeholder="请输入备注信息(选填)"
rows="3"
></textarea>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-save">保存修改</button>
</div>
</form>
</div>
</div>
<!-- 密码修改表单 -->
<div class="profile-card password-card">
<div class="card-header">
<h3>修改密码</h3>
</div>
<div class="card-body">
<form @submit.prevent="changePassword">
<div class="form-row">
<div class="form-item full-width">
<label>当前密码</label>
<input
type="password"
v-model="passwordForm.oldPassword"
placeholder="请输入当前密码"
required
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>新密码</label>
<input
type="password"
v-model="passwordForm.newPassword"
placeholder="请输入新密码至少8位包含字母和数字"
pattern="^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$"
required
>
</div>
</div>
<div class="form-row">
<div class="form-item full-width">
<label>确认新密码</label>
<input
type="password"
v-model="passwordForm.confirmPassword"
placeholder="请再次输入新密码"
required
>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-change-pwd">修改密码</button>
</div>
</form>
</div>
</div>
</div>
</div>
<!-- 操作成功提示 -->
<div class="toast" v-if="showToast">
{{ toastMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
// 使Element Plus
//
const defaultAvatar = 'images/avatar/default-avatar.png'
//
interface UserInfo {
id: string
username: string
realName: string
role: 'student' | 'teacher' | 'visitor' | 'admin' | 'maintenance'
phone?: string
avatar?: string
status: 'active' | 'disabled'
lastLoginTime: string
remark?: string
}
//
const avatarInput = ref<HTMLInputElement | null>(null)
const showToast = ref(false)
const toastMessage = ref('')
//
const userInfo = ref<UserInfo>({
id: '1',
username: 'admin01',
realName: '管理员',
role: 'admin',
phone: '13800138000',
avatar: '',
status: 'active',
lastLoginTime: '2025-12-05 09:23:45',
remark: '系统超级管理员'
})
//
const profileForm = reactive({
realName: '',
phone: '',
remark: ''
})
//
const passwordForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
//
onMounted(() => {
profileForm.realName = userInfo.value.realName
profileForm.phone = userInfo.value.phone || ''
profileForm.remark = userInfo.value.remark || ''
})
//
const formatRole = (role: string) => {
const roleMap: Record<string, string> = {
student: '学生',
teacher: '老师',
visitor: '游客',
admin: '管理员',
maintenance: '维修人员'
}
return roleMap[role] || '未知身份'
}
//
const formatDate = (dateStr: string) => {
if (!dateStr) return '暂无记录'
return new Date(dateStr).toLocaleString('zh-CN')
}
//
const triggerAvatarUpload = () => {
avatarInput.value?.click()
}
//
const handleAvatarUpload = (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
//
const isImage = file.type.startsWith('image/')
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
showToastMessage('请上传图片格式的文件!')
return
}
if (!isLt2M) {
showToastMessage('头像大小不能超过2MB')
return
}
//
const reader = new FileReader()
reader.onload = (e) => {
userInfo.value.avatar = e.target?.result as string
showToastMessage('头像上传成功!')
}
reader.readAsDataURL(file)
// input便
target.value = ''
}
}
//
const updateProfile = () => {
//
if (!profileForm.realName.trim()) {
showToastMessage('真实姓名不能为空!')
return
}
//
if (profileForm.phone && !/^1[3-9]\d{9}$/.test(profileForm.phone)) {
showToastMessage('请输入正确的手机号码!')
return
}
//
userInfo.value.realName = profileForm.realName
userInfo.value.phone = profileForm.phone
userInfo.value.remark = profileForm.remark
showToastMessage('个人信息修改成功!')
}
//
const changePassword = () => {
//
if (!passwordForm.oldPassword) {
showToastMessage('请输入当前密码!')
return
}
if (passwordForm.newPassword.length < 8) {
showToastMessage('新密码长度不能少于8位')
return
}
if (!/^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/.test(passwordForm.newPassword)) {
showToastMessage('新密码必须包含字母和数字!')
return
}
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
showToastMessage('两次输入的新密码不一致!')
return
}
//
showToastMessage('密码修改成功,请重新登录!')
//
passwordForm.oldPassword = ''
passwordForm.newPassword = ''
passwordForm.confirmPassword = ''
}
//
const showToastMessage = (message: string) => {
toastMessage.value = message
showToast.value = true
// 3
setTimeout(() => {
showToast.value = false
}, 3000)
}
</script>
<style scoped>
/* 基础样式 */
.profile-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 布局容器 */
.profile-container {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
/* 左侧侧边栏 */
.profile-sidebar {
width: 300px;
flex-shrink: 0;
}
.avatar-container {
text-align: center;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 16px;
}
.user-avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 12px;
}
.change-avatar-btn {
background: #667eea;
color: white;
border: none;
padding: 6px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.change-avatar-btn:hover {
background: #556cd6;
}
.avatar-input {
display: none;
}
.basic-info {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
}
.info-item {
display: flex;
margin-bottom: 12px;
font-size: 14px;
}
.info-item:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: #666;
flex-shrink: 0;
}
.value {
color: #333;
flex: 1;
}
/* 角色和状态标签 */
.role-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 右侧内容区 */
.profile-content {
flex: 1;
min-width: 500px;
}
.profile-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
overflow: hidden;
}
.password-card {
margin-bottom: 0;
}
.card-header {
padding: 16px 20px;
background: #f8f9fa;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 16px;
color: #333;
font-weight: 600;
}
.card-body {
padding: 20px;
}
/* 表单样式 */
.form-row {
display: flex;
gap: 20px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.form-item {
flex: 1;
min-width: 200px;
}
.form-item.full-width {
flex: 1 1 100%;
min-width: 100%;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
font-weight: 500;
}
.form-item input,
.form-item textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
transition: border-color 0.3s;
}
.form-item input:focus,
.form-item textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.form-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
.btn-save, .btn-change-pwd {
background: #42b983;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-save:hover, .btn-change-pwd:hover {
background: #359e75;
}
.btn-change-pwd {
background: #667eea;
}
.btn-change-pwd:hover {
background: #556cd6;
}
/* 提示框样式 */
.toast {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 1000;
animation: fadeIn 0.3s, fadeOut 0.3s 2.7s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px); }
}
/* 响应式调整 */
@media (max-width: 900px) {
.profile-container {
flex-direction: column;
}
.profile-sidebar {
width: 100%;
}
.profile-content {
min-width: 100%;
}
}
@media (max-width: 576px) {
.form-row {
flex-direction: column;
gap: 16px;
}
.form-item {
min-width: 100%;
}
}
</style>

@ -0,0 +1,598 @@
<!-- src/views/area/CampusManagement.vue -->
<template>
<div class="campus-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>校区管理</h2>
<div class="breadcrumb">校园矿化水平台 / 区域管理 / 校区管理</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<!-- 新增校区按钮 -->
<button class="btn-add" @click="handleAddCampus"></button>
<!-- 校区下拉筛选 -->
<div class="filter-box">
<label>选择校区</label>
<select v-model="selectedCampus" @change="handleCampusChange" class="campus-select">
<option value="">全部校区</option>
<option v-for="campus in campusList" :key="campus.id" :value="campus.id">
{{ campus.name }}
</option>
</select>
</div>
</div>
<!-- 校区表格新增所属市区列 -->
<div class="card">
<table class="campus-table">
<thead>
<tr>
<th>校区</th>
<th>所属市区</th> <!-- 新增列 -->
<th>设备数量</th>
<th>范围</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="campus in filteredCampus" :key="campus.id">
<td>{{ campus.name }}</td>
<td>{{ getAreaName(campus.areaId) }}</td> <!-- 显示所属市区名称 -->
<td>{{ campus.deviceCount }}</td>
<td>{{ campus.range }}</td>
<td class="operation-buttons">
<button
class="btn-delete"
@click="handleDelete(campus.id)"
>
删除
</button>
</td>
</tr>
<tr v-if="filteredCampus.length === 0">
<td colspan="5" class="no-data">暂无校区数据</td> <!-- 列数调整为5 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 新增/编辑校区弹窗新增所属市区选择 -->
<div class="modal-mask" v-if="showModal">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isEdit ? '编辑校区' : '新增校区' }}</h3>
<button class="close-btn" @click="showModal = false">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSave">
<div class="form-item">
<label>校区名称</label>
<input
type="text"
v-model="formData.name"
placeholder="请输入校区名称"
required
>
</div>
<div class="form-item">
<label>所属市区</label> <!-- 新增表单项 -->
<select
v-model="formData.areaId"
class="area-select"
required
>
<option value="">请选择所属市区</option>
<option v-for="area in areaList" :key="area.id" :value="area.id">
{{ area.name }}
</option>
</select>
</div>
<div class="form-item">
<label>校区范围</label>
<textarea
v-model="formData.range"
placeholder="请输入校区范围描述"
rows="3"
required
></textarea>
</div>
<div class="form-item">
<label>设备数量</label>
<input
type="number"
v-model="formData.deviceCount"
placeholder="请输入设备数量"
min="0"
required
>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showModal = false">取消</button>
<button type="submit" class="btn-submit">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal-mask" v-if="showDeleteConfirm">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>确认删除</h3>
<button class="close-btn" @click="showDeleteConfirm = false">×</button>
</div>
<div class="modal-body">
<p>确定要删除 "{{ deleteCampusName }}" 校区吗此操作不可撤销</p>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showDeleteConfirm = false">取消</button>
<button type="button" class="btn-delete-confirm" @click="confirmDelete"></button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
interface Area {
id: string
name: string
deviceCount: number
range: string
}
// areaId
interface Campus {
id: string
name: string
areaId: string // ID
deviceCount: number
range: string
}
//
const areaList = ref<Area[]>([
{
id: '1',
name: '市区东区',
deviceCount: 28,
range: '东至东风路,西至解放路,南至人民路,北至建设路'
},
{
id: '2',
name: '市区西区',
deviceCount: 19,
range: '东至解放路,西至滨河路,南至青年路,北至黄河路'
}
])
//
const campusList = ref<Campus[]>([
{
id: '1',
name: '主校区',
areaId: '1', //
deviceCount: 89,
range: '包含教学区、生活区、运动区,覆盖整个主校区范围'
},
{
id: '2',
name: '分校区',
areaId: '2', // 西
deviceCount: 45,
range: '包含新教学楼、实训楼、学生公寓1-5号楼'
},
{
id: '3',
name: '南校区',
areaId: '1', //
deviceCount: 67,
range: '包含研究生院、国际交流中心、留学生公寓'
}
])
//
const selectedCampus = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const showModal = ref(false)
const isEdit = ref(false)
const showDeleteConfirm = ref(false)
const deleteCampusId = ref('')
const deleteCampusName = ref('')
// areaId
const formData = ref<Campus>({
id: '',
name: '',
areaId: '',
deviceCount: 0,
range: ''
})
// ID
const getAreaName = (areaId: string) => {
const area = areaList.value.find(item => item.id === areaId)
return area ? area.name : '未指定'
}
//
const filteredCampus = computed(() => {
let filtered = [...campusList.value]
//
if (selectedCampus.value) {
filtered = filtered.filter(campus => campus.id === selectedCampus.value)
}
//
const startIndex = (currentPage.value - 1) * pageSize.value
const endIndex = startIndex + pageSize.value
return filtered.slice(startIndex, endIndex)
})
//
const totalPages = computed(() => {
const filteredCount = selectedCampus.value
? campusList.value.filter(campus => campus.id === selectedCampus.value).length
: campusList.value.length
return Math.ceil(filteredCount / pageSize.value)
})
//
const handleCampusChange = () => {
currentPage.value = 1 //
}
//
const handleAddCampus = () => {
isEdit.value = false
//
formData.value = {
id: '',
name: '',
areaId: '',
deviceCount: 0,
range: ''
}
showModal.value = true
}
//
const handleDelete = (id: string) => {
const campus = campusList.value.find(item => item.id === id)
if (campus) {
deleteCampusId.value = id
deleteCampusName.value = campus.name
showDeleteConfirm.value = true
}
}
//
const confirmDelete = () => {
campusList.value = campusList.value.filter(campus => campus.id !== deleteCampusId.value)
showDeleteConfirm.value = false
//
if (selectedCampus.value === deleteCampusId.value) {
selectedCampus.value = ''
}
}
//
const handleSave = () => {
if (isEdit.value) {
//
const index = campusList.value.findIndex(item => item.id === formData.value.id)
if (index !== -1) {
campusList.value[index] = { ...formData.value }
}
} else {
//
const newCampus: Campus = {
...formData.value,
id: Date.now().toString() // ID
}
campusList.value.unshift(newCampus)
}
showModal.value = false
}
</script>
<style scoped>
/* 基础样式 - 与市区管理页面完全一致 */
.campus-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filter-box {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
.campus-select, .area-select { /* 新增area-select样式 */
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 200px;
font-size: 14px;
}
/* 表格样式 */
.campus-table {
width: 100%;
border-collapse: collapse;
}
.campus-table th,
.campus-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.campus-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.campus-table tbody tr:hover {
background-color: #f8f9fa;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-delete {
background-color: #ffebe6;
color: #cf1322;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.btn-delete:hover {
opacity: 0.9;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.confirm-modal {
width: 400px;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.close-btn {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #666;
}
.modal-body {
padding: 16px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
.form-item input,
.form-item textarea,
.form-item select { /* 新增select样式 */
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-submit {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-delete-confirm {
background: #cf1322;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-box {
width: 100%;
}
.campus-select, .area-select {
width: 100%;
}
}
</style>

@ -0,0 +1,553 @@
<!-- src/views/area/AreaManagement.vue -->
<template>
<div class="area-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>片区管理</h2>
<div class="breadcrumb">校园矿化水平台 / 区域管理 / 片区管理</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<!-- 新增片区按钮 -->
<button class="btn-add" @click="handleAddArea"></button>
<!-- 片区下拉筛选 -->
<div class="filter-box">
<label>选择片区</label>
<select v-model="selectedArea" @change="handleAreaChange" class="area-select">
<option value="">全部片区</option>
<!-- 核心修改移除下拉选项中的设备数量展示仅保留片区名称 -->
<option v-for="area in areaList" :key="area.id" :value="area.id">
{{ area.name }}
</option>
</select>
</div>
</div>
<!-- 片区表格 -->
<div class="card">
<table class="area-table">
<thead>
<tr>
<th>片区</th>
<th>设备数量</th>
<th>范围</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="area in filteredAreas" :key="area.id">
<td>{{ area.name }}</td>
<td>{{ area.deviceCount }}</td>
<td>{{ area.range }}</td>
<td class="operation-buttons">
<button
class="btn-delete"
@click="handleDelete(area.id)"
>
删除
</button>
</td>
</tr>
<tr v-if="filteredAreas.length === 0">
<td colspan="4" class="no-data">暂无片区数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 新增/编辑片区弹窗 -->
<div class="modal-mask" v-if="showModal">
<div class="modal-container">
<div class="modal-header">
<h3>{{ isEdit ? '编辑片区' : '新增片区' }}</h3>
<button class="close-btn" @click="showModal = false">×</button>
</div>
<div class="modal-body">
<form @submit.prevent="handleSave">
<div class="form-item">
<label>片区名称</label>
<input
type="text"
v-model="formData.name"
placeholder="请输入片区名称"
required
>
</div>
<div class="form-item">
<label>片区范围</label>
<textarea
v-model="formData.range"
placeholder="请输入片区范围描述"
rows="3"
required
></textarea>
</div>
<div class="form-item">
<label>设备数量</label>
<input
type="number"
v-model="formData.deviceCount"
placeholder="请输入设备数量"
min="0"
required
>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showModal = false">取消</button>
<button type="submit" class="btn-submit">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- 删除确认弹窗 -->
<div class="modal-mask" v-if="showDeleteConfirm">
<div class="modal-container confirm-modal">
<div class="modal-header">
<h3>确认删除</h3>
<button class="close-btn" @click="showDeleteConfirm = false">×</button>
</div>
<div class="modal-body">
<p>确定要删除 "{{ deleteAreaName }}" 片区吗此操作不可撤销</p>
<div class="form-actions">
<button type="button" class="btn-cancel" @click="showDeleteConfirm = false">取消</button>
<button type="button" class="btn-delete-confirm" @click="confirmDelete"></button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
interface Area {
id: string
name: string
deviceCount: number
range: string
}
//
const areaList = ref<Area[]>([
{
id: '1',
name: '市区东区',
deviceCount: 28,
range: '东至东风路,西至解放路,南至人民路,北至建设路'
},
{
id: '2',
name: '市区西区',
deviceCount: 19,
range: '东至解放路,西至滨河路,南至青年路,北至黄河路'
},
{
id: '3',
name: '校区南区',
deviceCount: 45,
range: '大学校园内南部区域包含教学楼、图书馆、学生宿舍1-8号楼'
},
{
id: '4',
name: '校区北区',
deviceCount: 32,
range: '大学校园内北部区域包含实验楼、体育馆、学生宿舍9-16号楼'
}
])
//
const selectedArea = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const showModal = ref(false)
const isEdit = ref(false)
const showDeleteConfirm = ref(false)
const deleteAreaId = ref('')
const deleteAreaName = ref('')
//
const formData = ref<Area>({
id: '',
name: '',
deviceCount: 0,
range: ''
})
//
const filteredAreas = computed(() => {
let filtered = [...areaList.value]
//
if (selectedArea.value) {
filtered = filtered.filter(area => area.id === selectedArea.value)
}
//
const startIndex = (currentPage.value - 1) * pageSize.value
const endIndex = startIndex + pageSize.value
return filtered.slice(startIndex, endIndex)
})
//
const totalPages = computed(() => {
const filteredCount = selectedArea.value
? areaList.value.filter(area => area.id === selectedArea.value).length
: areaList.value.length
return Math.ceil(filteredCount / pageSize.value)
})
//
const handleAreaChange = () => {
currentPage.value = 1 //
}
//
const handleAddArea = () => {
isEdit.value = false
//
formData.value = {
id: '',
name: '',
deviceCount: 0,
range: ''
}
showModal.value = true
}
//
const handleDelete = (id: string) => {
const area = areaList.value.find(item => item.id === id)
if (area) {
deleteAreaId.value = id
deleteAreaName.value = area.name
showDeleteConfirm.value = true
}
}
//
const confirmDelete = () => {
areaList.value = areaList.value.filter(area => area.id !== deleteAreaId.value)
showDeleteConfirm.value = false
//
if (selectedArea.value === deleteAreaId.value) {
selectedArea.value = ''
}
}
//
const handleSave = () => {
if (isEdit.value) {
//
const index = areaList.value.findIndex(item => item.id === formData.value.id)
if (index !== -1) {
areaList.value[index] = { ...formData.value }
}
} else {
//
const newArea: Area = {
...formData.value,
id: Date.now().toString() // ID
}
areaList.value.unshift(newArea)
}
showModal.value = false
}
</script>
<style scoped>
/* 基础样式 - 与人员管理页面保持一致 */
.area-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filter-box {
display: flex;
align-items: center;
gap: 8px;
color: #666;
}
.area-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
min-width: 200px;
font-size: 14px;
}
/* 表格样式 */
.area-table {
width: 100%;
border-collapse: collapse;
}
.area-table th,
.area-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.area-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.area-table tbody tr:hover {
background-color: #f8f9fa;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-delete {
background-color: #ffebe6;
color: #cf1322;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.btn-delete:hover {
opacity: 0.9;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.confirm-modal {
width: 400px;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.close-btn {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.close-btn:hover {
color: #666;
}
.modal-body {
padding: 16px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 8px;
color: #666;
font-size: 14px;
}
.form-item input,
.form-item textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 24px;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-submit {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-delete-confirm {
background: #cf1322;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-box {
width: 100%;
}
.area-select {
width: 100%;
}
}
</style>

@ -0,0 +1,7 @@
<!-- src/views/equipment/EquipmentView.vue -->
<template>
<div class="page-container">
<h1>设备监控</h1>
<p>请选择左侧子菜单查看具体设备信息</p>
</div>
</template>

@ -0,0 +1,804 @@
<!-- src/views/equipment/WaterMaker.vue -->
<template>
<div class="water-maker-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>制水机管理</h2>
<div class="breadcrumb">校园矿化水平台 / 设备监控 / 制水机</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="showAddModal = true">添加制水机</button>
<div class="filters">
<!-- 搜索框 -->
<div class="search-box">
<input
type="text"
placeholder="搜索设备ID或位置..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
<!-- 片区筛选 -->
<select
v-model="selectedArea"
class="filter-select"
@change="handleSearch"
>
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
<!-- 状态筛选 -->
<select
v-model="selectedStatus"
class="filter-select"
@change="handleSearch"
>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warning">警告</option>
<option value="fault">故障</option>
</select>
</div>
</div>
<!-- 设备表格 - 新增设备机型列 -->
<div class="card">
<table class="equipment-table">
<thead>
<tr>
<th>设备ID</th>
<th>设备机型</th> <!-- 新增机型列 -->
<th>所属片区</th>
<th>详细位置</th>
<th>状态</th>
<th>最后上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in paginatedDevices" :key="device.deviceId">
<td>{{ device.deviceId }}</td>
<td>{{ device.deviceType === 'WATER_MAKER' ? '制水机' : device.deviceType }}</td>
<td>{{ device.areaId }}</td>
<td>{{ device.installLocation }}</td>
<td>
<span :class="`status-tag ${device.status}`">
{{ formatStatus(device.status) }}
</span>
</td>
<td>{{ formatDate(device.lastHeartbeatTime) }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewDevice(device.deviceId)"></button>
<button
class="btn-online"
@click="updateDeviceStatus(device.deviceId, 'online')"
:disabled="device.status === 'online'"
>
设为在线
</button>
<button
class="btn-offline"
@click="showOfflineModal(device.deviceId)"
:disabled="device.status === 'offline'"
>
设为离线
</button>
<button
class="btn-fault"
@click="showFaultModalFunc(device.deviceId)"
:disabled="device.status === 'fault'"
>
设为故障
</button>
</td>
</tr>
<tr v-if="paginatedDevices.length === 0">
<td colspan="7" class="no-data">暂无设备数据</td> <!-- colspan从6改为7 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }} ( {{ filteredDevices.length }} 条记录)
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 添加设备模态框 -->
<div v-if="showAddModal" class="modal-overlay" @click="showAddModal = false">
<div class="modal-content" @click.stop>
<h3>添加制水机</h3>
<form @submit.prevent="addDevice">
<div class="form-group">
<label>设备ID:</label>
<input v-model="newDevice.deviceId" type="text" required>
</div>
<div class="form-group">
<label>设备名称:</label>
<input v-model="newDevice.deviceName" type="text" required>
</div>
<div class="form-group">
<label>所属片区:</label>
<select v-model="newDevice.areaId" required>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<div class="form-group">
<label>安装位置:</label>
<input v-model="newDevice.installLocation" type="text" required>
</div>
<div class="form-actions">
<button type="button" @click="showAddModal = false">取消</button>
<button type="submit">添加</button>
</div>
</form>
</div>
</div>
<!-- 离线原因模态框 -->
<div v-if="showOfflineReasonModal" class="modal-overlay" @click="showOfflineReasonModal = false">
<div class="modal-content" @click.stop>
<h3>设置离线原因</h3>
<form @submit.prevent="confirmOffline">
<div class="form-group">
<label>离线原因:</label>
<textarea v-model="offlineReason" placeholder="请输入离线原因"></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showOfflineReasonModal = false">取消</button>
<button type="submit">确认</button>
</div>
</form>
</div>
</div>
<!-- 故障信息模态框 -->
<div v-if="showFaultModal" class="modal-overlay" @click="showFaultModal = false">
<div class="modal-content" @click.stop>
<h3>设置故障信息</h3>
<form @submit.prevent="confirmFault">
<div class="form-group">
<label>故障类型:</label>
<input v-model="faultInfo.faultType" type="text" placeholder="请输入故障类型" required>
</div>
<div class="form-group">
<label>故障描述:</label>
<textarea v-model="faultInfo.description" placeholder="请输入故障描述" required></textarea>
</div>
<div class="form-actions">
<button type="button" @click="showFaultModal = false">取消</button>
<button type="submit">确认</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { DeviceStatusApi } from '@/api/deviceStatus'
//
type DeviceStatus = 'online' | 'offline' | 'fault'
//
interface WaterMakerDevice {
deviceId: string
deviceName: string
deviceType: string
areaId: string
installLocation: string
status: DeviceStatus
lastHeartbeatTime?: string
createTime?: string
}
//
const devices = ref<WaterMakerDevice[]>([])
const searchKeyword = ref('')
const selectedArea = ref('') //
const selectedStatus = ref('') //
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const showAddModal = ref(false)
const showOfflineReasonModal = ref(false)
const showFaultModal = ref(false)
// ID
const currentDeviceId = ref('')
//
const newDevice = ref({
deviceId: '',
deviceName: '',
areaId: '市区',
installLocation: '',
deviceType: 'WATER_MAKER'
})
const offlineReason = ref('')
const faultInfo = ref({
faultType: '',
description: ''
})
//
// - loadDevices
// loadDevices deviceType
const loadDevices = async (): Promise<WaterMakerDevice[]> => {
try {
const statuses = ['online', 'offline', 'fault']
const allDevices: WaterMakerDevice[] = []
for (const status of statuses) {
// deviceType
const result = await DeviceStatusApi.getDevicesByStatus(status, undefined, 'water_maker')
if (result.code === 200 && result.data && Array.isArray(result.data)) {
allDevices.push(...result.data.map((item: any) => ({
deviceId: item.deviceId,
deviceName: item.deviceName,
deviceType: item.deviceType,
areaId: item.areaId,
installLocation: item.installLocation,
status: item.status,
lastHeartbeatTime: item.lastHeartbeatTime
})))
}
}
devices.value = allDevices
return allDevices
} catch (error) {
console.error('加载设备数据失败:', error)
return []
}
}
//
const filteredDevices = computed(() => {
return devices.value.filter(device => {
const keywordMatch = searchKeyword.value.trim() === '' ||
device.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
device.installLocation.toLowerCase().includes(searchKeyword.value.toLowerCase())
const areaMatch = selectedArea.value === '' || device.areaId === selectedArea.value
const statusMatch = selectedStatus.value === '' || device.status === selectedStatus.value
return keywordMatch && areaMatch && statusMatch
})
})
//
const paginatedDevices = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredDevices.value.slice(start, end)
})
const totalPages = computed(() => {
return Math.ceil(filteredDevices.value.length / pageSize)
})
//
// - script setup
const formatStatus = (status: DeviceStatus): string => {
const statusMap: Record<string, string> = {
online: '在线',
offline: '离线',
fault: '故障'
}
return statusMap[status] || status
}
//
const formatDate = (dateString?: string): string => {
if (!dateString) return '-'
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const viewDevice = (id: string) => {
router.push(`/home/equipment/water-maker/${id}`)
}
// 线
const showOfflineModal = (deviceId: string) => {
currentDeviceId.value = deviceId
showOfflineReasonModal.value = true
}
//
const showFaultModalFunc = (deviceId: string) => {
currentDeviceId.value = deviceId
showFaultModal.value = true
}
// 线
const confirmOffline = async () => {
try {
const result = await DeviceStatusApi.markDeviceOffline(
currentDeviceId.value,
offlineReason.value
)
if (result.code === 200) {
const device = devices.value.find(d => d.deviceId === currentDeviceId.value)
if (device) {
device.status = 'offline'
}
showOfflineReasonModal.value = false
offlineReason.value = ''
}
} catch (error) {
console.error('设置设备离线失败:', error)
}
}
//
const confirmFault = async () => {
try {
const result = await DeviceStatusApi.markDeviceFault(
currentDeviceId.value,
faultInfo.value.faultType,
faultInfo.value.description
)
if (result.code === 200) {
const device = devices.value.find(d => d.deviceId === currentDeviceId.value)
if (device) {
device.status = 'fault'
}
showFaultModal.value = false
faultInfo.value = { faultType: '', description: '' }
}
} catch (error) {
console.error('设置设备故障失败:', error)
}
}
//
// - updateDeviceStatus
const updateDeviceStatus = async (deviceId: string, status: string, remark: string = '') => {
try {
let result: any;
switch (status) {
case 'online':
result = await DeviceStatusApi.markDeviceOnline(deviceId)
break
case 'offline':
result = await DeviceStatusApi.markDeviceOffline(deviceId, remark)
break
case 'fault':
//
result = await DeviceStatusApi.markDeviceFault(deviceId, 'MANUAL_FAULT', remark || '手动设置故障')
break
default:
throw new Error('不支持的状态类型')
}
if (result.code === 200) {
//
const device = devices.value.find(d => d.deviceId === deviceId)
if (device) {
device.status = status as DeviceStatus
}
return true
} else {
throw new Error(result.message || '操作失败')
}
} catch (error) {
console.error('更新设备状态失败:', error)
throw error
}
}
//
const addDevice = async () => {
try {
//
const deviceToAdd = {
deviceId: newDevice.value.deviceId,
deviceName: newDevice.value.deviceName,
areaId: newDevice.value.areaId,
installLocation: newDevice.value.installLocation,
deviceType: newDevice.value.deviceType
};
// API
const result = await fetch('/api/web/device/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(deviceToAdd)
}).then(response => response.json());
if (result.code === 200) {
//
await loadDevices();
//
showAddModal.value = false;
newDevice.value = {
deviceId: '',
deviceName: '',
areaId: '市区',
installLocation: '',
deviceType: 'WATER_MAKER'
};
console.log('设备添加成功');
} else {
console.error('设备添加失败:', result.message);
}
} catch (error) {
console.error('添加设备失败:', error);
}
};
//
// onMounted
onMounted(async () => {
console.log('🚀 开始加载设备数据...')
try {
const result = await loadDevices()
console.log('🌐 API返回 data:', result)
if (result.length === 0) {
console.warn('⚠️ 数据库中无设备数据')
} else {
console.log('✅ 成功加载设备数据:', result)
}
} catch (error) {
console.error('❌ 加载设备数据失败:', error)
}
})
</script>
<style scoped>
/* 样式与供水机页面保持一致 */
.water-maker-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.equipment-table {
width: 100%;
border-collapse: collapse;
}
.equipment-table th,
.equipment-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.equipment-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.equipment-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.fault {
background-color: #ffebe6;
color: #cf1322;
}
.operation-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover:not(:disabled) {
opacity: 0.9;
}
.operation-buttons button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-view {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-online {
background-color: #e6f7ee;
color: #00875a;
}
.btn-offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.btn-fault {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
min-width: 400px;
max-width: 500px;
}
.modal-content h3 {
margin-top: 0;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 4px;
font-weight: 500;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.form-group textarea {
min-height: 80px;
resize: vertical;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.form-actions button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
border: 1px solid #ddd;
}
.form-actions button[type="button"] {
background: #f5f5f5;
}
.form-actions button[type="submit"] {
background: #42b983;
color: white;
border: none;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filters {
flex-direction: column;
width: 100%;
}
.search-box, .filter-select {
width: 100%;
}
.modal-content {
width: 90%;
min-width: auto;
}
}
</style>

@ -0,0 +1,804 @@
<!-- src/views/equipment/WaterMakerDetail.vue -->
<template>
<div class="water-machine-detail">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>制水机设备详情</h2>
<div class="breadcrumb">校园矿化水平台 / 设备管理 / 制水机 / 设备详情</div>
</div>
<!-- 设备基本信息卡片 -->
<div class="card device-info-card">
<div class="info-header">
<h3 class="card-title">设备基本信息</h3>
<button class="btn-refresh" @click="refreshData">
<i class="refresh-icon"></i> 刷新数据
</button>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">设备ID</span>
<span class="info-value">{{ machineInfo.deviceId }}</span>
</div>
<div class="info-item">
<span class="info-label">设备型号</span>
<span class="info-value">{{ machineInfo.model }}</span>
</div>
<div class="info-item">
<span class="info-label">所属片区</span>
<span class="info-value">{{ machineInfo.area }}</span>
</div>
<div class="info-item">
<span class="info-label">安装位置</span>
<span class="info-value">{{ machineInfo.location }}</span>
</div>
<div class="info-item">
<span class="info-label">安装日期</span>
<span class="info-value">{{ formatDate(machineInfo.installDate) }}</span>
</div>
<div class="info-item">
<span class="info-label">运行状态</span>
<span class="info-value status-tag" :class="machineInfo.status">{{ formatStatus(machineInfo.status) }}</span>
</div>
<div class="info-item">
<span class="info-label">最后在线时间</span>
<span class="info-value">{{ formatDate(machineInfo.lastOnlineTime) }}</span>
</div>
</div>
</div>
<!-- 设备状态和数据 -->
<div class="left-column">
<!-- 实时监测数据 -->
<div class="card">
<h3 class="card-title">实时监测数据</h3>
<div class="realtime-data">
<!-- 自来水TDS -->
<div class="data-item">
<div class="data-label">自来水TDS</div>
<div class="data-value">{{ realtimeData.tapWaterTds }} ppm</div>
<div class="data-status" :class="getTdsStatus(realtimeData.tapWaterTds, 'tap')"></div>
</div>
<!-- 纯净水TDS -->
<div class="data-item">
<div class="data-label">纯净水TDS</div>
<div class="data-value">{{ realtimeData.pureWaterTds }} ppm</div>
<div class="data-status" :class="getTdsStatus(realtimeData.pureWaterTds, 'pure')"></div>
</div>
<div class="data-item">
<div class="data-label">水温</div>
<div class="data-value">{{ realtimeData.temperature }} °C</div>
</div>
<div class="data-item">
<div class="data-label">出水压力</div>
<div class="data-value">{{ realtimeData.pressure }} MPa</div>
</div>
<div class="data-item">
<div class="data-label">流量计1</div>
<div class="data-value">{{ realtimeData.flow1 }} L/min</div>
</div>
<div class="data-item">
<div class="data-label">流量计2</div>
<div class="data-value">{{ realtimeData.flow2 }} L/min</div>
</div>
</div>
</div>
<!-- 滤芯状态 -->
<div class="card">
<h3 class="card-title">滤芯状态</h3>
<div class="filter-status">
<div class="filter-item" v-for="filter in filterStatus" :key="filter.id">
<div class="filter-name">{{ filter.name }}</div>
<div class="filter-progress">
<div class="progress-bar" :style="{ width: filter.usage + '%' }" :class="getFilterStatusClass(filter.usage)"></div>
</div>
<div class="filter-info">
<span class="usage">{{ filter.usage }}%</span>
<span class="remaining">剩余{{ filter.remainingDays }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 历史数据记录 -->
<div class="card">
<div class="table-header">
<h3 class="card-title">历史数据记录</h3>
<div class="table-filter">
<label>日期筛选</label>
<input type="date" v-model="historyDate" @change="fetchHistoryData">
</div>
</div>
<table class="history-table">
<thead>
<tr>
<th>日期</th>
<th>自来水TDS平均值 (ppm)</th>
<th>纯净水TDS平均值 (ppm)</th>
<th>矿化水TDS平均值 (ppm)</th>
</tr>
</thead>
<tbody>
<tr v-if="historyData.length > 0">
<td>{{ historyData[0]?.date }}</td>
<td>{{ historyData[0]?.tapWaterTdsAvg }}</td>
<td>{{ historyData[0]?.pureWaterTdsAvg }}</td>
<td>{{ historyData[0]?.mineralWaterTdsAvg }}</td>
</tr>
<tr v-if="historyData.length === 0">
<td colspan="4" class="no-data">暂无历史数据记录</td>
</tr>
</tbody>
</table>
</div>
<!-- 维护记录 -->
<div class="card">
<h3 class="card-title">维护记录</h3>
<table class="maintenance-table">
<thead>
<tr>
<th>工单编号</th>
<th>维护类型</th>
<th>维护人员</th>
<th>维护时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="record in maintenanceRecords" :key="record.orderNo">
<td>{{ record.orderNo }}</td>
<td>{{ record.maintenanceType }}</td>
<td>{{ record.maintainer }}</td>
<td>{{ formatDate(record.maintenanceTime) }}</td>
<td><span class="status-tag small" :class="record.status">{{ formatStatus(record.status) }}</span></td>
<td>
<button class="btn-view" @click="viewMaintenanceDetail(record.orderNo)"></button>
</td>
</tr>
<tr v-if="maintenanceRecords.length === 0">
<td colspan="6" class="no-data">暂无维护记录</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { WaterMakerApi } from '@/api/waterMaker'
//
type DeviceStatus = 'online' | 'offline' | 'warning' | 'error'
//
interface MachineInfo {
deviceId: string
model: string
area: string
location: string
installDate: string
status: DeviceStatus
lastOnlineTime: string
}
//
interface RealtimeData {
tapWaterTds: number // TDS
pureWaterTds: number // TDS
temperature: number
pressure: number
flow1: number
flow2: number
updateTime: string
}
//
interface FilterStatus {
id: string
name: string
usage: number
remainingDays: number
}
//
interface HistoryRecord {
date: string //
tapWaterTdsAvg: number // TDS
pureWaterTdsAvg: number // TDS
mineralWaterTdsAvg: number // TDS
}
//
interface MaintenanceRecord {
orderNo: string
maintenanceType: string
maintainer: string
maintenanceTime: string
status: 'completed' | 'processing' | 'pending'
}
//
const route = useRoute()
const router = useRouter()
const historyDate = ref('')
// deviceIdstring
const deviceId: string = (() => {
const paramId = route.params.id as string | undefined
return paramId ?? 'WM-2023-001'
})()
//
const machineInfo = ref<MachineInfo>({
deviceId: deviceId,
model: 'WM-RO-500G',
area: '校区',
location: '第一教学楼一楼大厅',
installDate: '2023-06-15',
status: 'online',
lastOnlineTime: new Date().toISOString()
})
//
const realtimeData = ref<RealtimeData>({
tapWaterTds: 186, // TDS
pureWaterTds: 12, // TDS
temperature: 25.6,
pressure: 0.45,
flow1: 1.2,
flow2: 8.1,
updateTime: new Date().toISOString()
})
//
const filterStatus = ref<FilterStatus[]>([
{ id: 'f1', name: '滤芯1', usage: 65, remainingDays: 45 },
{ id: 'f2', name: '滤芯2', usage: 42, remainingDays: 90 },
{ id: 'f3', name: '滤芯3', usage: 30, remainingDays: 120 }
])
//
const historyData = ref<HistoryRecord[]>([])
//
const maintenanceRecords = ref<MaintenanceRecord[]>([
{
orderNo: 'ORD-20231015-002',
maintenanceType: '滤芯更换',
maintainer: '张三',
maintenanceTime: '2023-10-15 14:30:00',
status: 'completed'
},
{
orderNo: 'ORD-20230920-015',
maintenanceType: '常规检修',
maintainer: '李四',
maintenanceTime: '2023-09-20 09:15:00',
status: 'completed'
}
])
//
onMounted(async () => {
//
const today = new Date().toISOString().substring(0, 10)
historyDate.value = today
//
await Promise.all([
loadDeviceBasicInfo(),
loadRealtimeData(),
loadFilterStatus(),
fetchHistoryData(),
loadMaintenanceRecords()
])
})
//
const loadDeviceBasicInfo = async () => {
try {
const result = await WaterMakerApi.getDeviceById(deviceId)
if (result.code === 200) {
const data = result.data
machineInfo.value = {
deviceId: data.deviceId,
model: data.model || '未知型号',
area: data.areaId,
location: data.installLocation,
installDate: data.installDate,
status: data.status,
lastOnlineTime: data.lastHeartbeatTime
}
}
} catch (error) {
console.error('加载设备基本信息失败:', error)
}
}
//
const loadRealtimeData = async () => {
try {
const result = await WaterMakerApi.getRealtimeData(deviceId)
if (result.code === 200) {
const data = result.data
realtimeData.value = {
tapWaterTds: data.rawWaterTds || 0,
pureWaterTds: data.pureWaterTds || 0,
temperature: data.temperature || 0,
pressure: data.waterPressure || 0,
flow1: data.flowRate1 || 0,
flow2: data.flowRate2 || 0,
updateTime: new Date().toISOString()
}
}
} catch (error) {
console.error('加载实时数据失败:', error)
}
}
//
const loadFilterStatus = async () => {
try {
const result = await WaterMakerApi.getFilterStatus(deviceId)
if (result.code === 200) {
filterStatus.value = result.data.map((item: any) => ({
id: item.filterId,
name: item.filterName,
usage: item.usagePercentage,
remainingDays: item.remainingDays
}))
}
} catch (error) {
console.error('加载滤芯状态失败:', error)
}
}
//
const fetchHistoryData = async () => {
try {
const result = await WaterMakerApi.getHistoryData(deviceId, historyDate.value)
if (result.code === 200) {
historyData.value = result.data.map((item: any) => ({
date: item.statDate,
tapWaterTdsAvg: item.rawWaterTdsAvg,
pureWaterTdsAvg: item.pureWaterTdsAvg,
mineralWaterTdsAvg: item.mineralWaterTdsAvg
}))
}
} catch (error) {
console.error('获取历史数据失败:', error)
}
}
//
const loadMaintenanceRecords = async () => {
try {
const result = await WaterMakerApi.getMaintenanceRecords(deviceId)
if (result.code === 200) {
maintenanceRecords.value = result.data.map((item: any) => ({
orderNo: item.orderNo,
maintenanceType: item.type,
maintainer: item.maintainer,
maintenanceTime: item.maintenanceTime,
status: item.status
}))
}
} catch (error) {
console.error('加载维护记录失败:', error)
}
}
//
const formatStatus = (status: string): string => {
const statusMap: Record<string, string> = {
online: '在线',
offline: '离线',
warning: '警告',
error: '故障',
completed: '已完成',
processing: '处理中',
pending: '待处理'
}
return statusMap[status] || status
}
//
const formatDate = (dateString?: string): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return isNaN(date.getTime()) ? '-' : date.toLocaleString('zh-CN')
} catch {
return '-'
}
}
// TDS
const getTdsStatus = (tds: number, type: 'tap' | 'pure'): string => {
if (type === 'pure') {
// TDS
if (tds < 10) return 'excellent'
if (tds < 20) return 'good'
return 'warning'
} else {
// TDS
if (tds < 200) return 'good'
if (tds < 300) return 'warning'
return 'error'
}
}
//
const getFilterStatusClass = (usage: number) => {
if (usage < 70) return 'normal'
if (usage < 90) return 'warning'
return 'error'
}
//
const refreshData = async () => {
try {
const result = await WaterMakerApi.refreshDeviceData(deviceId)
if (result.code === 200) {
//
await Promise.all([
loadRealtimeData(),
loadFilterStatus()
])
}
} catch (error) {
console.error('刷新数据失败:', error)
}
}
//
const viewMaintenanceDetail = (orderNo: string) => {
router.push(`/home/order/detail/${orderNo}`)
}
</script>
<style scoped>
.water-machine-detail {
padding: 20px;
}
/* 页面头部样式 */
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 信息卡片样式 */
.info-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin: 0 0 16px 0;
}
.btn-refresh {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-refresh:hover {
background-color: #e9ecef;
}
.refresh-icon {
font-size: 16px;
}
/* 信息网格样式 */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.info-value {
font-size: 15px;
color: #333;
font-weight: 500;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.error {
background-color: #ffebe6;
color: #cf1322;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.small {
padding: 2px 6px;
font-size: 11px;
}
/* 左侧列样式 */
.left-column {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 20px;
}
/* 实时数据样式 */
.realtime-data {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.data-item {
background-color: #f8f9fa;
padding: 16px;
border-radius: 6px;
text-align: center;
}
.data-label {
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.data-value {
font-size: 22px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.data-status {
width: 10px;
height: 10px;
border-radius: 50%;
margin: 0 auto;
}
.data-status.excellent {
background-color: #52c41a;
}
.data-status.good {
background-color: #1890ff;
}
.data-status.warning {
background-color: #faad14;
}
.data-status.error {
background-color: #ff4d4f;
}
/* 滤芯状态样式 */
.filter-status {
display: flex;
flex-direction: column;
gap: 16px;
}
.filter-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.filter-name {
font-size: 14px;
color: #333;
}
.filter-progress {
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
.progress-bar {
height: 100%;
transition: width 0.3s;
}
.progress-bar.normal {
background-color: #52c41a;
}
.progress-bar.warning {
background-color: #faad14;
}
.progress-bar.error {
background-color: #ff4d4f;
}
.filter-info {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
}
/* 表格样式 */
.table-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.table-filter {
display: flex;
align-items: center;
gap: 8px;
}
.table-filter input {
padding: 6px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.history-table, .maintenance-table {
width: 100%;
border-collapse: collapse;
}
.history-table th, .history-table td,
.maintenance-table th, .maintenance-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.history-table th, .maintenance-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.history-table tbody tr:hover,
.maintenance-table tbody tr:hover {
background-color: #f8f9fa;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 按钮样式 */
.btn-view {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-view:hover {
opacity: 0.9;
}
/* 响应式调整 */
@media (max-width: 768px) {
.info-grid {
grid-template-columns: 1fr 1fr;
}
.realtime-data {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
@media (max-width: 480px) {
.info-grid {
grid-template-columns: 1fr;
}
.history-table, .maintenance-table {
font-size: 12px;
}
.history-table th, .history-table td,
.maintenance-table th, .maintenance-table td {
padding: 8px 6px;
}
}
</style>

@ -0,0 +1,412 @@
<!-- src/views/equipment/WaterSupplier.vue -->
<template>
<div class="water-supplier-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>供水机管理</h2>
<div class="breadcrumb">校园矿化水平台 / 设备监控 / 供水机</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add">添加供水机</button>
<div class="filters">
<!-- 搜索框 -->
<div class="search-box">
<input
type="text"
placeholder="搜索设备ID或位置..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn">搜索</button>
</div>
<!-- 片区筛选 -->
<select
v-model="selectedArea"
class="filter-select"
@change="handleSearch"
>
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
<!-- 状态筛选 -->
<select
v-model="selectedStatus"
class="filter-select"
@change="handleSearch"
>
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
<option value="warning">警告</option>
<option value="error">故障</option>
</select>
</div>
</div>
<!-- 设备表格 - 新增设备机型列 -->
<div class="card">
<table class="equipment-table">
<thead>
<tr>
<th>设备ID</th>
<th>设备机型</th> <!-- 新增机型列 -->
<th>所属片区</th>
<th>详细位置</th>
<th>状态</th>
<th>最后上传时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="device in filteredDevices" :key="device.id">
<td>{{ device.id }}</td>
<td>供水机</td> <!-- 固定显示供水机机型 -->
<td>{{ device.area }}</td>
<td>{{ device.location }}</td>
<td>
<span :class="`status-tag ${device.status}`">
{{ formatStatus(device.status) }}
</span>
</td>
<td>{{ device.lastUploadTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewDevice(device.id)"></button>
</td>
</tr>
<tr v-if="filteredDevices.length === 0">
<td colspan="7" class="no-data">暂无设备数据</td> <!-- colspan从6改为7 -->
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type DeviceStatus = 'online' | 'offline' | 'warning' | 'error'
//
interface WaterSupplierDevice {
id: string
area: string
location: string
status: DeviceStatus
lastUploadTime: string
}
//
const waterSupplierDevices: WaterSupplierDevice[] = [
{
id: 'WS-2023-001',
area: '市区',
location: '行政中心大楼1楼大厅',
status: 'online',
lastUploadTime: '2023-10-25 10:15:33'
},
{
id: 'WS-2023-002',
area: '校区',
location: '研究生公寓3号楼一层',
status: 'online',
lastUploadTime: '2023-10-25 09:30:22'
},
{
id: 'WS-2023-003',
area: '市区',
location: '科技园区A座大厅',
status: 'warning',
lastUploadTime: '2023-10-25 08:45:11'
},
{
id: 'WS-2023-004',
area: '校区',
location: '留学生公寓1楼',
status: 'offline',
lastUploadTime: '2023-10-24 23:05:47'
},
{
id: 'WS-2023-005',
area: '市区',
location: '图书馆新馆2楼',
status: 'error',
lastUploadTime: '2023-10-25 07:20:35'
}
]
//
const devices = ref<WaterSupplierDevice[]>(waterSupplierDevices)
const searchKeyword = ref('')
const selectedArea = ref('') //
const selectedStatus = ref('') //
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredDevices = computed(() => {
return devices.value.filter(device => {
const keywordMatch = searchKeyword.value.trim() === '' ||
device.id.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
device.location.toLowerCase().includes(searchKeyword.value.toLowerCase())
const areaMatch = selectedArea.value === '' || device.area === selectedArea.value
const statusMatch = selectedStatus.value === '' || device.status === selectedStatus.value
return keywordMatch && areaMatch && statusMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredDevices.value.length / pageSize)
})
//
const formatStatus = (status: DeviceStatus): string => {
const statusMap = {
online: '在线',
offline: '离线',
warning: '警告',
error: '故障'
}
return statusMap[status]
}
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const viewDevice = (id: string) => {
router.push(`/home/equipment/water-supplier/${id}`)
}
</script>
<style scoped>
/* 样式与制水机页面保持一致 */
.water-supplier-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.filter-select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
}
.equipment-table {
width: 100%;
border-collapse: collapse;
}
.equipment-table th,
.equipment-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.equipment-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.equipment-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.online {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.offline {
background-color: #f5f5f5;
color: #8c8c8c;
}
.status-tag.warning {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.error {
background-color: #ffebe6;
color: #cf1322;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #e6f7ff;
color: #1890ff;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filters {
flex-direction: column;
width: 100%;
}
.search-box, .filter-select {
width: 100%;
}
}
</style>

@ -0,0 +1,371 @@
<!-- src/views/personnel/Admin.vue -->
<template>
<div class="admin-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>管理员管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 管理员</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="handleAddAdmin"></button>
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 管理员表格 -->
<div class="card">
<table class="admin-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>身份</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="admin in filteredAdmins" :key="admin.id">
<td>{{ admin.name }}</td>
<td>{{ admin.account }}</td>
<td>{{ admin.phone }}</td>
<td>{{ admin.role }}</td>
<td>
<span :class="`status-tag ${admin.status}`">
{{ admin.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-edit"
@click="handleEdit(admin.id)"
>
编辑
</button>
<button
class="btn-status"
:class="admin.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(admin.id, admin.status)"
>
{{ admin.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredAdmins.length === 0">
<td colspan="6" class="no-data">暂无管理员数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type AdminStatus = 'active' | 'disabled'
//
interface Admin {
id: string
name: string
account: string
phone: string
role: string
status: AdminStatus
}
//
const adminList: Admin[] = [
{
id: '1',
name: '张三',
account: 'admin01',
phone: '13800138000',
role: '超级管理员',
status: 'active'
},
{
id: '2',
name: '李四',
account: 'admin02',
phone: '13900139000',
role: '设备管理员',
status: 'active'
},
{
id: '3',
name: '王五',
account: 'admin03',
phone: '13700137000',
role: '系统管理员',
status: 'disabled'
}
]
//
const admins = ref<Admin[]>(adminList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredAdmins = computed(() => {
return admins.value.filter(admin => {
const keywordMatch = searchKeyword.value.trim() === '' ||
admin.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
admin.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredAdmins.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: AdminStatus) => {
const newStatus: AdminStatus = currentStatus === 'active' ? 'disabled' : 'active'
admins.value = admins.value.map(admin =>
admin.id === id ? { ...admin, status: newStatus } : admin
)
// API
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/admin/edit/${id}`)
}
//
const handleAddAdmin = () => {
router.push('/home/personnel/admin/add')
}
</script>
<style scoped>
.admin-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.admin-table {
width: 100%;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.admin-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.admin-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,387 @@
<!-- src/views/personnel/Maintenance.vue -->
<template>
<div class="maintenance-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>维修人员管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 维修人员</div>
</div>
<!-- 操作按钮区 -->
<div class="action-bar">
<button class="btn-add" @click="handleAddMaintenance"></button>
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 维修人员表格 -->
<div class="card">
<table class="maintenance-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>维修片区</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="staff in filteredStaff" :key="staff.id">
<td>{{ staff.name }}</td>
<td>{{ staff.account }}</td>
<td>{{ staff.phone }}</td>
<td>{{ staff.area }}</td>
<td>
<span :class="`status-tag ${staff.status}`">
{{ staff.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-view"
@click="handleViewRecords(staff.id)"
>
查看维修记录
</button>
<button
class="btn-edit"
@click="handleEdit(staff.id)"
>
编辑
</button>
<button
class="btn-status"
:class="staff.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(staff.id, staff.status)"
>
{{ staff.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredStaff.length === 0">
<td colspan="6" class="no-data">暂无维修人员数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type StaffStatus = 'active' | 'disabled'
//
interface MaintenanceStaff {
id: string
name: string
account: string
phone: string
area: string
status: StaffStatus
}
//
const staffList: MaintenanceStaff[] = [
{
id: '1',
name: '赵六',
account: 'repair01',
phone: '13500135000',
area: '市区',
status: 'active'
},
{
id: '2',
name: '孙七',
account: 'repair02',
phone: '13600136000',
area: '校区',
status: 'active'
},
{
id: '3',
name: '周八',
account: 'repair03',
phone: '13400134000',
area: '市区',
status: 'disabled'
}
]
//
const staff = ref<MaintenanceStaff[]>(staffList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
//
const filteredStaff = computed(() => {
return staff.value.filter(person => {
const keywordMatch = searchKeyword.value.trim() === '' ||
person.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
person.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredStaff.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: StaffStatus) => {
const newStatus: StaffStatus = currentStatus === 'active' ? 'disabled' : 'active'
staff.value = staff.value.map(person =>
person.id === id ? { ...person, status: newStatus } : person
)
// API
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/maintenance/edit/${id}`)
}
//
const handleViewRecords = (id: string) => {
router.push(`/home/personnel/maintenance/records/${id}`)
}
//
const handleAddMaintenance = () => {
router.push('/home/personnel/maintenance/add')
}
</script>
<style scoped>
.maintenance-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.btn-add {
background: #42b983;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.btn-add:hover {
background: #359e75;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.maintenance-table {
width: 100%;
border-collapse: collapse;
}
.maintenance-table th,
.maintenance-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.maintenance-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.maintenance-table tbody tr:hover {
background-color: #f8f9fa;
}
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #f6f7ff;
color: #667eea;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,407 @@
<!-- src/views/personnel/User.vue -->
<template>
<div class="user-page">
<!-- 页面标题和面包屑恢复与管理员/维修人员页面一致 -->
<div class="page-header">
<h2>用户管理</h2>
<div class="breadcrumb">校园矿化水平台 / 人员管理 / 用户</div>
</div>
<!-- 操作按钮区移除新增按钮保留搜索功能 -->
<div class="action-bar">
<!-- 移除新增用户按钮 -->
<div class="search-box">
<input
type="text"
placeholder="搜索姓名或账号..."
v-model="searchKeyword"
@input="handleSearch"
>
<button class="search-btn" @click="handleSearch"></button>
</div>
</div>
<!-- 用户表格保持与管理员/维修人员页面一致的列结构 -->
<div class="card">
<table class="user-table">
<thead>
<tr>
<th>姓名</th>
<th>账号</th>
<th>联系电话</th>
<th>身份</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredUsers" :key="user.id">
<td>{{ user.name }}</td>
<td>{{ user.account }}</td>
<td>{{ user.phone }}</td>
<td>
<span :class="`role-tag ${user.role}`">
{{ formatRole(user.role) }}
</span>
</td>
<td>
<span :class="`status-tag ${user.status}`">
{{ user.status === 'active' ? '启用' : '禁用' }}
</span>
</td>
<td class="operation-buttons">
<button
class="btn-view"
@click="handleView(user.id)"
>
查看
</button>
<button
class="btn-edit"
@click="handleEdit(user.id)"
>
编辑
</button>
<button
class="btn-status"
:class="user.status === 'active' ? 'btn-disable' : 'btn-enable'"
@click="handleStatusChange(user.id, user.status)"
>
{{ user.status === 'active' ? '禁用' : '启用' }}
</button>
</td>
</tr>
<tr v-if="filteredUsers.length === 0">
<td colspan="6" class="no-data">暂无用户数据</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件与管理员/维修人员页面样式一致 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type UserRole = 'student' | 'teacher' | 'visitor'
type UserStatus = 'active' | 'disabled'
//
interface User {
id: string
name: string
account: string
phone: string
role: UserRole
status: UserStatus
}
//
const userList: User[] = [
{
id: '1',
name: '吴九',
account: 'user01',
phone: '13100131000',
role: 'student', //
status: 'active'
},
{
id: '2',
name: '郑十',
account: 'user02',
phone: '13200132000',
role: 'teacher', //
status: 'active'
},
{
id: '3',
name: '王十一',
account: 'user03',
phone: '13300133000',
role: 'visitor', //
status: 'disabled'
}
]
// /
const users = ref<User[]>(userList)
const searchKeyword = ref('')
const currentPage = ref(1)
const pageSize = 10
const router = useRouter()
//
const formatRole = (role: UserRole) => {
switch(role) {
case 'student': return '学生';
case 'teacher': return '老师';
case 'visitor': return '游客';
default: return '未知身份';
}
}
//
const filteredUsers = computed(() => {
return users.value.filter(user => {
const keywordMatch = searchKeyword.value.trim() === '' ||
user.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
user.account.toLowerCase().includes(searchKeyword.value.toLowerCase())
return keywordMatch
})
})
// /
const totalPages = computed(() => {
return Math.ceil(filteredUsers.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
//
const handleStatusChange = (id: string, currentStatus: UserStatus) => {
const newStatus: UserStatus = currentStatus === 'active' ? 'disabled' : 'active'
users.value = users.value.map(user =>
user.id === id ? { ...user, status: newStatus } : user
)
}
//
const handleView = (id: string) => {
router.push(`/home/personnel/user/view/${id}`)
}
//
const handleEdit = (id: string) => {
router.push(`/home/personnel/user/edit/${id}`)
}
</script>
<style scoped>
/* 完全复用管理员/维修人员页面的样式,仅调整角色标签颜色 */
.user-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 16px;
}
.search-box {
display: flex;
gap: 8px;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 240px;
}
.search-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.user-table {
width: 100%;
border-collapse: collapse;
}
.user-table th,
.user-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.user-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.user-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 状态标签样式(与管理员/维修人员页面一致) */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.active {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.disabled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 角色标签样式(新增,区分学生/老师/游客) */
.role-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.role-tag.student {
background-color: #e6f7ff;
color: #1890ff;
}
.role-tag.teacher {
background-color: #f6f7ff;
color: #667eea;
}
.role-tag.visitor {
background-color: #fff7e6;
color: #d48806;
}
.operation-buttons {
display: flex;
gap: 8px;
}
.operation-buttons button {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
}
.operation-buttons button:hover {
opacity: 0.9;
}
.btn-view {
background-color: #f6f7ff;
color: #667eea;
}
.btn-edit {
background-color: #e6f7ff;
color: #1890ff;
}
.btn-enable {
background-color: #e6f7ee;
color: #00875a;
}
.btn-disable {
background-color: #ffebe6;
color: #cf1322;
}
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 响应式调整(与管理员/维修人员页面一致) */
@media (max-width: 768px) {
.action-bar {
flex-direction: column;
align-items: flex-start;
}
.search-box {
width: 100%;
}
.search-box input {
width: 100%;
}
}
</style>

@ -0,0 +1,290 @@
<!-- src/views/order/OrderCompleted.vue -->
<template>
<div class="order-completed-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>已结单工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 已结单</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无已结单工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface CompletedOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
}
//
const orderList: CompletedOrder[] = [
{
id: '11',
orderNo: 'ORD-20231023-001',
deviceType: '制水机',
deviceId: 'WM-2023-001',
area: '市区',
problemDesc: '更换密封垫后漏水问题已解决,设备运行正常',
status: 'completed',
createTime: '2023-10-23 08:10:05'
},
{
id: '12',
orderNo: 'ORD-20231023-002',
deviceType: '供水机',
deviceId: 'WS-2023-001',
area: '校区',
problemDesc: '水质检测合格,出水口感异常问题已解决',
status: 'completed',
createTime: '2023-10-23 14:20:33'
},
{
id: '13',
orderNo: 'ORD-20231024-003',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '水泵检修完成,出水速度恢复正常,已审核通过',
status: 'completed',
createTime: '2023-10-24 11:30:15'
}
]
//
const orders = ref<CompletedOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
router.push(`/home/work-order/completed/${id}`)
}
</script>
<style scoped>
/* 样式与前几个页面完全一致,仅修改页面容器类名 */
.order-completed-page {
padding: 20px;
}
/* 复用相同样式 */
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
}
</style>

@ -0,0 +1,455 @@
<!-- src/views/workorder/CompletedDetail.vue -->
<template>
<div class="completed-detail-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<div>
<h2>结单信息</h2>
<div class="breadcrumb">矿化水平台 / 工单管理 / 已结单 / 工单信息</div>
</div>
<button class="btn-back" @click="handleBack"></button>
</div>
<!-- 工单基本信息卡片 -->
<div class="card main-card">
<div class="card-header">
<h3>工单基本信息</h3>
</div>
<div class="card-body">
<div class="info-grid">
<div class="info-item">
<span class="info-label">工单号</span>
<span class="info-value">{{ orderDetail.orderNo }}</span>
</div>
<div class="info-item">
<span class="info-label">工单状态</span>
<span class="info-value status-tag completed">{{ formatStatus(orderDetail.status) }}</span>
</div>
<div class="info-item">
<span class="info-label">维修人员</span>
<span class="info-value">{{ orderDetail.repairman }}</span>
</div>
<div class="info-item">
<span class="info-label">联系电话</span>
<span class="info-value">{{ orderDetail.phone }}</span>
</div>
<div class="info-item">
<span class="info-label">接单时间</span>
<span class="info-value">{{ orderDetail.acceptTime }}</span>
</div>
<div class="info-item">
<span class="info-label">设备ID</span>
<span class="info-value">{{ orderDetail.deviceId }}</span>
</div>
<div class="info-item">
<span class="info-label">警告项目</span>
<span class="info-value">{{ orderDetail.warningItem }}</span>
</div>
<div class="info-item">
<span class="info-label">设备位置</span>
<span class="info-value">{{ orderDetail.location }}</span>
</div>
</div>
</div>
</div>
<!-- 处理详情 -->
<div class="card">
<div class="card-header">
<h3>处理详情</h3>
</div>
<div class="card-body">
<div class="detail-item">
<span class="detail-label">处理备注</span>
<div class="detail-content">{{ orderDetail.processRemark }}</div>
</div>
<div class="detail-item">
<span class="detail-label">实际处理</span>
<div class="detail-content">{{ orderDetail.actualProcess }}</div>
</div>
</div>
</div>
<!-- 现场照片 -->
<div class="card">
<div class="card-header">
<h3>现场照片</h3>
</div>
<div class="card-body">
<div class="photos-container">
<div class="photo-item" v-for="(photo, index) in orderDetail.photos" :key="index">
<img :src="photo" :alt="`现场照片 ${index + 1}`" class="现场照片" @click="previewPhoto(photo)">
</div>
<div v-if="orderDetail.photos.length === 0" class="no-photos"></div>
</div>
</div>
</div>
<!-- 结单信息 -->
<div class="card">
<div class="card-header">
<h3>结单信息</h3>
</div>
<div class="card-body">
<div class="detail-item total-cost">
<span class="detail-label">结单总费用</span>
<div class="detail-content cost-value">{{ orderDetail.totalCost }}</div>
</div>
<div class="detail-item">
<span class="detail-label">审核意见</span>
<div class="detail-content">{{ orderDetail.reviewOpinion || '无审核意见' }}</div>
</div>
<div class="detail-item">
<span class="detail-label">结单时间</span>
<div class="detail-content">{{ orderDetail.completeTime }}</div>
</div>
</div>
</div>
<!-- 照片预览弹窗 -->
<div v-if="previewImage" class="photo-preview-overlay" @click="previewImage = ''">
<div class="photo-preview-container" @click.stop>
<img :src="previewImage" alt="照片预览" class="preview-img">
<button class="preview-close" @click="previewImage = ''">×</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface OrderDetail {
id: string
orderNo: string
status: OrderStatus
repairman: string
phone: string
acceptTime: string
completeTime: string
deviceId: string
warningItem: string
location: string
processRemark: string
actualProcess: string
totalCost: string
reviewOpinion?: string
photos: string[]
}
//
const orderDetail = ref<OrderDetail>({
id: '',
orderNo: '',
status: 'completed',
repairman: '',
phone: '',
acceptTime: '',
completeTime: '',
deviceId: '',
warningItem: '',
location: '',
processRemark: '',
actualProcess: '',
totalCost: '',
photos: []
})
const previewImage = ref('')
//
const route = useRoute()
const router = useRouter()
//
const formatStatus = (status: OrderStatus): string => {
const statusMap: Record<OrderStatus, string> = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const fetchOrderDetail = async (id: string) => {
try {
// API
// const response = await getCompletedOrderDetailApi(id)
// orderDetail.value = response.data
// API
const mockData: Record<string, OrderDetail> = {
'11': {
id: '11',
orderNo: '1O05',
status: 'completed',
repairman: '王五',
phone: '123456789',
acceptTime: '2025/10/20-16:21',
completeTime: '2025/10/20-18:45',
deviceId: '供水机#A11',
warningItem: '更换滤芯',
location: 'A区图书馆',
processRemark: '原滤芯老旧已损,更换了新滤芯',
actualProcess: '更换滤芯',
totalCost: '110元',
reviewOpinion: '审核通过,已结单',
photos: [
'https://picsum.photos/seed/filter1/400/300',
'https://picsum.photos/seed/filter2/400/300'
]
},
'12': {
id: '12',
orderNo: '1O06',
status: 'completed',
repairman: '赵六',
phone: '987654321',
acceptTime: '2025/10/21-09:15',
completeTime: '2025/10/21-11:30',
deviceId: '制水机#B07',
warningItem: '水泵故障',
location: 'B区教学楼',
processRemark: '水泵已修复,设备运行正常',
actualProcess: '维修水泵,更换损坏零件',
totalCost: '260元',
reviewOpinion: '维修合格,同意结单',
photos: [
'https://picsum.photos/seed/pump1/400/300',
'https://picsum.photos/seed/pump2/400/300'
]
}
}
//
await new Promise(resolve => setTimeout(resolve, 300))
if (mockData[id]) {
orderDetail.value = mockData[id]
} else {
throw new Error('未找到工单数据')
}
} catch (error) {
console.error('获取工单详情失败:', error)
alert('获取工单详情详情失败,请重试')
router.push('/home/work-order/completed')
}
}
//
const previewPhoto = (photo: string) => {
previewImage.value = photo
}
//
const handleBack = () => {
router.go(-1)
}
//
onMounted(() => {
const id = route.params.id as string
if (id) {
fetchOrderDetail(id)
} else {
router.push('/home/work-order/completed')
}
})
</script>
<style scoped>
.completed-detail-page {
padding: 20px;
background-color: #f5f7fa;
min-height: 100vh;
box-sizing: border-box;
}
.page-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin: 0 0 8px 0;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
.btn-back {
padding: 8px 16px;
background-color: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-back:hover {
background-color: #f5f5f5;
}
.card {
background-color: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 24px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.card-body {
padding: 20px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.info-item {
display: flex;
margin-bottom: 8px;
}
.info-label, .detail-label {
color: #666;
width: 100px;
flex-shrink: 0;
font-weight: 500;
}
.info-value, .detail-content {
flex-grow: 1;
color: #333;
}
.status-tag {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
color: white;
}
.status-tag.completed {
background-color: #52c41a;
}
.detail-item {
margin-bottom: 16px;
display: flex;
}
.detail-content {
line-height: 1.5;
}
.photos-container {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 10px;
}
.photo-item {
width: 160px;
height: 120px;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
}
.photo-item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.photo-item img:hover {
transform: scale(1.05);
}
.no-photos {
color: #999;
padding: 20px 0;
}
.total-cost .cost-value {
color: #f5222d;
font-weight: 600;
font-size: 16px;
}
/* 照片预览样式 */
.photo-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.photo-preview-container {
position: relative;
max-width: 90%;
max-height: 90%;
}
.preview-img {
max-width: 100%;
max-height: 80vh;
border: 4px solid white;
border-radius: 4px;
}
.preview-close {
position: absolute;
top: -30px;
right: -30px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
border: none;
width: 30px;
height: 30px;
border-radius: 50%;
font-size: 20px;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
}
</style>

@ -0,0 +1,654 @@
<!-- src/views/workorder/Pending.vue -->
<template>
<div class="order-to-claim-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>待抢单工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 待抢单</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无待抢单工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 工单详情弹窗 -->
<div v-if="showDetailModal" class="modal-overlay" @click="closeDetailModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>工单详情</h3>
<button class="modal-close" @click="closeDetailModal">×</button>
</div>
<div class="modal-body">
<div class="detail-item">
<span class="detail-label">工单号</span>
<span class="detail-value">{{ currentOrder?.orderNo }}</span>
</div>
<div class="detail-item">
<span class="detail-label">建单时间</span>
<span class="detail-value">{{ currentOrder?.createTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备机型</span>
<span class="detail-value">{{ currentOrder?.deviceType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备ID</span>
<span class="detail-value">{{ currentOrder?.deviceId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">警告内容</span>
<span class="detail-value">{{ currentOrder?.problemDesc }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备最后上传时间</span>
<span class="detail-value">{{ currentOrder?.lastUploadTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">位置</span>
<span class="detail-value">{{ currentOrder?.location }}</span>
</div>
<div class="detail-item">
<span class="detail-label">当前状态</span>
<span :class="`detail-status status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-close" @click="closeDetailModal"></button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
//
type OrderStatus = 'pending' | 'processing' | 'completed' | 'cancelled'
//
interface ToClaimOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime: string //
location: string //
}
//
const orderList: ToClaimOrder[] = [
{
id: '1',
orderNo: 'ORD-20231025-001',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '出水速度慢,水压不足,需要检修水泵',
status: 'pending',
createTime: '2023-10-25 08:30:15',
lastUploadTime: '2023-10-25 08:25:30',
location: '教学区A栋一楼大厅'
},
{
id: '2',
orderNo: 'ORD-20231025-002',
deviceType: '供水机',
deviceId: 'WS-2023-005',
area: '市区',
problemDesc: '设备显示故障代码E12无法正常出水',
status: 'pending',
createTime: '2023-10-25 09:15:22',
lastUploadTime: '2023-10-25 09:10:05',
location: '市区图书馆二楼北侧'
},
{
id: '3',
orderNo: 'ORD-20231025-003',
deviceType: '制水机',
deviceId: 'WM-2023-003',
area: '市区',
problemDesc: '滤芯更换提醒,需要更换一级滤芯',
status: 'pending',
createTime: '2023-10-24 16:40:08',
lastUploadTime: '2023-10-24 16:35:22',
location: '行政楼一楼大厅'
},
{
id: '4',
orderNo: 'ORD-20231025-004',
deviceType: '供水机',
deviceId: 'WS-2023-004',
area: '校区',
problemDesc: '设备离线,无法上传数据,检查网络连接',
status: 'pending',
createTime: '2023-10-25 10:05:11',
lastUploadTime: '2023-10-25 09:50:45',
location: '学生宿舍3号楼一楼'
}
]
//
const orders = ref<ToClaimOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const showDetailModal = ref(false)
const currentOrder = ref<ToClaimOrder | null>(null)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
pending: '待抢单',
processing: '处理中',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (order: ToClaimOrder) => {
currentOrder.value = order
showDetailModal.value = true
}
//
const closeDetailModal = () => {
showDetailModal.value = false
currentOrder.value = null
}
</script>
<style scoped>
.order-to-claim-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 筛选区样式 */
.filter-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
/* 搜索输入框样式 */
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 240px;
}
.filter-select, .filter-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 160px;
}
.btn-reset {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-reset:hover {
background-color: #f0f0f0;
}
/* 表格样式 */
.order-table {
width: 100%;
border-collapse: collapse;
}
.order-table th,
.order-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.order-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.order-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 设备信息样式 */
.device-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-type {
font-weight: 500;
color: #333;
}
.device-id {
font-size: 12px;
color: #666;
}
/* 问题描述单元格 */
.desc-cell {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.desc-cell:hover {
white-space: normal;
overflow: visible;
background-color: white;
z-index: 10;
position: relative;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
.status-tag.cancelled {
background-color: #f5f5f5;
color: #8c8c8c;
}
/* 操作按钮样式 */
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-view {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-view:hover {
opacity: 0.9;
}
/* 空数据样式 */
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
width: 100%;
max-width: 500px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
padding: 0;
line-height: 1;
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 16px;
display: flex;
flex-wrap: wrap;
}
.detail-label {
flex: 0 0 120px;
color: #666;
font-size: 14px;
}
.detail-value {
flex: 1;
font-size: 14px;
color: #333;
}
.detail-status {
margin-top: 2px;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
.btn-close {
padding: 6px 16px;
background-color: #f8f9fa;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-close:hover {
background-color: #e9ecef;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-item {
width: 100%;
}
.search-input, .filter-select, .filter-input {
width: 100%;
}
.modal-content {
width: 90%;
}
.detail-label {
flex: 0 0 100px;
}
}
</style>

@ -0,0 +1,460 @@
<!-- src/views/workorder/Processing.vue -->
<template>
<div class="order-processing-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>处理中工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 处理中</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无处理中工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 详情弹窗 -->
<div v-if="showDetailModal" class="modal-overlay">
<div class="modal-container">
<div class="modal-header">
<h3>工单详情</h3>
<button class="modal-close" @click="showDetailModal = false">×</button>
</div>
<div class="modal-body">
<div class="detail-item">
<span class="detail-label">工单号</span>
<span class="detail-value">{{ currentOrder?.orderNo }}</span>
</div>
<div class="detail-item">
<span class="detail-label">建单时间</span>
<span class="detail-value">{{ currentOrder?.createTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备机型</span>
<span class="detail-value">{{ currentOrder?.deviceType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备ID</span>
<span class="detail-value">{{ currentOrder?.deviceId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">警告内容</span>
<span class="detail-value">{{ currentOrder?.problemDesc }}</span>
</div>
<div class="detail-item">
<span class="detail-label">设备最后上传时间</span>
<span class="detail-value">{{ currentOrder?.lastUploadTime }}</span>
</div>
<div class="detail-item">
<span class="detail-label">位置</span>
<span class="detail-value">{{ currentOrder?.location }}</span>
</div>
<div class="detail-item">
<span class="detail-label">接单师傅</span>
<span class="detail-value">{{ currentOrder?.maintenanceName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">师傅电话</span>
<span class="detail-value">{{ currentOrder?.maintenancePhone }}</span>
</div>
<div class="detail-item">
<span class="detail-label">当前状态</span>
<span :class="`detail-value status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="modal-footer">
<button class="btn-close" @click="showDetailModal = false">关闭</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface ProcessingOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime: string //
location: string //
maintenanceName: string //
maintenancePhone: string //
}
//
const orderList: ProcessingOrder[] = [
{
id: '9',
orderNo: 'ORD-20231025-007',
deviceType: '制水机',
deviceId: 'WM-2023-003',
area: '市区',
problemDesc: '正在更换一级滤芯预计1小时内完成',
status: 'processing',
createTime: '2023-10-25 15:10:08',
lastUploadTime: '2023-10-25 15:45:22',
location: '市区商业广场B区3号柜位',
maintenanceName: '李师傅',
maintenancePhone: '13800138000'
},
{
id: '10',
orderNo: 'ORD-20231025-008',
deviceType: '供水机',
deviceId: 'WS-2023-004',
area: '校区',
problemDesc: '正在检查网络连接,设备离线问题排查中',
status: 'processing',
createTime: '2023-10-25 16:05:11',
lastUploadTime: '2023-10-25 13:30:05',
location: '校区图书馆2楼大厅',
maintenanceName: '王师傅',
maintenancePhone: '13900139000'
}
]
//
const orders = ref<ProcessingOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const showDetailModal = ref(false)
const currentOrder = ref<ProcessingOrder | null>(null)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
const order = orders.value.find(item => item.id === id)
if (order) {
currentOrder.value = order
showDetailModal.value = true
}
}
</script>
<style scoped>
/* 原有样式保持不变 */
.order-processing-page {
padding: 20px;
}
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-container {
background-color: white;
border-radius: 8px;
width: 500px;
max-width: 90%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.modal-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 0;
line-height: 1;
}
.modal-close:hover {
color: #333;
}
.modal-body {
padding: 20px;
}
.detail-item {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
}
.detail-label {
width: 120px;
flex-shrink: 0;
color: #666;
font-size: 14px;
}
.detail-value {
flex-grow: 1;
font-size: 14px;
color: #333;
}
.modal-footer {
padding: 12px 20px;
border-top: 1px solid #eee;
display: flex;
justify-content: flex-end;
}
.btn-close {
padding: 6px 16px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-close:hover {
background-color: #096dd9;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
.detail-item {
flex-direction: column;
}
.detail-label {
width: auto;
margin-bottom: 4px;
font-weight: 500;
}
}
</style>

@ -0,0 +1,281 @@
<!-- src/views/order/OrderReview.vue -->
<template>
<div class="order-review-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>待审核工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 待审核</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-view" @click="viewOrderDetail(order.id)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无待审核工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface ReviewOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
}
//
const orderList: ReviewOrder[] = [
{
id: '7',
orderNo: 'ORD-20231025-005',
deviceType: '制水机',
deviceId: 'WM-2023-002',
area: '校区',
problemDesc: '已完成水泵检修,出水速度恢复正常',
status: 'reviewing',
createTime: '2023-10-25 11:30:15'
},
{
id: '8',
orderNo: 'ORD-20231025-006',
deviceType: '供水机',
deviceId: 'WS-2023-005',
area: '市区',
problemDesc: '已修复故障代码E12设备出水正常',
status: 'reviewing',
createTime: '2023-10-25 14:20:22'
}
]
//
const orders = ref<ReviewOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const viewOrderDetail = (id: string) => {
//
router.push(`/home/work-order/review/${id}`)
}
</script>
<style scoped>
/* 样式与超时未抢页面完全一致,仅修改页面容器类名 */
.order-review-page {
padding: 20px;
}
/* 以下样式与OrderTimeout.vue完全相同仅保留状态标签样式示例 */
.page-header { margin-bottom: 24px; }
.page-header h2 { font-size: 24px; font-weight: 600; color: #333; margin-bottom: 8px; }
.breadcrumb { color: #666; font-size: 14px; }
.filter-bar { display: flex; align-items: center; gap: 20px; padding: 16px; background-color: #f8f9fa; border-radius: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-item { display: flex; align-items: center; gap: 8px; }
.filter-item label { font-size: 14px; color: #4e5969; font-weight: 500; }
.search-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 240px; }
.filter-select, .filter-input { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 160px; }
.btn-reset { padding: 8px 16px; border: 1px solid #ddd; background-color: white; border-radius: 4px; cursor: pointer; font-size: 14px; color: #666; transition: all 0.3s; }
.btn-reset:hover { background-color: #f0f0f0; }
.order-table { width: 100%; border-collapse: collapse; }
.order-table th, .order-table td { padding: 12px 16px; text-align: left; border-bottom: 1px solid #f0f0f0; }
.order-table th { background-color: #f8f9fa; font-weight: 600; color: #4e5969; font-size: 14px; }
.order-table tbody tr:hover { background-color: #f8f9fa; }
.device-info { display: flex; flex-direction: column; gap: 4px; }
.device-type { font-weight: 500; color: #333; }
.device-id { font-size: 12px; color: #666; }
.desc-cell { max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.desc-cell:hover { white-space: normal; overflow: visible; background-color: white; z-index: 10; position: relative; }
.status-tag { display: inline-block; padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.status-tag.timeout { background-color: #ffebe6; color: #cf1322; }
.status-tag.pending { background-color: #fff7e6; color: #d48806; }
.status-tag.processing { background-color: #e6f7ff; color: #1890ff; }
.status-tag.reviewing { background-color: #f6f7ff; color: #667eea; }
.status-tag.completed { background-color: #e6f7ee; color: #00875a; }
.operation-buttons { display: flex; gap: 8px; }
.btn-view { padding: 4px 8px; border-radius: 4px; font-size: 12px; cursor: pointer; border: none; transition: opacity 0.3s; background-color: #e6f7ff; color: #1890ff; }
.btn-view:hover { opacity: 0.9; }
.no-data { text-align: center; padding: 40px 0; color: #8c8c8c; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 24px; color: #666; font-size: 14px; }
.page-btn { padding: 4px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; }
.page-btn:disabled { opacity: 0.6; cursor: not-allowed; }
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar { flex-direction: column; align-items: flex-start; }
.filter-item { width: 100%; }
.search-input, .filter-select, .filter-input { width: 100%; }
}
</style>

@ -0,0 +1,779 @@
<!-- src/views/order/OrderTimeout.vue -->
<template>
<div class="order-timeout-page">
<!-- 页面标题和面包屑 -->
<div class="page-header">
<h2>超时未抢工单</h2>
<div class="breadcrumb">校园矿化水平台 / 工单管理 / 超时未抢</div>
</div>
<!-- 检索筛选区 -->
<div class="filter-bar">
<!-- 工单号/设备ID搜索 -->
<div class="filter-item search-item">
<label>搜索</label>
<input
type="text"
v-model="searchKeyword"
class="search-input"
placeholder="输入工单号或设备ID搜索"
@input="handleSearch"
>
</div>
<!-- 片区筛选 -->
<div class="filter-item">
<label>所属片区</label>
<select v-model="filterForm.area" class="filter-select" @change="handleFilter">
<option value="">全部片区</option>
<option value="市区">市区</option>
<option value="校区">校区</option>
</select>
</div>
<!-- 日期筛选 -->
<div class="filter-item">
<label>创建日期</label>
<input
type="date"
v-model="filterForm.createDate"
class="filter-input"
@change="handleFilter"
>
</div>
<!-- 重置按钮 -->
<button class="btn-reset" @click="resetFilter"></button>
</div>
<!-- 工单表格 -->
<div class="card">
<table class="order-table">
<thead>
<tr>
<th>工单号</th>
<th>设备</th>
<th>片区</th>
<th>问题描述</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td>{{ order.orderNo }}</td>
<td>
<div class="device-info">
<div class="device-type">{{ order.deviceType }}</div>
<div class="device-id">{{ order.deviceId }}</div>
</div>
</td>
<td>{{ order.area }}</td>
<td class="desc-cell">{{ order.problemDesc }}</td>
<td>
<span :class="`status-tag ${order.status}`">
{{ formatStatus(order.status) }}
</span>
</td>
<td>{{ order.createTime }}</td>
<td class="operation-buttons">
<button class="btn-assign" @click="openAssignDialog(order)"></button>
</td>
</tr>
<tr v-if="filteredOrders.length === 0">
<td colspan="7" class="no-data">暂无超时未抢工单</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="currentPage--"
>
上一页
</button>
<span class="page-info">
{{ currentPage }} / {{ totalPages }}
</span>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="currentPage++"
>
下一页
</button>
</div>
<!-- 人工派单弹窗 -->
<div class="dialog-mask" v-if="assignDialogVisible">
<div class="dialog-container">
<div class="dialog-header">
<h3>人工派单</h3>
<button class="dialog-close" @click="closeAssignDialog">×</button>
</div>
<div class="dialog-body">
<div class="form-group">
<label class="form-label">工单号</label>
<div class="form-value">{{ currentOrder?.orderNo || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">建单时间</label>
<div class="form-value">{{ currentOrder?.createTime || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备机型与ID</label>
<div class="form-value">
{{ currentOrder?.deviceType || '' }}({{ currentOrder?.deviceId || '' }})
</div>
</div>
<div class="form-group">
<label class="form-label">警告内容</label>
<div class="form-value">{{ currentOrder?.problemDesc || '' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备最后上传时间</label>
<div class="form-value">{{ currentOrder?.lastUploadTime || '未知' }}</div>
</div>
<div class="form-group">
<label class="form-label">设备位置</label>
<div class="form-value">{{ currentOrder?.location || '未知' }}</div>
</div>
<div class="form-group">
<label class="form-label">当前状态</label>
<div class="form-value">
<span :class="`status-tag ${currentOrder?.status}`">
{{ currentOrder ? formatStatus(currentOrder.status) : '' }}
</span>
</div>
</div>
<div class="form-group">
<label class="form-label required">选择维修人员</label>
<select
v-model="selectedStaffId"
class="form-select"
@change="handleStaffChange"
>
<option value="">请选择维修人员</option>
<option
v-for="staff in filteredStaff"
:key="staff.id"
:value="staff.id"
>
{{ staff.name }} ({{ staff.phone }})
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">派单备注</label>
<textarea
v-model="assignRemark"
class="form-textarea"
placeholder="请输入派单备注信息"
rows="3"
></textarea>
</div>
</div>
<div class="dialog-footer">
<button class="btn-cancel" @click="closeAssignDialog"></button>
<button
class="btn-confirm"
@click="confirmAssign"
:disabled="!selectedStaffId"
>
确认派单
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
//
type OrderStatus = 'timeout' | 'pending' | 'processing' | 'reviewing' | 'completed'
//
interface TimeoutOrder {
id: string
orderNo: string
deviceType: string // /
deviceId: string // ID
area: string //
problemDesc: string //
status: OrderStatus //
createTime: string //
lastUploadTime?: string //
location?: string //
}
//
interface MaintenanceStaff {
id: string
name: string
phone: string
area: string //
status: 'active' | 'disabled' //
}
//
const orderList: TimeoutOrder[] = [
{
id: '5',
orderNo: 'ORD-20231024-001',
deviceType: '制水机',
deviceId: 'WM-2023-001',
area: '市区',
problemDesc: '设备漏水,需要更换密封垫',
status: 'timeout',
createTime: '2023-10-24 09:10:05',
lastUploadTime: '2023-10-24 08:50:12',
location: '市区图书馆一楼大厅'
},
{
id: '6',
orderNo: 'ORD-20231024-002',
deviceType: '供水机',
deviceId: 'WS-2023-001',
area: '校区',
problemDesc: '出水口感异常,需要检测水质',
status: 'timeout',
createTime: '2023-10-24 10:20:33',
lastUploadTime: '2023-10-24 10:15:30',
location: '校区宿舍3号楼一层'
}
]
//
const staffList: MaintenanceStaff[] = [
{ id: 's1', name: '张三', phone: '13800138000', area: '市区', status: 'active' },
{ id: 's2', name: '李四', phone: '13900139000', area: '校区', status: 'active' },
{ id: 's3', name: '王五', phone: '13700137000', area: '市区', status: 'active' },
{ id: 's4', name: '赵六', phone: '13600136000', area: '校区', status: 'disabled' }
]
//
const orders = ref<TimeoutOrder[]>(orderList)
const currentPage = ref(1)
const pageSize = 10 //
const router = useRouter()
// /ID
const searchKeyword = ref('')
//
const filterForm = ref({
area: '', //
createDate: '' //
})
//
const assignDialogVisible = ref(false)
const currentOrder = ref<TimeoutOrder | null>(null)
const selectedStaffId = ref('')
const assignRemark = ref('')
const allStaff = ref<MaintenanceStaff[]>(staffList)
//
const formatStatus = (status: OrderStatus): string => {
const statusMap = {
timeout: '超时未抢',
pending: '待抢单',
processing: '处理中',
reviewing: '待审核',
completed: '已结单'
}
return statusMap[status]
}
//
const filteredOrders = computed(() => {
return orders.value.filter(order => {
// /ID
const keywordMatch = searchKeyword.value.trim() === '' ||
order.orderNo.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
order.deviceId.toLowerCase().includes(searchKeyword.value.toLowerCase())
//
const areaMatch = filterForm.value.area === '' || order.area === filterForm.value.area
//
const dateMatch = filterForm.value.createDate === '' ||
order.createTime.split(' ')[0] === filterForm.value.createDate
return keywordMatch && areaMatch && dateMatch
})
})
//
const filteredStaff = computed(() => {
if (!currentOrder.value) return []
return allStaff.value.filter(staff =>
staff.area === currentOrder.value!.area && staff.status === 'active'
)
})
//
const totalPages = computed(() => {
return Math.ceil(filteredOrders.value.length / pageSize)
})
//
const handleSearch = () => {
currentPage.value = 1 //
}
// /
const handleFilter = () => {
currentPage.value = 1 //
}
//
const resetFilter = () => {
searchKeyword.value = '' //
filterForm.value = {
area: '',
createDate: ''
}
currentPage.value = 1
}
//
const openAssignDialog = (order: TimeoutOrder) => {
currentOrder.value = order
selectedStaffId.value = ''
assignRemark.value = ''
assignDialogVisible.value = true
}
//
const closeAssignDialog = () => {
assignDialogVisible.value = false
currentOrder.value = null
selectedStaffId.value = ''
assignRemark.value = ''
}
//
const handleStaffChange = () => {
//
}
//
const confirmAssign = () => {
if (!currentOrder.value || !selectedStaffId.value) return
// API
console.log('派单信息:', {
orderId: currentOrder.value.id,
staffId: selectedStaffId.value,
remark: assignRemark.value,
assignTime: new Date().toLocaleString()
})
//
closeAssignDialog()
//
}
</script>
<style scoped>
/* 基础页面样式 */
.order-timeout-page {
padding: 20px;
}
.page-header {
margin-bottom: 24px;
}
.page-header h2 {
font-size: 24px;
font-weight: 600;
color: #333;
margin-bottom: 8px;
}
.breadcrumb {
color: #666;
font-size: 14px;
}
/* 筛选区样式 */
.filter-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 16px;
background-color: #f8f9fa;
border-radius: 4px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filter-item {
display: flex;
align-items: center;
gap: 8px;
}
.filter-item label {
font-size: 14px;
color: #4e5969;
font-weight: 500;
}
/* 搜索输入框样式 */
.search-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 240px;
}
.filter-select, .filter-input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
min-width: 160px;
}
.btn-reset {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-reset:hover {
background-color: #f0f0f0;
}
/* 表格样式 */
.order-table {
width: 100%;
border-collapse: collapse;
}
.order-table th,
.order-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid #f0f0f0;
}
.order-table th {
background-color: #f8f9fa;
font-weight: 600;
color: #4e5969;
font-size: 14px;
}
.order-table tbody tr:hover {
background-color: #f8f9fa;
}
/* 设备信息样式 */
.device-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.device-type {
font-weight: 500;
color: #333;
}
.device-id {
font-size: 12px;
color: #666;
}
/* 问题描述单元格 */
.desc-cell {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.desc-cell:hover {
white-space: normal;
overflow: visible;
background-color: white;
z-index: 10;
position: relative;
}
/* 状态标签样式 */
.status-tag {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.status-tag.timeout {
background-color: #ffebe6;
color: #cf1322;
}
.status-tag.pending {
background-color: #fff7e6;
color: #d48806;
}
.status-tag.processing {
background-color: #e6f7ff;
color: #1890ff;
}
.status-tag.reviewing {
background-color: #f6f7ff;
color: #667eea;
}
.status-tag.completed {
background-color: #e6f7ee;
color: #00875a;
}
/* 操作按钮样式 */
.operation-buttons {
display: flex;
gap: 8px;
}
.btn-assign {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
border: none;
transition: opacity 0.3s;
background-color: #e6f7ff;
color: #1890ff;
}
.btn-assign:hover {
opacity: 0.9;
}
/* 空数据样式 */
.no-data {
text-align: center;
padding: 40px 0;
color: #8c8c8c;
}
/* 分页样式 */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
color: #666;
font-size: 14px;
}
.page-btn {
padding: 4px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.page-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 弹窗样式 */
.dialog-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog-container {
width: 600px;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.dialog-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.dialog-header h3 {
margin: 0;
font-size: 18px;
color: #333;
}
.dialog-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
transition: color 0.2s;
}
.dialog-close:hover {
color: #333;
}
.dialog-body {
padding: 20px;
}
.form-group {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
}
.form-label {
width: 120px;
flex-shrink: 0;
font-size: 14px;
color: #4e5969;
padding-top: 6px;
}
.form-label.required::after {
content: '*';
color: #cf1322;
margin-left: 4px;
}
.form-value {
flex-grow: 1;
font-size: 14px;
color: #333;
padding-top: 4px;
}
.form-select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.form-textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.dialog-footer {
padding: 16px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.btn-cancel {
padding: 8px 16px;
border: 1px solid #ddd;
background-color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: #666;
transition: all 0.3s;
}
.btn-cancel:hover {
background-color: #f0f0f0;
}
.btn-confirm {
padding: 8px 16px;
border: none;
background-color: #1890ff;
color: white;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.btn-confirm:hover {
background-color: #096dd9;
}
.btn-confirm:disabled {
background-color: #8cc5ff;
cursor: not-allowed;
}
/* 响应式调整 */
@media (max-width: 768px) {
.filter-bar {
flex-direction: column;
align-items: flex-start;
}
.filter-item {
width: 100%;
}
.search-input, .filter-select, .filter-input {
width: 100%;
}
.dialog-container {
width: 90%;
max-width: 500px;
}
.form-group {
flex-direction: column;
}
.form-label {
width: 100%;
margin-bottom: 8px;
padding-top: 0;
}
}
</style>

@ -0,0 +1,7 @@
<!-- src/views/workorder/WorkOrderView.vue -->
<template>
<div class="page-container">
<h1>工单管理</h1>
<p>请选择左侧子菜单查看具体工单信息</p>
</div>
</template>

@ -6,7 +6,11 @@
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"lib": ["ES2015", "DOM"], // ES2015 Promise
"target": "ES2015", // ES2015
"module": "ESNext", //
"moduleResolution": "NodeNext"
}
}
}

@ -1,8 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
@ -11,7 +8,7 @@ export default defineConfig({
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
'@': '/src'
},
},
})

@ -1,12 +1,25 @@
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import '@/router/permission'
const app = createApp(App)
const pinia = createPinia()
app.use(createPinia())
app.use(pinia)
app.use(router)
// 挂载前初始化
app.mount('#app')
// 全局属性(可选)
app.config.globalProperties.$auth = {
isAuthenticated: () => {
const token = localStorage.getItem('token')
return !!token
},
getUserType: () => localStorage.getItem('userType')
}

@ -1,3 +1,4 @@
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
@ -5,15 +6,83 @@ const router = createRouter({
routes: [
{
path: '/',
name: 'LoginPage', // 改为多单词
component: () => import('../views/LoginPage.vue')
name: 'LoginPage',
component: () => import('../views/LoginPage.vue'),
meta: { requiresAuth: false }
},
{
path: '/repairer-register',
name: 'RepairerRegisterPage',
component: () => import('../views/RepairerRegisterPage.vue'),
meta: { requiresAuth: false }
},
{
path: '/home',
name: 'HomePage', // 改为多单词
component: () => import('../views/HomePage.vue')
name: 'HomePage',
component: () => import('../views/HomePage.vue'),
meta: { requiresAuth: true }
},
{
path: '/work-orders',
name: 'WorkOrderList',
component: () => import('../views/WorkOrderList.vue'),
meta: { requiresAuth: true }
},
{
path: '/profile',
name: 'ProfilePage',
component: () => import('../views/ProfilePage.vue'),
meta: { requiresAuth: true }
},
{
path: '/work-orders/:id',
name: 'WorkOrderDetail',
component: () => import('../views/WorkOrderDetail.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection',
name: 'InspectionPage',
component: () => import('../views/InspectionPage.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/scan',
name: 'InspectionScan',
component: () => import('../views/InspectionScan.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/water-maker',
name: 'WaterMakerList',
component: () => import('../views/WaterMakerList.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/water-maker/:id',
name: 'WaterMakerDetail',
component: () => import('../views/WaterMakerDetail.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/water-supplier',
name: 'WaterSupplierList',
component: () => import('../views/WaterSupplierList.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/water-supplier/:id',
name: 'WaterSupplierDetail',
component: () => import('../views/WaterSupplierDetail.vue'),
meta: { requiresAuth: true }
},
{
path: '/inspection/form',
name: 'InspectionForm',
component: () => import('../views/InspectionForm.vue'),
meta: { requiresAuth: true }
}
],
]
})
export default router

@ -0,0 +1,36 @@
// src/router/permission.js
import router from './index'
import { useAuthStore } from '@/stores/auth'
// 需要登录的白名单
const whiteList = ['/', '/repairer-register']
router.beforeEach((to, from, next) => {
console.log('路由守卫执行:', to.path)
const authStore = useAuthStore()
// 如果目标路由在白名单中,直接放行
if (whiteList.includes(to.path)) {
return next()
}
// 检查是否已登录
if (!authStore.isAuthenticated) {
console.warn('未登录,跳转到登录页')
next('/')
} else {
// 已登录,验证用户权限(如果需要)
const userType = authStore.getUserType
console.log('当前用户类型:', userType)
// 这里可以添加权限验证逻辑
// 例如:只有特定用户类型可以访问某些页面
next()
}
})
// 路由守卫后置钩子
router.afterEach((to, from) => {
console.log('路由切换完成:', from.path, '->', to.path)
})

@ -1,11 +1,47 @@
// src/services/api.js
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
// 创建最基本的axios实例
const apiClient = axios.create({
baseURL: 'http://localhost:8080', // Adjust to your backend URL
baseURL: 'http://localhost:8080',
headers: {
'Content-Type': 'application/json'
}
})
// 请求拦截器
apiClient.interceptors.request.use(
(config) => {
console.log(`请求: ${config.method.toUpperCase()} ${config.url}`)
// 添加认证头
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
apiClient.interceptors.response.use(
(response) => {
return response
},
(error) => {
// 处理401未授权错误
if (error.response?.status === 401) {
const authStore = useAuthStore()
authStore.logout()
window.location.href = '/'
}
return Promise.reject(error)
}
)
export default apiClient

@ -2,15 +2,54 @@
import api from './api'
export const authServices = {
async login(username, password) {
// 通用登录接口(已有)
async login(loginData) {
try {
const response = await api.post('/api/common/login', {
username: username,
password: password
})
const response = await api.post('/api/common/login', loginData)
return response.data
} catch (error) {
throw error.response?.data || error.message
}
},
// 新增:维修人员注册接口(核心注册功能)
async repairerRegister(registerData) {
try {
// 构建符合后端RegisterRequest的数据结构
const requestData = {
username: registerData.username,
password: registerData.password,
userType: 'repairer', // 固定为repairer类型
repairmanId: registerData.repairmanId
}
console.log('发送注册请求:', requestData)
// 调用后端注册接口
const response = await api.post('/api/common/register', requestData)
console.log('注册响应:', response.data)
return response.data
} catch (error) {
console.error('注册失败:', error)
// 返回标准化的错误格式
if (error.response?.data) {
// 后端返回的错误
throw error.response.data
} else if (error.message) {
// 网络或其他错误
throw {
code: 500,
message: error.message || '网络错误'
}
} else {
// 未知错误
throw {
code: 500,
message: '注册失败,请重试'
}
}
}
}
}

@ -0,0 +1,82 @@
// src/stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '@/router/index.js'
export const useAuthStore = defineStore('auth', () => {
// 状态
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const isAuthenticated = computed(() => !!token.value && !!user.value)
// 登录
const login = (userData, authToken) => {
user.value = userData
token.value = authToken
// 存储到本地
localStorage.setItem('token', authToken)
localStorage.setItem('user', JSON.stringify(userData))
}
// 获取用户信息
const getUserInfo = () => {
if (!user.value) {
const storedUser = localStorage.getItem('user')
if (storedUser) {
user.value = JSON.parse(storedUser)
}
}
return user.value
}
// 获取用户类型
const getUserType = computed(() => {
return user.value?.userType || localStorage.getItem('userType')
})
// 获取维修人员ID
const getRepairmanId = computed(() => {
return user.value?.repairmanId || localStorage.getItem('repairmanId')
})
// 登出
const logout = () => {
user.value = null
token.value = null
// 清除本地存储
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('userType')
localStorage.removeItem('repairmanId')
localStorage.removeItem('userId')
localStorage.removeItem('username')
// 跳转到登录页
router.push('/')
}
// 初始化时从本地存储恢复
const initFromStorage = () => {
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
if (storedToken && storedUser) {
token.value = storedToken
user.value = JSON.parse(storedUser)
}
}
return {
user,
token,
isAuthenticated,
getUserType,
getRepairmanId,
login,
logout,
getUserInfo,
initFromStorage
}
})

@ -0,0 +1,33 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useDeviceStore = defineStore('device', () => {
const waterSuppliers = ref([
{
id: 'A106',
name: '供水机 #A106',
location: 'A区教学楼 1楼走廊',
status: '正常运行',
waterLevel: 66,
storage: 360,
floatValves: {
high: { status: '开启', threshold: 90 },
low: { status: '关闭', threshold: 10 }
},
leakDetection: {
status: '无漏水',
lastChecked: '2024-12-26 10:30'
}
},
// ... 更多设备数据
])
const getWaterSupplierById = (id) => {
return waterSuppliers.value.find(device => device.id === id || device.name.includes(id))
}
return {
waterSuppliers,
getWaterSupplierById
}
})

@ -2,9 +2,15 @@
<div class="home-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-left">
<div class="user-info">
<div class="user-name">{{ userInfo?.username || '未登录' }}</div>
<div class="user-type">{{ userInfo?.userType === 'repairer' ? '维修人员' : userInfo?.userType }}</div>
</div>
</div>
<div class="header-title">运维工作台</div>
<div class="header-right"></div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 统计卡片 -->
@ -55,16 +61,16 @@
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item active">
<div class="nav-item active" @click="goToHome">
<span>首页</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToInspection">
<span>巡检</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToWorkOrders">
<span>工单</span>
</div>
<div class="nav-item">
<div class="nav-item" @click="goToProfile">
<span>我的</span>
</div>
</div>
@ -72,19 +78,32 @@
</template>
<script setup>
// 使 router
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const userInfo = authStore.getUserInfo()
const router = useRouter()
const goToInspection = () => {
console.log('跳转到巡检页面')
//
// const router = useRouter()
// router.push('/inspection')
router.push('/inspection')
}
const goToWorkOrders = () => {
console.log('跳转到工单页面')
//
// const router = useRouter()
// router.push('/work-orders')
router.push('/work-orders')
}
const goToHome = () => {
if (router.currentRoute.value.path !== '/home') {
router.push('/home')
}
}
const goToProfile = () => {
console.log('跳转到我的页面')
router.push('/profile')
}
</script>
@ -273,13 +292,41 @@ const goToWorkOrders = () => {
color: #666;
cursor: pointer;
transition: color 0.3s;
-webkit-tap-highlight-color: transparent;
}
.nav-item.active {
color: #1890ff;
position: relative;
}
.nav-item.active::after {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: #1890ff;
border-radius: 1px;
}
.nav-item:hover {
color: #1890ff;
}
.user-info {
font-size: 12px;
}
.user-name {
font-weight: 500;
color: #333;
}
.user-type {
color: #666;
font-size: 11px;
}
</style>

@ -0,0 +1,534 @@
<template>
<div class="inspection-form">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-left">
<span class="back-btn" @click="goBack"></span>
</div>
<div class="header-title">巡检表单</div>
<div class="header-right"></div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="form-container">
<!-- 设备状态 -->
<div class="form-section">
<div class="section-title">设备状态</div>
<div class="status-selector">
<div
class="status-option"
:class="{ 'active': selectedStatus === 'normal' }"
@click="selectStatus('normal')"
>
正常
</div>
<div
class="status-option"
:class="{ 'active': selectedStatus === 'warning' }"
@click="selectStatus('warning')"
>
异常
</div>
</div>
</div>
<!-- 异常描述 -->
<div v-if="selectedStatus === 'warning'" class="form-section">
<div class="section-title">异常描述</div>
<textarea
v-model="abnormalDescription"
class="abnormal-textarea"
placeholder="请描述处理异常..."
rows="4"
></textarea>
</div>
<!-- 现场照片 -->
<div class="form-section">
<div class="section-title">现场照片</div>
<div class="photo-upload">
<div class="upload-area" @click="uploadPhoto">
<div class="upload-icon">📷</div>
<div class="upload-text">点击上传照片</div>
</div>
<input
type="file"
ref="fileInput"
@change="handleFileUpload"
accept="image/*"
style="display: none"
/>
<!-- 照片预览 -->
<div v-if="uploadedPhotos.length > 0" class="photo-preview">
<div v-for="(photo, index) in uploadedPhotos" :key="index" class="preview-item">
<div class="preview-image">
<img :src="photo.url" :alt="`照片${index + 1}`" />
</div>
<div class="preview-actions">
<button class="preview-btn delete" @click="removePhoto(index)">
删除
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<button class="submit-btn" @click="submitInspection" :disabled="submitting">
{{ submitting ? '提交中...' : '提交巡检' }}
</button>
</div>
<!-- 提交成功弹窗 -->
<div v-if="showSuccessModal" class="success-modal">
<div class="modal-content">
<div class="modal-icon"></div>
<div class="modal-title">提交成功</div>
<button class="modal-btn" @click="closeSuccessModal">
返回
</button>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item" @click="goToHome"></div>
<div class="nav-item" @click="goToInspection"></div>
<div class="nav-item" @click="goToWorkOrders"></div>
<div class="nav-item" @click="goToProfile"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
//
const selectedStatus = ref('normal')
const abnormalDescription = ref('')
//
const uploadedPhotos = ref([])
const fileInput = ref(null)
//
const submitting = ref(false)
const showSuccessModal = ref(false)
const goBack = () => {
router.back()
}
const goToHome = () => {
router.push('/home')
}
const goToInspection = () => {
router.push('/inspection')
}
const goToWorkOrders = () => {
router.push('/work-orders')
}
const goToProfile = () => {
router.push('/profile')
}
//
const selectStatus = (status) => {
selectedStatus.value = status
if (status === 'normal') {
abnormalDescription.value = ''
}
}
//
const uploadPhoto = () => {
fileInput.value.click()
}
const handleFileUpload = (event) => {
const file = event.target.files[0]
if (file) {
const reader = new FileReader()
reader.onload = (e) => {
uploadedPhotos.value.push({
file: file,
url: e.target.result,
name: file.name
})
}
reader.readAsDataURL(file)
//
event.target.value = ''
}
}
const removePhoto = (index) => {
uploadedPhotos.value.splice(index, 1)
}
//
const submitInspection = async () => {
if (selectedStatus.value === 'warning' && !abnormalDescription.value.trim()) {
alert('请填写异常描述')
return
}
submitting.value = true
// API
try {
await new Promise(resolve => setTimeout(resolve, 1500))
console.log('提交巡检数据:', {
status: selectedStatus.value,
description: abnormalDescription.value,
photos: uploadedPhotos.value.length
})
//
showSuccessModal.value = true
} catch (error) {
console.error('提交失败:', error)
alert('提交失败,请重试')
} finally {
submitting.value = false
}
}
//
const closeSuccessModal = () => {
showSuccessModal.value = false
//
router.push('/inspection')
}
onMounted(() => {
//
console.log('巡检表单页面加载')
})
</script>
<style scoped>
.inspection-form {
width: 100%;
height: 100%;
background: #f8f9fa;
display: flex;
flex-direction: column;
position: relative;
}
/* 顶部标题栏 */
.header {
background: white;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.header-left {
width: 80px;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
flex: 1;
}
.header-right {
width: 80px;
}
.back-btn {
font-size: 14px;
color: #1890ff;
cursor: pointer;
transition: color 0.3s;
}
.back-btn:hover {
color: #096dd9;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.form-container {
flex: 1;
}
/* 表单区块 */
.form-section {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
}
/* 状态选择器 */
.status-selector {
display: flex;
gap: 12px;
}
.status-option {
flex: 1;
padding: 12px;
text-align: center;
border: 2px solid #f0f0f0;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
color: #666;
cursor: pointer;
transition: all 0.3s;
}
.status-option:hover {
border-color: #1890ff;
color: #1890ff;
}
.status-option.active {
border-color: #1890ff;
background: #f0f7ff;
color: #1890ff;
}
/* 异常描述文本框 */
.abnormal-textarea {
width: 100%;
padding: 12px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 80px;
}
.abnormal-textarea:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.abnormal-textarea::placeholder {
color: #999;
}
/* 照片上传区域 */
.photo-upload {
width: 100%;
}
.upload-area {
width: 100%;
padding: 40px 20px;
border: 2px dashed #e0e0e0;
border-radius: 6px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 16px;
}
.upload-area:hover {
border-color: #1890ff;
background: #f8fbff;
}
.upload-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.6;
}
.upload-text {
font-size: 14px;
color: #666;
}
/* 照片预览 */
.photo-preview {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.preview-item {
background: #f8f9fa;
border-radius: 6px;
overflow: hidden;
}
.preview-image {
width: 100%;
height: 120px;
overflow: hidden;
}
.preview-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-actions {
padding: 8px;
text-align: center;
}
.preview-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.preview-btn.delete {
background: #ff4d4f;
color: white;
}
.preview-btn.delete:hover {
background: #ff7875;
}
/* 提交按钮 */
.submit-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-top: 20px;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.3);
}
.submit-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
/* 成功弹窗 */
.success-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 32px 24px;
text-align: center;
width: 280px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-icon {
font-size: 64px;
margin-bottom: 16px;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #333;
margin-bottom: 24px;
}
.modal-btn {
width: 100%;
padding: 12px;
background: #1890ff;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.modal-btn:hover {
background: #096dd9;
transform: translateY(-1px);
}
/* 底部导航栏 */
.bottom-nav {
display: flex;
background: white;
border-top: 1px solid #e8e8e8;
padding: 8px 0;
z-index: 100;
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.nav-item:hover {
color: #1890ff;
}
</style>

@ -0,0 +1,245 @@
<template>
<div class="inspection-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-left">
<span class="back-btn" @click="goBack"></span>
</div>
<div class="header-title">设备巡检</div>
<div class="header-right">
<span class="my-area-btn">我的片区</span>
</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 片区列表 -->
<div class="area-list">
<!-- 岳麓片区 -->
<div class="area-card">
<div class="area-info">
<div class="area-name">岳麓片区</div>
<div class="area-details">5所学校 · 12台制水机</div>
</div>
<div class="area-divider"></div>
<button class="view-btn" @click="viewYueluArea"></button>
</div>
<!-- 雨花片区 -->
<div class="area-card">
<div class="area-info">
<div class="area-name">雨花片区</div>
<div class="area-details">3所学校 · 8台制水机</div>
</div>
<div class="area-divider"></div>
<button class="view-btn" @click="viewYuhuaArea"></button>
</div>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item" @click="goToHome">
<span>首页</span>
</div>
<div class="nav-item active">
<span>巡检</span>
</div>
<div class="nav-item" @click="goToWorkOrders">
<span>工单</span>
</div>
<div class="nav-item" @click="goToProfile">
<span>我的</span>
</div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.back()
}
const goToHome = () => {
router.push('/home')
}
const goToWorkOrders = () => {
console.log('跳转到工单页面')
router.push('/work-orders')
}
const goToProfile = () => {
console.log('跳转到我的页面')
router.push('/profile')
}
const viewYueluArea = () => {
console.log('查看岳麓片区详情')
//
router.push('/inspection/scan')
}
const viewYuhuaArea = () => {
console.log('查看雨花片区详情')
//
router.push('/inspection/scan')
}
</script>
<style scoped>
.inspection-page {
width: 100%;
height: 100%;
background: #f8f9fa;
display: flex;
flex-direction: column;
position: relative;
}
/* 顶部标题栏 */
.header {
background: white;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.header-left,
.header-right {
width: 80px;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
flex: 1;
}
.back-btn,
.my-area-btn {
font-size: 14px;
color: #1890ff;
cursor: pointer;
transition: color 0.3s;
}
.back-btn:hover,
.my-area-btn:hover {
color: #096dd9;
}
.back-btn {
text-align: left;
}
.my-area-btn {
text-align: right;
display: block;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 20px 16px;
overflow-y: auto;
}
/* 片区列表 */
.area-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.area-card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
padding: 16px;
transition: transform 0.3s, box-shadow 0.3s;
}
.area-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.area-info {
flex: 1;
}
.area-name {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.area-details {
font-size: 13px;
color: #666;
}
.area-divider {
width: 1px;
height: 40px;
background: #e8e8e8;
margin: 0 16px;
}
.view-btn {
padding: 8px 24px;
background: #1890ff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.view-btn:hover {
background: #096dd9;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.3);
}
/* 底部导航栏 */
.bottom-nav {
display: flex;
background: white;
border-top: 1px solid #e8e8e8;
padding: 8px 0;
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.nav-item.active {
color: #1890ff;
}
.nav-item:hover {
color: #1890ff;
}
</style>

@ -0,0 +1,255 @@
<template>
<div class="inspection-scan">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-left">
<span class="back-btn" @click="goBack"></span>
</div>
<div class="header-title">设备巡检</div>
<div class="header-right"></div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="scan-container">
<!-- 扫码框 -->
<div class="scan-box">
<div class="scan-icon">📷</div>
<div class="scan-border">
<div class="scan-line"></div>
</div>
</div>
<div class="scan-text">请扫描设备上二维码以开始巡检</div>
<button class="scan-complete-btn" @click="simulateScanComplete">
扫描完成
</button>
<div class="scan-tips">
<p>💡 提示将二维码放入框内自动扫描</p>
<p>或点击"扫描完成"模拟测试</p>
</div>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item" @click="goToHome"></div>
<div class="nav-item active" @click="goToInspection"></div>
<div class="nav-item" @click="goToWorkOrders"></div>
<div class="nav-item" @click="goToProfile"></div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.back()
}
const goToHome = () => {
router.push('/home')
}
const goToInspection = () => {
router.push('/inspection')
}
const goToWorkOrders = () => {
router.push('/work-orders')
}
const goToProfile = () => {
router.push('/profile')
}
//
const simulateScanComplete = () => {
console.log('模拟扫码完成,跳转到制水机列表')
router.push('/inspection/water-maker')
}
</script>
<style scoped>
.inspection-scan {
width: 100%;
height: 100%;
background: #f8f9fa;
display: flex;
flex-direction: column;
}
/* 顶部标题栏 */
.header {
background: white;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.header-left {
width: 80px;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
flex: 1;
}
.header-right {
width: 80px;
}
.back-btn {
font-size: 14px;
color: #1890ff;
cursor: pointer;
transition: color 0.3s;
}
.back-btn:hover {
color: #096dd9;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 40px 20px;
display: flex;
flex-direction: column;
align-items: center;
}
/* 扫码容器 */
.scan-container {
width: 100%;
max-width: 320px;
background: white;
border-radius: 12px;
padding: 30px 20px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
text-align: center;
}
/* 扫码框 */
.scan-box {
position: relative;
width: 200px;
height: 200px;
margin: 0 auto 30px;
background: #f8f9fa;
border-radius: 8px;
overflow: hidden;
}
.scan-icon {
font-size: 60px;
margin: 40px 0 20px;
opacity: 0.7;
}
.scan-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px dashed #1890ff;
border-radius: 8px;
}
.scan-line {
position: absolute;
top: 0;
left: 10px;
right: 10px;
height: 2px;
background: #1890ff;
animation: scanAnimation 2s linear infinite;
}
@keyframes scanAnimation {
0% {
top: 0;
}
100% {
top: 100%;
}
}
/* 扫码文本 */
.scan-text {
font-size: 16px;
color: #333;
margin-bottom: 30px;
font-weight: 500;
}
/* 扫码完成按钮 */
.scan-complete-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 20px;
}
.scan-complete-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(24, 144, 255, 0.3);
}
/* 扫码提示 */
.scan-tips {
font-size: 13px;
color: #666;
line-height: 1.5;
}
.scan-tips p {
margin: 5px 0;
}
/* 底部导航栏 */
.bottom-nav {
display: flex;
background: white;
border-top: 1px solid #e8e8e8;
padding: 8px 0;
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.nav-item.active {
color: #1890ff;
}
.nav-item:hover {
color: #1890ff;
}
</style>

@ -1,33 +1,74 @@
<!-- src/views/LoginPage.vue -->
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authServices } from '@/services/authServices'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const usertype = ref('repairer')
const username = ref('')
const password = ref('')
const loading = ref(false)
// handleLogin
// LoginPage.vue
const handleLogin = async () => {
console.log('开始登录流程');
if (!username.value || !password.value) {
alert('请输入账号和密码')
return
alert('请输入账号和密码');
return;
}
loading.value = true
loading.value = true;
try {
const result = await authServices.login(username.value, password.value)
console.log('登录成功:', result)
// Store user info or token if needed
router.push('/home')
console.log('调用 authServices.login', {
username: username.value,
password: password.value,
userType: usertype.value
});
const result = await authServices.login({
username: username.value,
password: password.value,
userType: usertype.value
});
if (result && result.code === 200) {
console.log('登录成功:', result);
//
authStore.login({
username: result.data.username,
userType: result.data.userType,
userId: result.data.userId,
repairmanId: username.value
}, result.data.token);
//
localStorage.setItem('token', result.data.token);
localStorage.setItem('userId', result.data.userId);
localStorage.setItem('username', result.data.username);
localStorage.setItem('userType', result.data.userType);
localStorage.setItem('repairmanId', username.value);
alert('登录成功!');
router.push('/home');
} else {
alert('登录失败: ' + (result?.message || '未知错误'));
}
} catch (error) {
console.error('登录失败:', error)
alert('登录失败: ' + (error.message || '未知错误'))
console.error('登录过程异常:', error);
alert('登录失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false
loading.value = false;
}
}
};
//
const goToRegister = () => {
router.push('/repairer-register');
};
</script>
<template>
@ -64,6 +105,14 @@ const handleLogin = async () => {
<button type="submit" class="login-button" :disabled="loading">
{{ loading ? '登录中...' : '登录' }}
</button>
<!-- 添加注册按钮 -->
<div class="register-section">
<p class="register-text">还没有账户</p>
<button type="button" class="register-button" @click="goToRegister" :disabled="loading">
立即注册
</button>
</div>
</form>
</div>
</div>
@ -164,7 +213,8 @@ const handleLogin = async () => {
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-top: auto;
margin-top: 20px;
margin-bottom: 15px;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
@ -182,4 +232,46 @@ const handleLogin = async () => {
.login-button:active:not(:disabled) {
transform: translateY(0);
}
/* 注册部分样式 */
.register-section {
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.register-text {
color: #666;
font-size: 14px;
margin-bottom: 10px;
}
.register-button {
width: 100%;
padding: 12px;
background: white;
color: #667eea;
border: 1px solid #667eea;
border-radius: 6px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.register-button:hover:not(:disabled) {
background: #f8f9ff;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
}
.register-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.register-button:active:not(:disabled) {
transform: translateY(0);
}
</style>

@ -0,0 +1,545 @@
<template>
<div class="profile-page">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-title">个人中心</div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<!-- 个人信息卡片 -->
<div class="profile-card">
<div class="profile-header">
<div class="user-avatar">
<div class="avatar-icon">👤</div>
</div>
<div class="user-info">
<div class="user-name">维修员 张三</div>
<div class="user-id">工号REP2023001</div>
<div class="user-area">负责片区教学楼A区图书馆宿舍C区</div>
</div>
</div>
</div>
<!-- 本月工作统计 -->
<div class="stats-section">
<div class="section-title">本月工作统计</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-number">23</div>
<div class="stat-label">处理工单</div>
</div>
<div class="stat-item">
<div class="stat-number">45</div>
<div class="stat-label">设备巡检</div>
</div>
<div class="stat-item">
<div class="stat-number">98%</div>
<div class="stat-label">响应率</div>
</div>
<div class="stat-item">
<div class="stat-number">95%</div>
<div class="stat-label">完成率</div>
</div>
</div>
</div>
<!-- 功能菜单 -->
<div class="menu-section">
<div class="section-title">功能菜单</div>
<div class="menu-list">
<div class="menu-item" @click="goToSettings">
<div class="menu-left">
<div class="menu-icon"></div>
<div class="menu-text">系统设置</div>
</div>
<div class="menu-right">></div>
</div>
<div class="menu-item" @click="goToNotifications">
<div class="menu-left">
<div class="menu-icon">🔔</div>
<div class="menu-text">消息通知</div>
<div class="badge" v-if="unreadCount > 0">{{ unreadCount }}</div>
</div>
<div class="menu-right">></div>
</div>
<div class="menu-item" @click="goToFeedback">
<div class="menu-left">
<div class="menu-icon">📝</div>
<div class="menu-text">问题反馈</div>
</div>
<div class="menu-right">></div>
</div>
<div class="menu-item" @click="goToHelp">
<div class="menu-left">
<div class="menu-icon"></div>
<div class="menu-text">使用帮助</div>
</div>
<div class="menu-right">></div>
</div>
<div class="menu-item" @click="goToAbout">
<div class="menu-left">
<div class="menu-icon"></div>
<div class="menu-text">关于我们</div>
</div>
<div class="menu-right">></div>
</div>
</div>
</div>
<!-- 退出登录按钮 -->
<div class="logout-section">
<button class="logout-btn" @click="logout">
退出登录
</button>
</div>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item" @click="goToHome"></div>
<div class="nav-item" @click="goToInspection"></div>
<div class="nav-item" @click="goToWorkOrders"></div>
<div class="nav-item active" @click="goToProfile"></div>
</div>
<!-- 退出确认弹窗 -->
<div v-if="showLogoutConfirm" class="confirm-modal">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title">确认退出</div>
</div>
<div class="modal-body">
<div class="modal-message">确定要退出登录吗</div>
</div>
<div class="modal-footer">
<button class="modal-btn cancel" @click="cancelLogout"></button>
<button class="modal-btn confirm" @click="confirmLogout"></button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
//
const unreadCount = ref(3)
// 退
const showLogoutConfirm = ref(false)
//
const goToHome = () => {
router.push('/home')
}
const goToInspection = () => {
router.push('/inspection')
}
const goToWorkOrders = () => {
router.push('/work-orders')
}
const goToProfile = () => {
//
if (router.currentRoute.value.path === '/profile') {
//
console.log('刷新个人中心')
}
}
//
const goToSettings = () => {
console.log('跳转到系统设置')
alert('系统设置页面开发中')
}
const goToNotifications = () => {
console.log('跳转到消息通知')
alert('消息通知页面开发中')
//
// unreadCount.value = 0
}
const goToFeedback = () => {
console.log('跳转到问题反馈')
alert('问题反馈页面开发中')
}
const goToHelp = () => {
console.log('跳转到使用帮助')
alert('使用帮助页面开发中')
}
const goToAbout = () => {
console.log('跳转到关于我们')
alert('关于我们页面开发中')
}
// 退
const logout = () => {
showLogoutConfirm.value = true
}
// 退
const cancelLogout = () => {
showLogoutConfirm.value = false
}
// 退
const confirmLogout = () => {
console.log('用户退出登录');
showLogoutConfirm.value = false;
//
authStore.logout();
}
</script>
<style scoped>
.profile-page {
width: 100%;
height: 100%;
background: #f8f9fa;
display: flex;
flex-direction: column;
}
/* 顶部标题栏 */
.header {
background: white;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
}
/* 个人信息卡片 */
.profile-card {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.profile-header {
display: flex;
align-items: flex-start;
gap: 16px;
}
.user-avatar {
width: 60px;
height: 60px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-icon {
font-size: 32px;
color: white;
}
.user-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-name {
font-size: 18px;
font-weight: 600;
color: #333;
}
.user-id {
font-size: 14px;
color: #666;
}
.user-area {
font-size: 13px;
color: #666;
line-height: 1.4;
margin-top: 2px;
}
/* 统计区域 */
.stats-section {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.stat-item {
background: #f8f9fa;
border-radius: 6px;
padding: 16px;
text-align: center;
transition: all 0.3s;
}
.stat-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #1890ff;
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: #666;
}
/* 菜单区域 */
.menu-section {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.menu-list {
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
cursor: pointer;
transition: all 0.3s;
}
.menu-item:not(:last-child) {
border-bottom: 1px solid #f5f5f5;
}
.menu-item:hover {
background: #fafafa;
padding-left: 8px;
padding-right: 8px;
margin: 0 -8px;
border-radius: 4px;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-icon {
font-size: 20px;
}
.menu-text {
font-size: 15px;
color: #333;
font-weight: 500;
}
.badge {
background: #ff4d4f;
color: white;
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
.menu-right {
font-size: 16px;
color: #999;
transition: transform 0.3s;
}
.menu-item:hover .menu-right {
transform: translateX(2px);
color: #1890ff;
}
/* 退出登录按钮 */
.logout-section {
padding: 16px;
}
.logout-btn {
width: 100%;
padding: 14px;
background: #ff4d4f;
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.2);
}
.logout-btn:hover {
background: #ff7875;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.3);
}
/* 底部导航栏 */
.bottom-nav {
display: flex;
background: white;
border-top: 1px solid #e8e8e8;
padding: 8px 0;
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.nav-item.active {
color: #1890ff;
}
.nav-item:hover {
color: #1890ff;
}
/* 确认弹窗 */
.confirm-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
width: 280px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modal-header {
padding: 20px 24px 12px;
text-align: center;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #333;
}
.modal-body {
padding: 0 24px 20px;
text-align: center;
}
.modal-message {
font-size: 14px;
color: #666;
line-height: 1.5;
}
.modal-footer {
display: flex;
border-top: 1px solid #f0f0f0;
}
.modal-btn {
flex: 1;
padding: 14px;
border: none;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.modal-btn.cancel {
background: #f5f5f5;
color: #666;
border-right: 1px solid #f0f0f0;
}
.modal-btn.cancel:hover {
background: #e8e8e8;
}
.modal-btn.confirm {
background: #1890ff;
color: white;
}
.modal-btn.confirm:hover {
background: #096dd9;
}
</style>

@ -0,0 +1,401 @@
<!-- src/views/RepairerRegisterPage.vue -->
<template>
<div class="register-page">
<!-- 顶部返回按钮 -->
<div class="header">
<button class="back-btn" @click="goBack">
<span></span> 返回
</button>
<h1 class="title">维修人员注册</h1>
</div>
<!-- 注册表单 -->
<div class="form-container">
<form @submit.prevent="handleRegister">
<!-- 用户名 -->
<div class="form-group">
<label>用户名 *</label>
<input
v-model="form.username"
type="text"
placeholder="请输入用户名"
:class="{ 'error': errors.username }"
:disabled="loading"
/>
<div v-if="errors.username" class="error-message">
{{ errors.username }}
</div>
</div>
<!-- 维修人员ID -->
<div class="form-group">
<label>维修人员ID *</label>
<input
v-model="form.repairmanId"
type="text"
placeholder="请输入维修人员ID"
:class="{ 'error': errors.repairmanId }"
:disabled="loading"
/>
<div v-if="errors.repairmanId" class="error-message">
{{ errors.repairmanId }}
</div>
</div>
<!-- 密码 -->
<div class="form-group">
<label>密码 *</label>
<div class="password-wrapper">
<input
v-model="form.password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码6-16位字母或数字"
:class="{ 'error': errors.password }"
:disabled="loading"
/>
<button
type="button"
class="toggle-password"
@click="showPassword = !showPassword"
:disabled="loading"
>
{{ showPassword ? '隐藏' : '显示' }}
</button>
</div>
<div v-if="errors.password" class="error-message">
{{ errors.password }}
</div>
</div>
<!-- 确认密码 -->
<div class="form-group">
<label>确认密码 *</label>
<div class="password-wrapper">
<input
v-model="form.confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="请再次输入密码"
:class="{ 'error': errors.confirmPassword }"
:disabled="loading"
/>
<button
type="button"
class="toggle-password"
@click="showConfirmPassword = !showConfirmPassword"
:disabled="loading"
>
{{ showConfirmPassword ? '隐藏' : '显示' }}
</button>
</div>
<div v-if="errors.confirmPassword" class="error-message">
{{ errors.confirmPassword }}
</div>
</div>
<!-- 注册按钮 -->
<button type="submit" class="submit-btn" :disabled="loading">
{{ loading ? '注册中...' : '立即注册' }}
</button>
<!-- 已有账号 -->
<div class="login-link">
已有账号
<button type="button" @click="goToLogin" :disabled="loading">
去登录
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { authServices } from '@/services/authServices'
const router = useRouter()
//
const form = reactive({
username: '',
repairmanId: '',
password: '',
confirmPassword: ''
})
//
const errors = reactive({
username: '',
repairmanId: '',
password: '',
confirmPassword: ''
})
//
const loading = ref(false)
const showPassword = ref(false)
const showConfirmPassword = ref(false)
//
const validateForm = () => {
//
Object.keys(errors).forEach(key => errors[key] = '')
let isValid = true
//
if (!form.username.trim()) {
errors.username = '请输入用户名'
isValid = false
} else if (form.username.trim().length < 2) {
errors.username = '用户名至少2个字符'
isValid = false
}
// ID
if (!form.repairmanId.trim()) {
errors.repairmanId = '请输入维修人员ID'
isValid = false
}
//
if (!form.password) {
errors.password = '请输入密码'
isValid = false
} else if (form.password.length < 6 || form.password.length > 16) {
errors.password = '密码长度应为6-16位'
isValid = false
} else if (!/^[a-zA-Z0-9]+$/.test(form.password)) {
errors.password = '密码只能包含字母和数字'
isValid = false
}
//
if (!form.confirmPassword) {
errors.confirmPassword = '请确认密码'
isValid = false
} else if (form.password !== form.confirmPassword) {
errors.confirmPassword = '两次输入的密码不一致'
isValid = false
}
return isValid
}
//
const handleRegister = async () => {
if (!validateForm()) return
loading.value = true
try {
//
const registerData = {
username: form.username.trim(),
password: form.password,
repairmanId: form.repairmanId.trim()
}
//
const result = await authServices.repairerRegister(registerData)
//
if (result && result.code === 200) {
alert('注册成功!请使用用户名和密码登录')
//
form.username = ''
form.repairmanId = ''
form.password = ''
form.confirmPassword = ''
//
router.push('/')
} else {
alert('注册失败: ' + (result?.message || '未知错误'))
}
} catch (error) {
//
console.error('注册过程错误:', error)
let errorMessage = '注册失败'
if (error.message) {
if (error.message.includes('用户名已存在')) {
errorMessage = '用户名已存在,请更换用户名'
} else if (error.message.includes('维修人员ID已被注册')) {
errorMessage = '维修人员ID已被注册'
} else {
errorMessage = error.message
}
}
alert(errorMessage)
} finally {
loading.value = false
}
}
//
const goBack = () => {
router.back()
}
const goToLogin = () => {
router.push('/')
}
</script>
<style scoped>
.register-page {
min-height: 100vh;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px 16px;
color: white;
}
.back-btn {
background: none;
border: none;
color: white;
font-size: 16px;
padding: 8px 0;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
}
.title {
font-size: 20px;
font-weight: 600;
margin: 16px 0 0;
}
.form-container {
padding: 30px 20px;
background: white;
margin-top: 20px;
border-radius: 12px 12px 0 0;
min-height: calc(100vh - 150px);
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 8px;
}
.form-group input {
width: 100%;
padding: 14px 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
font-size: 15px;
box-sizing: border-box;
background: #fafafa;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
background: white;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-group input.error {
border-color: #ff4d4f;
}
.form-group input::placeholder {
color: #999;
}
.form-group input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.password-wrapper {
position: relative;
}
.toggle-password {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #667eea;
font-size: 12px;
cursor: pointer;
padding: 4px 8px;
}
.toggle-password:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #ff4d4f;
font-size: 12px;
margin-top: 4px;
}
.submit-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-top: 30px;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.9;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-link {
text-align: center;
margin-top: 20px;
color: #666;
font-size: 14px;
}
.login-link button {
background: none;
border: none;
color: #667eea;
font-weight: 500;
padding: 0 4px;
cursor: pointer;
}
.login-link button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

@ -0,0 +1,412 @@
<template>
<div class="water-maker-detail">
<!-- 顶部标题栏 -->
<div class="header">
<div class="header-left">
<span class="back-btn" @click="goBack"></span>
</div>
<div class="header-title">制水机详情</div>
<div class="header-right"></div>
</div>
<!-- 主要内容区域 -->
<div class="main-content">
<div class="device-detail-container">
<!-- 设备信息 -->
<div class="info-section">
<div class="section-title">设备信息</div>
<div class="info-grid">
<div class="info-item">
<div class="info-label">设备ID</div>
<div class="info-value">制水机 #A102</div>
</div>
<div class="info-item">
<div class="info-label">安装位置</div>
<div class="info-value">A区教学楼 2楼水房</div>
</div>
<div class="info-item">
<div class="info-label">设备状态</div>
<div class="info-value status-normal">正常运行</div>
</div>
<div class="info-item">
<div class="info-label">运行时间</div>
<div class="info-value">1,246 小时</div>
</div>
</div>
</div>
<!-- 水质监测 -->
<div class="info-section">
<div class="section-title">水质监测</div>
<div class="monitoring-grid">
<div class="monitoring-item">
<div class="monitoring-label">自来水TDS</div>
<div class="monitoring-value">285</div>
<div class="monitoring-unit">mg/L</div>
</div>
<div class="monitoring-item">
<div class="monitoring-label">纯净水TDS</div>
<div class="monitoring-value">13</div>
<div class="monitoring-unit">mg/L</div>
</div>
<div class="monitoring-item">
<div class="monitoring-label">矿化水TDS</div>
<div class="monitoring-value">87</div>
<div class="monitoring-unit">mg/L</div>
</div>
</div>
</div>
<!-- 滤芯状态 -->
<div class="info-section">
<div class="section-title">滤芯状态</div>
<div class="filter-grid">
<div class="filter-item">
<div class="filter-name">滤芯1</div>
<div class="filter-status status-normal">正常</div>
<div class="filter-lifespan">剩余寿命65%</div>
</div>
<div class="filter-item">
<div class="filter-name">滤芯2</div>
<div class="filter-status status-warning">寿命低</div>
<div class="filter-lifespan">剩余寿命35%</div>
</div>
<div class="filter-item">
<div class="filter-name">滤芯3</div>
<div class="filter-status status-normal">正常</div>
<div class="filter-lifespan">剩余寿命72%</div>
</div>
</div>
</div>
<!-- 传感器监控 -->
<div class="info-section">
<div class="section-title">传感器监控</div>
<div class="sensor-grid">
<div class="sensor-item">
<div class="sensor-name">流量计1</div>
<div class="sensor-value">12.9</div>
<div class="sensor-unit">L/min</div>
<div class="sensor-extra">今日流量 1,245L</div>
</div>
<div class="sensor-item">
<div class="sensor-name">流量计2</div>
<div class="sensor-value">8.1</div>
<div class="sensor-unit">L/min</div>
<div class="sensor-extra">今日流量 865L</div>
</div>
<div class="sensor-item">
<div class="sensor-name">自来水压力</div>
<div class="sensor-value">2.8</div>
<div class="sensor-unit">kg/cm²</div>
<div class="sensor-extra status-normal">正常</div>
</div>
</div>
</div>
</div>
<button class="inspect-btn" @click="startInspection">
开始巡检
</button>
</div>
<!-- 底部导航栏 -->
<div class="bottom-nav">
<div class="nav-item" @click="goToHome"></div>
<div class="nav-item" @click="goToInspection"></div>
<div class="nav-item" @click="goToWorkOrders"></div>
<div class="nav-item" @click="goToProfile"></div>
</div>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router'
const router = useRouter()
const goBack = () => {
router.back()
}
const goToHome = () => {
router.push('/home')
}
const goToInspection = () => {
router.push('/inspection')
}
const goToWorkOrders = () => {
router.push('/work-orders')
}
const goToProfile = () => {
router.push('/profile')
}
const startInspection = () => {
console.log('开始巡检制水机')
router.push('/inspection/form')
}
</script>
<style scoped>
.water-maker-detail {
width: 100%;
height: 100%;
background: #f8f9fa;
display: flex;
flex-direction: column;
}
/* 顶部标题栏 */
.header {
background: white;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.header-left {
width: 80px;
}
.header-title {
font-size: 18px;
font-weight: 600;
color: #333;
text-align: center;
flex: 1;
}
.header-right {
width: 80px;
}
.back-btn {
font-size: 14px;
color: #1890ff;
cursor: pointer;
transition: color 0.3s;
}
.back-btn:hover {
color: #096dd9;
}
/* 主要内容区域 */
.main-content {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.device-detail-container {
flex: 1;
}
/* 信息区块 */
.info-section {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
/* 设备信息网格 */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.info-item {
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
}
.info-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.info-value {
font-size: 14px;
font-weight: 500;
color: #333;
}
.status-normal {
color: #52c41a;
}
.status-warning {
color: #faad14;
}
/* 水质监测网格 */
.monitoring-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.monitoring-item {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
.monitoring-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.monitoring-value {
font-size: 20px;
font-weight: 700;
color: #1890ff;
margin-bottom: 2px;
}
.monitoring-unit {
font-size: 11px;
color: #999;
}
/* 滤芯状态网格 */
.filter-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.filter-item {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
.filter-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.filter-status {
font-size: 12px;
margin-bottom: 4px;
}
.filter-lifespan {
font-size: 11px;
color: #666;
}
/* 传感器监控网格 */
.sensor-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.sensor-item {
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
text-align: center;
}
.sensor-name {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.sensor-value {
font-size: 18px;
font-weight: 700;
color: #333;
margin-bottom: 2px;
}
.sensor-unit {
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.sensor-extra {
font-size: 11px;
color: #666;
}
/* 巡检按钮 */
.inspect-btn {
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
color: white;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
margin-top: 20px;
box-shadow: 0 4px 12px rgba(24, 144, 255, 0.2);
}
.inspect-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(24, 144, 255, 0.3);
}
/* 底部导航栏 */
.bottom-nav {
display: flex;
background: white;
border-top: 1px solid #e8e8e8;
padding: 8px 0;
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
font-size: 12px;
color: #666;
cursor: pointer;
transition: color 0.3s;
}
.nav-item.active {
color: #1890ff;
}
.nav-item:hover {
color: #1890ff;
}
</style>

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

Loading…
Cancel
Save