Compare commits

...

9 Commits

Author SHA1 Message Date
杨默涵 f57c126b30 终极版
1 month ago
杨默涵 5ed78dfa5c 小程序端实装
1 month ago
杨默涵 f3dcff9670 小程序端基本功能实现
2 months ago
杨默涵 11141724a6 小程序端接口实现
2 months ago
杨默涵 818343ae62 更新子模块引用
2 months ago
杨默涵 8354353b87 可运行web端以及数据库以及后端接口源代码上传
2 months ago
杨默涵 2052b849a8 忽略编译生成文件
3 months ago
杨默涵 2c6c075ae2
3 months ago
杨默涵 1932399e1a 源文件第一次提交
3 months ago

1
.gitignore vendored

@ -0,0 +1 @@
src/数智水管家/unpackage/dist/dev/

8
.idea/.gitignore vendored

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/WaterManager.iml" filepath="$PROJECT_DIR$/.idea/WaterManager.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

@ -1,36 +0,0 @@
# 小组周计划-第4周
## 团队名称和起止时间
**团队名称:** 1班-羊了个羊
**开始时间:** 2025-10-13
**结束时间:** 2025-10-19
## 本周任务计划安排
| 序号 | 计划内容 | 执行人 | 情况说明 |
|------|----------|--------|----------|
| 1 | 明确需求功能模块 | 全体成员 | 2025-10-14全体成员进行讨论明确需求功能模块以便推进原型设计 |
| 2 | 明确原型设计分工 | 全体成员 | 2025-10-14全体成员依据功能模块进行分工分别主要负责一部分模块原型界面的设计 |
| 3 | 原型界面功能设计 | 全体成员 | 2025-10-15负责各模块原型界面设计的人员确定需原型界面的大致思路有多少界面每个界面有哪些功能可以以文字或图片的形式展示。原型界面数量不足的问题各模块负责人思考添加合适功能完成后在QQ群中分享大家再一起讨论 |
| 4 | 正式原型界面实现 | 全体成员 | 2025-10-17前使用墨刀原型开发工具完成原型界面实现 |
| 5 | 向指导老师展示原型界面并进行改进 | 全体成员 | 2025-10-17后对陆绍飞老师展示原型界面并寻求建议进行改进 |
| 6 | 框架理解 | 全体成员 | 2025-10-14到2025-10-19 |
| 7 | 确定开发环境 | 全体成员 | 2025-10-14 |
| 8 | 软件开发专业知识的初步学习 | 全体成员 | 2025-10-14到2025-10-19全体成员完成对各领域软件开发专业知识的初步学习 |
## 小结
1. **增强自信心:** 小组成员应增强自信心,发挥想象力和创造力,在原型设计环节积极思考。
2. **沟通协作:** 小组成员应积极主动沟通,遇到困难及时寻求帮助,也可以主动向指导老师及研究生学长寻求建议。
3. **学习安排:** 小组成员仍处于软件开发专业知识的初步学习阶段,应合理安排自主学习时间,以便后续开发的顺利进行。
4. **项目管理:** PM及时推进项目流程确保项目有条不紊。
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. PM综合本小组成员工作情况提交小组周计划、周总结报告按时上传至代码托管平台。

@ -0,0 +1,36 @@
# 小组周计划-第4周
## 团队名称和起止时间
**团队名称:** 1班-羊了个羊
**开始时间:** 2025-10-27
**结束时间:** 2025-11-02
## 本周任务计划安排
| 序号 | 计划内容 | 执行人 | 情况说明 |
|------|----------|--------|----------|
| 1 | RUOYI框架学习 | 后端组 | 2025-10-28全体成员深入学习RUOYI框架的架构、核心组件和使用方法为后续开发奠定基础 |
| 2 | 数据库设计 | 后端组 | 2025-10-29根据项目需求完成数据库表结构设计包括用户表、设备表、数据表等核心表的设计 |
| 3 | 原型展示完善 | 全体成员 | 2025-10-30完善原型界面设计确保所有功能模块都有对应的原型展示 |
| 4 | UniApp学习 | 前端组 | 2025-10-31学习UniApp跨平台开发框架了解其特性和开发流程 |
| 5 | Vue.js学习 | 前端组 | 2025-11-01深入学习Vue.js前端框架掌握组件化开发、状态管理等核心概念 |
| 6 | 需求文档编写 | 全体成员 | 2025-10-28编写系统架构用例文档需求文档等 |
| 7 | 技术方案整合 | 全体成员 | 2025-11-02整合本周学习的技术栈制定下一阶段的开发计划 |
## 小结
1. **技术学习重点:** 本周重点学习RUOYI框架、UniApp和Vue.js为后续开发做好技术储备建议组织技术分享会加深理解。
2. **数据库设计:** 需要重点关注数据库表结构设计的合理性和扩展性,建议参考相关行业标准。
3. **原型完善:** 在已有原型基础上进行完善,确保用户体验的流畅性和功能的完整性。
4. **学习资源:** 希望指导老师能提供RUOYI框架的学习资料和实战案例以及UniApp和Vue.js的最佳实践指导。
5. **项目管理:** PM及时推进项目流程确保技术学习和项目进度同步进行。
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. PM综合本小组成员工作情况提交小组周计划、周总结报告按时上传至代码托管平台。

@ -0,0 +1,186 @@
# 小组会议纪要-第5周
## 会议记录概要
**团队名称:** 1班-羊了个羊
**指导老师:** 陆绍飞
**主 持 人:** 杨默涵
**记录人员:** 杨默涵
**会议主题:** 技术栈确认与需求文档编写讨论
**会议地点:** 线上
**会议时间:** 2025-10-26 1400-1600
**纪录时间:** 2025-10-26 2000
**参与人员:** 杨默涵,梁晨旭,杨捷,杨振宇,杨帅路
---
## 会议内容
### 1. 技术栈最终确认
经过充分讨论,团队最终确认了前后端技术选型:
**1前端技术栈**
- 采用 **uniapp + Vue** 框架进行移动端开发
- 原因uniapp支持多端发布具备良好的跨平台能力便于后续扩展至小程序、H5等平台
- Vue框架与团队已有技术基础相符开发效率高
- 与后端RuYi-APP架构能够良好对接
**2后端技术栈**
- 采用 **RuoYi-APP** 作为后端框架
- 沿用第四周确定的RuoYi核心模块系统管理、菜单与权限、任务调度、监控审计、代码生成、数据字典等
- 与前端uniapp通过RESTful API进行通信
**3技术优势**
- uniapp + RuoYi-APP组合具有良好的生态支持
- 前后端分离,便于团队并行开发
- 代码复用率高,降低维护成本
### 2. 用户需求进一步明确
在前期需求调研基础上,进一步细化并明确用户需求:
**1核心功能模块确认**
- **滤芯管理模块**:滤芯信息的增删改查、更换周期管理、供应商信息维护
- **用水监控模块**:实时用水量监控、用水数据统计分析、趋势展示
- **水质感知模块**水质指标实时监测TDS、浊度、余氯等、阈值预警
- **设备维护模块**:设备状态监控、工单管理、维护记录
- **用户权限管理**基于RuoYi的角色权限体系支持多角色管理
**2用户角色权限设计**
- **系统管理员**:拥有全部权限,可管理系统配置、用户权限等
- **滤芯更换工作人员**:负责滤芯更换工单执行,可查看工单详情、提交维护记录
- **滤芯供应商**:管理供应商信息,查看设备使用情况
- **运维人员**:监控设备状态,处理告警信息,查看统计报表
- **普通用水用户**:查看用水数据、水质指标等基础信息
**3数据权限控制**
- 基于RuoYi的数据权限功能实现按部门、按设备、按区域的数据隔离
- 敏感操作记录操作日志,支持审计追踪
### 3. 需求文档编写讨论
**1需求文档结构讨论**
- **项目概述**:项目背景、目标、范围
- **用户需求分析**:用户角色、使用场景、功能需求
- **系统架构设计**:技术架构、系统架构、部署架构
- **功能需求规格说明**:各功能模块的详细需求
- **非功能需求**:性能、安全、可靠性要求
- **接口设计规范**:前后端接口定义、数据格式
- **数据库设计文档**:表结构、字段说明、关联关系
- **UI/UX设计规范**:界面规范、交互规范
**2文档编写任务分工**
- **需求分析文档**:由全体成员参与讨论,杨默涵负责汇总整理
- **系统架构文档**由杨帅路、杨捷负责结合RuoYi框架特点
- **接口文档**:前后端协作编写,梁晨旭负责整理
- **数据库设计文档**:后端组负责完成
- **UI设计稿**:前端组负责配合原型设计
**3文档编写要求**
- 文档格式统一使用Markdown格式
- 文档需要保持版本控制,及时更新
- 重要决策需要记录讨论过程和依据
- 文档完成后需要团队评审通过
### 4. 后端界面编写需求讨论
**1后端管理界面开发计划**
- 基于RuoYi-Vue框架开发管理端界面
- 优先开发核心业务模块的后台管理页面
**2优先级排序**
- **第一阶段**(高优先级):
- 用户与角色管理界面
- 设备管理界面
- 滤芯管理界面
- **第二阶段**(中优先级):
- 工单管理界面
- 用水数据监控界面
- 水质数据监控界面
- **第三阶段**(低优先级):
- 统计分析报表界面
- 系统配置界面
- 告警中心界面
**3界面设计要求**
- 遵循RuoYi-UI设计规范保持界面风格统一
- 响应式布局,适配不同屏幕尺寸
- 交互友好,操作流程清晰
- 关键操作需要确认提示,防止误操作
**4开发任务分工**
- **后端接口开发**后端组负责RESTful API接口编写
- **管理界面开发**前端组负责Vue页面开发
- **联调测试**:前后端协作完成接口联调
### 5. 开发计划调整
**1迭代计划调整**
- 调整迭代0计划增加需求文档编写时间
- 将需求文档编写作为迭代0的核心任务之一
- 原型设计和数据库设计同步推进
**2时间节点安排**
- **本周末前**:完成需求文档初稿
- **下周三前**:完成需求文档评审,确定最终版本
- **下周末前**:完成数据库设计,开始代码生成
---
## 问题总结
### 已解决问题:
1. 确定前后端技术栈前端使用uniapp+Vue后端使用RuoYi-APP
2. 进一步明确用户需求,细化功能模块和用户角色
3. 讨论需求文档结构,明确文档编写任务分工
4. 明确后端界面编写需求,确定开发优先级
### 待解决问题:
1. 完善需求文档内容,需要各模块负责人提供详细的功能描述
2. 确定具体的接口设计规范和数据格式标准
3. 细化UI设计规范完成主要页面的原型设计
4. 制定详细的开发时间表和里程碑计划
---
## 小组协作情况总结
1. **协作情况:** 小组成员积极参与讨论,对技术选型和需求分析达成一致意见,团队协作顺畅
## 一周纪律情况总结
1. **纪律情况:** 小组成员按时参加会议,讨论认真,会议氛围良好
---
## 下周计划
1. **任务安排:**
- 各模块负责人完成详细需求描述
- 开始编写需求文档初稿
- 继续完善原型设计
- 开始数据库表设计工作
2. **重点事项:**
- 需求文档的质量直接关系到后续开发工作,需要认真对待
- 保持前后端沟通,确保接口设计合理
- 继续跟进RuoYi框架学习掌握代码生成器使用
---
## 备注
1. 需求文档是项目的基础,需要充分讨论和细致编写
2. 在文档编写过程中,如发现需求不明确的地方,需要及时沟通确认
3. 注意记录需求变更,维护需求文档的版本
---
## 【注】
1. 本文档为小组软件过程会议记录记录人员须在会议后一个工作日内如实填写并汇报给PM、Lead及相关人员
2. 文档内容已编号,若需调整编号,请在保证文档格式正确前提下修改
3. 文档填写完毕后,文件命名按"meeting-minutes-周次"规范;如一周多次会议,命名为"meeting-minutes-05-01"

@ -0,0 +1,34 @@
# 小组周总结-第4周
## 团队名称和起止时间
**团队名称:** 1班-羊了个羊
**开始时间:** 2025-10-13
**结束时间:** 2025-10-19
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
|------|----------|----------|----------|
| 1 | 原型页面分工 | 完成 | 2025-10-14全体成员进行讨论明确了各系统用户所需功能并进行页面的分工 |
| 2 | 正式原型界面实现 | 未完成 | 延后到2025-10-23 ,使用墨刀原型开发工具完成原型界面实现 |
| 3 | 向指导老师展示原型界面并进行改进 | 未完成 | 延后至2025-10-24对陆绍飞老师展示原型界面并寻求建议进行改进 |
| 4 | 框架理解 | 完成 | 2025-10-09到2025-10-15参考基于deepseek本地部署的糖健康系统客户端基本理解本系统框架 |
| 5 | 确定开发环境 | 完成 | 2025-10-15前后端人员完成开发工具的统一与确定前端微信开发者工具后端ROUYI与网页端后台控制系统 |
| 6 | 软件开发专业知识的初步学习 | 完成 | 2025-10-13到2025-10-19全体成员完成对各领域软件开发专业知识的初步学习 |
| 7 |需求文档的编写 | 完成 | 本周初步完成用例文档第一版的编写,等待原型设计结束后进一步完善 |
## 小结
1. **沟通协作:** 小组成员应积极主动沟通,遇到困难及时寻求帮助,也可以主动向指导老师及研究生学长寻求建议。
2. **学习安排:** 小组成员仍处于软件开发专业知识的初步学习阶段,应合理安排自主学习时间,以便后续开发的顺利进行。
3. **项目管理:** 项目进度管理杨安然同学及时推进项目流程,确保项目有条不紊。
---
## 【注】
1. 在小结一栏中写出希望得到如何的帮助,如讲座等;
2. 请将个人计划和总结提前发给负责人;
3. 周任务总结与计划是项目小组评分考核的重要依据,将直接记入平时成绩,请各位同学按要求认真填写并按时提交;
4. PM综合本小组成员工作情况提交小组周计划、周总结报告按时上传至代码托管平台。

@ -0,0 +1,30 @@
# 个人周计划-第4周
## 姓名和起止时间
**姓  名:** 杨默涵
**团队名称:** 软件2301班-羊了个羊
**开始时间:** 2025-10-27
**结束时间:** 2025-11-2
## 本周任务计划安排
| 序号 | 计划内容| 协作人 | 情况说明 |
| ----| ------ | ------| ------- |
| 1 | 完成数据库设计 | 个人 | 设计数智水管家系统的数据库表结构,包括用户、设备、数据等核心表 |
| 2 | RUOYI框架本地部署 | 个人 | 在本地环境成功部署RUOYI框架确保开发环境正常运行 |
| 3 | 继续学习RUOYI相关知识 | 个人 | 深入学习RUOYI框架功能和后端开发技术为后续开发做准备 |
| 4 | 向陆绍飞老师寻求意见并修改原型 | 陆绍飞老师 | 2025-10-22 与老师沟通原型设计,根据建议修改微信小程序原型 |
| 5 | 完善系统架构设计 | 组员 | 基于老师建议优化整体系统架构,确定技术选型和模块划分 |
| 6 | 编写需求文档 | 个人 | 基于修改后的原型和架构设计编写详细的需求文档 |
| 7 | 讨论任务分配 | 组员 | 2025-10-24 开会汇报进展,讨论后续任务分配 |
## 小结
1. **技术学习:** 重点学习RUOYI框架的部署和使用掌握后端开发技术
2. **设计完善:** 通过与陆绍飞老师的沟通,完善原型设计和系统架构;
3. **文档输出:** 完成数据库设计和需求文档的编写,为后续开发奠定基础;
4. **团队协作:** 与组员讨论任务分配,确保项目进度有序推进。
---

@ -0,0 +1,31 @@
# 个人周总结-第4周
## 姓名和起止时间
**姓  名:** 杨默涵
**团队名称:** 软件2301班-羊了个羊
**开始时间:** 2025-10-20
**结束时间:** 2025-10-26
## 本周任务完成情况
| 序号 | 总结内容 | 是否完成 | 情况说明 |
| ---- | ------------------------------------------------ | -------- | ------------------------------------------------------------ |
|1 | 完成工作人员界面原型设计并完善原型初稿| 完成| 完成了原型中工作人员界面的设计,整体完善了原型初稿|
|2 | 召开例会,讨论系统架构设计等下周任务| 完成| 召开了例会,讨论系统架构设计和下周任务安排|
|3 | 学习uniapp、vue以及RUOYI架构知识| 完成| 学习了uniapp和vue相关知识以及RUOYI架构的使用方法|
## 对团队工作的建议
1. **互助学习:** 小组成员应该根据自身的技能长短开展互帮互助的活动,可以各自负责自己擅长的部分,共同努力提高小组成员的专业水平;
2. **进度统一:** 团队成员尽量统一项目进度,以免造成麻烦;
## 小结
1. **原型制作:** 完成了登录界面基本构架的制作;
2. **技能学习:** 小组成员各自开展自己所负责部分的个人技能的学习;
3. **项目管理:** PM及时推进项目进度确保工作有条不紊
4. **计划制定:** 根据本周任务完成情况与下一阶段文档提交要求,制定团队任务计划。
---

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KubernetesApiProvider"><![CDATA[{}]]></component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/WaterManager_Web.iml" filepath="$PROJECT_DIR$/.idea/WaterManager_Web.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
</component>
</project>

@ -0,0 +1,27 @@
<script setup>
import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
import { setTabBar } from './utils/tabBar.js'
onLaunch(() => {
console.log('App Launch - 数智水管家')
// tabBar
const userRole = uni.getStorageSync('userRole') || 'user'
setTabBar(userRole)
})
onShow(() => {
console.log('App Show')
})
onHide(() => {
console.log('App Hide')
})
</script>
<style>
/* 全局样式 */
page {
background-color: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
}
</style>

@ -0,0 +1,317 @@
# Web 端登录超时问题排查
## 📋 问题描述
Web 端可以进入登录页面,输入验证码点击登录后显示"接口超时"。
## 🔍 排查步骤
### 1. 检查后端服务是否正常运行
在服务器上执行:
```bash
# 检查 Java 进程是否运行
ps aux | grep java
# 检查 8080 端口是否监听
ss -tlnp | grep :8080
# 或
netstat -tlnp | grep :8080
# 检查服务日志
tail -f /path/to/ruoyi-admin.log
# 或查看 Spring Boot 日志
journalctl -u ruoyi-admin -f
```
**预期结果**
- 应该能看到 Java 进程运行
- 8080 端口应该处于 LISTEN 状态
- 日志中不应该有错误信息
### 2. 检查 Nginx 代理配置
查看 Nginx 配置文件:
```bash
# 查看 Nginx 配置
cat /etc/nginx/conf.d/default.conf | grep -A 20 "prod-api"
# 或查看 RuoYi 专用配置
cat /etc/nginx/conf.d/ruoyi.conf
```
**检查要点**
1. **proxy_pass 地址**:应该指向 `http://127.0.0.1:8080``http://172.18.40.251:8080`
2. **超时时间配置**:应该包含以下配置
```nginx
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
```
3. **代理路径**`/prod-api/` 应该正确配置
**正确的 Nginx 配置示例**
```nginx
location /prod-api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时配置(重要!)
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 缓冲配置
proxy_buffering off;
proxy_request_buffering off;
}
```
### 3. 测试后端接口是否可访问
在服务器上测试:
```bash
# 测试登录接口POST 请求)
curl -X POST http://127.0.0.1:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","code":"","uuid":""}'
# 测试验证码接口GET 请求)
curl http://127.0.0.1:8080/captchaImage
# 测试通过 Nginx 代理访问
curl -X POST http://localhost/prod-api/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123","code":"","uuid":""}'
```
**预期结果**
- 应该返回 JSON 响应(成功或错误)
- 不应该出现连接超时或连接拒绝
### 4. 检查前端超时配置
查看前端请求超时设置:
**文件位置**`E:\ideaproject\RuoYi-Vue\ruoyi-ui\src\utils\request.js`
**当前配置**
```javascript
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 10000 // 10秒超时
})
```
**如果后端响应慢,可以增加超时时间**
```javascript
timeout: 30000 // 改为 30 秒
```
### 5. 检查浏览器网络请求
在浏览器中打开开发者工具F12查看 Network 标签:
1. **查看请求状态**
- 请求是否发送成功?
- 状态码是什么200、404、500、timeout
- 响应时间是多少?
2. **查看请求详情**
- Request URL应该是 `https://tjkhnu.com/prod-api/login``http://47.122.2.5/prod-api/login`
- Request Method应该是 `POST`
- Request Headers检查 Content-Type 等
3. **查看响应**
- 如果有响应,查看响应内容
- 如果超时,查看错误信息
### 6. 检查 Nginx 错误日志
```bash
# 查看 Nginx 错误日志
tail -f /var/log/nginx/error.log
# 查看访问日志
tail -f /var/log/nginx/access.log
```
**常见错误**
- `upstream timed out`:后端响应超时
- `connect() failed (111: Connection refused)`:后端服务未运行
- `no resolver defined`DNS 解析问题
## 🔧 解决方案
### 方案1增加 Nginx 超时时间
编辑 Nginx 配置文件:
```bash
sudo vi /etc/nginx/conf.d/default.conf
```
`/prod-api/` location 块中添加:
```nginx
location /prod-api/ {
proxy_pass http://127.0.0.1:8080/;
# ... 其他配置 ...
# 增加超时时间
proxy_connect_timeout 120s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
```
保存后重载 Nginx
```bash
sudo nginx -t # 测试配置
sudo systemctl reload nginx # 重载配置
```
### 方案2检查后端服务性能
如果后端响应慢,检查:
1. **数据库连接**
```bash
# 检查数据库连接数
mysql -u water_app -p -e "SHOW PROCESSLIST;"
```
2. **Java 进程资源**
```bash
# 查看 Java 进程资源使用
top -p $(pgrep -f ruoyi-admin)
```
3. **应用日志**
```bash
# 查看应用日志中的慢查询或错误
tail -f /path/to/ruoyi-admin.log | grep -i "error\|timeout\|slow"
```
### 方案3增加前端超时时间
如果后端响应确实需要更长时间,修改前端超时配置:
**文件**`E:\ideaproject\RuoYi-Vue\ruoyi-ui\src\utils\request.js`
```javascript
const service = axios.create({
baseURL: process.env.VUE_APP_BASE_API,
timeout: 30000 // 改为 30 秒
})
```
然后重新构建并部署前端:
```bash
cd E:\ideaproject\RuoYi-Vue\ruoyi-ui
npm run build:prod
# 然后上传到服务器
```
### 方案4检查防火墙和安全组
确保:
1. **服务器防火墙**:允许 8080 端口(如果从外部访问)
2. **云服务器安全组**:允许 80、443、8080 端口
3. **Nginx 到后端**:使用 `127.0.0.1:8080`(本地回环,不需要防火墙规则)
## 📝 快速诊断命令
在服务器上执行以下命令进行快速诊断:
```bash
#!/bin/bash
echo "=== 1. 检查后端服务 ==="
ps aux | grep java | grep -v grep
echo ""
echo "=== 2. 检查 8080 端口 ==="
ss -tlnp | grep :8080
echo ""
echo "=== 3. 测试后端接口 ==="
curl -s -o /dev/null -w "HTTP状态码: %{http_code}\n响应时间: %{time_total}s\n" http://127.0.0.1:8080/captchaImage
echo ""
echo "=== 4. 检查 Nginx 配置 ==="
nginx -t
echo ""
echo "=== 5. 检查 Nginx 状态 ==="
systemctl status nginx --no-pager | head -10
echo ""
echo "=== 6. 测试 Nginx 代理 ==="
curl -s -o /dev/null -w "HTTP状态码: %{http_code}\n响应时间: %{time_total}s\n" http://localhost/prod-api/captchaImage
echo ""
echo "=== 7. 查看最近的 Nginx 错误日志 ==="
tail -20 /var/log/nginx/error.log
```
## ✅ 验证修复
修复后,验证步骤:
1. **清除浏览器缓存**Ctrl+Shift+Delete
2. **重新访问登录页面**`https://tjkhnu.com` 或 `http://47.122.2.5`
3. **输入验证码和账号密码**
4. **点击登录**
5. **查看浏览器 Network 标签**:确认请求成功
## 🐛 常见问题
### Q1: 验证码可以加载,但登录超时
**可能原因**
- 登录接口处理时间较长(数据库查询慢)
- Nginx 超时时间设置过短
**解决方案**
- 增加 Nginx `proxy_read_timeout`
- 检查数据库查询性能
### Q2: 验证码和登录都超时
**可能原因**
- 后端服务未运行
- Nginx 代理配置错误
- 网络连接问题
**解决方案**
- 检查后端服务状态
- 检查 Nginx 配置
- 测试本地接口访问
### Q3: 本地测试正常,服务器超时
**可能原因**
- 服务器资源不足
- 数据库连接慢
- Nginx 配置问题
**解决方案**
- 检查服务器资源使用情况
- 优化数据库查询
- 检查 Nginx 配置和日志
---
**最后更新**2025-12-19

@ -0,0 +1,379 @@
# 小程序 API 接口使用文档
## 📋 目录结构
```
api/
├── index.js # 统一导出
├── auth.js # 认证相关(登录、退出)
├── device.js # 设备管理
├── order.js # 订单管理
├── product.js # 商品管理
├── quality.js # 水质检测
├── message.js # 消息通知
├── user.js # 用户信息
└── staff.js # 员工端接口
```
## 🚀 快速开始
### 1. 导入 API
```javascript
// 方式1导入单个模块
import { userLogin, getUserInfo } from '../api/auth.js'
import { getDeviceList } from '../api/device.js'
// 方式2统一导入
import * as api from '../api/index.js'
```
### 2. 使用示例
```javascript
// 用户登录
import { userLogin } from '../api/auth.js'
async function handleLogin() {
try {
const res = await userLogin('12345678', 'wgll')
console.log('登录成功', res)
// res.token - Token
// res.userInfo - 用户信息
} catch (error) {
console.error('登录失败', error)
}
}
```
## 📚 API 详细说明
### 认证相关auth.js
#### 用户登录
```javascript
import { userLogin } from '../api/auth.js'
// 登录成功后会自动保存 Token
const res = await userLogin('12345678', 'wgll')
```
#### 员工登录
```javascript
import { staffLogin } from '../api/auth.js'
const res = await staffLogin('202326010123', 'lcxtangshi')
```
#### 退出登录
```javascript
import { logout } from '../api/auth.js'
logout() // 会自动清除 Token 并跳转到登录页
```
### 设备管理device.js
#### 获取设备列表
```javascript
import { getDeviceList } from '../api/device.js'
// 获取所有设备
const devices = await getDeviceList()
// 获取在线设备
const onlineDevices = await getDeviceList({ status: '1' })
```
#### 获取设备详情
```javascript
import { getDeviceDetail } from '../api/device.js'
const device = await getDeviceDetail(1)
```
#### 绑定设备
```javascript
import { bindDevice } from '../api/device.js'
await bindDevice({
deviceSn: 'DEV001',
deviceName: '客厅净水器',
location: '客厅'
})
```
### 订单管理order.js
#### 获取订单列表
```javascript
import { getOrderList } from '../api/order.js'
// 获取所有订单
const orders = await getOrderList()
// 获取待支付订单
const unpaidOrders = await getOrderList({ status: 'unpaid' })
```
#### 创建订单
```javascript
import { createOrder } from '../api/order.js'
await createOrder({
orderType: 'service',
serviceType: 'quality_check',
deviceSn: 'DEV001',
appointmentDate: '2025-12-10',
appointmentTime: '14:00',
contactPhone: '13800138000',
address: '北京市朝阳区xxx',
paymentMethod: 'wechat',
remark: '备注信息'
})
```
#### 订单支付
```javascript
import { payOrder } from '../api/order.js'
await payOrder(1, 'wechat')
```
### 商品管理product.js
#### 获取商品列表
```javascript
import { getProductList } from '../api/product.js'
// 获取所有商品
const products = await getProductList()
// 搜索商品
const searchResults = await getProductList({ keyword: '净水器' })
```
### 水质检测quality.js
#### 获取水质检测记录
```javascript
import { getQualityList } from '../api/quality.js'
const records = await getQualityList({ deviceId: 1 })
```
#### 申请水质检测
```javascript
import { applyQualityCheck } from '../api/quality.js'
await applyQualityCheck({
deviceId: 1,
appointmentDate: '2025-12-10',
appointmentTime: '14:00',
contactPhone: '13800138000',
address: '北京市朝阳区xxx'
})
```
### 消息通知message.js
#### 获取消息列表
```javascript
import { getMessageList } from '../api/message.js'
// 获取所有消息
const messages = await getMessageList()
// 获取未读消息
const unreadMessages = await getMessageList({ isRead: 0 })
```
#### 标记消息已读
```javascript
import { markMessageRead } from '../api/message.js'
// 标记单条消息已读
await markMessageRead(1)
// 标记全部已读
await markMessageRead(0)
```
### 用户信息user.js
#### 获取用户信息
```javascript
import { getUserInfo } from '../api/user.js'
const userInfo = await getUserInfo()
```
#### 更新用户信息
```javascript
import { updateUserInfo } from '../api/user.js'
await updateUserInfo({
nickname: '新昵称',
avatar: 'http://example.com/avatar.jpg'
})
```
### 员工端staff.js
#### 获取工单列表
```javascript
import { getWorkorderList } from '../api/staff.js'
// 获取待接单工单
const pendingOrders = await getWorkorderList({ status: 'pending' })
```
#### 接单
```javascript
import { acceptWorkorder } from '../api/staff.js'
await acceptWorkorder(1)
```
#### 完成工单
```javascript
import { completeWorkorder } from '../api/staff.js'
await completeWorkorder({
orderId: 1,
result: '已完成维修',
images: ['http://example.com/image1.jpg']
})
```
## 🔧 高级用法
### 自定义请求配置
```javascript
import request from '../utils/request.js'
// 不显示加载提示
const data = await request.get('/water/app/device/list', {}, {
showLoading: false
})
// 自定义加载文字
const data = await request.get('/water/app/device/list', {}, {
loadingText: '加载设备中...'
})
// 不需要 Token用于公开接口
const data = await request.get('/water/app/notice/list', {}, {
needAuth: false
})
```
### 错误处理
```javascript
import { getDeviceList } from '../api/device.js'
try {
const devices = await getDeviceList()
// 处理成功
} catch (error) {
// 错误已在 request.js 中统一处理(显示 Toast
// 这里可以添加额外的错误处理逻辑
console.error('获取设备列表失败', error)
}
```
### Token 管理
```javascript
import request from '../utils/request.js'
// 获取 Token
const token = request.getToken()
// 设置 Token登录接口会自动设置
request.setToken('your_token_here')
// 清除 Token退出登录接口会自动清除
request.removeToken()
```
## 📝 在页面中使用
### Vue 3 Composition API
```vue
<template>
<view>
<button @click="loadDevices">加载设备</button>
<view v-for="device in devices" :key="device.deviceId">
{{ device.deviceName }}
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { getDeviceList } from '../api/device.js'
const devices = ref([])
async function loadDevices() {
try {
devices.value = await getDeviceList()
} catch (error) {
console.error('加载失败', error)
}
}
</script>
```
### Options API
```vue
<template>
<view>
<button @click="loadDevices">加载设备</button>
</view>
</template>
<script>
import { getDeviceList } from '../api/device.js'
export default {
data() {
return {
devices: []
}
},
methods: {
async loadDevices() {
try {
this.devices = await getDeviceList()
} catch (error) {
console.error('加载失败', error)
}
}
}
}
</script>
```
## ⚠️ 注意事项
1. **Token 自动管理**:登录接口会自动保存 Token退出登录会自动清除
2. **错误处理**:所有错误都会自动显示 Toast 提示,无需手动处理
3. **加载提示**:默认显示加载提示,可通过配置关闭
4. **Token 过期**Token 过期会自动跳转到登录页
5. **网络错误**:网络错误会自动提示,无需手动处理
## 🔗 相关文档
- [API 接口测试文档](../../小程序API接口测试文档.md)
- [API 接口设计文档](../../小程序API接口设计文档.md)
---
**最后更新**2025-12-09

@ -0,0 +1,79 @@
/**
* 地址管理 API
*/
import request from '../utils/request.js'
/**
* 获取地址列表
*/
export function getAddressList() {
return request.get('/water/app/address/list')
}
/**
* 创建地址
* @param {Object} data 地址信息
* @param {String} data.contactName 联系人姓名
* @param {String} data.contactPhone 联系电话
* @param {String} data.address 详细地址
* @param {Boolean} data.isDefault 是否默认地址true/false
*/
export function createAddress(data) {
// 后端接收Boolean类型需要转换
const requestData = {
contactName: data.contactName,
contactPhone: data.contactPhone,
address: data.address,
isDefault: typeof data.isDefault === 'string' ? data.isDefault === '1' : (data.isDefault || false)
}
return request.post('/water/app/address/create', requestData, {
showLoading: true,
loadingText: '添加中...'
})
}
/**
* 更新地址
* @param {Number} addressId 地址ID
* @param {Object} data 地址信息
* @param {String} data.contactName 联系人姓名
* @param {String} data.contactPhone 联系电话
* @param {String} data.address 详细地址
* @param {Boolean} data.isDefault 是否默认地址true/false
*/
export function updateAddress(addressId, data) {
// 后端接收Boolean类型需要转换
const requestData = {
contactName: data.contactName,
contactPhone: data.contactPhone,
address: data.address,
isDefault: typeof data.isDefault === 'string' ? data.isDefault === '1' : (data.isDefault || false)
}
return request.post(`/water/app/address/update/${addressId}`, requestData, {
showLoading: true,
loadingText: '更新中...'
})
}
/**
* 删除地址
* @param {Number} addressId 地址ID
*/
export function deleteAddress(addressId) {
return request.post(`/water/app/address/delete/${addressId}`, {}, {
showLoading: true,
loadingText: '删除中...'
})
}
/**
* 设置默认地址
* @param {Number} addressId 地址ID
*/
export function setDefaultAddress(addressId) {
return request.post(`/water/app/address/setDefault/${addressId}`, {}, {
showLoading: true,
loadingText: '设置中...'
})
}

@ -0,0 +1,106 @@
/**
* 认证相关 API
*/
import request from '../utils/request.js'
/**
* 用户登录
* @param {String} phone 手机号
* @param {String} password 密码
*/
export function userLogin(phone, password) {
return request.post('/water/app/auth/login', {
phone,
password
}, {
needAuth: false,
showLoading: false // 页面已有 loading 状态,不显示加载提示
}).then(res => {
// 保存 Token
if (res.token) {
request.setToken(res.token)
}
return res
})
}
/**
* 微信登录
* @param {String} code 微信授权code
*/
export function wechatLogin(code) {
return request.post('/water/app/auth/wechat', {
code
}, {
needAuth: false,
showLoading: false // 页面已有 loading 状态,不显示加载提示
}).then(res => {
// 保存 Token
if (res.token) {
request.setToken(res.token)
}
return res
})
}
/**
* 员工登录
* @param {String} staffNo 工号
* @param {String} phone 手机号
*/
export function staffLogin(staffNo, phone) {
return request.post('/water/staff/auth/login', {
staffNo,
phone
}, {
needAuth: false,
showLoading: false // 页面已有 loading 状态,不显示加载提示
}).then(res => {
// 保存 Token
if (res.token) {
request.setToken(res.token)
}
return res
})
}
/**
* 退出登录
*/
export function logout() {
request.removeToken()
uni.reLaunch({
url: '/pages/login/login'
})
}
/**
* 修改密码
* @param {String} oldPassword 旧密码
* @param {String} newPassword 新密码
*/
export function changePassword(oldPassword, newPassword) {
return request.post('/water/app/auth/changePassword', {
oldPassword,
newPassword
}, {
showLoading: true,
loadingText: '提交中...'
})
}
/**
* 用户注册
* @param {Object} data 注册信息
* @param {String} data.phone 手机号
* @param {String} data.password 密码
* @param {String} data.nickname 昵称可选
*/
export function userRegister(data) {
return request.post('/water/app/auth/register', data, {
showLoading: true,
needAuth: false,
loadingText: '注册中...'
})
}

@ -0,0 +1,94 @@
/**
* 设备管理 API
*/
import request from '../utils/request.js'
/**
* 获取设备列表
* 注意后端会根据当前登录用户的Token自动过滤只返回该用户拥有的设备
* @param {Object} params 查询参数
* @param {String} params.status 设备状态0离线/1在线/2故障
* @param {Number} params.pageNum 页码可选
* @param {Number} params.pageSize 每页数量可选
*/
export function getDeviceList(params = {}) {
return request.get('/water/app/device/list', params)
}
/**
* 获取设备详情
* @param {String} deviceSn 设备序列号
*/
export function getDeviceDetail(deviceSn) {
return request.get(`/water/app/device/detail/${deviceSn}`)
}
/**
* 绑定设备
* @param {Object} data 绑定信息
* @param {String} data.deviceSn 设备序列号必填
* @param {String} data.deviceName 设备名称必填
* @param {String} data.installAddress 安装地址必填
*/
export function bindDevice(data) {
return request.post('/water/app/device/bind', data, {
showLoading: true,
loadingText: '绑定中...'
})
}
/**
* 解绑设备
* @param {Number} deviceId 设备ID
*/
export function unbindDevice(deviceId) {
return request.post('/water/app/device/unbind', {
deviceId
})
}
/**
* 更新设备信息
* @param {Number} deviceId 设备ID
* @param {Object} data 更新数据
*/
export function updateDevice(deviceId, data) {
return request.put(`/water/app/device/${deviceId}`, data)
}
/**
* 申请设备维修
* @param {Object} data 维修信息
* @param {Number} data.deviceId 设备ID
* @param {String} data.problem 问题描述
* @param {Array} data.images 问题图片
*/
export function applyDeviceRepair(data) {
return request.post('/water/app/device/repair', data)
}
/**
* 添加滤芯
* @param {Number} deviceId 设备ID
* @param {Object} data 滤芯信息
* @param {String} data.filterName 滤芯名称必填
* @param {String} data.filterType 滤芯类型可选
* @param {String} data.model 滤芯型号可选
*/
export function addFilter(deviceId, data) {
return request.post(`/water/app/device/${deviceId}/filter`, data)
}
/**
* 更新滤芯更换滤芯
* @param {Number} deviceId 设备ID
* @param {Number} filterId 滤芯ID
* @param {Object} data 滤芯信息
* @param {String} data.filterName 滤芯名称可选
* @param {String} data.filterType 滤芯类型可选
* @param {String} data.model 滤芯型号可选
*/
export function updateFilter(deviceId, filterId, data) {
return request.put(`/water/app/device/${deviceId}/filter/${filterId}`, data)
}

@ -0,0 +1,12 @@
/**
* API 统一导出
*/
export * from './auth'
export * from './device'
export * from './order'
export * from './product'
export * from './quality'
export * from './message'
export * from './user'
export * from './staff'

@ -0,0 +1,32 @@
/**
* 消息通知 API
*/
import request from '../utils/request.js'
/**
* 获取消息列表
* @param {Object} params 查询参数
* @param {String} params.messageType 消息类型order/device/system/service
* @param {Number} params.isRead 是否已读0未读/1已读
*/
export function getMessageList(params = {}) {
return request.get('/water/app/message/list', params)
}
/**
* 标记消息已读
* @param {Number} messageId 消息ID0表示标记全部已读
*/
export function markMessageRead(messageId) {
return request.post('/water/app/message/read', {
messageId
})
}
/**
* 获取公告列表
*/
export function getNoticeList() {
return request.get('/water/app/notice/list')
}

@ -0,0 +1,80 @@
/**
* 订单管理 API商品订单 - water_order表
* 订单状态unpaid待支付/paid已支付/delivering配送中/completed已完成/cancelled已取消
*/
import request from '../utils/request.js'
/**
* 获取订单列表商品订单
* @param {Object} params 查询参数
* @param {String} params.orderStatus 订单状态unpaid/paid/delivering/completed/cancelled
*/
export function getOrderList(params = {}) {
return request.get('/water/app/order/list', params)
}
/**
* 获取订单详情
* @param {Number} orderId 订单ID
*/
export function getOrderDetail(orderId) {
return request.get(`/water/app/order/detail/${orderId}`)
}
/**
* 创建订单商品订单
* @param {Object} data 订单信息
* @param {Number} data.addressId 地址ID关联water_address表
* @param {String} data.contactName 收货人姓名
* @param {String} data.contactPhone 收货人电话
* @param {String} data.deliveryAddress 配送地址
* @param {String} data.paymentMethod 支付方式wechat/balance/alipay
* @param {String} data.remark 备注
* @param {Array} data.products 商品列表
*/
export function createOrder(data) {
return request.post('/water/app/order/create', data, {
showLoading: true,
loadingText: '创建订单中...'
})
}
/**
* 订单支付
* @param {Number} orderId 订单ID
* @param {String} paymentMethod 支付方式wechat/balance/alipay
*/
export function payOrder(orderId, paymentMethod = 'wechat') {
return request.post('/water/app/order/pay', {
orderId,
paymentMethod
}, {
showLoading: true,
loadingText: '支付中...'
})
}
/**
* 取消订单
* @param {Number} orderId 订单ID
* @param {String} reason 取消原因
*/
export function cancelOrder(orderId, reason) {
return request.post('/water/app/order/cancel', {
orderId,
reason
})
}
/**
* 订单评价
* @param {Object} data 评价信息
* @param {Number} data.orderId 订单ID
* @param {Number} data.rating 评分1-5
* @param {String} data.content 评价内容
* @param {Array} data.images 评价图片
*/
export function evaluateOrder(data) {
return request.post('/water/app/order/evaluate', data)
}

@ -0,0 +1,23 @@
/**
* 商品管理 API
*/
import request from '../utils/request.js'
/**
* 获取商品列表
* @param {Object} params 查询参数
* @param {String} params.category 商品分类purifier/meter/parts/service
* @param {String} params.keyword 搜索关键词
*/
export function getProductList(params = {}) {
return request.get('/water/app/product/list', params)
}
/**
* 获取商品详情
* @param {Number} productId 商品ID
*/
export function getProductDetail(productId) {
return request.get(`/water/app/product/detail/${productId}`)
}

@ -0,0 +1,47 @@
/**
* 水质检测 API
*/
import request from '../utils/request.js'
/**
* 获取水质检测记录列表
* @param {Object} params 查询参数
* @param {Number} params.deviceId 设备ID可选
*/
export function getQualityList(params = {}) {
return request.get('/water/app/quality/list', params)
}
/**
* 获取水质检测详情
* @param {Number} qualityId 检测记录ID
*/
export function getQualityDetail(qualityId) {
return request.get(`/water/app/quality/detail/${qualityId}`)
}
/**
* 申请水质检测
* @param {Object} data 检测信息
* @param {Number} data.deviceId 设备ID
* @param {String} data.appointmentDate 预约日期
* @param {String} data.appointmentTime 预约时间
* @param {String} data.contactPhone 联系电话
* @param {String} data.address 地址
* @param {String} data.remark 备注
*/
export function applyQualityCheck(data) {
return request.post('/water/app/quality/apply', data, {
showLoading: true,
loadingText: '申请中...'
})
}
/**
* 获取水质预警列表
* 注意通过获取水质检测列表然后筛选出异常记录等级为"较差""一般"
*/
export function getQualityAlertList(params = {}) {
return request.get('/water/app/quality/list', params)
}

@ -0,0 +1,150 @@
/**
* 员工端 API工单管理 - water_service_order表
* 工单状态pending待接单/accepted已接单/processing处理中/completed已完成/cancelled已取消
*/
import request from '../utils/request.js'
/**
* 获取工单列表服务工单
* @param {Object} params 查询参数
* @param {String} params.orderStatus 工单状态pending/accepted/processing/completed/cancelled
* @param {String} params.serviceType 服务类型设备安装/滤芯更换/维修/水质检测
*/
export function getWorkorderList(params = {}) {
return request.get('/water/staff/workorder/list', params)
}
/**
* 获取工单详情
* @param {Number} orderId 订单ID
*/
export function getWorkorderDetail(orderId) {
return request.get(`/water/staff/workorder/detail/${orderId}`)
}
/**
* 接单
* @param {Number} orderId 订单ID
*/
export function acceptWorkorder(orderId) {
return request.post('/water/staff/workorder/accept', {
orderId
}, {
showLoading: true,
loadingText: '接单中...'
})
}
/**
* 开始处理工单
* @param {Number} orderId 订单ID
*/
export function startWorkorder(orderId) {
return request.post('/water/staff/workorder/start', {
orderId
}, {
showLoading: true,
loadingText: '开始处理...'
})
}
/**
* 完成工单
* @param {Object} data 完成信息
* @param {Number} data.orderId 订单ID
* @param {String} data.result 处理结果
* @param {Array} data.images 处理图片
*/
export function completeWorkorder(data) {
return request.post('/water/staff/workorder/complete', data, {
showLoading: true,
loadingText: '提交中...'
})
}
/**
* 获取配送任务列表
* @param {Object} params 查询参数
* @param {String} params.status 任务状态pending/delivering/completed
*/
export function getDeliveryList(params = {}) {
return request.get('/water/staff/delivery/list', params)
}
/**
* 获取配送任务详情
* @param {Number} orderId 订单ID
*/
export function getDeliveryDetail(orderId) {
return request.get(`/water/staff/delivery/detail/${orderId}`)
}
/**
* 开始配送
* @param {Number} orderId 订单ID
*/
export function startDelivery(orderId) {
return request.post('/water/staff/delivery/start', {
orderId
})
}
/**
* 完成配送
* @param {Object} data 完成信息
* @param {Number} data.orderId 订单ID
* @param {String} data.signature 签收信息
* @param {Array} data.images 签收图片
*/
export function completeDelivery(data) {
return request.post('/water/staff/delivery/complete', data)
}
/**
* 获取我的统计
*/
export function getStaffStatistics() {
return request.get('/water/staff/statistics')
}
/**
* 获取我的评价列表
*/
export function getStaffEvaluationList() {
return request.get('/water/staff/evaluation/list')
}
/**
* 获取员工信息
*/
export function getStaffInfo() {
return request.get('/water/staff/info')
}
/**
* 更新员工信息
* @param {Object} data 员工信息
* @param {String} data.staffName 姓名
* @param {String} data.avatar 头像
*/
export function updateStaffInfo(data) {
return request.put('/water/staff/info', data, {
showLoading: true,
loadingText: '保存中...'
})
}
/**
* 智能规划配送路径
* @param {Object} data 规划参数
* @param {Number} data.startLat 起点纬度员工当前位置
* @param {Number} data.startLng 起点经度员工当前位置
* @returns {Promise} 返回规划结果包含route配送顺序totalDistance总距离estimatedDuration预计时长
*/
export function planDeliveryRoute(data) {
return request.post('/water/staff/delivery/plan-route', data, {
showLoading: true,
loadingText: '正在规划最优路径...'
})
}

@ -0,0 +1,59 @@
/**
* 用户信息 API
*/
import request from '../utils/request.js'
/**
* 获取用户信息
*/
export function getUserInfo() {
return request.get('/water/app/user/info')
}
/**
* 更新用户信息
* @param {Object} data 用户信息
* @param {String} data.nickname 昵称
* @param {String} data.avatar 头像URL
*/
export function updateUserInfo(data) {
return request.post('/water/app/user/update', data)
}
/**
* 充值余额
* @param {Object} data 充值信息
* @param {Number} data.amount 充值金额
* @param {String} data.paymentMethod 支付方式wechat/alipay
*/
export function recharge(data) {
return request.post('/water/app/user/recharge', data, {
showLoading: true,
loadingText: '充值中...'
})
}
/**
* 发送手机号验证码
* @param {String} phone 手机号
*/
export function sendPhoneCode(phone) {
return request.post('/water/app/user/sendPhoneCode', { phone }, {
showLoading: true,
loadingText: '发送中...'
})
}
/**
* 绑定手机号
* @param {Object} data 绑定信息
* @param {String} data.phone 手机号
* @param {String} data.code 验证码
*/
export function bindPhone(data) {
return request.post('/water/app/user/bindPhone', data, {
showLoading: true,
loadingText: '绑定中...'
})
}

@ -0,0 +1,117 @@
<!--
小程序 API 使用示例
这个文件展示了如何在页面中使用 API 接口
-->
<template>
<view class="container">
<!-- 登录示例 -->
<view class="section">
<text class="title">登录示例</text>
<button @click="handleUserLogin"></button>
<button @click="handleStaffLogin"></button>
</view>
<!-- 设备列表示例 -->
<view class="section">
<text class="title">设备列表</text>
<button @click="loadDevices"></button>
<view v-for="device in devices" :key="device.deviceId" class="item">
<text>{{ device.deviceName }}</text>
</view>
</view>
<!-- 订单列表示例 -->
<view class="section">
<text class="title">订单列表</text>
<button @click="loadOrders"></button>
<view v-for="order in orders" :key="order.orderId" class="item">
<text>{{ order.orderNo }}</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { userLogin, staffLogin } from '../api/auth.js'
import { getDeviceList } from '../api/device.js'
import { getOrderList } from '../api/order.js'
//
const devices = ref([])
const orders = ref([])
//
async function handleUserLogin() {
try {
const res = await userLogin('12345678', 'wgll')
console.log('登录成功', res)
uni.showToast({
title: '登录成功',
icon: 'success'
})
} catch (error) {
console.error('登录失败', error)
}
}
//
async function handleStaffLogin() {
try {
const res = await staffLogin('202326010123', 'lcxtangshi')
console.log('登录成功', res)
uni.showToast({
title: '登录成功',
icon: 'success'
})
} catch (error) {
console.error('登录失败', error)
}
}
//
async function loadDevices() {
try {
devices.value = await getDeviceList()
console.log('设备列表', devices.value)
} catch (error) {
console.error('加载设备失败', error)
}
}
//
async function loadOrders() {
try {
orders.value = await getOrderList({ status: 'pending' })
console.log('订单列表', orders.value)
} catch (error) {
console.error('加载订单失败', error)
}
}
</script>
<style scoped>
.container {
padding: 20rpx;
}
.section {
margin-bottom: 40rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
display: block;
}
.item {
padding: 20rpx;
background-color: #f5f5f5;
margin-bottom: 10rpx;
border-radius: 8rpx;
}
</style>

@ -0,0 +1,273 @@
# 基础 UI 组件使用文档
本目录包含小程序的基础 UI 组件,用于提高代码复用性和维护性。
## 组件列表
### 1. EmptyState 空状态组件
用于显示列表为空时的提示。
**位置**: `components/empty-state/empty-state.vue`
**Props**:
- `icon` (String, 默认: '📋'): 图标emoji或文字
- `text` (String, 默认: '暂无数据'): 提示文字
**使用示例**:
```vue
<template>
<view>
<empty-state icon="📋" text="暂无订单" />
<!-- 自定义内容 -->
<empty-state text="暂无设备">
<button @click="addDevice">添加设备</button>
</empty-state>
</view>
</template>
<script setup>
import EmptyState from '@/components/empty-state/empty-state.vue'
</script>
```
---
### 2. Loading 加载组件
用于显示加载状态。
**位置**: `components/loading/loading.vue`
**Props**:
- `visible` (Boolean, 默认: false): 是否显示
- `text` (String, 默认: '加载中...'): 加载提示文字
**使用示例**:
```vue
<template>
<view>
<loading :visible="loading" text="加载中..." />
</view>
</template>
<script setup>
import { ref } from 'vue'
import Loading from '@/components/loading/loading.vue'
const loading = ref(false)
</script>
```
---
### 3. StatusTag 状态标签组件
用于显示订单、工单等状态标签。
**位置**: `components/status-tag/status-tag.vue`
**Props**:
- `status` (String, 必需): 状态值
- 订单状态: `unpaid`, `pending`, `accepted`, `processing`, `delivering`, `completed`, `cancelled`
- 工单状态: `pending`, `accepted`, `processing`, `completed`
- 优先级: `high`, `normal`
- `text` (String, 可选): 显示文字(不传则使用默认映射)
- `type` (String, 默认: 'default'): 标签类型 (`order`, `workorder`, `priority`, `default`)
**使用示例**:
```vue
<template>
<view>
<!-- 订单状态 -->
<status-tag type="order" status="unpaid" />
<status-tag type="order" status="completed" text="已完成" />
<!-- 工单状态 -->
<status-tag type="workorder" status="pending" />
<!-- 优先级 -->
<status-tag type="priority" status="high" />
<!-- 自定义文字 -->
<status-tag status="pending" text="待处理中" />
</view>
</template>
<script setup>
import StatusTag from '@/components/status-tag/status-tag.vue'
</script>
```
**状态文本映射**:
- `unpaid` → 待支付
- `pending` → 待处理
- `accepted` → 已接单
- `processing` → 处理中
- `delivering` → 配送中
- `completed` → 已完成
- `cancelled` → 已取消
- `high` → 紧急
- `normal` → 普通
---
### 4. StatsCard 统计卡片组件
用于显示统计数据卡片。
**位置**: `components/stats-card/stats-card.vue`
**Props**:
- `items` (Array, 必需): 统计数据数组
- 每个元素格式: `{ value: String, label: String, clickable: Boolean? }`
- `showDivider` (Boolean, 默认: true): 是否显示分割线
**Events**:
- `itemClick`: 点击可点击项时触发,参数 `(item, index)`
**使用示例**:
```vue
<template>
<view>
<stats-card
:items="stats"
:show-divider="true"
@item-click="handleStatClick"
/>
</view>
</template>
<script setup>
import { ref } from 'vue'
import StatsCard from '@/components/stats-card/stats-card.vue'
const stats = ref([
{ value: '12', label: '待处理工单' },
{ value: '8', label: '今日完成' },
{ value: '256', label: '在线用户', clickable: true }
])
const handleStatClick = (item, index) => {
console.log('点击了统计项:', item, index)
}
</script>
```
---
## 在 pages.json 中注册组件(可选)
如果要在全局使用这些组件,可以在 `pages.json``easycom` 中配置:
```json
{
"easycom": {
"autoscan": true,
"custom": {
"^empty-state$": "@/components/empty-state/empty-state.vue",
"^loading$": "@/components/loading/loading.vue",
"^status-tag$": "@/components/status-tag/status-tag.vue",
"^stats-card$": "@/components/stats-card/stats-card.vue"
}
}
}
```
配置后可以直接在模板中使用,无需导入:
```vue
<template>
<empty-state text="暂无数据" />
</template>
```
---
## 迁移指南
### 替换 EmptyState
**之前**:
```vue
<view v-if="list.length === 0" class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无数据</text>
</view>
```
**之后**:
```vue
<empty-state v-if="list.length === 0" icon="📋" text="暂无数据" />
```
### 替换 Loading
**之前**:
```vue
<view v-if="loading" class="loading-wrapper">
<text class="loading-text">加载中...</text>
</view>
```
**之后**:
```vue
<loading :visible="loading" text="加载中..." />
```
### 替换 StatusTag
**之前**:
```vue
<text class="order-status" :class="getStatusClass(order.status)">
{{ order.statusText }}
</text>
```
**之后**:
```vue
<status-tag type="order" :status="order.status" />
```
### 替换 StatsCard
**之前**:
```vue
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">12</text>
<text class="stat-label">待处理</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">8</text>
<text class="stat-label">已完成</text>
</view>
</view>
```
**之后**:
```vue
<stats-card :items="[
{ value: '12', label: '待处理' },
{ value: '8', label: '已完成' }
]" />
```
---
## 注意事项
1. 所有组件使用 Vue 3 Composition API
2. 组件样式使用 scoped避免样式污染
3. 组件设计遵循 uni-app 规范
4. 建议按需导入,减少包体积
---
**最后更新**: 2025-12-19

@ -0,0 +1,48 @@
<template>
<view class="empty-state">
<text v-if="icon" class="empty-icon">{{ icon }}</text>
<text class="empty-text">{{ text || '暂无数据' }}</text>
<slot></slot>
</view>
</template>
<script setup>
/**
* 空状态组件
* 用于显示列表为空时的提示
*
* @props {String} icon - 图标emoji或文字
* @props {String} text - 提示文字
*/
defineProps({
icon: {
type: String,
default: '📋'
},
text: {
type: String,
default: '暂无数据'
}
})
</script>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
</style>

@ -0,0 +1,39 @@
<template>
<view v-if="visible" class="loading-wrapper">
<text class="loading-text">{{ text }}</text>
</view>
</template>
<script setup>
/**
* 加载组件
* 用于显示加载状态
*
* @props {Boolean} visible - 是否显示
* @props {String} text - 加载提示文字
*/
defineProps({
visible: {
type: Boolean,
default: false
},
text: {
type: String,
default: '加载中...'
}
})
</script>
<style scoped>
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
</style>

@ -0,0 +1,97 @@
<template>
<view class="stats-card">
<template v-for="(item, index) in items" :key="index">
<view
class="stat-item"
:class="{ 'clickable': item.clickable }"
@click="handleItemClick(item, index)"
>
<text class="stat-value">{{ item.value }}</text>
<text class="stat-label">{{ item.label }}</text>
</view>
<!-- 分割线 -->
<view
v-if="showDivider && index < items.length - 1"
class="stat-divider"
></view>
</template>
</view>
</template>
<script setup>
/**
* 统计卡片组件
* 用于显示统计数据
*
* @props {Array} items - 统计数据数组每个元素包含 {value: String, label: String, clickable: Boolean}
* @props {Boolean} showDivider - 是否显示分割线
* @emits {itemClick} 点击事件返回 (item, index)
*/
const props = defineProps({
items: {
type: Array,
required: true,
default: () => []
},
showDivider: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['itemClick'])
const handleItemClick = (item, index) => {
if (item.clickable) {
emit('itemClick', item, index)
}
}
</script>
<style scoped>
.stats-card {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin: 0 30rpx 30rpx;
display: flex;
align-items: center;
justify-content: space-around;
position: relative;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stat-item.clickable {
cursor: pointer;
}
.stat-item.clickable:active {
opacity: 0.7;
}
.stat-value {
font-size: 36rpx;
font-weight: 600;
color: #303133;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 24rpx;
color: #909399;
}
.stat-divider {
width: 1rpx;
height: 60rpx;
background: #e4e7ed;
flex-shrink: 0;
}
</style>

@ -0,0 +1,156 @@
<template>
<text class="status-tag" :class="statusClass">
{{ text }}
</text>
</template>
<script setup>
import { computed } from 'vue'
/**
* 状态标签组件
* 用于显示订单工单等状态
*
* @props {String} status - 状态值pending/accepted/processing/completed/cancelled/unpaid/high/normal等
* @props {String} text - 显示文字
* @props {String} type - 标签类型order/workorder/priority/default
*/
const props = defineProps({
status: {
type: String,
required: true
},
text: {
type: String,
default: ''
},
type: {
type: String,
default: 'default',
validator: (value) => ['order', 'workorder', 'priority', 'default'].includes(value)
}
})
//
const statusTextMap = {
//
unpaid: '待支付',
pending: '待处理',
accepted: '已接单',
processing: '处理中',
delivering: '配送中',
completed: '已完成',
cancelled: '已取消',
//
high: '紧急',
normal: '普通'
}
//
const statusText = computed(() => {
return props.text || statusTextMap[props.status] || props.status
})
//
const statusClass = computed(() => {
const baseClass = `status-${props.status}`
const typeClass = `type-${props.type}`
return [baseClass, typeClass]
})
</script>
<style scoped>
.status-tag {
display: inline-block;
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
font-weight: 500;
}
/* 订单状态样式 */
.type-order.status-unpaid,
.type-order.status-pending {
background: #fef0f0;
color: #e6a23c;
}
.type-order.status-processing,
.type-order.status-accepted {
background: #ecf5ff;
color: #409eff;
}
.type-order.status-delivering {
background: #f0f9ff;
color: #67c23a;
}
.type-order.status-completed {
background: #f0f9ff;
color: #67c23a;
}
.type-order.status-cancelled {
background: #f4f4f5;
color: #909399;
}
/* 工单状态样式 */
.type-workorder.status-pending {
background: #fef0f0;
color: #e6a23c;
}
.type-workorder.status-accepted {
background: #ecf5ff;
color: #409eff;
}
.type-workorder.status-processing {
background: #f0f9ff;
color: #409eff;
}
.type-workorder.status-completed {
background: #f0f9ff;
color: #67c23a;
}
/* 优先级样式 */
.type-priority.status-high {
background: #fef0f0;
color: #f56c6c;
}
.type-priority.status-normal {
background: #f0f9ff;
color: #409eff;
}
/* 默认样式兼容旧的status-xxx类名 */
.status-pending {
background: #fef0f0;
color: #e6a23c;
}
.status-accepted {
background: #ecf5ff;
color: #409eff;
}
.status-processing {
background: #f0f9ff;
color: #409eff;
}
.status-completed {
background: #f0f9ff;
color: #67c23a;
}
.status-cancelled {
background: #f4f4f5;
color: #909399;
}
</style>

@ -0,0 +1,448 @@
# 数智水管家数据库 ER 关系图
## 📊 完整 ER 图
```mermaid
erDiagram
%% 用户相关
water_user ||--o{ water_device : "拥有"
water_user ||--o{ water_service_order : "创建"
water_user ||--o{ water_evaluation : "评价"
water_user ||--o{ water_message : "接收"
water_user ||--o{ water_quality : "关联"
%% 员工相关
water_staff ||--o{ water_service_order : "处理"
water_staff ||--o{ water_evaluation : "被评价"
water_staff ||--o{ water_message : "接收"
%% 设备相关
water_device ||--o{ water_filter : "包含"
water_device ||--o{ water_quality : "检测"
%% 订单相关
water_service_order ||--o{ water_order_product : "包含"
water_service_order ||--o| water_evaluation : "评价"
water_service_order ||--o| water_quality : "关联"
%% 商品相关
water_product ||--o{ water_order_product : "被订购"
%% 用户表
water_user {
bigint user_id PK "用户ID"
varchar phone UK "手机号"
varchar password "密码"
varchar nick_name "昵称"
varchar avatar "头像"
char gender "性别"
varchar openid UK "微信openid"
decimal balance "余额"
int total_orders "订单总数"
char status "状态"
}
%% 员工表
water_staff {
bigint staff_id PK "员工ID"
bigint user_id FK "关联sys_user"
varchar employee_no UK "工号"
varchar staff_name "姓名"
varchar phone UK "手机号"
bigint dept_id FK "部门ID"
varchar position "职位"
int total_orders "工单总数"
int completed_orders "完成数"
decimal avg_rating "平均评分"
char status "状态"
}
%% 设备表
water_device {
bigint device_id PK "设备ID"
bigint user_id FK "用户ID"
varchar sn UK "设备编号"
varchar device_name "设备名称"
varchar device_type "设备类型"
char status "状态"
varchar install_address "安装地址"
decimal location_lat "纬度"
decimal location_lng "经度"
datetime bind_time "绑定时间"
}
%% 滤芯表
water_filter {
bigint filter_id PK "滤芯ID"
bigint device_id FK "设备ID"
varchar filter_name "滤芯名称"
varchar model "型号"
datetime install_time "安装时间"
int total_days "总天数"
int used_days "已用天数"
int life_percentage "剩余百分比"
date next_replace_time "预计更换时间"
char status "状态"
}
%% 水质检测表
water_quality {
bigint quality_id PK "检测ID"
bigint device_id FK "设备ID"
bigint user_id FK "用户ID"
bigint order_id FK "订单ID"
datetime test_time "检测时间"
varchar quality_level "水质等级"
decimal ph_value "pH值"
decimal turbidity "浊度"
decimal residual_chlorine "余氯"
decimal total_hardness "总硬度"
decimal tds "TDS"
char status "状态"
}
%% 商品表
water_product {
bigint product_id PK "商品ID"
varchar product_name "商品名称"
varchar product_desc "商品描述"
varchar category "商品分类"
decimal price "价格"
int stock "库存"
varchar icon "图标"
char status "状态"
}
%% 服务订单表(核心)
water_service_order {
bigint order_id PK "订单ID"
varchar order_no UK "订单号"
bigint user_id FK "用户ID"
bigint staff_id FK "员工ID"
varchar service_type "服务类型"
varchar order_source "订单来源"
varchar order_status "订单状态"
varchar payment_status "支付状态"
decimal amount "订单金额"
decimal actual_amount "实付金额"
varchar payment_method "支付方式"
varchar contact_name "联系人"
varchar contact_phone "联系电话"
varchar service_address "服务地址"
varchar priority "优先级"
datetime submit_time "提交时间"
datetime accept_time "接单时间"
datetime complete_time "完成时间"
}
%% 订单商品关联表
water_order_product {
bigint id PK "主键ID"
bigint order_id FK "订单ID"
bigint product_id FK "商品ID"
varchar product_name "商品名称"
decimal product_price "商品单价"
int quantity "数量"
decimal total_price "小计"
}
%% 订单评价表
water_evaluation {
bigint evaluation_id PK "评价ID"
bigint order_id FK UK "订单ID"
bigint user_id FK "用户ID"
bigint staff_id FK "员工ID"
decimal rating "综合评分"
decimal service_rating "服务态度"
decimal quality_rating "服务质量"
decimal speed_rating "服务速度"
text content "评价内容"
char is_anonymous "是否匿名"
}
%% 系统消息表
water_message {
bigint message_id PK "消息ID"
bigint user_id FK "用户ID"
bigint staff_id FK "员工ID"
varchar message_type "消息类型"
varchar title "标题"
text content "内容"
bigint related_id "关联ID"
char is_read "是否已读"
datetime send_time "发送时间"
}
%% 通知公告表
water_notice {
bigint notice_id PK "公告ID"
varchar notice_title "标题"
text notice_content "内容"
varchar notice_type "类型"
varchar target_type "目标对象"
char status "状态"
datetime publish_time "发布时间"
}
%% 系统配置表
water_config {
bigint config_id PK "配置ID"
varchar config_key UK "配置键"
varchar config_value "配置值"
varchar config_type "配置类型"
varchar config_desc "配置描述"
}
```
## 🔗 核心关联关系
### 1. 用户中心
```
用户 (water_user)
├── 1:N → 设备 (water_device)
│ └── 1:N → 滤芯 (water_filter)
│ └── 1:N → 水质检测 (water_quality)
├── 1:N → 订单 (water_service_order)
│ └── 1:N → 订单商品 (water_order_product)
│ └── 1:1 → 评价 (water_evaluation)
└── 1:N → 消息 (water_message)
```
### 2. 员工中心
```
员工 (water_staff)
├── 1:N → 工单 (water_service_order)
│ └── 1:1 → 评价 (water_evaluation)
└── 1:N → 消息 (water_message)
```
### 3. 订单中心
```
订单 (water_service_order)
├── N:1 → 用户 (water_user)
├── N:1 → 员工 (water_staff)
├── 1:N → 订单商品 (water_order_product)
│ └── N:1 → 商品 (water_product)
├── 1:1 → 评价 (water_evaluation)
└── 1:1 → 水质检测 (water_quality) [可选]
```
## 📋 业务流程图
### 订单流程
```mermaid
graph LR
A[用户下单] --> B[创建订单]
B --> C{需要支付?}
C -->|是| D[待支付]
C -->|否| E[已支付]
D --> F[用户支付]
F --> E
E --> G[员工接单]
G --> H[处理中]
H --> I[完成]
I --> J[用户评价]
style A fill:#e1f5ff
style B fill:#fff4e1
style E fill:#d4edda
style I fill:#d1ecf1
```
### 设备绑定流程
```mermaid
graph TD
A[用户扫描设备二维码] --> B[获取设备SN]
B --> C{设备是否存在?}
C -->|否| D[提示设备不存在]
C -->|是| E{设备是否已绑定?}
E -->|是| F[提示已被绑定]
E -->|否| G[绑定设备]
G --> H[创建滤芯记录]
H --> I[绑定成功]
style A fill:#e1f5ff
style G fill:#d4edda
style I fill:#d1ecf1
```
### 水质检测流程
```mermaid
graph LR
A[用户申请检测] --> B[创建检测订单]
B --> C[员工接单]
C --> D[上门检测]
D --> E[录入检测数据]
E --> F[生成检测报告]
F --> G[推送消息通知]
G --> H[用户查看报告]
style A fill:#e1f5ff
style F fill:#d4edda
style H fill:#d1ecf1
```
## 🎯 索引关系图
### 主要索引
```
water_user
├── PRIMARY KEY (user_id)
├── UNIQUE KEY (phone)
└── UNIQUE KEY (openid)
water_device
├── PRIMARY KEY (device_id)
├── UNIQUE KEY (sn)
├── INDEX (user_id)
└── INDEX (status)
water_service_order
├── PRIMARY KEY (order_id)
├── UNIQUE KEY (order_no)
├── INDEX (user_id)
├── INDEX (staff_id)
├── INDEX (order_status)
└── COMPOSITE INDEX (staff_id, order_status)
water_filter
├── PRIMARY KEY (filter_id)
├── INDEX (device_id)
└── INDEX (life_percentage)
```
## 🔄 数据流向图
```mermaid
graph TD
subgraph "用户端"
U1[用户注册/登录]
U2[绑定设备]
U3[查看设备状态]
U4[下单购买]
U5[评价服务]
end
subgraph "核心数据层"
D1[(water_user)]
D2[(water_device)]
D3[(water_filter)]
D4[(water_service_order)]
D5[(water_evaluation)]
D6[(water_quality)]
end
subgraph "员工端"
S1[员工登录]
S2[查看工单]
S3[接单处理]
S4[完成服务]
S5[查看绩效]
end
U1 --> D1
U2 --> D2
U3 --> D3
U4 --> D4
U5 --> D5
D1 --> S2
D4 --> S2
S3 --> D4
S4 --> D6
D5 --> S5
style D1 fill:#ffd4d4
style D2 fill:#ffe4d4
style D3 fill:#fff4d4
style D4 fill:#d4f4ff
style D5 fill:#e4d4ff
style D6 fill:#d4ffe4
```
## 📊 数据统计关系
### 视图:员工绩效
```sql
v_staff_performance
├── 数据来源
│ ├── water_staff (基础信息)
│ ├── water_service_order (订单统计)
│ └── water_evaluation (评价统计)
└── 统计指标
├── 总订单数 (total_orders)
├── 完成订单数 (completed_orders)
├── 完成率 (completion_rate)
├── 平均评分 (avg_rating)
└── 平均时长 (avg_duration)
```
### 视图:订单统计
```sql
v_order_statistics
├── 数据来源
│ └── water_service_order
└── 统计维度
├── 按日期 (order_date)
├── 按服务类型 (service_type)
├── 按订单状态 (order_status)
└── 按订单来源 (order_source)
```
## 💡 设计说明
### 1. 为什么使用统一订单表?
**优点**
- ✅ 减少数据冗余
- ✅ 统一状态管理
- ✅ 简化业务逻辑
- ✅ 便于统计分析
**实现方式**
- 通过 `order_source` 区分来源(用户/后台)
- 通过 `service_type` 区分服务类型
- 通过 `order_status` 管理流程状态
### 2. 如何保证数据一致性?
**策略**
- ✅ 使用外键约束(可选)
- ✅ 使用唯一索引防止重复
- ✅ 使用事务保证原子性
- ✅ 使用触发器自动更新冗余字段(可选)
### 3. 如何优化查询性能?
**措施**
- ✅ 合理设计索引(单列索引 + 联合索引)
- ✅ 使用 Redis 缓存热点数据
- ✅ 使用视图简化复杂查询
- ✅ 使用分页查询减少数据量
- ✅ 使用批量操作减少IO
---
**文档版本**v2.0
**更新日期**2025-11-13
**工具推荐**dbdiagram.io, draw.io, Navicat
## 🛠️ 在线查看 ER 图
推荐使用以下工具在线查看和编辑 ER 图:
1. **Mermaid Live Editor**: https://mermaid.live
2. **dbdiagram.io**: https://dbdiagram.io
3. **draw.io**: https://app.diagrams.net
将上述 Mermaid 代码复制到 Mermaid Live Editor 即可查看交互式 ER 图。

@ -0,0 +1,349 @@
# RuoYi 连接 MySQL 8.0 服务器配置指南
## 📋 服务器信息
- **服务器公网 IP**`47.122.2.5`
- **服务器内网 IP**`172.18.40.251`
- **数据库端口**`3306`
- **数据库名称**`water_manager`
- **应用用户**`water_app` / `WaterApp@2024`
- **只读用户**`water_readonly` / `WaterReadonly@2024`
- **Root 用户**`root` / `WaterManager@2024`(仅管理员使用)
## 🔧 配置步骤
### 1. 修改 RuoYi 数据库配置
**文件位置**`ruoyi-admin/src/main/resources/application.yml` 或 `ruoyi-admin/src/main/resources/application-druid.yml`
#### 方式一:使用公网地址(本地开发推荐)
```yaml
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://47.122.2.5:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
```
#### 方式二:使用内网地址(如果 RuoYi 部署在同一服务器)
```yaml
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
master:
url: jdbc:mysql://172.18.40.251:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
```
### 2. 关键配置参数说明
| 参数 | 说明 | 必需性 |
|------|------|--------|
| `com.mysql.cj.jdbc.Driver` | MySQL 8.0 必须使用此驱动 | ✅ 必需 |
| `allowPublicKeyRetrieval=true` | 允许客户端从服务器获取公钥MySQL 8.0 需要) | ✅ 必需 |
| `useSSL=true/false` | 是否使用 SSL公网建议 true内网可用 false | ⚠️ 建议 |
| `serverTimezone=GMT%2B8` | 时区设置GMT+8即 Asia/Shanghai | ✅ 必需 |
| `useUnicode=true` | 使用 Unicode 编码 | ✅ 必需 |
| `characterEncoding=utf8` | 字符编码 | ✅ 必需 |
### 3. 完整配置示例
**文件**`ruoyi-admin/src/main/resources/application-druid.yml`
```yaml
# 数据源配置
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
url: jdbc:mysql://47.122.2.5:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
username: water_app
password: WaterApp@2024
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
# 打开 PSCache并且指定每个连接上 PSCache 的大小
poolPreparedStatements: true
maxPoolPreparedStatementPerConnectionSize: 20
# 配置监控统计拦截的 filters去掉后监控界面 SQL 无法统计,'wall' 用于防火墙
filters: stat,wall,slf4j
# 通过 connectProperties 属性来打开 mergeSql 功能;慢 SQL 记录
connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
# 配置 DruidStatFilter
web-stat-filter:
enabled: true
# 配置 DruidStatViewServlet
stat-view-servlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123
```
## 🔍 验证连接
### 1. 启动 RuoYi 项目
```bash
# 在 RuoYi 项目根目录
mvn clean install
cd ruoyi-admin
mvn spring-boot:run
```
### 2. 检查连接日志
启动时查看日志,应该看到:
```
Loading class `com.mysql.cj.jdbc.Driver'. This is deprecated...
Creating a new instance of com.mysql.cj.jdbc.Driver...
```
### 3. 访问 Druid 监控页面
```
http://localhost:8080/druid/index.html
```
在监控页面可以看到:
- 数据源连接数
- SQL 统计
- 连接池状态
### 4. 测试数据库连接
在 RuoYi 后台管理系统中:
1. 登录系统管理
2. 查看数据库连接状态
3. 执行简单的查询测试
## 🐛 常见问题
### 问题1连接失败 "Public Key Retrieval is not allowed"
**错误信息**
```
Public Key Retrieval is not allowed
```
**解决方案**
在连接 URL 中添加 `allowPublicKeyRetrieval=true`
```yaml
url: jdbc:mysql://47.122.2.5:3306/water_manager?allowPublicKeyRetrieval=true&...
```
### 问题2时区错误
**错误信息**
```
The server time zone value 'CST' is unrecognized
```
**解决方案**
在连接 URL 中添加时区参数:
```yaml
url: jdbc:mysql://47.122.2.5:3306/water_manager?serverTimezone=GMT%2B8&...
```
### 问题3驱动类找不到
**错误信息**
```
Could not load JDBC driver class [com.mysql.jdbc.Driver]
```
**解决方案**
1. 确保使用 MySQL 8.0 驱动:`com.mysql.cj.jdbc.Driver`
2. 检查 `pom.xml` 中的 MySQL 驱动版本:
```xml
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
```
### 问题4无法连接到服务器
**错误信息**
```
Communications link failure
```
**解决方案**
1. **检查防火墙**
```bash
# 在服务器上检查防火墙
firewall-cmd --list-all
# 确保 3306 端口已开放
```
2. **检查 MySQL 用户权限**
```sql
-- 在服务器上执行
SELECT user, host FROM mysql.user WHERE user='water_app';
-- 确保允许从你的 IP 连接
```
3. **测试网络连接**
```bash
# 在本地测试
telnet 47.122.2.5 3306
# 或
nc -zv 47.122.2.5 3306
```
4. **配置服务器安全组**(阿里云):
- 登录阿里云控制台
- 进入 ECS 实例
- 配置安全组规则,开放 3306 端口
- 添加你的本地 IP 到白名单
### 问题5认证插件错误
**错误信息**
```
Access denied for user 'water_app'@'xxx' (using password: YES)
```
**解决方案**
1. **检查用户认证插件**
```sql
-- 在服务器上执行
SELECT user, host, plugin FROM mysql.user WHERE user='water_app';
```
2. **如果使用的是 caching_sha2_password修改为 mysql_native_password**
```sql
ALTER USER 'water_app'@'%' IDENTIFIED WITH mysql_native_password BY 'WaterApp@2024';
FLUSH PRIVILEGES;
```
## 📝 多环境配置
### 开发环境(本地连接服务器)
**application-dev.yml**
```yaml
spring:
datasource:
druid:
master:
url: jdbc:mysql://47.122.2.5:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
```
### 生产环境(服务器内网)
**application-prod.yml**
```yaml
spring:
datasource:
druid:
master:
url: jdbc:mysql://172.18.40.251:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
```
## 🔐 安全建议
1. **使用应用用户**:不要使用 root 用户连接
2. **密码加密**:生产环境使用配置中心或环境变量管理密码
3. **IP 白名单**:配置服务器安全组,仅允许必要 IP 访问
4. **SSL 连接**:生产环境建议启用 SSL
5. **连接池限制**:合理配置连接池大小,避免连接数过多
## ✅ 连接测试 SQL
连接成功后,可以执行以下 SQL 验证:
```sql
-- 查看数据库
SHOW DATABASES;
-- 查看表
USE water_manager;
SHOW TABLES;
-- 查看用户权限
SHOW GRANTS FOR 'water_app'@'%';
-- 测试查询
SELECT COUNT(*) FROM water_user;
SELECT COUNT(*) FROM water_device;
```
## 📚 相关文档
- [MySQL8.0部署说明.md](./MySQL8.0部署说明.md)
- [数据库设计说明_v2.0.md](./数据库设计说明_v2.0.md)
- [部署说明.md](./部署说明.md)
---
**最后更新**2025-11-13

@ -0,0 +1,364 @@
# 数智水管家 Web 管理后台需求文档
> 基于 RuoYi 框架的管理后台系统
## 1. 项目概述
### 1.1 项目背景
数智水管家 Web 管理后台是基于 RuoYi 框架开发的后台管理系统,用于管理小程序用户、设备、订单、工单等核心业务数据,为管理员和工作人员提供数据管理和业务处理能力。
### 1.2 技术架构
- **后端框架**RuoYi v4.x+
- **前端框架**Vue 2.x + Element UI
- **数据库**MySQL 8.0
- **服务器**47.122.2.5172.18.40.251
## 2. 用户角色
### 2.1 角色定义
| 角色 | 权限范围 | 主要功能 |
|------|----------|----------|
| **超级管理员** | 全部权限 | 系统配置、用户管理、数据统计 |
| **业务管理员** | 业务数据管理 | 订单管理、工单管理、商品管理 |
| **客服人员** | 客服相关功能 | 消息管理、订单处理、用户咨询 |
| **运营人员** | 内容管理 | 公告管理、商品管理、数据统计 |
## 3. 功能模块
### 3.1 系统管理RuoYi 原有功能)
- 用户管理:系统用户账号管理
- 角色管理:角色权限配置
- 菜单管理:菜单权限配置
- 部门管理:组织架构管理
- 岗位管理:岗位信息管理
- 字典管理:系统字典维护
- 参数设置:系统参数配置
- 通知公告:系统公告发布
- 操作日志:系统操作记录
- 登录日志:登录记录查询
### 3.2 用户管理
**功能描述**:管理小程序用户信息
**主要功能**
- 用户列表查询(支持手机号、昵称搜索)
- 用户详情查看(基本信息、设备列表、订单统计)
- 用户状态管理(启用/停用)
- 用户余额管理(充值、扣减)
- 用户设备绑定记录
- 用户订单历史查询
**数据来源**`water_user` 表
### 3.3 员工管理
**功能描述**:管理工作人员信息
**主要功能**
- 员工列表查询(支持工号、姓名搜索)
- 员工信息维护(基本信息、部门、职位)
- 员工状态管理(启用/停用)
- 员工绩效查看(工单统计、评价统计)
- 员工工单列表
- 员工配送任务列表
**数据来源**`water_staff` 表,关联 RuoYi `sys_user``sys_dept`
### 3.4 设备管理
**功能描述**:管理净水器设备信息
**主要功能**
- 设备列表查询(支持设备编号、用户、状态筛选)
- 设备详情查看(基本信息、滤芯状态、水质记录)
- 设备状态监控(在线/离线/故障)
- 设备解绑操作
- 滤芯管理(查看滤芯寿命、更换记录)
- 设备预警管理(离线设备、滤芯预警)
**数据来源**`water_device`、`water_filter` 表
### 3.5 订单管理
**功能描述**:统一管理用户订单和工单
**主要功能**
- 订单列表查询(支持订单号、用户、状态、服务类型筛选)
- 订单详情查看(订单信息、商品明细、状态流转记录)
- 订单状态管理(待支付、已支付、处理中、已完成、已取消)
- 订单派单(分配工作人员)
- 订单取消(退款处理)
- 订单统计(按日期、类型、状态统计)
**数据来源**`water_service_order`、`water_order_product` 表
### 3.6 工单管理
**功能描述**:管理工作人员工单
**主要功能**
- 工单列表查询(支持工单号、状态、优先级、工作人员筛选)
- 工单详情查看(工单信息、处理记录、评价信息)
- 工单派单(分配工作人员)
- 工单状态跟踪(待接单、已接单、处理中、已完成)
- 工单优先级管理(普通/紧急)
- 工单统计(按工作人员、状态、类型统计)
**数据来源**`water_service_order` 表(`order_source='admin'` 或 `service_type` 为服务类型)
### 3.7 配送管理
**功能描述**:管理配送任务
**主要功能**
- 配送任务列表(支持工作人员、状态筛选)
- 配送任务详情(配送信息、距离、时长)
- 配送任务分配(分配配送人员)
- 配送状态跟踪(待配送、配送中、已完成)
- 配送统计(配送效率、距离统计)
**数据来源**`water_service_order` 表(特定服务类型)
### 3.8 商品管理
**功能描述**:管理可订购的商品
**主要功能**
- 商品列表查询(支持分类、状态筛选)
- 商品信息维护(名称、描述、价格、库存、图片)
- 商品分类管理(净水器、水表、配件、服务)
- 商品上架/下架
- 商品排序管理
- 商品销售统计
**数据来源**`water_product` 表
### 3.9 水质检测管理
**功能描述**:管理水质检测记录
**主要功能**
- 检测记录列表(支持设备、用户、时间筛选)
- 检测记录详情(检测指标、水质等级、报告)
- 检测报告查看/下载
- 水质预警管理(异常水质记录)
- 检测统计(按设备、时间统计)
**数据来源**`water_quality` 表
### 3.10 消息管理
**功能描述**:管理系统消息
**主要功能**
- 消息列表查询(支持用户、类型、状态筛选)
- 消息发送(单发、群发)
- 消息状态管理(已读/未读)
- 消息类型管理(订单、设备、系统、服务)
- 消息统计(发送量、阅读率)
**数据来源**`water_message` 表
### 3.11 公告管理
**功能描述**:管理通知公告
**主要功能**
- 公告列表查询(支持类型、状态筛选)
- 公告发布(标题、内容、类型、目标对象)
- 公告状态管理(发布/草稿/下架)
- 公告过期管理(自动下架)
- 公告类型管理(通知/警告/紧急)
**数据来源**`water_notice` 表
### 3.12 评价管理
**功能描述**:管理订单评价
**主要功能**
- 评价列表查询(支持订单、用户、工作人员、评分筛选)
- 评价详情查看(评分、内容、图片)
- 评价回复功能
- 评价统计(平均评分、评价数量)
- 工作人员评价统计
**数据来源**`water_evaluation` 表
### 3.13 数据统计
**功能描述**:数据统计和报表
**主要功能**
- **数据看板**:关键指标展示(用户数、设备数、订单数、工单数)
- **订单统计**:按日期、类型、状态统计订单数据
- **工单统计**:按工作人员、状态、类型统计工单数据
- **员工绩效**:员工工单完成率、平均评分、服务时长统计
- **设备统计**:设备在线率、故障率、滤芯更换统计
- **水质统计**:水质检测次数、异常率统计
- **数据导出**:支持 Excel 导出
**数据来源**:各业务表 + `v_staff_performance`、`v_order_statistics` 视图
## 4. 页面结构
### 4.1 菜单结构
```
数智水管家
├─ 系统管理RuoYi 原有)
│ ├─ 用户管理
│ ├─ 角色管理
│ ├─ 菜单管理
│ └─ ...
├─ 用户管理
│ ├─ 用户列表
│ └─ 用户详情
├─ 员工管理
│ ├─ 员工列表
│ ├─ 员工详情
│ └─ 员工绩效
├─ 设备管理
│ ├─ 设备列表
│ ├─ 设备详情
│ └─ 设备预警
├─ 订单管理
│ ├─ 订单列表
│ ├─ 订单详情
│ └─ 订单统计
├─ 工单管理
│ ├─ 工单列表
│ ├─ 工单详情
│ └─ 工单统计
├─ 配送管理
│ ├─ 配送任务列表
│ └─ 配送统计
├─ 商品管理
│ ├─ 商品列表
│ └─ 商品分类
├─ 水质检测
│ ├─ 检测记录列表
│ └─ 水质预警
├─ 消息管理
│ ├─ 消息列表
│ └─ 消息发送
├─ 公告管理
│ └─ 公告列表
├─ 评价管理
│ └─ 评价列表
└─ 数据统计
├─ 数据看板
├─ 订单统计
├─ 工单统计
└─ 员工绩效
```
## 5. 核心业务流程
### 5.1 订单处理流程
1. **订单创建**:用户在小程序下单
2. **订单审核**:管理员查看订单详情
3. **订单派单**:分配工作人员处理
4. **订单处理**:工作人员接单、处理、完成
5. **订单完成**:用户评价,订单完结
### 5.2 工单处理流程
1. **工单创建**:用户申请服务或管理员创建工单
2. **工单派单**:分配工作人员
3. **工单处理**:工作人员接单、处理、完成
4. **工单评价**:用户评价服务
5. **工单归档**:工单完成,数据统计更新
### 5.3 设备预警流程
1. **预警触发**:设备离线、滤芯寿命低、水质异常
2. **预警展示**:在设备管理页面显示预警列表
3. **预警处理**:管理员查看详情,创建工单
4. **预警消除**:问题解决后,预警状态更新
## 6. 权限设计
### 6.1 权限控制
- **基于 RuoYi 权限体系**:使用 RuoYi 的角色-菜单权限控制
- **数据权限**:员工只能查看本部门数据(使用 RuoYi 数据权限)
- **按钮权限**:关键操作按钮权限控制(如:派单、取消订单)
### 6.2 权限分配
| 角色 | 可访问模块 | 特殊权限 |
|------|-----------|----------|
| 超级管理员 | 全部模块 | 系统配置、用户管理 |
| 业务管理员 | 订单、工单、商品、设备 | 订单派单、工单派单 |
| 客服人员 | 订单、消息、用户 | 订单处理、消息发送 |
| 运营人员 | 商品、公告、统计 | 商品管理、公告发布 |
## 7. 界面要求
### 7.1 设计规范
- **遵循 RuoYi 设计规范**:使用 RuoYi 默认 UI 风格
- **响应式设计**:支持 PC 端和移动端访问
- **操作便捷**:关键操作提供快捷入口
- **数据可视化**:统计页面使用图表展示
### 7.2 关键页面要求
- **数据看板**:卡片式展示关键指标,图表展示趋势
- **列表页面**:支持多条件筛选、排序、分页
- **详情页面**:信息展示清晰,操作按钮明确
- **统计页面**:图表 + 表格结合,支持数据导出
## 8. 性能要求
- **页面加载时间**< 2
- **列表查询响应**< 1
- **数据统计响应**< 3
- **并发支持**:支持 50+ 用户同时在线
## 9. 开发优先级
### 9.1 第一阶段(核心功能)
1. 用户管理
2. 员工管理
3. 订单管理
4. 工单管理
5. 设备管理
### 9.2 第二阶段(扩展功能)
1. 商品管理
2. 消息管理
3. 公告管理
4. 评价管理
### 9.3 第三阶段(统计分析)
1. 数据看板
2. 订单统计
3. 工单统计
4. 员工绩效统计
## 10. 验收标准
- [ ] 所有功能模块正常运行
- [ ] 权限控制正确生效
- [ ] 数据统计准确
- [ ] 操作日志完整记录
- [ ] 性能指标达标
- [ ] 界面友好,操作便捷
---
**文档版本**v1.0
**创建日期**2025-11-13
**适配框架**RuoYi v4.x+

@ -0,0 +1,63 @@
# RuoYi 数据源配置模板
# 文件位置ruoyi-admin/src/main/resources/application-druid.yml
# 或ruoyi-admin/src/main/resources/application.yml
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源
master:
# 公网连接(本地开发使用)
url: jdbc:mysql://47.122.2.5:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
# 内网连接(服务器部署使用)
# url: jdbc:mysql://172.18.40.251:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
username: water_app
password: WaterApp@2024
# 初始连接数
initialSize: 5
# 最小连接池数量
minIdle: 10
# 最大连接池数量
maxActive: 20
# 配置获取连接等待超时的时间
maxWait: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000
# 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒
maxEvictableIdleTimeMillis: 900000
# 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true
testOnBorrow: false
testOnReturn: false
# 打开 PSCache并且指定每个连接上 PSCache 的大小
poolPreparedStatements: true
maxPoolPreparedStatementPerConnectionSize: 20
# 配置监控统计拦截的 filters去掉后监控界面 SQL 无法统计,'wall' 用于防火墙
filters: stat,wall,slf4j
# 通过 connectProperties 属性来打开 mergeSql 功能;慢 SQL 记录
connectionProperties: druid.stat.mergeSql\=true;druid.stat.slowSqlMillis\=5000
# 配置 DruidStatFilter
web-stat-filter:
enabled: true
# 配置 DruidStatViewServlet
stat-view-servlet:
enabled: true
# 设置白名单,不填则允许所有访问
allow:
url-pattern: /druid/*
# 控制台管理用户名和密码
login-username: admin
login-password: admin123

File diff suppressed because it is too large Load Diff

@ -0,0 +1,523 @@
-- =============================================
-- 数智水管家数据库设计 v2.0
-- 创建日期2025-11-13
-- 适配框架RuoYi v4.x+
-- 数据库版本MySQL 5.7+
-- 表数量12张核心表 + 2个视图
-- =============================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS `water_manager`
DEFAULT CHARACTER SET utf8mb4
DEFAULT COLLATE utf8mb4_general_ci;
USE `water_manager`;
-- =============================================
-- 1. 用户表
-- =============================================
DROP TABLE IF EXISTS `water_user`;
CREATE TABLE `water_user` (
`user_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`phone` VARCHAR(11) DEFAULT NULL COMMENT '手机号',
`password` VARCHAR(128) DEFAULT NULL COMMENT '密码BCrypt加密',
`nick_name` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像URL',
`gender` CHAR(1) DEFAULT '0' COMMENT '性别0未知 1男 2女',
`openid` VARCHAR(128) DEFAULT NULL COMMENT '微信openid',
`unionid` VARCHAR(128) DEFAULT NULL COMMENT '微信unionid',
`balance` DECIMAL(10,2) DEFAULT 0.00 COMMENT '账户余额',
`total_orders` INT(11) DEFAULT 0 COMMENT '订单总数(冗余)',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0正常 1停用',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_phone` (`phone`),
UNIQUE KEY `uk_openid` (`openid`),
KEY `idx_status` (`status`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- =============================================
-- 2. 员工表
-- =============================================
DROP TABLE IF EXISTS `water_staff`;
CREATE TABLE `water_staff` (
`staff_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '员工ID',
`user_id` BIGINT(20) DEFAULT NULL COMMENT '关联sys_user的user_id',
`employee_no` VARCHAR(20) NOT NULL COMMENT '工号',
`staff_name` VARCHAR(50) NOT NULL COMMENT '姓名',
`phone` VARCHAR(11) NOT NULL COMMENT '手机号',
`dept_id` BIGINT(20) DEFAULT NULL COMMENT '部门ID关联sys_dept',
`position` VARCHAR(50) DEFAULT NULL COMMENT '职位',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0正常 1停用',
`total_orders` INT(11) DEFAULT 0 COMMENT '工单总数(冗余)',
`completed_orders` INT(11) DEFAULT 0 COMMENT '完成工单数(冗余)',
`avg_rating` DECIMAL(3,2) DEFAULT 5.00 COMMENT '平均评分(冗余)',
`avg_duration` INT(11) DEFAULT 0 COMMENT '平均服务时长-分钟(冗余)',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`staff_id`),
UNIQUE KEY `uk_employee_no` (`employee_no`),
UNIQUE KEY `uk_phone` (`phone`),
KEY `idx_user_id` (`user_id`),
KEY `idx_dept_id` (`dept_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工表';
-- =============================================
-- 3. 设备表
-- =============================================
DROP TABLE IF EXISTS `water_device`;
CREATE TABLE `water_device` (
`device_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '设备ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`sn` VARCHAR(50) NOT NULL COMMENT '设备编号(唯一)',
`device_name` VARCHAR(100) DEFAULT NULL COMMENT '设备名称',
`device_type` VARCHAR(50) DEFAULT '智能净水器' COMMENT '设备类型',
`model` VARCHAR(50) DEFAULT NULL COMMENT '设备型号',
`status` CHAR(1) DEFAULT '1' COMMENT '状态0离线 1在线 2故障',
`install_address` VARCHAR(255) DEFAULT NULL COMMENT '安装地址',
`location_lat` DECIMAL(10,7) DEFAULT NULL COMMENT '纬度',
`location_lng` DECIMAL(10,7) DEFAULT NULL COMMENT '经度',
`bind_time` DATETIME DEFAULT NULL COMMENT '绑定时间',
`last_online_time` DATETIME DEFAULT NULL COMMENT '最后在线时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`device_id`),
UNIQUE KEY `uk_sn` (`sn`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_bind_time` (`bind_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备表';
-- =============================================
-- 4. 滤芯表
-- =============================================
DROP TABLE IF EXISTS `water_filter`;
CREATE TABLE `water_filter` (
`filter_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '滤芯ID',
`device_id` BIGINT(20) NOT NULL COMMENT '设备ID',
`filter_name` VARCHAR(50) NOT NULL COMMENT '滤芯名称PP棉滤芯',
`filter_type` VARCHAR(50) DEFAULT NULL COMMENT '滤芯类型',
`model` VARCHAR(50) DEFAULT NULL COMMENT '滤芯型号',
`install_time` DATETIME DEFAULT NULL COMMENT '安装时间',
`total_days` INT(11) DEFAULT 180 COMMENT '总使用天数默认180天',
`used_days` INT(11) DEFAULT 0 COMMENT '已使用天数',
`life_percentage` INT(11) DEFAULT 100 COMMENT '剩余寿命百分比',
`next_replace_time` DATE DEFAULT NULL COMMENT '预计更换时间',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0正常 1需更换',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`filter_id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_status` (`status`),
KEY `idx_life_percentage` (`life_percentage`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='滤芯表';
-- =============================================
-- 5. 水质检测记录表
-- =============================================
DROP TABLE IF EXISTS `water_quality`;
CREATE TABLE `water_quality` (
`quality_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '检测ID',
`device_id` BIGINT(20) NOT NULL COMMENT '设备ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`order_id` BIGINT(20) DEFAULT NULL COMMENT '关联订单ID如果是服务订单',
`test_time` DATETIME NOT NULL COMMENT '检测时间',
`test_address` VARCHAR(255) DEFAULT NULL COMMENT '检测地址',
`quality_level` VARCHAR(20) DEFAULT NULL COMMENT '水质等级(优质/良好/一般/较差)',
`quality_desc` VARCHAR(500) DEFAULT NULL COMMENT '水质描述',
-- 检测指标
`ph_value` DECIMAL(4,2) DEFAULT NULL COMMENT 'pH值6.5-8.5',
`turbidity` DECIMAL(6,2) DEFAULT NULL COMMENT '浊度 NTU≤1',
`residual_chlorine` DECIMAL(6,2) DEFAULT NULL COMMENT '余氯 mg/L0.05-4',
`total_hardness` DECIMAL(8,2) DEFAULT NULL COMMENT '总硬度 mg/L≤450',
`tds` DECIMAL(8,2) DEFAULT NULL COMMENT 'TDS溶解性固体 mg/L≤1000',
`cod` DECIMAL(8,2) DEFAULT NULL COMMENT 'COD化学需氧量 mg/L',
`ammonia_nitrogen` DECIMAL(6,2) DEFAULT NULL COMMENT '氨氮 mg/L',
`report_url` VARCHAR(255) DEFAULT NULL COMMENT '检测报告URL',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0检测中 1已完成',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`quality_id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_test_time` (`test_time`),
KEY `idx_quality_level` (`quality_level`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='水质检测记录表';
-- =============================================
-- 6. 商品表
-- =============================================
DROP TABLE IF EXISTS `water_product`;
CREATE TABLE `water_product` (
`product_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`product_name` VARCHAR(100) NOT NULL COMMENT '商品名称',
`product_desc` VARCHAR(500) DEFAULT NULL COMMENT '商品描述',
`category` VARCHAR(50) DEFAULT NULL COMMENT '商品分类purifier净水器/meter水表/parts配件/service服务',
`price` DECIMAL(10,2) DEFAULT 0.00 COMMENT '商品价格',
`stock` INT(11) DEFAULT 0 COMMENT '库存数量',
`image_url` VARCHAR(255) DEFAULT NULL COMMENT '商品图片URL',
`icon` VARCHAR(50) DEFAULT NULL COMMENT '图标Emoji',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0上架 1下架',
`sort_order` INT(11) DEFAULT 0 COMMENT '排序',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`product_id`),
KEY `idx_category` (`category`),
KEY `idx_status` (`status`),
KEY `idx_sort_order` (`sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';
-- =============================================
-- 7. 服务订单表(核心表)
-- =============================================
DROP TABLE IF EXISTS `water_service_order`;
CREATE TABLE `water_service_order` (
`order_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`order_no` VARCHAR(50) NOT NULL COMMENT '订单号(唯一)',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`staff_id` BIGINT(20) DEFAULT NULL COMMENT '负责员工ID',
-- 订单类型与来源
`service_type` VARCHAR(50) NOT NULL COMMENT '服务类型(设备安装/滤芯更换/维修/订购/水质检测)',
`order_source` VARCHAR(20) DEFAULT 'user' COMMENT '订单来源user用户下单/admin后台派单',
-- 订单状态
`order_status` VARCHAR(20) DEFAULT 'pending' COMMENT '订单状态pending待支付/paid已支付/accepted已接单/processing处理中/completed已完成/cancelled已取消',
`payment_status` VARCHAR(20) DEFAULT 'unpaid' COMMENT '支付状态unpaid未支付/paid已支付/refunded已退款',
-- 金额信息
`amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '订单金额',
`actual_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '实付金额',
`discount_amount` DECIMAL(10,2) DEFAULT 0.00 COMMENT '优惠金额',
`payment_method` VARCHAR(20) DEFAULT NULL COMMENT '支付方式wechat微信/balance余额/alipay支付宝',
-- 服务信息
`contact_name` VARCHAR(50) DEFAULT NULL COMMENT '联系人',
`contact_phone` VARCHAR(11) DEFAULT NULL COMMENT '联系电话',
`service_address` VARCHAR(255) DEFAULT NULL COMMENT '服务地址',
`location_lat` DECIMAL(10,7) DEFAULT NULL COMMENT '纬度',
`location_lng` DECIMAL(10,7) DEFAULT NULL COMMENT '经度',
`description` TEXT DEFAULT NULL COMMENT '服务描述/问题描述',
`priority` VARCHAR(20) DEFAULT 'normal' COMMENT '优先级normal普通/high紧急',
-- 配送信息
`delivery_distance` DECIMAL(10,2) DEFAULT NULL COMMENT '配送距离(公里)',
`estimated_duration` INT(11) DEFAULT NULL COMMENT '预计时长(分钟)',
`actual_duration` INT(11) DEFAULT NULL COMMENT '实际时长(分钟)',
-- 时间轴
`submit_time` DATETIME DEFAULT NULL COMMENT '提交时间',
`payment_time` DATETIME DEFAULT NULL COMMENT '支付时间',
`accept_time` DATETIME DEFAULT NULL COMMENT '接单时间',
`start_time` DATETIME DEFAULT NULL COMMENT '开始配送时间',
`complete_time` DATETIME DEFAULT NULL COMMENT '完成时间',
`cancel_time` DATETIME DEFAULT NULL COMMENT '取消时间',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`order_id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_id` (`user_id`),
KEY `idx_staff_id` (`staff_id`),
KEY `idx_order_status` (`order_status`),
KEY `idx_service_type` (`service_type`),
KEY `idx_submit_time` (`submit_time`),
KEY `idx_staff_status` (`staff_id`, `order_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='服务订单表';
-- =============================================
-- 8. 订单商品关联表
-- =============================================
DROP TABLE IF EXISTS `water_order_product`;
CREATE TABLE `water_order_product` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_id` BIGINT(20) NOT NULL COMMENT '订单ID',
`product_id` BIGINT(20) NOT NULL COMMENT '商品ID',
`product_name` VARCHAR(100) NOT NULL COMMENT '商品名称(冗余)',
`product_price` DECIMAL(10,2) NOT NULL COMMENT '商品单价(冗余)',
`quantity` INT(11) DEFAULT 1 COMMENT '购买数量',
`total_price` DECIMAL(10,2) NOT NULL COMMENT '小计金额',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_order_id` (`order_id`),
KEY `idx_product_id` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单商品关联表';
-- =============================================
-- 9. 订单评价表
-- =============================================
DROP TABLE IF EXISTS `water_evaluation`;
CREATE TABLE `water_evaluation` (
`evaluation_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '评价ID',
`order_id` BIGINT(20) NOT NULL COMMENT '订单ID',
`user_id` BIGINT(20) NOT NULL COMMENT '用户ID',
`staff_id` BIGINT(20) DEFAULT NULL COMMENT '员工ID',
`rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '综合评分1-5星',
`service_rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '服务态度评分',
`quality_rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '服务质量评分',
`speed_rating` DECIMAL(2,1) DEFAULT 5.0 COMMENT '服务速度评分',
`content` TEXT DEFAULT NULL COMMENT '评价内容',
`images` VARCHAR(1000) DEFAULT NULL COMMENT '评价图片JSON数组',
`is_anonymous` CHAR(1) DEFAULT '0' COMMENT '是否匿名0否 1是',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`evaluation_id`),
UNIQUE KEY `uk_order_id` (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_staff_id` (`staff_id`),
KEY `idx_rating` (`rating`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单评价表';
-- =============================================
-- 10. 系统消息表
-- =============================================
DROP TABLE IF EXISTS `water_message`;
CREATE TABLE `water_message` (
`message_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '消息ID',
`user_id` BIGINT(20) DEFAULT NULL COMMENT '用户ID用户消息',
`staff_id` BIGINT(20) DEFAULT NULL COMMENT '员工ID员工消息',
`message_type` VARCHAR(50) DEFAULT NULL COMMENT '消息类型order订单/device设备/system系统/service服务',
`title` VARCHAR(100) NOT NULL COMMENT '消息标题',
`content` TEXT NOT NULL COMMENT '消息内容',
`related_id` BIGINT(20) DEFAULT NULL COMMENT '关联ID如订单ID、设备ID',
`is_read` CHAR(1) DEFAULT '0' COMMENT '是否已读0未读 1已读',
`send_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
`read_time` DATETIME DEFAULT NULL COMMENT '阅读时间',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`message_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_staff_id` (`staff_id`),
KEY `idx_message_type` (`message_type`),
KEY `idx_is_read` (`is_read`),
KEY `idx_send_time` (`send_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统消息表';
-- =============================================
-- 11. 通知公告表
-- =============================================
DROP TABLE IF EXISTS `water_notice`;
CREATE TABLE `water_notice` (
`notice_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '公告ID',
`notice_title` VARCHAR(100) NOT NULL COMMENT '公告标题',
`notice_content` TEXT NOT NULL COMMENT '公告内容',
`notice_type` VARCHAR(20) DEFAULT 'info' COMMENT '公告类型info通知/warning警告/urgent紧急',
`target_type` VARCHAR(20) DEFAULT 'all' COMMENT '目标对象all全部/user用户/staff员工',
`status` CHAR(1) DEFAULT '0' COMMENT '状态0发布 1草稿 2下架',
`publish_time` DATETIME DEFAULT NULL COMMENT '发布时间',
`expire_time` DATETIME DEFAULT NULL COMMENT '过期时间',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`del_flag` CHAR(1) DEFAULT '0' COMMENT '删除标志0存在 2删除',
PRIMARY KEY (`notice_id`),
KEY `idx_status` (`status`),
KEY `idx_target_type` (`target_type`),
KEY `idx_publish_time` (`publish_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知公告表';
-- =============================================
-- 12. 系统配置表
-- =============================================
DROP TABLE IF EXISTS `water_config`;
CREATE TABLE `water_config` (
`config_id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '配置ID',
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
`config_value` VARCHAR(500) NOT NULL COMMENT '配置值',
`config_type` VARCHAR(50) DEFAULT 'string' COMMENT '配置类型string/number/boolean/json',
`config_desc` VARCHAR(500) DEFAULT NULL COMMENT '配置描述',
`create_by` VARCHAR(64) DEFAULT NULL COMMENT '创建者',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_by` VARCHAR(64) DEFAULT NULL COMMENT '更新者',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`config_id`),
UNIQUE KEY `uk_config_key` (`config_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
-- =============================================
-- 视图1员工绩效视图
-- =============================================
DROP VIEW IF EXISTS `v_staff_performance`;
CREATE OR REPLACE VIEW `v_staff_performance` AS
SELECT
s.staff_id,
s.employee_no,
s.staff_name,
s.phone,
s.dept_id,
s.position,
-- 订单统计
COUNT(o.order_id) AS total_orders,
SUM(CASE WHEN o.order_status = 'completed' THEN 1 ELSE 0 END) AS completed_orders,
SUM(CASE WHEN o.order_status = 'cancelled' THEN 1 ELSE 0 END) AS cancelled_orders,
ROUND(SUM(CASE WHEN o.order_status = 'completed' THEN 1 ELSE 0 END) * 100.0 / NULLIF(COUNT(o.order_id), 0), 2) AS completion_rate,
-- 评价统计
IFNULL(AVG(e.rating), 5.00) AS avg_rating,
COUNT(e.evaluation_id) AS evaluation_count,
-- 时效统计
IFNULL(AVG(o.actual_duration), 0) AS avg_duration,
-- 服务类型统计
SUM(CASE WHEN o.service_type = '设备安装' THEN 1 ELSE 0 END) AS install_count,
SUM(CASE WHEN o.service_type = '滤芯更换' THEN 1 ELSE 0 END) AS filter_count,
SUM(CASE WHEN o.service_type = '维修' THEN 1 ELSE 0 END) AS repair_count,
SUM(CASE WHEN o.service_type = '水质检测' THEN 1 ELSE 0 END) AS quality_count,
-- 等级评定
CASE
WHEN AVG(e.rating) >= 4.8 AND COUNT(o.order_id) >= 50 THEN '金牌员工'
WHEN AVG(e.rating) >= 4.5 AND COUNT(o.order_id) >= 30 THEN '优秀员工'
WHEN AVG(e.rating) >= 4.0 THEN '普通员工'
ELSE '待提升'
END AS staff_level
FROM water_staff s
LEFT JOIN water_service_order o ON s.staff_id = o.staff_id AND o.del_flag = '0'
LEFT JOIN water_evaluation e ON o.order_id = e.order_id AND e.del_flag = '0'
WHERE s.del_flag = '0'
GROUP BY s.staff_id;
-- =============================================
-- 视图2订单统计视图
-- =============================================
DROP VIEW IF EXISTS `v_order_statistics`;
CREATE OR REPLACE VIEW `v_order_statistics` AS
SELECT
DATE(submit_time) AS order_date,
service_type,
order_status,
order_source,
COUNT(*) AS order_count,
SUM(amount) AS total_amount,
SUM(actual_amount) AS total_actual_amount,
SUM(discount_amount) AS total_discount,
AVG(actual_duration) AS avg_duration,
AVG(delivery_distance) AS avg_distance,
COUNT(DISTINCT user_id) AS user_count,
COUNT(DISTINCT staff_id) AS staff_count
FROM water_service_order
WHERE del_flag = '0'
GROUP BY DATE(submit_time), service_type, order_status, order_source;
-- =============================================
-- 初始化系统配置数据
-- =============================================
INSERT INTO `water_config` (`config_key`, `config_value`, `config_type`, `config_desc`, `create_by`, `remark`) VALUES
('order.auto_complete_hours', '24', 'number', '订单自动完成时间(小时)', 'admin', '订单在处理中状态超过指定小时后自动完成'),
('filter.warning_threshold', '30', 'number', '滤芯寿命预警阈值(%', 'admin', '滤芯寿命低于此百分比时发送预警'),
('service.quality_test_price', '99.00', 'number', '水质检测服务价格', 'admin', '水质检测服务的默认价格'),
('delivery.base_fee', '5.00', 'number', '配送费起步价', 'admin', '配送服务的起步价格'),
('filter.pp_days', '180', 'number', 'PP棉滤芯使用天数', 'admin', 'PP棉滤芯的默认使用天数'),
('filter.cto_days', '180', 'number', '活性炭滤芯使用天数', 'admin', '活性炭滤芯的默认使用天数'),
('filter.ro_days', '720', 'number', 'RO膜使用天数', 'admin', 'RO反渗透膜的默认使用天数'),
('device.offline_minutes', '60', 'number', '设备离线判定时间(分钟)', 'admin', '设备超过指定分钟未上线判定为离线');
-- =============================================
-- 初始化商品数据
-- =============================================
INSERT INTO `water_product` (`product_name`, `product_desc`, `category`, `price`, `stock`, `icon`, `status`, `sort_order`, `create_by`) VALUES
('智能净水器', 'RO反渗透5级过滤', 'purifier', 1299.00, 100, '', '0', 1, 'admin'),
('智能水表', '远程抄表,实时监测', 'meter', 299.00, 200, '', '0', 2, 'admin'),
('净水器滤芯-PP棉', 'PP棉滤芯5微米过滤', 'parts', 59.00, 500, '', '0', 3, 'admin'),
('净水器滤芯-活性炭', '前置活性炭滤芯,吸附异味', 'parts', 89.00, 500, '', '0', 4, 'admin'),
('净水器滤芯-RO膜', 'RO反渗透膜0.0001微米过滤', 'parts', 199.00, 300, '', '0', 5, 'admin'),
('水质检测服务', '上门检测,专业报告', 'service', 99.00, 9999, '', '0', 6, 'admin'),
('前置过滤器', '保护全屋用水安全', 'purifier', 399.00, 80, '', '0', 7, 'admin'),
('软水机', '去除水垢,软化水质', 'purifier', 2599.00, 50, '', '0', 8, 'admin');
-- =============================================
-- 初始化通知公告数据
-- =============================================
INSERT INTO `water_notice` (`notice_title`, `notice_content`, `notice_type`, `target_type`, `status`, `publish_time`, `create_by`) VALUES
('欢迎使用数智水管家', '感谢您使用数智水管家系统,我们将为您提供优质的水质管理服务。如有任何问题,请随时联系我们的客服团队。', 'info', 'all', '0', NOW(), 'admin'),
('关于临时停水通知', '因设备维护需要部分区域将在本周末11月16-17日临时停水请提前做好储水准备。', 'warning', 'user', '0', NOW(), 'admin'),
('春节供水保障公告', '春节期间我们将安排员工值班确保供水服务正常进行。紧急情况请拨打24小时客服热线。', 'info', 'all', '0', NOW(), 'admin');
-- =============================================
-- 示例数据:用户
-- =============================================
INSERT INTO `water_user` (`phone`, `password`, `nick_name`, `gender`, `balance`, `total_orders`, `status`) VALUES
('13800138000', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE/sXHZWzUzn', '张三', '1', 100.00, 5, '0'),
('13800138001', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE/sXHZWzUzn', '李四', '2', 50.00, 3, '0'),
('13800138002', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE/sXHZWzUzn', '王五', '1', 200.00, 8, '0');
-- =============================================
-- 示例数据:员工
-- =============================================
INSERT INTO `water_staff` (`employee_no`, `staff_name`, `phone`, `position`, `status`, `total_orders`, `completed_orders`, `avg_rating`, `create_by`) VALUES
('EMP001', '工程师张', '13900139000', '高级工程师', '0', 50, 48, 4.8, 'admin'),
('EMP002', '工程师李', '13900139001', '工程师', '0', 30, 28, 4.5, 'admin'),
('EMP003', '工程师王', '13900139002', '初级工程师', '0', 20, 18, 4.2, 'admin');
-- =============================================
-- 示例数据:设备
-- =============================================
INSERT INTO `water_device` (`user_id`, `sn`, `device_name`, `device_type`, `model`, `status`, `install_address`, `bind_time`, `last_online_time`) VALUES
(1, 'JSQ20240115001', '净水器-001', '智能净水器', 'RO-5级复合滤芯', '1', 'XX小区3栋2单元501', '2024-01-10 14:30:00', NOW()),
(1, 'JSQ20240112002', '净水器-002', '智能净水器', 'RO-5级复合滤芯', '1', 'XX小区3栋2单元501', '2024-01-08 10:15:00', NOW()),
(2, 'JSQ20240105003', '净水器-003', '智能净水器', 'RO-5级复合滤芯', '0', 'XX路88号', '2024-01-05 09:00:00', '2024-01-14 18:00:00');
-- =============================================
-- 示例数据:滤芯
-- =============================================
INSERT INTO `water_filter` (`device_id`, `filter_name`, `filter_type`, `model`, `install_time`, `total_days`, `used_days`, `life_percentage`, `next_replace_time`, `status`) VALUES
(1, 'PP棉滤芯', 'PP棉', 'PP-001', '2024-01-10', 180, 35, 65, '2024-06-08', '0'),
(1, '前置活性炭', '活性炭', 'CTO-001', '2024-01-10', 180, 35, 58, '2024-06-08', '0'),
(1, 'RO反渗透膜', 'RO膜', 'RO-001', '2024-01-10', 720, 35, 72, '2026-01-05', '0'),
(1, '后置活性炭', '活性炭', 'GAC-001', '2024-01-10', 180, 35, 61, '2024-06-08', '0'),
(2, 'PP棉滤芯', 'PP棉', 'PP-001', '2024-01-08', 180, 45, 45, '2024-06-06', '0'),
(2, '前置活性炭', '活性炭', 'CTO-001', '2024-01-08', 180, 45, 38, '2024-06-06', '0'),
(2, 'RO反渗透膜', 'RO膜', 'RO-001', '2024-01-08', 720, 45, 52, '2026-01-03', '0'),
(2, '后置活性炭', '活性炭', 'GAC-001', '2024-01-08', 180, 45, 42, '2024-06-06', '0');
-- =============================================
-- 示例数据:水质检测
-- =============================================
INSERT INTO `water_quality` (`device_id`, `user_id`, `test_time`, `test_address`, `quality_level`, `quality_desc`, `ph_value`, `turbidity`, `residual_chlorine`, `total_hardness`, `tds`, `status`) VALUES
(1, 1, '2024-01-15 10:00:00', 'XX小区3栋2单元501', '优质', '水质指标稳定,符合饮用标准', 7.2, 0.5, 0.3, 150, 220, '1'),
(2, 1, '2024-01-10 14:00:00', 'XX小区3栋2单元501', '良好', '上次检测显示浊度接近上限,建议再次检测确认', 7.0, 1.2, 0.25, 180, 250, '1'),
(3, 2, '2024-01-08 09:00:00', 'XX路88号', '较差', '余氯低于安全值,建议暂停饮用并联系工作人员', 6.8, 0.8, 0.04, 200, 300, '1');
-- =============================================
-- 创建完成提示
-- =============================================
SELECT '数据库创建完成!' AS message;
SELECT '核心表数量12张' AS summary;
SELECT '视图数量2个' AS summary;
SELECT '已初始化示例数据' AS summary;

@ -0,0 +1,624 @@
## 1. 简介
本迭代开发计划基于现有的「数智水管家」小程序,围绕用户端与工作人员端(工单、配送、统计)的既有功能,规划后续两个阶段(α版、β版)的持续演进与优化工作。计划重点在于:将当前前端演示数据逐步替换为真实后端服务、完善业务闭环、提升性能与用户体验,并为正式上线与后期运维打好基础。
## 2. 目的
- **明确目标**:明确数智水管家后续版本迭代目标与优先级。
- **路径清晰**:规划从演示原型到可上线版本的演进路径。
- **可控过程**:明确各阶段可交付成果和责任角色,便于过程管理与验收。
- **降低风险**:减少需求变更和技术债带来的风险,提升系统稳定性。
## 3. 范围
本迭代开发计划覆盖以下内容:
- **用户端小程序**
- 登录与角色识别(普通用户 / 工作人员)。
- 首页信息展示(问题设备、水质预警、公告通知、快捷服务)。
- 设备管理与设备详情(设备状态、滤芯寿命、水质信息跳转)。
- 水质检测与检测报告(记录列表、详情与预警联动)。
- 商品订购与订单管理(商品列表、下单、支付、订单状态流转)。
- 消息通知与公告展示。
- 在线客服入口与基础交互。
- **工作人员端**
- 工单列表与工单详情(状态流转:待接单、已接单、处理中、已完成)。
- 配送任务与配送详情。
- 工作台与工作统计(数据统计面板)。
- 员工个人中心(基本信息展示)。
- **后端与基础设施**
- 与新设计数据库v2.0)的对应接口服务。
- 基础权限与角色控制(用户 / 员工 / 管理员)。
- 日志、监控及必要的运维支撑。
## 4. 定义、首字母缩写词和缩略语
- **小程序**:本文指微信小程序 / uni-app 打包的微信平台前端应用。
- **用户端**:普通用户使用的小程序界面与功能。
- **工作人员端 / 员工端**:面向运维、客服、工程师的工作台及相关功能。
- **设备**:接入系统的净水器 / 水表等智能设备。
- **工单**:与维修、安装、水质检测等服务相关的工作任务记录。
- **订单**:用户发起的商业订单,包括商品订购、服务订购、水质检测等。
- **α版**:内部可用版,功能完整但允许有一定瑕疵,主要用于内部联调与业务验证。
- **β版**:对外体验版,功能稳定,性能与体验基本达标,可小范围推广。
## 5. 参考文档
- `database/数据库设计说明_v2.0.md`
- `database/数智水管家数据库设计_v2.0.sql`
- 现有小程序代码(示例):
- 用户端页面:`pages/index/index.vue`、`pages/order/order.vue`、`pages/purchase/purchase.vue`、`pages/device/device.vue`、`pages/device-detail/device-detail.vue`、`pages/water-quality/water-quality.vue`、`pages/water-quality-detail/water-quality-detail.vue`、`pages/water-quality-alert/water-quality-alert.vue`、`pages/message/message.vue`、`pages/customer-service/customer-service.vue`、`pages/profile/profile.vue` 等。
- 工作人员端页面:`pages/staff/index.vue`、`pages/staff/workorder.vue`、`pages/staff/workorder-detail.vue`、`pages/staff/delivery.vue`、`pages/staff/delivery-detail.vue`、`pages/staff/delivery-task.vue`、`pages/staff/stats.vue`、`pages/staff/profile.vue` 等。
- 前端路由与导航配置:`pages.json`。
- 历史设计文档:`database/数据库设计说明_精简版.md`。
## 6. 计划
整体采用两轮迭代:
- **α版**
- **目标**:实现端到端业务闭环(设备–水质–订单–工单),前后端全链路打通,替换前端 Mock 数据为真实接口,完成基础权限与员工端工作流。
- **特征**:功能完整,允许界面与交互存在一定瑕疵,主要用于内部测试与业务验证。
- **β版**
- **目标**:在 α 版基础上进行体验优化、性能优化、安全与监控补强,完善统计与报表,做好上线前准备。
- **特征**:功能稳定,用户体验较好,可用于试点或小规模对外发布。
## 7. 数据库云端部署计划
### 7.1 部署概述
**服务器信息**
- 公有 IP 地址:`47.122.2.5`
- 私有 IP 地址:`172.18.40.251`
- 部署方案:**yum 直接安装 MySQL 8.0**(已部署)
- 数据库名称:`water_manager`
- MySQL 版本:**8.0**(已部署)
- 端口:`3306`
**部署目标**
- 为团队提供统一的数据库服务,支持多人协作开发
- 确保数据库安全、稳定、可维护
- 建立完善的备份与监控机制
### 7.2 环境准备
#### 7.2.1 服务器基础环境
| 任务 | 操作步骤 | 责任人 |
|------|----------|--------|
| 服务器访问配置 | 1. 配置 SSH 密钥登录2. 开放必要端口3306/33073. 配置防火墙规则 | 平台管理员 |
| Docker 环境安装 | 1. 安装 Docker Engine2. 安装 Docker Compose3. 配置 Docker 镜像加速 | 平台管理员 |
| 目录结构创建 | 创建 `/data/mysql`、`/data/mysql-backup`、`/data/mysql-logs` 目录 | 平台管理员 |
**Docker 安装命令**
```bash
# 安装 Docker
curl -fsSL https://get.docker.com | bash
# 启动 Docker
systemctl start docker
systemctl enable docker
# 安装 Docker Compose
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
#### 7.2.2 安全配置
| 任务 | 配置项 | 责任人 |
|------|--------|--------|
| 防火墙规则 | 仅开放必要端口,限制访问来源 IP | 平台管理员 |
| SSH 安全 | 禁用密码登录,仅允许密钥登录 | 平台管理员 |
| MySQL 用户权限 | 创建专用数据库用户,限制权限范围 | 后端开发、平台管理员 |
### 7.3 MySQL 部署方案
#### 7.3.1 Docker Compose 配置
**文件位置**`/opt/water-manager/docker-compose.yml`
```yaml
version: '3.8'
services:
mysql:
image: mysql:5.7
container_name: water-mysql
restart: always
ports:
- "3306:3306" # 内网访问
# - "3307:3306" # 公网访问(需要时开放)
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: water_manager
TZ: Asia/Shanghai
volumes:
- /data/mysql/data:/var/lib/mysql
- /data/mysql/conf:/etc/mysql/conf.d
- /data/mysql/logs:/var/log/mysql
- /data/mysql/init:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_general_ci
- --default-time-zone=+8:00
- --max_connections=200
- --innodb_buffer_pool_size=512M
networks:
- water-network
networks:
water-network:
driver: bridge
```
**环境变量文件**`/opt/water-manager/.env`
```bash
MYSQL_ROOT_PASSWORD=your_strong_password_here
```
#### 7.3.2 MySQL 配置文件
**文件位置**`/data/mysql/conf/my.cnf`
```ini
[mysqld]
# 字符集配置
character-set-server=utf8mb4
collation-server=utf8mb4_general_ci
# 时区配置
default-time-zone='+8:00'
# 连接配置
max_connections=200
max_connect_errors=10
# InnoDB 配置
innodb_buffer_pool_size=512M
innodb_log_file_size=128M
innodb_flush_log_at_trx_commit=2
# 慢查询日志
slow_query_log=1
slow_query_log_file=/var/log/mysql/slow.log
long_query_time=2
# 二进制日志(用于主从复制和恢复)
log_bin=/var/log/mysql/mysql-bin
binlog_format=ROW
expire_logs_days=7
# 错误日志
log_error=/var/log/mysql/error.log
[client]
default-character-set=utf8mb4
```
### 7.4 数据库初始化
#### 7.4.1 初始化脚本部署
| 任务 | 操作步骤 | 责任人 |
|------|----------|--------|
| SQL 脚本上传 | 将 `数智水管家数据库设计_v2.0.sql` 上传至 `/data/mysql/init/` | 后端开发 |
| 数据库初始化 | 执行 Docker Compose 启动,自动执行初始化脚本 | 平台管理员 |
| 验证初始化结果 | 检查表、视图、示例数据是否创建成功 | 后端开发、测试 |
**初始化步骤**
```bash
# 1. 上传 SQL 脚本
scp database/数智水管家数据库设计_v2.0.sql root@47.122.2.5:/data/mysql/init/
# 2. 启动 MySQL 容器
cd /opt/water-manager
docker-compose up -d mysql
# 3. 查看日志确认初始化
docker-compose logs -f mysql
# 4. 验证数据库
docker exec -it water-mysql mysql -uroot -p -e "USE water_manager; SHOW TABLES;"
```
#### 7.4.2 数据库用户创建
**创建专用数据库用户**(避免使用 root
```sql
-- 创建应用用户
CREATE USER 'water_app'@'%' IDENTIFIED BY 'app_password_here';
GRANT SELECT, INSERT, UPDATE, DELETE ON water_manager.* TO 'water_app'@'%';
-- 创建只读用户(用于报表查询)
CREATE USER 'water_readonly'@'%' IDENTIFIED BY 'readonly_password_here';
GRANT SELECT ON water_manager.* TO 'water_readonly'@'%';
-- 刷新权限
FLUSH PRIVILEGES;
```
### 7.5 连接配置
#### 7.5.1 RuoYi 框架连接配置
**详细配置文档**:参考 `database/RuoYi连接MySQL服务器配置.md`
**RuoYi 配置**`application-druid.yml`
```yaml
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driverClassName: com.mysql.cj.jdbc.Driver
druid:
master:
# 公网连接(本地开发使用)
url: jdbc:mysql://47.122.2.5:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
# 内网连接(服务器部署使用)
# url: jdbc:mysql://172.18.40.251:3306/water_manager?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
```
**关键参数**
- `com.mysql.cj.jdbc.Driver` - MySQL 8.0 驱动(必须)
- `allowPublicKeyRetrieval=true` - 允许获取公钥MySQL 8.0 需要)
- `serverTimezone=GMT%2B8` - 时区设置GMT+8
#### 7.5.2 Spring Boot 连接配置(非 RuoYi
**内网连接**(服务器部署):
```yaml
# application.yml
spring:
datasource:
url: jdbc:mysql://172.18.40.251:3306/water_manager?useSSL=false&serverTimezone=GMT%2B8&characterEncoding=utf8mb4&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
driver-class-name: com.mysql.cj.jdbc.Driver
```
**公网连接**(本地开发):
```yaml
# application.yml
spring:
datasource:
url: jdbc:mysql://47.122.2.5:3306/water_manager?useSSL=true&serverTimezone=GMT%2B8&characterEncoding=utf8mb4&allowPublicKeyRetrieval=true
username: water_app
password: WaterApp@2024
driver-class-name: com.mysql.cj.jdbc.Driver
```
#### 7.5.3 客户端工具连接(开发阶段)
**团队成员本地开发连接**
```bash
# MySQL 客户端连接
mysql -h 47.122.2.5 -P 3306 -u water_app -p water_manager
# Navicat/DBeaver 等工具配置
主机47.122.2.5
端口3307
用户名water_app
密码app_password_here
数据库water_manager
```
**安全建议**
- 开发阶段可临时开放公网端口3307
- 配置 IP 白名单,仅允许团队成员 IP 访问
- 生产环境必须关闭公网访问,仅使用内网地址
#### 7.5.3 防火墙配置
```bash
# 开放内网端口(必须)
firewall-cmd --permanent --add-port=3306/tcp
# 开放公网端口(可选,仅开发阶段)
firewall-cmd --permanent --add-port=3307/tcp
# 配置 IP 白名单(推荐)
# 仅允许特定 IP 访问 3307 端口
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="团队成员IP1" port protocol="tcp" port="3307" accept'
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="团队成员IP2" port protocol="tcp" port="3307" accept'
# 重载防火墙
firewall-cmd --reload
```
### 7.6 备份策略
#### 7.6.1 自动备份脚本
**文件位置**`/opt/water-manager/scripts/backup-mysql.sh`
```bash
#!/bin/bash
# MySQL 自动备份脚本
BACKUP_DIR="/data/mysql-backup"
DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="water_manager"
DB_USER="root"
DB_PASSWORD="your_root_password"
CONTAINER_NAME="water-mysql"
# 创建备份目录
mkdir -p $BACKUP_DIR
# 执行备份
docker exec $CONTAINER_NAME mysqldump -u$DB_USER -p$DB_PASSWORD \
--single-transaction \
--routines \
--triggers \
$DB_NAME > $BACKUP_DIR/water_manager_$DATE.sql
# 压缩备份文件
gzip $BACKUP_DIR/water_manager_$DATE.sql
# 删除 7 天前的备份
find $BACKUP_DIR -name "water_manager_*.sql.gz" -mtime +7 -delete
echo "Backup completed: water_manager_$DATE.sql.gz"
```
**定时任务配置**
```bash
# 编辑 crontab
crontab -e
# 每天凌晨 2 点执行备份
0 2 * * * /opt/water-manager/scripts/backup-mysql.sh >> /var/log/mysql-backup.log 2>&1
```
#### 7.6.2 备份恢复流程
| 场景 | 恢复步骤 | 责任人 |
|------|----------|--------|
| 数据误删恢复 | 1. 停止应用2. 恢复指定时间点的备份3. 验证数据4. 重启应用 | 平台管理员、后端开发 |
| 数据库迁移 | 1. 导出完整备份2. 在新服务器导入3. 验证数据完整性 | 平台管理员 |
| 灾难恢复 | 1. 恢复最新备份2. 应用二进制日志恢复3. 验证系统功能 | 平台管理员、后端开发 |
**恢复命令示例**
```bash
# 恢复备份
gunzip < /data/mysql-backup/water_manager_20241113_020000.sql.gz | \
docker exec -i water-mysql mysql -uroot -p water_manager
```
### 7.7 监控与维护
#### 7.7.1 监控指标
| 监控项 | 监控方式 | 告警阈值 | 责任人 |
|--------|----------|----------|--------|
| 数据库连接数 | MySQL 状态查询 | > 150 | 平台管理员 |
| 磁盘使用率 | 系统监控 | > 80% | 平台管理员 |
| 慢查询数量 | 慢查询日志分析 | 每小时 > 10 | 后端开发 |
| 备份执行状态 | 备份日志检查 | 备份失败 | 平台管理员 |
| 容器运行状态 | Docker 监控 | 容器停止 | 平台管理员 |
#### 7.7.2 日常维护任务
| 任务 | 频率 | 操作内容 | 责任人 |
|------|------|----------|--------|
| 数据库备份 | 每日 | 自动备份脚本执行 | 平台管理员 |
| 慢查询分析 | 每周 | 分析慢查询日志,优化 SQL | 后端开发 |
| 数据库优化 | 每月 | 分析表统计信息,优化索引 | 后端开发 |
| 日志清理 | 每月 | 清理过期日志文件 | 平台管理员 |
| 安全审计 | 每月 | 检查用户权限、访问日志 | 平台管理员 |
#### 7.7.3 常用维护命令
```bash
# 查看容器状态
docker ps | grep water-mysql
# 查看容器日志
docker logs -f water-mysql
# 进入容器
docker exec -it water-mysql bash
# 连接数据库
docker exec -it water-mysql mysql -uroot -p
# 查看数据库大小
docker exec -it water-mysql mysql -uroot -p -e "
SELECT
table_schema AS '数据库',
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS '大小(MB)'
FROM information_schema.tables
WHERE table_schema = 'water_manager'
GROUP BY table_schema;"
# 查看表大小
docker exec -it water-mysql mysql -uroot -p -e "
SELECT
table_name AS '表名',
ROUND((data_length + index_length) / 1024 / 1024, 2) AS '大小(MB)'
FROM information_schema.tables
WHERE table_schema = 'water_manager'
ORDER BY (data_length + index_length) DESC;"
```
### 7.8 部署时间表
| 阶段 | 任务 | 预计时间 | 责任人 |
|------|------|----------|--------|
| 环境准备 | Docker 环境安装、目录创建 | 1-2 小时 | 平台管理员 |
| MySQL 部署 | Docker Compose 配置、MySQL 启动 | 1 小时 | 平台管理员、后端开发 |
| 数据库初始化 | SQL 脚本执行、用户创建 | 1 小时 | 后端开发 |
| 安全配置 | 防火墙、用户权限配置 | 1 小时 | 平台管理员 |
| 备份配置 | 备份脚本、定时任务配置 | 1 小时 | 平台管理员 |
| 连接测试 | 内网、公网连接测试 | 1 小时 | 后端开发、测试 |
| 文档完善 | 部署文档、操作手册编写 | 2 小时 | 平台管理员、后端开发 |
**总计**:约 1-2 个工作日
### 7.9 应急预案
#### 7.9.1 常见问题处理
| 问题 | 可能原因 | 解决方案 | 责任人 |
|------|----------|----------|--------|
| 无法连接数据库 | 防火墙未开放端口 | 检查防火墙规则,开放 3306/3307 端口 | 平台管理员 |
| 容器无法启动 | 端口冲突、配置错误 | 检查端口占用、Docker Compose 配置 | 平台管理员 |
| 数据库性能下降 | 连接数过多、慢查询 | 优化 SQL、调整连接池配置 | 后端开发 |
| 磁盘空间不足 | 日志文件过大、备份文件过多 | 清理日志、删除过期备份 | 平台管理员 |
| 数据丢失 | 误操作、备份失败 | 从备份恢复、检查备份策略 | 平台管理员、后端开发 |
#### 7.9.2 紧急联系
- **平台管理员**:杨捷、杨帅路
- **后端负责人**:杨默涵、梁晨旭
- **紧急处理流程**:发现问题 → 通知平台管理员 → 评估影响 → 执行恢复 → 记录问题
### 7.10 部署验收标准
- [ ] MySQL 容器正常运行,可通过内网连接
- [ ] 数据库 `water_manager` 创建成功,包含 12 张表和 2 个视图
- [ ] 示例数据初始化成功(用户、商品、配置等)
- [ ] 专用数据库用户创建成功,权限配置正确
- [ ] 自动备份脚本配置成功,可正常执行
- [ ] 团队成员可通过公网地址连接数据库(开发阶段)
- [ ] 防火墙规则配置正确,安全策略生效
- [ ] 监控与日志配置完成,可正常查看
### 7.11 快速参考
#### 7.11.1 连接信息
**内网连接**(后端应用使用):
```
地址172.18.40.251:3306
数据库water_manager
用户water_app
```
**公网连接**(开发阶段,团队成员本地开发):
```
地址47.122.2.5:3307
数据库water_manager
用户water_app
```
#### 7.11.2 常用命令
```bash
# 启动 MySQL 容器
cd /opt/water-manager && docker-compose up -d mysql
# 停止 MySQL 容器
docker-compose stop mysql
# 重启 MySQL 容器
docker-compose restart mysql
# 查看容器日志
docker logs -f water-mysql
# 进入容器
docker exec -it water-mysql bash
# 连接数据库
docker exec -it water-mysql mysql -uroot -p
# 执行备份
/opt/water-manager/scripts/backup-mysql.sh
# 查看容器状态
docker ps | grep water-mysql
```
#### 7.11.3 配置文件位置
| 文件类型 | 路径 | 说明 |
|---------|------|------|
| Docker Compose | `/opt/water-manager/docker-compose.yml` | Docker 容器配置 |
| 环境变量 | `/opt/water-manager/.env` | MySQL root 密码等 |
| MySQL 配置 | `/data/mysql/conf/my.cnf` | MySQL 服务器配置 |
| 数据目录 | `/data/mysql/data` | 数据库数据文件 |
| 日志目录 | `/data/mysql/logs` | MySQL 日志文件 |
| 备份目录 | `/data/mysql-backup` | 数据库备份文件 |
| 初始化脚本 | `/data/mysql/init/` | 数据库初始化 SQL |
| 备份脚本 | `/opt/water-manager/scripts/backup-mysql.sh` | 自动备份脚本 |
## 8. 迭代任务
### 8.1 迭代阶段
> 说明:不精确到具体日期,仅给出相对阶段。
| 迭代时间段 | 任务 | 产品 |
|------------|------|------|
| 第 1 阶段(α版迭代) | 1数据库云端部署阿里云服务器2搭建后端服务3登录与角色识别打通4设备管理、工单、订单等核心场景与接口联调5基础水质检测记录与报告查询6工作人员端工单和配送流程跑通。 | 内部可用 α 版:可完整演示用户下单、工作人员处理、设备状态和水质查看的业务闭环。 |
| 第 2 阶段(β版迭代) | 1前端 UI/UX 优化2消息与预警推送完善3统计报表与工作台数据完善4性能与安全加固5上线前联调与灰度发布支持。 | 对外体验 β 版:功能稳定、体验良好,可小范围试运行或对接业务方验收。 |
### 8.2 迭代细分
#### 8.2.1 α版
| 任务 | 可交付工件 | 责任人 |
|------|------------|--------|
| 数据库云端部署 | ✅ **已完成**MySQL 8.0 已成功部署在阿里云服务器47.122.2.5);数据库 `water_manager` 已初始化12张表+2个视图用户权限已配置water_app、water_readonly连接配置文档已提供RuoYi连接MySQL服务器配置.md。 | 平台管理员、后端开发 |
| 用户与工作人员登录打通(含角色识别) | 登录接口、角色字段设计;小程序登录流程(含 token 存储);根据 `userRole` 控制首页与 tabBar 展示。 | 后端开发、前端开发 |
| 接入新数据库结构并完成基础表 CRUD | 基于 `water_user`、`water_staff`、`water_device`、`water_service_order` 等表的实体与 DAO基础管理接口用户、设备、订单。 | 后端开发 |
| 设备管理前后端打通 | `pages/device/device.vue`、`pages/device-detail/device-detail.vue` 改造:设备列表、详情、状态、滤芯信息从后端接口获取;设备解绑与新增接口联调。 | 前端开发、后端开发 |
| 首页数据来源改造 | 首页问题设备、水质预警、公告通知由接口提供;与设备、水质、通知表联动。 | 前端开发、后端开发 |
| 水质检测记录与报告 | 基于 `water_quality` 实现水质检测记录查询接口;改造 `pages/water-quality/water-quality.vue``pages/water-quality-detail/water-quality-detail.vue`,读取真实数据;水质等级映射与描述逻辑后端实现。 | 后端开发、前端开发 |
| 商品订购与订单创建 | 基于 `water_product`、`water_service_order`、`water_order_product` 完成商品列表、订单创建接口;改造 `pages/purchase/purchase.vue`、`pages/order/order.vue` 的数据来源和状态流转。 | 后端开发、前端开发 |
| 支付流程打通(可对接测试支付或模拟支付) | 支付请求接口、支付状态回调模拟;`pages/payment/payment.vue` 与订单状态联动pending → paid。 | 后端开发、前端开发 |
| 工单/服务单统一建模与工作人员端工单流程 | 使用 `water_service_order` 统一支持工单;改造 `pages/staff/workorder.vue`、`pages/staff/workorder-detail.vue`:工单列表、接单/开始处理/完成接口打通。 | 后端开发、前端开发 |
| 配送任务数据与界面联通 | 基于 `water_service_order` 的服务类型或标记,提供配送任务列表接口;改造 `pages/staff/delivery.vue`、`pages/staff/delivery-detail.vue`、`pages/staff/delivery-task.vue`。 | 后端开发、前端开发 |
| 基本系统消息与公告接口 | 基于 `water_message`、`water_notice` 表实现查询接口;改造 `pages/message/message.vue` 与首页公告模块。 | 后端开发、前端开发 |
| 基础接口鉴权与日志 | 接口鉴权中间件token 验证);关键接口日志记录(登录、下单、状态变更等)。 | 后端开发 |
| α版联调与冒烟测试 | α版功能列表与用例;手工测试报告(核心流程:登录–下单–工单–完结–评价–水质查看)。 | 测试、产品 |
#### 8.2.2 β版
| 任务 | 可交付工件 | 责任人 |
|------|------------|--------|
| UI/UX 优化与交互细节完善 | 首页、订单、设备、水质、工单、配送等页面的视觉与交互优化稿;统一样式(颜色、间距、图标);加载状态与空状态处理。 | 产品、前端开发 |
| 消息通知与预警机制完善 | 水质异常、设备离线、滤芯寿命预警的规则实现;通过 `water_message` 推送到用户端/工作人员端;前端消息红点和列表展示优化。 | 后端开发、前端开发 |
| 统计报表与工作台完善 | 基于 `v_staff_performance`、`v_order_statistics` 完善工作人员工作台与管理后台数据展示;`pages/staff/stats.vue` 展示员工个人绩效。 | 后端开发、前端开发 |
| 性能优化与缓存策略落地 | 针对高频接口(设备列表、水质记录、订单列表等)增加 Redis 缓存;数据库索引检查与优化;分页查询优化。 | 后端开发 |
| 安全加固 | 敏感数据脱敏(手机号等);权限控制完善(区分普通用户、工作人员、管理员);防止越权访问与注入攻击。 | 后端开发 |
| 异常处理与日志监控 | 接口错误码规范;前端友好错误提示;关键流程监控指标定义(下单成功率、接口失败率等)。 | 后端开发、测试 |
| 测试用例扩展与回归测试 | 功能测试、接口测试、兼容性测试用例编写与执行;发现并修复 β 版问题。 | 测试、前端开发、后端开发 |
| 上线预案与回滚策略 | 上线步骤文档(数据库脚本执行顺序、服务发布顺序);回滚方案(数据库备份、版本回退流程)。 | 后端开发、运维、产品 |
## 9. 人员配备
- **产品经理**
- 杨默涵
- **前端开发(小程序 / uni-app**
- 杨默涵
- 杨振宇
- **后端开发**
- 杨默涵
- 梁晨旭
- 杨捷
- 杨帅路
- **测试工程师**
- 梁晨旭
- 杨振宇
- **平台管理员**
- 杨捷
- 杨帅路

@ -0,0 +1,477 @@
# 数智水管家数据库部署说明
## 📋 部署信息
- **服务器地址**47.122.2.5(公网)/ 172.18.40.251(内网)
- **部署方案**Docker + MySQL 5.7
- **数据库名称**`water_manager`
- **端口**3306内网/ 3307公网可选
## 🚀 快速部署
### 方式一:安装 MySQL 8.0(推荐)
**推荐使用 MySQL 8.0**,性能更好,功能更强。
1. **上传脚本到服务器**
```bash
scp database/install-mysql-8.0.sh root@47.122.2.5:/root/
scp database/数智水管家数据库设计_v2.0.sql root@47.122.2.5:/root/
```
2. **SSH 登录服务器并执行**
```bash
ssh root@47.122.2.5
chmod +x install-mysql-8.0.sh
./install-mysql-8.0.sh
```
3. **验证安装**
```bash
mysql -uroot -pWaterManager@2024 -e "SELECT VERSION();"
mysql -uroot -pWaterManager@2024 water_manager -e "SHOW TABLES;"
```
**详细说明**:参考 [MySQL8.0部署说明.md](./MySQL8.0部署说明.md)
### 方式二:安装 MySQL 5.7(兼容性更好)
如果需要使用 MySQL 5.7
1. **上传脚本到服务器**
```bash
scp database/install-mysql-yum.sh root@47.122.2.5:/root/
scp database/数智水管家数据库设计_v2.0.sql root@47.122.2.5:/root/
```
2. **SSH 登录服务器并执行**
```bash
ssh root@47.122.2.5
chmod +x install-mysql-yum.sh
./install-mysql-yum.sh
```
### 方式三:使用本地文件部署(如果有本地 MySQL 文件)
如果你本地有 `mysql-5.7.37.tar.gz` 文件Docker 镜像或 MySQL 安装包):
1. **上传文件到服务器**
```bash
scp mysql-5.7.37.tar.gz root@47.122.2.5:/root/
scp database/使用本地文件部署MySQL.sh root@47.122.2.5:/root/
scp database/数智水管家数据库设计_v2.0.sql root@47.122.2.5:/root/
```
2. **SSH 登录服务器并执行**
```bash
ssh root@47.122.2.5
chmod +x 使用本地文件部署MySQL.sh
./使用本地文件部署MySQL.sh
```
3. **如果是 Docker 镜像,继续执行部署脚本**
```bash
./deploy-mysql.sh
```
### 方式二:使用自动部署脚本(在线拉取镜像)
1. **上传脚本到服务器**
```bash
scp database/deploy-mysql.sh root@47.122.2.5:/root/
scp database/数智水管家数据库设计_v2.0.sql root@47.122.2.5:/root/
```
2. **SSH 登录服务器**
```bash
ssh root@47.122.2.5
```
3. **执行部署脚本**
```bash
chmod +x deploy-mysql.sh
./deploy-mysql.sh
```
4. **修改默认密码**(重要!)
```bash
# 编辑环境变量文件
vi /opt/water-manager/.env
# 修改 MYSQL_ROOT_PASSWORD 的值
```
### 方式三:手动部署
参考 `迭代开发计划.md` 第 7 章「数据库云端部署计划」的详细步骤。
## 🔌 连接配置
### 内网连接(后端应用使用)
```yaml
# application.yml
spring:
datasource:
url: jdbc:mysql://172.18.40.251:3306/water_manager?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4
username: water_app
password: WaterApp@2024
```
### 公网连接(开发阶段,本地开发工具)
```
主机47.122.2.5
端口3306或 3307需在 docker-compose.yml 中配置)
用户名water_app
密码WaterApp@2024
数据库water_manager
```
**⚠️ 安全提示**
- 开发阶段可临时开放公网访问
- 生产环境必须关闭公网访问
- 建议配置 IP 白名单,仅允许团队成员 IP 访问
## 🔧 常用操作
### 查看容器状态
```bash
docker ps | grep water-mysql
```
### 查看容器日志
```bash
docker logs -f water-mysql
```
### 进入容器
```bash
docker exec -it water-mysql bash
```
### 连接数据库
```bash
docker exec -it water-mysql mysql -uroot -p
```
### 执行备份
```bash
/opt/water-manager/scripts/backup-mysql.sh
```
### 重启容器
```bash
cd /opt/water-manager
docker-compose restart mysql
```
### 停止容器
```bash
cd /opt/water-manager
docker-compose stop mysql
```
### 启动容器
```bash
cd /opt/water-manager
docker-compose up -d mysql
```
## 📊 数据库管理
### 查看数据库大小
```sql
SELECT
table_schema AS '数据库',
ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS '大小(MB)'
FROM information_schema.tables
WHERE table_schema = 'water_manager'
GROUP BY table_schema;
```
### 查看表大小
```sql
SELECT
table_name AS '表名',
ROUND((data_length + index_length) / 1024 / 1024, 2) AS '大小(MB)'
FROM information_schema.tables
WHERE table_schema = 'water_manager'
ORDER BY (data_length + index_length) DESC;
```
### 查看慢查询
```bash
docker exec -it water-mysql tail -f /var/log/mysql/slow.log
```
## 🔐 安全配置
### 1. 修改默认密码
```bash
# 编辑环境变量文件
vi /opt/water-manager/.env
# 修改密码后重启容器
cd /opt/water-manager
docker-compose restart mysql
```
### 2. 配置防火墙
```bash
# 开放内网端口(必须)
firewall-cmd --permanent --add-port=3306/tcp
# 开放公网端口(可选,仅开发阶段)
firewall-cmd --permanent --add-port=3307/tcp
# 配置 IP 白名单(推荐)
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="团队成员IP" port protocol="tcp" port="3307" accept'
# 重载防火墙
firewall-cmd --reload
```
### 3. 限制数据库用户权限
已创建专用用户:
- `water_app`应用用户SELECT, INSERT, UPDATE, DELETE
- `water_readonly`只读用户SELECT
避免在生产环境使用 root 用户。
## 💾 备份与恢复
### 自动备份
已配置每日凌晨 2 点自动备份,备份文件保存在 `/data/mysql-backup/`
### 手动备份
```bash
/opt/water-manager/scripts/backup-mysql.sh
```
### 恢复备份
```bash
# 解压并恢复
gunzip < /data/mysql-backup/water_manager_20241113_020000.sql.gz | \
docker exec -i water-mysql mysql -uroot -p water_manager
```
## 📁 文件位置
| 文件/目录 | 路径 | 说明 |
|-----------|------|------|
| Docker Compose | `/opt/water-manager/docker-compose.yml` | 容器配置 |
| 环境变量 | `/opt/water-manager/.env` | MySQL root 密码 |
| MySQL 配置 | `/data/mysql/conf/my.cnf` | MySQL 服务器配置 |
| 数据目录 | `/data/mysql/data` | 数据库数据文件 |
| 日志目录 | `/data/mysql/logs` | MySQL 日志文件 |
| 备份目录 | `/data/mysql-backup` | 数据库备份文件 |
| 初始化脚本 | `/data/mysql/init/` | 数据库初始化 SQL |
| 备份脚本 | `/opt/water-manager/scripts/backup-mysql.sh` | 自动备份脚本 |
## 🐛 故障排查
### Docker 镜像拉取失败
**错误信息**`Error response from daemon: Get "https://registry-1.docker.io/v2/": dial tcp ...:443: connect: connection refused`
**原因**:无法访问 Docker Hub 镜像仓库
**快速解决方案**
1. **使用快速修复脚本**(推荐)
```bash
scp database/快速修复-拉取MySQL镜像.sh root@47.122.2.5:/root/
ssh root@47.122.2.5
chmod +x 快速修复-拉取MySQL镜像.sh
./快速修复-拉取MySQL镜像.sh
```
2. **手动拉取镜像**(按优先级尝试)
```bash
# 方法1使用网易镜像推荐通常最稳定
docker pull hub-mirror.c.163.com/library/mysql:5.7
docker tag hub-mirror.c.163.com/library/mysql:5.7 mysql:5.7
# 方法2使用 Azure 镜像
docker pull dockerhub.azk8s.cn/library/mysql:5.7
docker tag dockerhub.azk8s.cn/library/mysql:5.7 mysql:5.7
# 方法3使用腾讯云镜像
docker pull ccr.ccs.tencentyun.com/library/mysql:5.7
docker tag ccr.ccs.tencentyun.com/library/mysql:5.7 mysql:5.7
# 方法4使用 DaoCloud 镜像
docker pull daocloud.io/library/mysql:5.7
docker tag daocloud.io/library/mysql:5.7 mysql:5.7
```
3. **配置镜像加速器后重试**
```bash
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << EOF
{
"registry-mirrors": [
"https://docker.mirrors.ustc.edu.cn",
"https://hub-mirror.c.163.com",
"https://mirror.baidubce.com"
]
}
EOF
systemctl daemon-reload
systemctl restart docker
docker pull mysql:5.7
```
### Docker 安装失败(网络问题)
**错误信息**`curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL`
**原因**:无法访问 Docker 官方源或 GitHub
**解决方案**
1. **手动安装 Docker**(推荐)
```bash
# 使用阿里云镜像源
yum install -y yum-utils device-mapper-persistent-data lvm2
yum-config-manager --add-repo https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo
yum makecache fast
yum install -y docker-ce docker-ce-cli containerd.io
systemctl start docker
systemctl enable docker
```
2. **手动安装 Docker Compose**
```bash
# 使用国内镜像源
curl -L "https://get.daocloud.io/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
```
3. **详细步骤请参考**`手动安装Docker指南.md`
### Docker 服务启动失败
**错误信息**`Failed to start docker.service: Unit docker.service not found`
**解决方案**
```bash
# 检查 Docker 是否已安装
rpm -qa | grep docker
# 如果未安装,按照上述步骤手动安装
# 如果已安装但服务不存在,重新安装
yum reinstall docker-ce docker-ce-cli containerd.io
systemctl daemon-reload
systemctl start docker
```
### 容器无法启动
```bash
# 查看容器日志
docker logs water-mysql
# 检查端口占用
netstat -tlnp | grep 3306
# 检查目录权限
ls -la /data/mysql/
# 检查 Docker 服务状态
systemctl status docker
```
### 无法连接数据库
1. **检查容器是否运行**
```bash
docker ps | grep water-mysql
```
2. **检查防火墙规则**
```bash
firewall-cmd --list-all
# 如果未开放端口,执行:
firewall-cmd --permanent --add-port=3306/tcp
firewall-cmd --reload
```
3. **检查网络连接**
```bash
telnet 47.122.2.5 3306
# 或
nc -zv 47.122.2.5 3306
```
4. **查看 MySQL 日志**
```bash
docker logs water-mysql
docker exec -it water-mysql tail -f /var/log/mysql/error.log
```
5. **检查 MySQL 用户权限**
```bash
docker exec -it water-mysql mysql -uroot -p -e "SELECT user, host FROM mysql.user;"
```
### 磁盘空间不足
```bash
# 查看磁盘使用情况
df -h
# 清理过期备份
find /data/mysql-backup -name "*.sql.gz" -mtime +7 -delete
# 清理 Docker 未使用的资源
docker system prune -a
# 清理 MySQL 日志(谨慎操作)
docker exec -it water-mysql mysql -uroot -p -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY);"
```
### 网络连接问题
**问题**:无法从外网访问数据库
**检查步骤**
1. 确认服务器安全组规则已开放 3306/3307 端口
2. 确认防火墙已开放端口
3. 确认 Docker 端口映射正确:`docker ps` 查看端口映射
4. 测试内网连接:`telnet 172.18.40.251 3306`
5. 测试公网连接:`telnet 47.122.2.5 3306`
**解决方案**
```bash
# 检查端口映射
docker port water-mysql
# 如果端口映射不正确,修改 docker-compose.yml 后重启
cd /opt/water-manager
docker-compose down
docker-compose up -d mysql
```
## 📞 联系方式
- **平台管理员**:杨捷、杨帅路
- **后端负责人**:杨默涵、梁晨旭
- **紧急问题**:请及时联系平台管理员
## 📚 相关文档
- [数据库设计说明_v2.0.md](./数据库设计说明_v2.0.md)
- [数智水管家数据库设计_v2.0.sql](./数智水管家数据库设计_v2.0.sql)
- [迭代开发计划.md](./迭代开发计划.md)
---
**最后更新**2025-11-13

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/main.js"></script>
</body>
</html>

@ -0,0 +1,22 @@
import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
Vue.config.productionTip = false
App.mpType = 'app'
const app = new Vue({
...App
})
app.$mount()
// #endif
// #ifdef VUE3
import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
return {
app
}
}
// #endif

@ -0,0 +1,75 @@
{
"name" : "数智水管家",
"appid" : "",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
"transformPx" : false,
/* 5+App */
"app-plus" : {
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
]
},
/* ios */
"ios" : {},
/* SDK */
"sdkConfigs" : {}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "",
"setting" : {
"urlCheck" : false
},
"usingComponents" : true,
"requiredPrivateInfos" : [
"chooseLocation"
]
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3"
}

@ -0,0 +1,253 @@
{
"pages": [
{
"path": "pages/login/login",
"style": {
"navigationBarTitleText": "登录",
"navigationStyle": "custom",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/profile/change-password",
"style": {
"navigationBarTitleText": "修改密码"
}
},
{
"path": "pages/profile/security",
"style": {
"navigationBarTitleText": "账户安全",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/profile/user-info",
"style": {
"navigationBarTitleText": "个人信息"
}
},
{
"path": "pages/profile/address",
"style": {
"navigationBarTitleText": "地址管理",
"enablePullDownRefresh": true,
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/profile/address-select",
"style": {
"navigationBarTitleText": "选择地址",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/profile/recharge",
"style": {
"navigationBarTitleText": "充值中心",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"enablePullDownRefresh": true,
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/order/order",
"style": {
"navigationBarTitleText": "订单管理",
"enablePullDownRefresh": true,
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/message/message",
"style": {
"navigationBarTitleText": "消息"
}
},
{
"path": "pages/profile/profile",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/staff/index",
"style": {
"navigationBarTitleText": "工作台"
}
},
{
"path": "pages/staff/delivery",
"style": {
"navigationBarTitleText": "配送"
}
},
{
"path": "pages/staff/delivery-detail",
"style": {
"navigationBarTitleText": "配送详情"
}
},
{
"path": "pages/staff/delivery-task",
"style": {
"navigationBarTitleText": "配送任务",
"navigationBarBackgroundColor": "#409eff",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/staff/workorder",
"style": {
"navigationBarTitleText": "工单"
}
},
{
"path": "pages/staff/workorder-detail",
"style": {
"navigationBarTitleText": "工单详情"
}
},
{
"path": "pages/staff/profile",
"style": {
"navigationBarTitleText": "我的"
}
},
{
"path": "pages/purchase/purchase",
"style": {
"navigationBarTitleText": "订购"
}
},
{
"path": "pages/device/device",
"style": {
"navigationBarTitleText": "设备管理",
"enablePullDownRefresh": true,
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/water-quality/water-quality",
"style": {
"navigationBarTitleText": "水质检测"
}
},
{
"path": "pages/water-quality-detail/water-quality-detail",
"style": {
"navigationBarTitleText": "检测报告"
}
},
{
"path": "pages/water-quality-alert/water-quality-alert",
"style": {
"navigationBarTitleText": "水质预警"
}
},
{
"path": "pages/customer-service/customer-service",
"style": {
"navigationBarTitleText": "在线客服"
}
},
{
"path": "pages/order-detail/order-detail",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/device-detail/device-detail",
"style": {
"navigationBarTitleText": "设备详情"
}
},
{
"path": "pages/device-config/device-config",
"style": {
"navigationBarTitleText": "设备配置",
"backgroundColor": "#f5f5f5"
}
},
{
"path": "pages/device-problem/device-problem",
"style": {
"navigationBarTitleText": "问题设备",
"navigationBarBackgroundColor": "#F56C6C",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/order-progress/order-progress",
"style": {
"navigationBarTitleText": "订单详情"
}
},
{
"path": "pages/payment/payment",
"style": {
"navigationBarTitleText": "支付"
}
},
{
"path": "pages/staff/stats",
"style": {
"navigationBarTitleText": "工作统计"
}
},
{
"path": "pages/staff/staff-info",
"style": {
"navigationBarTitleText": "个人信息"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "数智水管家",
"navigationBarBackgroundColor": "#F8F8F8",
"backgroundColor": "#F8F8F8"
},
"tabBar": {
"color": "#909399",
"selectedColor": "#409eff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "static/首页.png",
"selectedIconPath": "static/首页.png"
},
{
"pagePath": "pages/order/order",
"text": "订单",
"iconPath": "static/订单.png",
"selectedIconPath": "static/订单.png"
},
{
"pagePath": "pages/message/message",
"text": "消息",
"iconPath": "static/消息.png",
"selectedIconPath": "static/消息.png"
},
{
"pagePath": "pages/profile/profile",
"text": "我的",
"iconPath": "static/我的.png",
"selectedIconPath": "static/我的.png"
}
]
},
"uniIdRouter": {}
}

@ -0,0 +1,297 @@
<template>
<view class="service-container">
<!-- 常用问题 -->
<view class="faq-section">
<view class="section-title">常见问题</view>
<view class="faq-list">
<view
class="faq-item"
v-for="(faq, index) in faqs"
:key="index"
@click="handleFaqClick(faq)"
>
<text class="faq-text">{{ faq.question }}</text>
<text class="faq-arrow"></text>
</view>
</view>
</view>
<!-- 在线咨询 -->
<view class="chat-section">
<view class="section-title">在线咨询</view>
<view class="chat-container">
<!-- 消息列表 -->
<scroll-view class="message-list" scroll-y :scroll-top="scrollTop" scroll-into-view="bottom">
<view class="message-wrapper" id="bottom">
<view
class="message-item"
v-for="(msg, index) in messages"
:key="index"
:class="msg.type"
>
<view class="message-bubble">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ msg.time }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 输入框 -->
<view class="input-section">
<input
class="message-input"
v-model="inputText"
placeholder="请输入您的问题..."
@confirm="handleSend"
/>
<button class="send-btn" @click="handleSend"></button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
//
const faqs = ref([
{ question: '如何查询水费账单?' },
{ question: '如何申请停水?' },
{ question: '水质检测怎么申请?' },
{ question: '水表读数异常怎么办?' },
{ question: '如何绑定设备?' }
])
//
const messages = ref([
{
type: 'system',
content: '您好,有什么可以帮您的吗?',
time: '10:30'
},
{
type: 'user',
content: '我想查询一下我的水费',
time: '10:31'
}
])
//
const inputText = ref('')
const scrollTop = ref(0)
//
const handleFaqClick = (faq) => {
inputText.value = faq.question
handleSend()
}
//
const handleSend = () => {
if (!inputText.value.trim()) return
//
messages.value.push({
type: 'user',
content: inputText.value,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
const userMessage = inputText.value
inputText.value = ''
//
setTimeout(() => {
scrollTop.value = 9999
}, 100)
//
setTimeout(() => {
messages.value.push({
type: 'service',
content: getReply(userMessage),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
setTimeout(() => {
scrollTop.value = 9999
}, 100)
}, 1000)
}
//
const getReply = (message) => {
if (message.includes('水费') || message.includes('账单')) {
return '您可以在"我的订单"页面查看水费账单,也可以联系客服查询详细账单信息。'
} else if (message.includes('停水')) {
return '您可以通过"报修申请"功能申请停水,或直接联系客服处理。'
} else if (message.includes('水质') || message.includes('检测')) {
return '您可以在"水质检测"页面申请水质检测服务工作人员将在24小时内联系您。'
} else if (message.includes('水表')) {
return '如果水表读数异常,建议先拍照记录,然后通过"报修申请"提交问题,或联系客服处理。'
} else {
return '感谢您的咨询如需更多帮助请致电客服热线400-XXX-XXXX'
}
}
</script>
<style lang="scss" scoped>
.service-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.faq-section {
padding: 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.faq-list {
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.faq-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.faq-text {
font-size: 28rpx;
color: #303133;
}
.faq-arrow {
font-size: 40rpx;
color: #c0c4cc;
}
.chat-section {
padding: 0 30rpx;
}
.chat-container {
background: #ffffff;
border-radius: 20rpx;
height: 800rpx;
display: flex;
flex-direction: column;
}
.message-list {
flex: 1;
padding: 30rpx;
overflow-y: auto;
}
.message-wrapper {
display: flex;
flex-direction: column;
}
.message-item {
display: flex;
margin-bottom: 24rpx;
&.user {
justify-content: flex-end;
.message-bubble {
background: #409eff;
.message-text {
color: #ffffff;
}
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.service, &.system {
justify-content: flex-start;
.message-bubble {
background: #f0f0f0;
.message-text {
color: #303133;
}
.message-time {
color: #909399;
}
}
}
}
.message-bubble {
max-width: 70%;
padding: 20rpx 24rpx;
border-radius: 20rpx;
display: flex;
flex-direction: column;
}
.message-text {
font-size: 28rpx;
line-height: 1.6;
word-break: break-all;
}
.message-time {
font-size: 22rpx;
margin-top: 8rpx;
align-self: flex-end;
}
.input-section {
display: flex;
align-items: center;
padding: 20rpx 30rpx;
border-top: 1rpx solid #f0f0f0;
}
.message-input {
flex: 1;
background: #f5f5f5;
border-radius: 40rpx;
padding: 20rpx 30rpx;
font-size: 28rpx;
margin-right: 20rpx;
}
.send-btn {
width: 120rpx;
height: 68rpx;
line-height: 68rpx;
background: #409eff;
color: #ffffff;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,703 @@
<template>
<view class="device-config-container">
<scroll-view class="config-content" scroll-y>
<!-- 设备信息卡片 -->
<view class="device-card">
<view class="device-icon-wrapper">
<text class="device-icon">💧</text>
</view>
<text class="device-name">{{ deviceInfo.name || '设备' }}</text>
<text class="device-sn">设备编号{{ deviceInfo.sn }}</text>
</view>
<!-- 重命名设备 -->
<view class="config-section">
<view class="section-title">设备名称</view>
<view class="input-wrapper">
<input
class="input-field"
v-model="deviceName"
placeholder="请输入设备名称"
maxlength="20"
/>
</view>
</view>
<!-- 滤芯管理 -->
<view class="config-section">
<view class="section-title">滤芯管理</view>
<!-- 当前滤芯列表 -->
<view v-if="filters.length > 0" class="filter-list">
<view class="filter-item" v-for="(filter, index) in filters" :key="index">
<view class="filter-info">
<text class="filter-name">{{ filter.name }}</text>
<text class="filter-model">型号{{ filter.model || '未设置' }}</text>
<text class="filter-remain">剩余寿命{{ filter.remain }}%</text>
</view>
<button class="replace-btn" @click="handleReplaceFilter(filter, index)">更换</button>
</view>
</view>
<!-- 未安装滤芯 -->
<view v-else class="filter-empty">
<text class="empty-text">还未安装滤芯</text>
</view>
<!-- 添加滤芯按钮 -->
<button class="add-filter-btn" @click="handleAddFilter">+ </button>
</view>
<!-- 保存按钮 -->
<view class="action-section">
<button class="save-btn" @click="handleSave" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
</scroll-view>
<!-- 更换滤芯弹窗 -->
<view v-if="showReplaceModal" class="modal-overlay" @click="closeReplaceModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">更换滤芯</text>
<text class="modal-close" @click="closeReplaceModal">×</text>
</view>
<view class="modal-body">
<view class="filter-select-section">
<text class="select-label">选择滤芯型号</text>
<picker
mode="selector"
:range="filterTypeOptions"
range-key="label"
:value="selectedFilterTypeIndex"
@change="onFilterTypeChange"
>
<view class="picker-view">
<text class="picker-text">
{{ selectedFilterType ? selectedFilterType.label : '请选择滤芯型号' }}
</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-actions">
<button class="modal-btn cancel-btn" @click="closeReplaceModal"></button>
<button class="modal-btn confirm-btn" @click="confirmReplaceFilter"></button>
</view>
</view>
</view>
</view>
<!-- 添加滤芯弹窗 -->
<view v-if="showAddModal" class="modal-overlay" @click="closeAddModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">添加滤芯</text>
<text class="modal-close" @click="closeAddModal">×</text>
</view>
<view class="modal-body">
<view class="filter-select-section">
<text class="select-label">滤芯名称</text>
<input
class="modal-input"
v-model="newFilterName"
placeholder="请输入滤芯名称PP棉滤芯"
maxlength="20"
/>
</view>
<view class="filter-select-section">
<text class="select-label">选择滤芯型号</text>
<picker
mode="selector"
:range="filterTypeOptions"
range-key="label"
:value="selectedFilterTypeIndex"
@change="onFilterTypeChange"
>
<view class="picker-view">
<text class="picker-text">
{{ selectedFilterType ? selectedFilterType.label : '请选择滤芯型号' }}
</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="modal-actions">
<button class="modal-btn cancel-btn" @click="closeAddModal"></button>
<button class="modal-btn confirm-btn" @click="confirmAddFilter"></button>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getDeviceDetail, updateDevice, addFilter, updateFilter } from '@/api/device.js'
//
const deviceInfo = ref({
deviceId: null,
name: '',
sn: ''
})
const deviceName = ref('')
const filters = ref([])
const saving = ref(false)
//
const filterTypeOptions = ref([
{ label: 'PP-001 (PP棉滤芯)', value: 'PP-001', type: 'PP棉滤芯' },
{ label: 'CTO-001 (前置活性炭)', value: 'CTO-001', type: '前置活性炭' },
{ label: 'RO-001 (RO反渗透膜)', value: 'RO-001', type: 'RO反渗透膜' },
{ label: 'GAC-001 (后置活性炭)', value: 'GAC-001', type: '后置活性炭' },
{ label: 'RO-5级复合滤芯', value: 'RO-5级复合滤芯', type: 'RO-5级复合滤芯' }
])
//
const showReplaceModal = ref(false)
const showAddModal = ref(false)
const currentReplaceFilterIndex = ref(-1)
const selectedFilterTypeIndex = ref(0)
const selectedFilterType = ref(null)
const newFilterName = ref('')
//
onMounted(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const sn = options.sn
const deviceId = options.deviceId
if (sn) {
loadDeviceInfo(sn)
} else if (deviceId) {
deviceInfo.value.deviceId = deviceId
// sndeviceId
uni.showToast({
title: '设备信息不完整',
icon: 'none'
})
}
})
//
const loadDeviceInfo = async (sn) => {
try {
const res = await getDeviceDetail(sn)
if (res) {
deviceInfo.value = {
deviceId: res.deviceId,
name: res.deviceName || '',
sn: res.deviceSn || sn
}
deviceName.value = res.deviceName || ''
//
filters.value = (res.filters || []).map(filter => ({
filterId: filter.filterId,
name: filter.filterName || '',
model: filter.filterType || filter.model || '',
remain: filter.lifePercent || 0,
replaceTime: filter.replaceDate || filter.installDate
}))
}
} catch (error) {
console.error('加载设备信息失败', error)
uni.showToast({
title: '加载设备信息失败',
icon: 'none'
})
}
}
//
const handleReplaceFilter = (filter, index) => {
currentReplaceFilterIndex.value = index
selectedFilterTypeIndex.value = 0
selectedFilterType.value = null
showReplaceModal.value = true
}
//
const handleAddFilter = () => {
newFilterName.value = ''
selectedFilterTypeIndex.value = 0
selectedFilterType.value = null
showAddModal.value = true
}
//
const onFilterTypeChange = (e) => {
const index = parseInt(e.detail.value)
selectedFilterTypeIndex.value = index
selectedFilterType.value = filterTypeOptions.value[index]
}
//
const confirmReplaceFilter = async () => {
if (!selectedFilterType.value) {
uni.showToast({
title: '请选择滤芯型号',
icon: 'none'
})
return
}
const index = currentReplaceFilterIndex.value
if (index >= 0 && index < filters.value.length) {
const filter = filters.value[index]
if (!filter.filterId) {
uni.showToast({
title: '滤芯信息不完整',
icon: 'none'
})
return
}
try {
uni.showLoading({ title: '更换中...' })
// API
await updateFilter(deviceInfo.value.deviceId, filter.filterId, {
filterName: selectedFilterType.value.type,
filterType: selectedFilterType.value.type,
model: selectedFilterType.value.value
})
//
filter.model = selectedFilterType.value.value
filter.name = selectedFilterType.value.type
filter.remain = 100
// 3
const now = new Date()
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 3, now.getDate())
filter.replaceTime = nextMonth.toISOString().split('T')[0]
closeReplaceModal()
uni.hideLoading()
uni.showToast({
title: '更换成功',
icon: 'success'
})
} catch (error) {
uni.hideLoading()
console.error('更换滤芯失败', error)
// request.js
}
}
}
//
const confirmAddFilter = async () => {
if (!newFilterName.value.trim()) {
uni.showToast({
title: '请输入滤芯名称',
icon: 'none'
})
return
}
if (!selectedFilterType.value) {
uni.showToast({
title: '请选择滤芯型号',
icon: 'none'
})
return
}
if (!deviceInfo.value.deviceId) {
uni.showToast({
title: '设备信息不完整',
icon: 'none'
})
return
}
try {
uni.showLoading({ title: '添加中...' })
// API
await addFilter(deviceInfo.value.deviceId, {
filterName: newFilterName.value.trim(),
filterType: selectedFilterType.value.type,
model: selectedFilterType.value.value
})
//
if (deviceInfo.value.sn) {
await loadDeviceInfo(deviceInfo.value.sn)
}
closeAddModal()
uni.hideLoading()
uni.showToast({
title: '添加成功',
icon: 'success'
})
} catch (error) {
uni.hideLoading()
console.error('添加滤芯失败', error)
// request.js
}
}
//
const closeReplaceModal = () => {
showReplaceModal.value = false
currentReplaceFilterIndex.value = -1
selectedFilterType.value = null
}
//
const closeAddModal = () => {
showAddModal.value = false
newFilterName.value = ''
selectedFilterType.value = null
}
//
const handleSave = async () => {
if (!deviceName.value.trim()) {
uni.showToast({
title: '请输入设备名称',
icon: 'none'
})
return
}
if (!deviceInfo.value.deviceId) {
uni.showToast({
title: '设备信息不完整',
icon: 'none'
})
return
}
try {
saving.value = true
//
await updateDevice(deviceInfo.value.deviceId, {
deviceName: deviceName.value.trim()
})
uni.showToast({
title: '保存成功',
icon: 'success'
})
//
setTimeout(() => {
uni.navigateBack({
delta: 1
})
}, 1500)
} catch (error) {
console.error('保存失败', error)
// request.js
} finally {
saving.value = false
}
}
</script>
<style lang="scss" scoped>
.device-config-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.config-content {
flex: 1;
}
.device-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 40rpx 30rpx;
text-align: center;
margin-bottom: 20rpx;
}
.device-icon-wrapper {
width: 100rpx;
height: 100rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20rpx;
border: 3rpx solid rgba(255, 255, 255, 0.5);
}
.device-icon {
font-size: 50rpx;
}
.device-name {
display: block;
font-size: 32rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.device-sn {
display: block;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.9);
}
.config-section {
background: #ffffff;
margin: 0 30rpx 20rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.input-wrapper {
background: #f9f9f9;
border-radius: 12rpx;
padding: 0 24rpx;
}
.input-field {
height: 88rpx;
line-height: 88rpx;
font-size: 28rpx;
color: #303133;
}
.filter-list {
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 24rpx;
}
.filter-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.filter-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.filter-name {
font-size: 28rpx;
font-weight: bold;
color: #303133;
}
.filter-model,
.filter-remain {
font-size: 24rpx;
color: #909399;
}
.replace-btn {
height: 56rpx;
line-height: 56rpx;
padding: 0 24rpx;
background: #409eff;
color: #ffffff;
border-radius: 28rpx;
font-size: 24rpx;
border: none;
&::after {
border: none;
}
}
.filter-empty {
padding: 60rpx 0;
text-align: center;
margin-bottom: 24rpx;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.add-filter-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #f0f0f0;
color: #606266;
border-radius: 44rpx;
font-size: 28rpx;
border: 2rpx dashed #d0d0d0;
&::after {
border: none;
}
}
.action-section {
padding: 30rpx;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
opacity: 0.6;
}
}
//
.modal-overlay {
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 {
width: 600rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.modal-close {
font-size: 48rpx;
color: #909399;
line-height: 1;
}
.modal-body {
padding: 30rpx;
}
.filter-select-section {
margin-bottom: 30rpx;
}
.select-label {
display: block;
font-size: 28rpx;
color: #606266;
margin-bottom: 16rpx;
}
.picker-view {
display: flex;
justify-content: space-between;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.picker-text {
font-size: 28rpx;
color: #303133;
}
.picker-arrow {
font-size: 32rpx;
color: #909399;
}
.modal-input {
height: 88rpx;
line-height: 88rpx;
padding: 0 24rpx;
background: #f9f9f9;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
}
.modal-actions {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
.modal-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.cancel-btn {
background: #f0f0f0;
color: #606266;
}
.confirm-btn {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
</style>

@ -0,0 +1,776 @@
<template>
<view class="device-detail-container">
<scroll-view class="detail-content" scroll-y>
<!-- 加载状态 -->
<view v-if="loading" class="loading-wrapper">
<text>加载中...</text>
</view>
<!-- 设备状态卡片 -->
<view class="status-card">
<view class="device-icon-wrapper">
<text class="device-icon">💧</text>
</view>
<text class="device-status-text" :class="getStatusClass(deviceDetail.status)">
{{ deviceDetail.statusText }}
</text>
<text class="device-name">{{ deviceDetail.name }}</text>
</view>
<!-- 滤芯寿命 -->
<view class="filter-section">
<view class="section-title">滤芯寿命</view>
<view v-if="!deviceDetail.filters || deviceDetail.filters.length === 0" class="filter-empty">
<text class="filter-empty-text">还未安装滤芯</text>
</view>
<view v-else class="filter-list">
<view class="filter-item" v-for="(filter, index) in deviceDetail.filters" :key="index">
<view class="filter-header">
<text class="filter-name">{{ filter.name }}</text>
<text class="filter-remain">剩余 {{ filter.remain }}%</text>
</view>
<view class="progress-bar">
<view
class="progress-fill"
:style="{
width: filter.remain + '%',
background: getProgressColor(filter.remain)
}"
></view>
</view>
<view class="filter-info">
<text class="filter-model">型号{{ filter.model }}</text>
<text class="filter-time">预计更换{{ filter.replaceTime }}</text>
</view>
</view>
</view>
</view>
<!-- 最新水质情况 -->
<view class="water-quality-section">
<view class="section-title">最新水质情况</view>
<view v-if="latestWaterQuality" class="water-quality-card">
<view class="quality-header">
<text class="quality-level" :class="getWaterLevelClass(latestWaterQuality.level)">{{ latestWaterQuality.level }}</text>
<text class="quality-date">{{ latestWaterQuality.date }} 检测</text>
</view>
<text class="quality-desc">{{ latestWaterQuality.description }}</text>
<view class="indicator-grid">
<view class="indicator-item" v-for="indicator in latestWaterQuality.result" :key="indicator.name">
<text class="indicator-name">{{ indicator.name }}</text>
<text class="indicator-value">{{ indicator.value }} {{ indicator.unit }}</text>
<text class="indicator-status" :class="getIndicatorStatusClass(indicator)">{{ getIndicatorStatusText(indicator) }}</text>
</view>
</view>
<view class="quality-actions">
<button class="quality-btn" @click="handleViewWaterQuality"></button>
<button class="quality-btn outline" @click="handleWaterQualityDetail"></button>
</view>
</view>
<view v-else class="water-quality-empty">
<text>暂无最新水质数据</text>
</view>
</view>
<!-- 设备信息 -->
<view class="info-section">
<view class="section-title">设备信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">设备编号</text>
<text class="info-value">{{ deviceDetail.sn }}</text>
</view>
<view class="info-item">
<text class="info-label">设备类型</text>
<text class="info-value">{{ deviceDetail.type }}</text>
</view>
<view class="info-item">
<text class="info-label">滤芯型号</text>
<text class="info-value">{{ deviceDetail.filterModel }}</text>
</view>
<view class="info-item">
<text class="info-label">设备地址</text>
<text class="info-value">{{ deviceDetail.address }}</text>
</view>
<view class="info-item">
<text class="info-label">绑定时间</text>
<text class="info-value">{{ deviceDetail.bindTime }}</text>
</view>
</view>
</view>
<!-- 设备操作 -->
<view class="action-section">
<button class="action-btn primary-btn" @click="handleDeviceConfig"></button>
<button class="action-btn secondary-btn" @click="handleViewWaterQuality"></button>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getDeviceDetail } from '@/api/device.js'
//
const deviceDetail = ref({
deviceId: null,
name: '',
sn: '',
type: '',
filterModel: '',
address: '',
bindTime: '',
status: 'online',
statusText: '在线',
filters: []
})
const latestWaterQuality = ref(null)
const loading = ref(false)
let filterLifeTimer = null
const waterQualityStandards = {
'pH值': { min: 6.5, max: 8.5, unit: '' },
'浊度': { max: 1, unit: 'NTU' },
'余氯': { min: 0.05, max: 4, unit: 'mg/L' },
'总硬度': { max: 450, unit: 'mg/L' },
'TDS': { max: 1000, unit: 'mg/L' }
}
//
const getStatusClass = (status) => {
if (typeof status === 'string') {
if (status === 'online' || status === '1') return 'status-online'
if (status === 'offline' || status === '0' || status === '2') return 'status-offline'
}
return 'status-offline'
}
//
const getProgressColor = (remain) => {
if (remain > 60) {
return 'linear-gradient(90deg, #67c23a 0%, #85ce61 100%)'
} else if (remain > 30) {
return 'linear-gradient(90deg, #e6a23c 0%, #ebb563 100%)'
} else {
return 'linear-gradient(90deg, #f56c6c 0%, #f78989 100%)'
}
}
//
const handleDeviceConfig = () => {
const sn = deviceDetail.value.sn
const deviceId = deviceDetail.value.deviceId
if (!sn) {
uni.showToast({
title: '设备信息不完整',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/device-config/device-config?sn=${sn}&deviceId=${deviceId || ''}`
})
}
//
const handleViewWaterQuality = () => {
const sn = deviceDetail.value.sn
const url = sn ? `/pages/water-quality/water-quality?sn=${sn}` : '/pages/water-quality/water-quality'
uni.navigateTo({ url })
}
const handleWaterQualityDetail = () => {
if (!latestWaterQuality.value) {
uni.showToast({
title: '暂无水质报告',
icon: 'none'
})
return
}
const recordId = latestWaterQuality.value.id || `latest_${deviceDetail.value.sn}`
try {
uni.setStorageSync(`waterQualityRecord_${recordId}`, {
...latestWaterQuality.value,
id: recordId
})
uni.navigateTo({
url: `/pages/water-quality-detail/water-quality-detail?recordId=${recordId}`
})
} catch (e) {
uni.showToast({
title: '打开报告失败',
icon: 'none'
})
}
}
//
const formatDateTime = (dateTime) => {
if (!dateTime) return ''
if (typeof dateTime === 'string') {
//
if (dateTime.includes(' ')) return dateTime
// YYYY-MM-DD HH:mm:ss
return dateTime.replace('T', ' ').substring(0, 19)
}
// Date
const date = new Date(dateTime)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
//
const formatDate = (date) => {
if (!date) return ''
if (typeof date === 'string') {
return date.split(' ')[0]
}
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
//
onMounted(() => {
//
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const sn = options.sn
if (sn) {
loadDeviceDetail(sn)
}
})
//
onShow(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const sn = options.sn || deviceDetail.value.sn
if (sn) {
loadDeviceDetail(sn)
}
})
//
onUnmounted(() => {
if (filterLifeTimer) {
clearInterval(filterLifeTimer)
filterLifeTimer = null
}
})
//
const loadDeviceDetail = async (sn) => {
if (!sn) {
uni.showToast({
title: '设备编号不能为空',
icon: 'none'
})
return
}
try {
loading.value = true
const res = await getDeviceDetail(sn)
console.log('设备详情API返回:', res)
if (res) {
const data = res
// "0"线/"1"线/"2" -> "offline"/"online"/"offline"
let status = 'offline'
if (data.status === '1') {
status = 'online'
} else if (data.status === '0' || data.status === '2') {
status = 'offline'
}
//
const filters = (data.filters || []).map(filter => ({
name: filter.filterName || '',
model: filter.filterType || '',
remain: filter.lifePercent || 0,
replaceTime: formatDate(filter.replaceDate)
}))
// 使
let filterModel = 'RO-5级复合滤芯'
if (filters.length > 0 && filters[0].model) {
filterModel = filters[0].model
} else if (filters.length > 0 && filters[0].name) {
filterModel = filters[0].name
}
//
deviceDetail.value = {
deviceId: data.deviceId,
name: data.deviceName || '',
sn: data.deviceSn || sn,
type: data.deviceType || '智能净水器',
filterModel: filterModel,
address: data.installAddress || '',
bindTime: formatDateTime(data.installDate),
status: status,
statusText: data.statusText || '未知',
filters: filters
}
//
if (data.qualityRecords && data.qualityRecords.length > 0) {
loadLatestWaterQualityFromApi(data.qualityRecords[0], data.deviceSn || sn)
} else {
latestWaterQuality.value = null
}
} else {
uni.showToast({
title: '设备详情数据为空',
icon: 'none'
})
}
} catch (error) {
console.error('加载设备详情失败', error)
uni.showToast({
title: '获取设备详情失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
// API
const loadLatestWaterQualityFromApi = (qualityRecord, sn) => {
if (!qualityRecord) {
latestWaterQuality.value = null
return
}
//
const result = []
if (qualityRecord.ph !== null && qualityRecord.ph !== undefined) {
result.push({ name: 'pH值', value: qualityRecord.ph.toString(), unit: '' })
}
if (qualityRecord.turbidity !== null && qualityRecord.turbidity !== undefined) {
result.push({ name: '浊度', value: qualityRecord.turbidity.toString(), unit: 'NTU' })
}
if (qualityRecord.tds !== null && qualityRecord.tds !== undefined) {
result.push({ name: 'TDS', value: qualityRecord.tds.toString(), unit: 'mg/L' })
}
const level = qualityRecord.level || '一般'
latestWaterQuality.value = {
id: qualityRecord.qualityId || `latest_${sn}`,
name: deviceDetail.value.name,
sn: sn,
level: level,
date: formatDate(qualityRecord.checkDate),
description: `当前水质等级为${level},指标表现正常。`,
result: result
}
}
const getWaterLevelClass = (level) => {
if (level === '优质') return 'level-good'
if (level === '良好') return 'level-normal'
if (level === '一般') return 'level-warning'
return 'level-danger'
}
const getIndicatorStatusClass = (indicator) => {
const standard = waterQualityStandards[indicator.name]
if (!standard) return 'status-default'
const value = parseFloat(indicator.value)
if (isNaN(value)) return 'status-default'
if (standard.min !== undefined && value < standard.min) {
return 'status-low'
}
if (standard.max !== undefined && value > standard.max) {
return 'status-high'
}
return 'status-normal'
}
const getIndicatorStatusText = (indicator) => {
const standard = waterQualityStandards[indicator.name]
if (!standard) return '未知'
const value = parseFloat(indicator.value)
if (isNaN(value)) return '未知'
if (standard.min !== undefined && value < standard.min) {
return '偏低'
}
if (standard.max !== undefined && value > standard.max) {
return '偏高'
}
return '正常'
}
</script>
<style lang="scss" scoped>
.device-detail-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.detail-content {
flex: 1;
}
.loading-wrapper {
padding: 100rpx 0;
text-align: center;
color: #909399;
font-size: 28rpx;
}
.status-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 60rpx 30rpx;
text-align: center;
margin-bottom: 20rpx;
}
.device-icon-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.5);
}
.device-icon {
font-size: 60rpx;
}
.device-status-text {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 12rpx;
padding: 8rpx 24rpx;
border-radius: 40rpx;
background: rgba(255, 255, 255, 0.2);
width: fit-content;
margin-left: auto;
margin-right: auto;
&.status-online {
background: rgba(103, 194, 58, 0.3);
}
&.status-offline {
background: rgba(245, 108, 108, 0.3);
}
}
.device-name {
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
}
.filter-section,
.info-section,
.water-quality-section {
background: #ffffff;
margin: 0 30rpx 20rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.filter-list {
display: flex;
flex-direction: column;
gap: 30rpx;
}
.filter-empty {
padding: 80rpx 0;
text-align: center;
}
.filter-empty-text {
font-size: 28rpx;
color: #909399;
}
.filter-item {
padding: 24rpx;
background: #f9f9f9;
border-radius: 16rpx;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.filter-name {
font-size: 28rpx;
font-weight: bold;
color: #303133;
}
.filter-remain {
font-size: 28rpx;
font-weight: bold;
color: #409eff;
}
.progress-bar {
width: 100%;
height: 16rpx;
background: #e4e7ed;
border-radius: 8rpx;
overflow: hidden;
margin-bottom: 16rpx;
}
.progress-fill {
height: 100%;
border-radius: 8rpx;
transition: width 0.3s;
}
.filter-info {
display: flex;
justify-content: space-between;
font-size: 24rpx;
color: #909399;
}
.filter-model,
.filter-time {
font-size: 24rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.info-item {
display: flex;
padding-bottom: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.info-label {
font-size: 28rpx;
color: #909399;
min-width: 180rpx;
}
.info-value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
flex: 1;
}
.water-quality-card {
display: flex;
flex-direction: column;
gap: 24rpx;
background: linear-gradient(180deg, rgba(64, 158, 255, 0.12) 0%, rgba(64, 158, 255, 0.05) 100%);
border-radius: 16rpx;
padding: 24rpx;
}
.quality-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.quality-level {
font-size: 28rpx;
font-weight: bold;
padding: 6rpx 18rpx;
border-radius: 20rpx;
background: #ffffff;
}
.level-good {
color: #67c23a;
}
.level-normal {
color: #409eff;
}
.level-warning {
color: #e6a23c;
}
.level-danger {
color: #f56c6c;
}
.quality-date {
font-size: 24rpx;
color: #606266;
}
.quality-desc {
font-size: 26rpx;
color: #303133;
}
.indicator-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.indicator-item {
display: flex;
flex-direction: column;
gap: 8rpx;
background: #ffffff;
border-radius: 14rpx;
padding: 20rpx;
box-shadow: 0 6rpx 16rpx rgba(64, 158, 255, 0.08);
}
.indicator-name {
font-size: 26rpx;
color: #606266;
}
.indicator-value {
font-size: 30rpx;
font-weight: bold;
color: #303133;
}
.indicator-status {
font-size: 22rpx;
padding: 4rpx 16rpx;
border-radius: 16rpx;
align-self: flex-start;
}
.status-normal {
background: rgba(103, 194, 58, 0.15);
color: #67c23a;
}
.status-low {
background: rgba(230, 162, 60, 0.15);
color: #e6a23c;
}
.status-high {
background: rgba(245, 108, 108, 0.15);
color: #f56c6c;
}
.status-default {
background: rgba(144, 147, 153, 0.15);
color: #909399;
}
.quality-actions {
display: flex;
gap: 20rpx;
}
.quality-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
font-size: 26rpx;
font-weight: 500;
border: none;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
.quality-btn.outline {
background: #ffffff;
color: #409eff;
border: 2rpx solid #409eff;
}
.water-quality-empty {
padding: 80rpx 0;
text-align: center;
color: #909399;
font-size: 26rpx;
}
.action-section {
padding: 30rpx;
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
}
.primary-btn {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
.secondary-btn {
background: #ffffff;
color: #409eff;
border: 2rpx solid #409eff;
}
.tertiary-btn {
background: #f0f0f0;
color: #606266;
}
</style>

@ -0,0 +1,119 @@
<template>
<view class="problem-container">
<!-- 说明条 -->
<view class="notice-bar">
<text class="notice-icon"></text>
<text class="notice-text">以下为检测到的异常/离线设备请及时处理</text>
</view>
<!-- 问题设备列表 -->
<scroll-view class="problem-list" scroll-y>
<view v-if="problemDevices.length === 0" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无问题设备</text>
</view>
<view v-else>
<view
class="problem-item"
v-for="(d, index) in problemDevices"
:key="index"
@click="goDetail(d)"
>
<view class="item-header">
<text class="device-name">{{ d.name }}</text>
<text class="status-badge">{{ d.statusText || '异常' }}</text>
</view>
<view class="item-content">
<text class="row">设备编号{{ d.sn }}</text>
<text class="row">安装地址{{ d.address }}</text>
<text class="row">绑定时间{{ d.bindTime }}</text>
</view>
<view class="item-actions">
<button class="action-btn fix-btn" @click.stop="handleFix(d)">一键诊断</button>
<button class="action-btn detail-btn" @click.stop="goDetail(d)">查看详情</button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const problemDevices = ref([])
onMounted(() => {
try {
const list = uni.getStorageSync('problemDevices') || []
problemDevices.value = Array.isArray(list) ? list : []
} catch (e) {
problemDevices.value = []
}
})
const goDetail = (device) => {
uni.navigateTo({
url: `/pages/device-detail/device-detail?sn=${device.sn}`
})
}
const handleFix = (device) => {
uni.showLoading({ title: '诊断中...' })
setTimeout(() => {
uni.hideLoading()
uni.showToast({ title: '诊断完成,建议检查网络', icon: 'none' })
}, 1000)
}
</script>
<style lang="scss" scoped>
.problem-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.notice-bar {
display: flex;
align-items: center;
background: #fef0f0;
color: #f56c6c;
padding: 20rpx 30rpx;
}
.notice-icon { font-size: 32rpx; margin-right: 12rpx; }
.notice-text { font-size: 26rpx; }
.problem-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state { align-items: center; justify-content: center; display: flex; flex-direction: column; padding: 200rpx 0; }
.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.5; }
.empty-text { font-size: 28rpx; color: #909399; }
.problem-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
border: 2rpx solid #f0f0f0;
}
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }
.device-name { font-size: 30rpx; font-weight: 600; color: #303133; }
.status-badge { font-size: 22rpx; color: #f56c6c; background: #fde2e2; padding: 4rpx 16rpx; border-radius: 20rpx; }
.item-content { display: flex; flex-direction: column; gap: 8rpx; margin-bottom: 16rpx; }
.row { font-size: 26rpx; color: #606266; }
.item-actions { display: flex; gap: 20rpx; }
.action-btn { flex: 1; height: 64rpx; line-height: 64rpx; border-radius: 32rpx; font-size: 26rpx; border: none; }
.fix-btn { background: #f56c6c; color: #fff; }
.detail-btn { background: #f0f0f0; color: #606266; }
</style>

@ -0,0 +1,652 @@
<template>
<view class="device-container">
<!-- 设备统计 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">{{ deviceStats.total }}</text>
<text class="stat-label">设备总数</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ deviceStats.online }}</text>
<text class="stat-label">在线设备</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ deviceStats.offline }}</text>
<text class="stat-label">离线设备</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item problem-item" @click="handleProblemDevices">
<text class="stat-value">{{ deviceStats.problem }}</text>
<text class="stat-label">问题设备</text>
</view>
</view>
</view>
<!-- 设备列表 -->
<scroll-view class="device-list" scroll-y>
<view v-if="devices.length === 0" class="empty-state">
<text class="empty-icon">📱</text>
<text class="empty-text">暂无设备</text>
</view>
<view v-else>
<view
class="device-item"
v-for="(device, index) in devices"
:key="index"
@click="handleDeviceClick(device)"
>
<view class="device-header">
<view class="device-info">
<text class="device-name">{{ device.name }}</text>
<text class="device-sn">设备编号{{ device.sn }}</text>
</view>
<view class="device-status" :class="getStatusClass(device.status)">
<text class="status-dot"></text>
<text class="status-text">{{ device.statusText }}</text>
</view>
</view>
<view class="device-content">
<view class="device-detail">
<text class="detail-label">设备类型</text>
<text class="detail-value">{{ device.type }}</text>
</view>
<view class="device-detail">
<text class="detail-label">安装地址</text>
<text class="detail-value">{{ device.address }}</text>
</view>
<view class="device-detail">
<text class="detail-label">绑定时间</text>
<text class="detail-value">{{ device.bindTime }}</text>
</view>
</view>
<view class="device-actions">
<button class="action-btn detail-btn" @click.stop="handleDeviceDetail(device)">详情</button>
<button class="action-btn manage-btn" @click.stop="handleManage(device)">管理</button>
</view>
</view>
</view>
</scroll-view>
<!-- 底部添加设备按钮 -->
<view class="footer">
<button class="add-device-btn" @click="handleAddDevice">+ </button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getDeviceList, unbindDevice, bindDevice } from '../../api/device.js'
//
const loading = ref(false)
//
const deviceStats = ref({
total: 0,
online: 0,
offline: 0,
problem: 0
})
//
const devices = ref([])
//
const calculateStats = () => {
const total = devices.value.length
const online = devices.value.filter(d => d.status === '1' || d.status === 1).length
const offline = devices.value.filter(d => d.status === '0' || d.status === 0).length
const problem = devices.value.filter(d => {
// 线寿30%
return d.status === '0' || d.status === 0 ||
d.status === '2' || d.status === 2 ||
(d.filterLifePercent && d.filterLifePercent < 30)
}).length
deviceStats.value = {
total,
online,
offline,
problem
}
}
//
const getStatusClass = (status) => {
if (status === '1' || status === 1) return 'status-online'
if (status === '0' || status === 0) return 'status-offline'
if (status === '2' || status === 2) return 'status-offline' // 线
return 'status-offline'
}
//
const getDeviceTypeText = (type) => {
const typeMap = {
'purifier': '智能净水器',
'meter': '智能水表',
'other': '其他设备'
}
return typeMap[type] || type || '智能净水器'
}
//
const formatDate = (date) => {
if (!date) return '未知'
if (typeof date === 'string') {
//
return date.split(' ')[0]
}
// DateYYYY-MM-DD
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
//
const handleDeviceClick = (device) => {
handleDeviceDetail(device)
}
//
const handleDeviceDetail = (device) => {
uni.navigateTo({
url: `/pages/device-detail/device-detail?sn=${device.sn}`
})
}
//
const handleManage = (device) => {
uni.showActionSheet({
itemList: ['查看详情', '设备配置', '解绑设备'],
success: (res) => {
if (res.tapIndex === 0) {
handleDeviceDetail(device)
} else if (res.tapIndex === 1) {
uni.showToast({
title: '设备配置功能开发中',
icon: 'none'
})
} else if (res.tapIndex === 2) {
//
handleUnbindDevice(device)
}
}
})
}
//
const handleUnbindDevice = async (device) => {
uni.showModal({
title: '确认解绑',
content: `确定要解绑设备"${device.deviceName || device.deviceSn}"吗?`,
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '解绑中...' })
// API
await unbindDevice(device.deviceId)
//
const index = devices.value.findIndex(d => d.deviceId === device.deviceId)
if (index > -1) {
devices.value.splice(index, 1)
//
calculateStats()
}
uni.hideLoading()
uni.showToast({
title: '解绑成功',
icon: 'success'
})
} catch (error) {
uni.hideLoading()
console.error('解绑设备失败', error)
// request.jsToast
}
}
}
})
}
//
const handleAddDevice = () => {
//
uni.navigateTo({
url: '/pages/device-bind/device-bind',
fail: () => {
//
uni.showActionSheet({
itemList: ['扫描二维码', '手动输入'],
success: (res) => {
if (res.tapIndex === 0) {
//
scanQRCode()
} else {
//
manualInputDevice()
}
}
})
}
})
}
//
const scanQRCode = () => {
// #ifdef MP-WEIXIN
uni.scanCode({
success: (res) => {
const deviceSn = res.result
if (deviceSn) {
//
uni.navigateTo({
url: `/pages/device-bind/device-bind?sn=${deviceSn}`,
fail: () => {
// API
bindDeviceBySn(deviceSn)
}
})
}
},
fail: (err) => {
console.error('扫描失败', err)
uni.showToast({
title: '扫描失败,请重试',
icon: 'none'
})
}
})
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '请使用微信小程序扫描',
icon: 'none'
})
// #endif
}
//
const manualInputDevice = () => {
uni.showModal({
title: '绑定设备',
editable: true,
placeholderText: '请输入设备序列号',
success: async (res) => {
if (res.confirm && res.content) {
const deviceSn = res.content.trim()
if (deviceSn) {
bindDeviceBySn(deviceSn)
} else {
uni.showToast({
title: '请输入设备序列号',
icon: 'none'
})
}
}
}
})
}
//
const bindDeviceBySn = async (deviceSn) => {
try {
uni.showLoading({ title: '绑定中...' })
// API
await bindDevice({
deviceSn: deviceSn
})
uni.hideLoading()
uni.showToast({
title: '绑定成功',
icon: 'success'
})
//
loadDeviceList()
} catch (error) {
uni.hideLoading()
console.error('绑定设备失败', error)
// ""
uni.showToast({
title: '没有设备信息',
icon: 'none'
})
}
}
//
const loadDeviceList = async () => {
try {
loading.value = true
// Token
const token = uni.getStorageSync('token')
if (!token) {
console.warn('未找到Token无法获取设备列表')
devices.value = []
calculateStats()
return
}
// API
const res = await getDeviceList({ pageNum: 1, pageSize: 100 })
if (res && res.rows) {
//
devices.value = res.rows.map(device => {
return {
deviceId: device.deviceId,
name: device.deviceName || device.deviceSn,
sn: device.deviceSn,
type: getDeviceTypeText(device.deviceType),
address: device.installAddress || '未设置地址',
status: device.status,
statusText: device.statusText || getStatusText(device.status),
bindTime: formatDate(device.installDate || device.bindTime),
filterLifePercent: device.filterLife || device.filterLifePercent
}
})
} else {
devices.value = []
}
//
calculateStats()
} catch (error) {
console.error('获取设备列表失败', error)
devices.value = []
calculateStats()
} finally {
loading.value = false
}
}
//
const getStatusText = (status) => {
if (status === '1' || status === 1) return '在线'
if (status === '0' || status === 0) return '离线'
if (status === '2' || status === 2) return '故障'
return '未知'
}
//
const handleProblemDevices = () => {
const problemList = devices.value.filter(d => {
// 线寿30%
return d.status === '0' || d.status === 0 ||
d.status === '2' || d.status === 2 ||
(d.filterLifePercent && d.filterLifePercent < 30)
})
try {
uni.setStorageSync('problemDevices', problemList)
} catch (e) {}
uni.navigateTo({
url: '/pages/device-problem/device-problem'
})
}
//
onPullDownRefresh(() => {
loadDeviceList().finally(() => {
uni.stopPullDownRefresh()
})
})
//
onMounted(() => {
loadDeviceList()
})
//
onShow(() => {
loadDeviceList()
})
</script>
<style lang="scss" scoped>
.device-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.stats-section {
padding: 30rpx;
}
.stats-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 20rpx;
padding: 40rpx;
display: flex;
align-items: center;
justify-content: space-around;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.3);
}
.device-list {
flex: 1;
padding: 0 30rpx;
padding-bottom: 160rpx; //
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
margin-bottom: 40rpx;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #ffffff;
padding: 20rpx 30rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
border-top: 1rpx solid #e4e7ed;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
z-index: 100;
}
.add-device-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: 500;
border: none;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
&::after {
border: none;
}
}
.device-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.device-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.device-name {
display: block;
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-bottom: 8rpx;
}
.device-sn {
font-size: 24rpx;
color: #909399;
}
.device-status {
display: flex;
align-items: center;
padding: 8rpx 16rpx;
border-radius: 20rpx;
&.status-online {
background: #f0f9ff;
.status-dot {
background: #67c23a;
}
.status-text {
color: #67c23a;
}
}
&.status-offline {
background: #fef0f0;
.status-dot {
background: #f56c6c;
}
.status-text {
color: #f56c6c;
}
}
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
margin-right: 8rpx;
}
.status-text {
font-size: 24rpx;
font-weight: 500;
}
.device-content {
margin-bottom: 20rpx;
}
.device-detail {
display: flex;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
.detail-label {
font-size: 26rpx;
color: #909399;
margin-right: 12rpx;
}
.detail-value {
font-size: 26rpx;
color: #606266;
}
.device-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.detail-btn {
background: #f0f0f0;
color: #606266;
}
.manage-btn {
background: #409eff;
color: #ffffff;
}
.filter-warning {
color: #e6a23c;
font-weight: bold;
}
</style>

@ -0,0 +1,775 @@
<template>
<view class="index-container">
<!-- 设备预警提示 -->
<view v-if="!isStaff" class="alert-section">
<view class="alert-card">
<view class="section-title-wrapper">
<text class="section-title">问题设备</text>
<view v-if="problemDeviceOverflow" class="alert-dot"></view>
</view>
<view v-if="displayedProblemDevices.length" class="alert-list">
<view
class="alert-item"
v-for="(device, index) in displayedProblemDevices"
:key="index"
@click="handleProblemDeviceClick(device)"
>
<view class="alert-header">
<text class="alert-name">{{ device.name }}</text>
<text class="alert-tag" :class="getTagClass(device.tagType)">{{ device.tag }}</text>
</view>
<view class="alert-body">
<text class="alert-desc">{{ device.description }}</text>
<text class="alert-address">位置{{ device.address }}</text>
</view>
<view class="alert-footer">
<text class="alert-link">查看问题设备列表 </text>
</view>
</view>
</view>
<view v-else class="alert-empty">
<text>暂无问题设备</text>
</view>
</view>
<view class="alert-card">
<view class="section-title">水质预警</view>
<view v-if="waterQualityAlerts.length" class="alert-list">
<view
class="alert-item"
v-for="(item, index) in displayedWaterQualityAlerts"
:key="index"
@click="handleWaterQualityAlertClick(item)"
>
<view class="alert-header">
<text class="alert-name">{{ item.name }}</text>
<text class="alert-tag" :class="getTagClass(item.tagType)">{{ item.level }}</text>
</view>
<view class="alert-body">
<text class="alert-desc">{{ item.description }}</text>
<text class="alert-address">上次检测{{ item.lastChecked }}</text>
</view>
<view class="alert-footer">
<text class="alert-link">查看水质预警详情 </text>
</view>
</view>
</view>
<view v-else class="alert-empty">
<text>暂无水质异常水质表现良好</text>
</view>
</view>
</view>
<!-- 轮播图 -->
<view class="banner-section">
<swiper class="banner-swiper" :indicator-dots="true" :autoplay="true" :interval="3000" :duration="500" :circular="true">
<swiper-item v-for="(banner, index) in banners" :key="index" @click="handleBannerClick(banner)">
<view class="banner-item" :style="{ background: banner.bgColor }">
<text class="banner-text">{{ banner.title }}</text>
</view>
</swiper-item>
</swiper>
</view>
<!-- 快捷服务 -->
<view class="service-section">
<view class="section-title">快捷服务</view>
<view class="service-grid">
<view class="service-item" v-for="(service, index) in services" :key="index" @click="handleServiceClick(service)">
<view class="service-icon-wrapper" :style="{ background: service.color }">
<text class="service-icon">{{ service.icon }}</text>
</view>
<text class="service-name">{{ service.name }}</text>
</view>
</view>
</view>
<!-- 公告通知 -->
<view class="notice-section">
<view class="section-title">公告通知</view>
<view class="notice-list">
<view class="notice-item" v-for="(notice, index) in notices" :key="index" @click="handleNoticeClick(notice)">
<view class="notice-dot"></view>
<view class="notice-content">
<text class="notice-title">{{ notice.title }}</text>
<text class="notice-time">{{ notice.time }}</text>
</view>
<text class="notice-arrow"></text>
</view>
</view>
</view>
<!-- 数据统计仅工作人员显示 -->
<view v-if="isStaff" class="stats-section">
<view class="section-title">数据统计</view>
<view class="stats-grid">
<view class="stats-item" v-for="(stat, index) in stats" :key="index">
<text class="stats-value">{{ stat.value }}</text>
<text class="stats-label">{{ stat.label }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
import { getDeviceList } from '../../api/device.js'
import { getQualityAlertList } from '../../api/quality.js'
import { getNoticeList } from '../../api/message.js'
//
const userRole = ref('user')
const isStaff = computed(() => userRole.value === 'staff')
//
const loading = ref(false)
//
const problemDevices = ref([])
const displayedProblemDevices = computed(() => problemDevices.value.slice(0, 2))
const problemDeviceOverflow = computed(() => problemDevices.value.length > 2)
const displayedWaterQualityAlerts = computed(() => waterQualityAlerts.value.slice(0, 2))
watch(problemDevices, (newVal) => {
try {
uni.setStorageSync('problemDevices', newVal || [])
} catch (e) {}
}, { deep: true })
watch(waterQualityAlerts, (newVal) => {
try {
uni.setStorageSync('waterQualityAlerts', newVal || [])
} catch (e) {}
}, { deep: true })
//
const waterQualityAlerts = ref([])
//
const banners = ref([
{ title: '水质监测实时数据', bgColor: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)' },
{ title: '供水服务便民措施', bgColor: 'linear-gradient(135deg, #67c23a 0%, #85ce61 100%)' },
{ title: '节水知识科普宣传', bgColor: 'linear-gradient(135deg, #e6a23c 0%, #ebb563 100%)' }
])
//
const services = ref([
{ name: '订购', icon: '🛒', color: '#409eff', path: '/pages/purchase/purchase' },
{ name: '设备管理', icon: '📱', color: '#67c23a', path: '/pages/device/device' },
{ name: '水质检测', icon: '💧', color: '#f56c6c', path: '/pages/water-quality/water-quality' },
{ name: '在线客服', icon: '💬', color: '#409eff', path: '/pages/customer-service/customer-service' },
{ name: '订单管理', icon: '📋', color: '#e6a23c', path: '/pages/order/order' }
])
//
const notices = ref([])
//
const stats = ref([
{ label: '待处理工单', value: '12' },
{ label: '今日完成', value: '8' },
{ label: '在线用户', value: '256' },
{ label: '待审核', value: '5' }
])
//
const handleBannerClick = (banner) => {
uni.showToast({
title: banner.title,
icon: 'none'
})
}
//
const handleServiceClick = (service) => {
if (service.path) {
// 使 switchTab tabBar
if (service.path === '/pages/order/order') {
uni.switchTab({
url: service.path
})
} else {
// 使 navigateTo
uni.navigateTo({
url: service.path,
fail: () => {
uni.redirectTo({
url: service.path
})
}
})
}
} else {
uni.showToast({
title: service.name + '功能开发中',
icon: 'none'
})
}
}
const handleProblemDeviceClick = (device) => {
try {
uni.setStorageSync('problemDevices', problemDevices.value || [])
if (device && device.sn) {
uni.setStorageSync('problemDeviceSelectedSn', device.sn)
}
} catch (e) {}
uni.navigateTo({
url: '/pages/device-problem/device-problem'
})
}
const handleWaterQualityAlertClick = (alert) => {
try {
uni.setStorageSync('waterQualityAlerts', waterQualityAlerts.value || [])
if (alert && alert.sn) {
uni.setStorageSync('waterQualityAlertSelectedSn', alert.sn)
}
} catch (e) {}
uni.navigateTo({
url: '/pages/water-quality-alert/water-quality-alert'
})
}
//
const handleNoticeClick = (notice) => {
if (notice.noticeId) {
//
uni.navigateTo({
url: `/pages/notice-detail/notice-detail?id=${notice.noticeId}`,
fail: () => {
//
uni.showModal({
title: notice.title,
content: notice.content || '暂无详细内容',
showCancel: false
})
}
})
} else {
uni.showToast({
title: notice.title,
icon: 'none'
})
}
}
//
onPullDownRefresh(() => {
loadHomeData().finally(() => {
uni.stopPullDownRefresh()
})
})
const handleDeviceDetail = (sn) => {
if (!sn) {
uni.showToast({
title: '设备信息缺失',
icon: 'none'
})
return
}
const url = `/pages/device-detail/device-detail?sn=${sn}`
uni.navigateTo({
url,
fail: () => {
uni.redirectTo({ url })
}
})
}
const getTagClass = (type) => {
if (type === 'danger') return 'tag-danger'
if (type === 'warning') return 'tag-warning'
return 'tag-info'
}
//
// Token
const loadProblemDevices = async () => {
if (isStaff.value) return //
try {
loading.value = true
// Token
const token = uni.getStorageSync('token')
if (!token) {
console.warn('未找到Token无法获取设备列表')
problemDevices.value = []
return
}
// APITokenID
const res = await getDeviceList({ pageNum: 1, pageSize: 10 })
console.log('获取设备列表响应:', res)
if (res && res.rows) {
// 线
const problemList = res.rows.filter(device => {
// 0-线2-寿30%
return device.status === '0' || device.status === '2' ||
(device.filterLifePercent && device.filterLifePercent < 30)
}).map(device => {
//
let tag = '正常'
let tagType = 'info'
let description = ''
if (device.status === '0') {
tag = '离线'
tagType = 'danger'
description = '设备已离线,请检查电源与网络连接'
} else if (device.status === '2') {
tag = '故障'
tagType = 'danger'
description = '设备出现故障,请联系工作人员'
} else if (device.filterLifePercent && device.filterLifePercent < 30) {
tag = '滤芯预警'
tagType = 'warning'
description = `主滤芯寿命低于 ${device.filterLifePercent}%,建议尽快预约更换`
}
return {
deviceId: device.deviceId,
name: device.deviceName || device.deviceSn,
sn: device.deviceSn,
tag: tag,
tagType: tagType,
description: description,
address: device.installAddress || device.location || '未设置地址'
}
})
problemDevices.value = problemList
} else {
//
problemDevices.value = []
}
} catch (error) {
console.error('获取问题设备失败', error)
// 使
//
problemDevices.value = []
} finally {
loading.value = false
}
}
//
const loadWaterQualityAlerts = async () => {
if (isStaff.value) return //
// Token
const token = uni.getStorageSync('token')
if (!token) {
console.warn('未检测到登录Token跳过获取水质预警列表。')
waterQualityAlerts.value = [] //
return
}
try {
// API
const res = await getQualityAlertList({ pageNum: 1, pageSize: 100 })
if (res && res.rows) {
// hasAlerttrue""""
const alertList = res.rows
.filter(item => {
// hasAlerttrue""""
return item.hasAlert === true ||
item.level === '较差' ||
item.level === '一般' ||
item.levelCode === 'poor' ||
item.levelCode === 'normal'
})
.map(item => {
//
let tagType = 'warning'
if (item.level === '较差' || item.levelCode === 'poor') {
tagType = 'danger'
} else if (item.level === '一般' || item.levelCode === 'normal') {
tagType = 'warning'
}
//
let description = `水质等级:${item.level || '未知'}`
if (item.tds !== undefined && item.tds !== null) {
description += `TDS${item.tds} mg/L`
}
if (item.turbidity !== undefined && item.turbidity !== null) {
description += `,浊度:${item.turbidity} NTU`
}
//
let lastChecked = '未知'
if (item.checkDate) {
if (typeof item.checkDate === 'string') {
lastChecked = item.checkDate.split(' ')[0]
} else {
// DateYYYY-MM-DD
const d = new Date(item.checkDate)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
lastChecked = `${year}-${month}-${day}`
}
}
return {
qualityId: item.qualityId,
name: item.deviceName || item.deviceSn || '未知设备',
sn: item.deviceSn,
level: item.level || '未知',
tagType: tagType,
description: description,
lastChecked: lastChecked
}
})
waterQualityAlerts.value = alertList
} else {
waterQualityAlerts.value = []
}
} catch (error) {
console.error('获取水质预警失败', error)
// 使
//
waterQualityAlerts.value = []
}
}
//
const loadNotices = async () => {
try {
const res = await getNoticeList()
if (res && res.rows) {
//
const noticeList = res.rows.map(item => {
return {
noticeId: item.noticeId,
title: item.noticeTitle || item.title,
time: item.createTime ? item.createTime.split(' ')[0] : item.publishTime || '未知',
content: item.noticeContent || item.content
}
})
notices.value = noticeList
}
} catch (error) {
console.error('获取公告列表失败', error)
// 使
if (notices.value.length === 0) {
notices.value = [
{ title: '暂无公告', time: new Date().toISOString().split('T')[0] }
]
}
}
}
//
const loadHomeData = async () => {
if (isStaff.value) return //
await Promise.all([
loadProblemDevices(),
loadWaterQualityAlerts(),
loadNotices()
])
}
//
onMounted(() => {
const role = uni.getStorageSync('userRole')
if (role) {
userRole.value = role
}
//
//
problemDevices.value = []
waterQualityAlerts.value = []
//
loadHomeData()
})
//
onShow(() => {
//
loadHomeData()
})
</script>
<style lang="scss" scoped>
.index-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.banner-section {
margin: 20rpx 30rpx;
}
.banner-swiper {
height: 300rpx;
border-radius: 20rpx;
overflow: hidden;
}
.banner-item {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 20rpx;
}
.banner-text {
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
}
.service-section {
margin: 40rpx 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.service-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 30rpx;
background: #ffffff;
padding: 40rpx 30rpx;
border-radius: 20rpx;
}
.service-item {
display: flex;
flex-direction: column;
align-items: center;
}
.service-icon-wrapper {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.service-icon {
font-size: 48rpx;
}
.service-name {
font-size: 24rpx;
color: #606266;
}
.section-title-wrapper {
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
.alert-dot {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #f56c6c;
position: absolute;
top: 6rpx;
right: 0;
}
.alert-section {
margin: 20rpx 30rpx 0;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.alert-card {
background: #ffffff;
border-radius: 20rpx;
padding: 32rpx 30rpx;
}
.alert-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.alert-item {
background: #f9fafc;
padding: 24rpx 28rpx;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.alert-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.alert-name {
font-size: 30rpx;
font-weight: bold;
color: #303133;
}
.alert-tag {
padding: 6rpx 18rpx;
border-radius: 24rpx;
font-size: 22rpx;
}
.tag-danger {
background: rgba(245, 108, 108, 0.15);
color: #f56c6c;
}
.tag-warning {
background: rgba(230, 162, 60, 0.15);
color: #e6a23c;
}
.tag-info {
background: rgba(64, 158, 255, 0.15);
color: #409eff;
}
.alert-body {
display: flex;
flex-direction: column;
gap: 8rpx;
color: #606266;
font-size: 26rpx;
}
.alert-address {
color: #909399;
font-size: 24rpx;
}
.alert-footer {
display: flex;
justify-content: flex-start;
}
.alert-link {
font-size: 26rpx;
color: #409eff;
}
.alert-empty {
padding: 40rpx 0;
text-align: center;
font-size: 26rpx;
color: #909399;
}
.notice-section {
margin: 40rpx 30rpx;
}
.notice-list {
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.notice-item {
display: flex;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.notice-dot {
width: 12rpx;
height: 12rpx;
background: #409eff;
border-radius: 50%;
margin-right: 20rpx;
}
.notice-content {
flex: 1;
display: flex;
flex-direction: column;
}
.notice-title {
font-size: 28rpx;
color: #303133;
margin-bottom: 8rpx;
}
.notice-time {
font-size: 24rpx;
color: #909399;
}
.notice-arrow {
font-size: 40rpx;
color: #c0c4cc;
}
.stats-section {
margin: 40rpx 30rpx;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.stats-item {
background: #ffffff;
padding: 40rpx 30rpx;
border-radius: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.stats-value {
font-size: 48rpx;
font-weight: bold;
color: #409eff;
margin-bottom: 12rpx;
}
.stats-label {
font-size: 26rpx;
color: #909399;
}
</style>

@ -0,0 +1,979 @@
<template>
<view class="login-container">
<!-- 顶部装饰背景 -->
<view class="bg-decoration"></view>
<!-- Logo和标题区域 -->
<view class="header-section">
<view class="logo-wrapper">
<view class="logo-circle">
<text class="logo-text">💧</text>
</view>
</view>
<text class="app-title">数智水管家</text>
<text class="app-subtitle">智能化水务管理平台</text>
</view>
<!-- 身份切换选项卡 -->
<view class="role-tabs">
<view
class="tab-item"
:class="{ active: userRole === 'user' }"
@click="switchRole('user')"
>
<text class="tab-icon">👤</text>
<text class="tab-text">普通用户</text>
</view>
<view
class="tab-item"
:class="{ active: userRole === 'staff' }"
@click="switchRole('staff')"
>
<text class="tab-icon">👷</text>
<text class="tab-text">工作人员</text>
</view>
</view>
<!-- 登录表单区域 -->
<view class="form-section">
<view class="form-item">
<view class="input-wrapper">
<text class="input-icon">{{ userRole === 'user' ? '📱' : '🆔' }}</text>
<input
class="input-field"
type="text"
v-model="formData.account"
:placeholder="userRole === 'user' ? '请输入手机号' : '请输入工号'"
:maxlength="userRole === 'user' ? 11 : 20"
:focus="accountFocus"
/>
<view v-if="formData.account" class="clear-btn" @click="clearAccount">
<text>×</text>
</view>
</view>
</view>
<view class="form-item">
<view class="input-wrapper">
<text class="input-icon">{{ userRole === 'user' ? '🔒' : '📱' }}</text>
<input
class="input-field"
:type="userRole === 'user' ? (showPassword ? 'text' : 'password') : 'text'"
v-model="formData.password"
:placeholder="userRole === 'user' ? '请输入密码' : '请输入手机号'"
:maxlength="userRole === 'user' ? 50 : 11"
:focus="passwordFocus"
/>
<view v-if="userRole === 'user'" class="eye-btn" @click="togglePassword">
<text>{{ showPassword ? '👁️' : '👁️‍🗨️' }}</text>
</view>
<view v-else-if="formData.password" class="clear-btn" @click="clearPassword">
<text>×</text>
</view>
</view>
</view>
<!-- 记住密码和忘记密码仅普通用户 -->
<view v-if="userRole === 'user'" class="form-options">
<view class="remember-wrapper" @click="toggleRemember">
<view class="checkbox" :class="{ checked: rememberPassword }">
<text v-if="rememberPassword" class="check-icon"></text>
</view>
<text class="remember-text">记住密码</text>
</view>
<text class="forgot-password" @click="handleForgotPassword"></text>
</view>
<!-- 登录按钮 -->
<button
class="login-btn"
:class="{ disabled: !canLogin }"
:disabled="!canLogin || loading"
@click="handleLogin"
>
<text v-if="loading" class="loading-text">...</text>
<text v-else></text>
</button>
<!-- 微信快速登录仅普通用户 -->
<view v-if="userRole === 'user'">
<view class="divider">
<view class="divider-line"></view>
<text class="divider-text"></text>
<view class="divider-line"></view>
</view>
<button class="wechat-login-btn" @click="handleWechatLogin">
<text class="wechat-icon">💬</text>
<text class="wechat-text">微信快速登录</text>
</button>
</view>
<!-- 注册链接仅普通用户 -->
<view v-if="userRole === 'user'" class="register-section">
<text class="register-text">还没有账号</text>
<text class="register-link" @click="handleRegister"></text>
</view>
</view>
<!-- 注册弹窗 -->
<view v-if="showRegisterDialog" class="register-mask">
<view class="register-dialog">
<view class="dialog-title">用户注册</view>
<view class="dialog-body">
<view class="dialog-item">
<text class="dialog-label">手机号</text>
<input
class="dialog-input"
type="text"
v-model="registerForm.phone"
placeholder="请输入11位手机号"
maxlength="11"
/>
</view>
<view class="dialog-item">
<text class="dialog-label">密码</text>
<input
class="dialog-input"
:password="true"
v-model="registerForm.password"
placeholder="至少4位密码"
/>
</view>
<view class="dialog-item">
<text class="dialog-label">确认密码</text>
<input
class="dialog-input"
:password="true"
v-model="registerForm.confirmPassword"
placeholder="请再次输入密码"
/>
</view>
<view class="dialog-item">
<text class="dialog-label">昵称可选</text>
<input
class="dialog-input"
v-model="registerForm.nickname"
placeholder="例如:张三"
maxlength="20"
/>
</view>
</view>
<view class="dialog-footer">
<view class="dialog-btn cancel" @click="closeRegisterDialog"></view>
<view
class="dialog-btn confirm"
:class="{ disabled: registerLoading }"
@click="submitRegister"
>
<text v-if="registerLoading">...</text>
<text v-else></text>
</view>
</view>
</view>
</view>
<!-- 底部装饰 -->
<view class="footer-decoration">
<view class="wave"></view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { setTabBar } from '../../utils/tabBar.js'
import { userLogin, staffLogin, wechatLogin, userRegister } from '../../api/auth.js'
// user-staff-
const userRole = ref('user')
//
const formData = ref({
account: '',
password: ''
})
//
const showPassword = ref(false)
const rememberPassword = ref(false)
const loading = ref(false)
const accountFocus = ref(false)
const passwordFocus = ref(false)
//
const showRegisterDialog = ref(false)
const registerLoading = ref(false)
const registerForm = ref({
phone: '',
password: '',
confirmPassword: '',
nickname: ''
})
//
const switchRole = (role) => {
if (userRole.value === role) return
userRole.value = role
//
formData.value.account = ''
formData.value.password = ''
rememberPassword.value = false
accountFocus.value = true
}
//
const canLogin = computed(() => {
if (userRole.value === 'user') {
// 8 + 4
return formData.value.account.length >= 8 && formData.value.password.length >= 4
} else {
// 4 + 11
return formData.value.account.length >= 4 && formData.value.password.length === 11
}
})
//
const togglePassword = () => {
showPassword.value = !showPassword.value
}
//
const toggleRemember = () => {
rememberPassword.value = !rememberPassword.value
}
//
const clearAccount = () => {
formData.value.account = ''
accountFocus.value = true
}
// /
const clearPassword = () => {
formData.value.password = ''
passwordFocus.value = true
}
//
const handleLogin = async () => {
if (!canLogin.value || loading.value) return
loading.value = true
try {
//
if (userRole.value === 'user') {
// 811
const isTestPhone = formData.value.account === '12345678'
if (!isTestPhone && !/^1[3-9]\d{9}$/.test(formData.value.account)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
loading.value = false
return
}
} else {
// 4 +
if (formData.value.account.length < 4) {
uni.showToast({
title: '工号至少4位',
icon: 'none'
})
loading.value = false
return
}
if (!/^1[3-9]\d{9}$/.test(formData.value.password)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
loading.value = false
return
}
}
// API
let loginRes
if (userRole.value === 'user') {
//
loginRes = await userLogin(formData.value.account, formData.value.password)
} else {
//
loginRes = await staffLogin(formData.value.account, formData.value.password)
}
//
loading.value = false
//
if (rememberPassword.value && userRole.value === 'user') {
uni.setStorageSync('rememberedAccount', formData.value.account)
} else {
uni.removeStorageSync('rememberedAccount')
}
//
uni.setStorageSync('userRole', userRole.value)
uni.setStorageSync('loginAccount', formData.value.account)
//
if (userRole.value === 'user' && loginRes.userInfo) {
uni.setStorageSync('userInfo', loginRes.userInfo)
} else if (userRole.value === 'staff' && loginRes.staffInfo) {
uni.setStorageSync('staffInfo', loginRes.staffInfo)
}
// tabBar
try {
setTabBar(userRole.value)
} catch (e) {
console.log('设置 tabBar 失败:', e)
}
uni.showToast({
title: `${userRole.value === 'user' ? '用户' : '工作人员'}登录成功`,
icon: 'success',
duration: 1500
})
//
setTimeout(() => {
if (userRole.value === 'staff') {
//
uni.switchTab({
url: '/pages/staff/index',
success: () => {
console.log('跳转到工作台成功')
},
fail: () => {
// switchTab 使 reLaunch
uni.reLaunch({
url: '/pages/staff/index'
})
}
})
} else {
//
uni.switchTab({
url: '/pages/index/index',
success: () => {
console.log('跳转到首页成功')
},
fail: () => {
// switchTab 使 reLaunch
uni.reLaunch({
url: '/pages/index/index'
})
}
})
}
}, 1500)
} catch (error) {
// request.js Toast
loading.value = false
console.error('登录失败', error)
}
}
//
const handleWechatLogin = async () => {
//
if (userRole.value !== 'user') {
uni.showToast({
title: '工作人员请使用工号登录',
icon: 'none'
})
return
}
// #ifdef MP-WEIXIN
try {
// code
const loginRes = await uni.login({
provider: 'weixin'
})
if (!loginRes.code) {
uni.showToast({
title: '获取微信授权失败',
icon: 'none'
})
return
}
// API
const res = await wechatLogin(loginRes.code)
//
//
uni.setStorageSync('userRole', 'user')
if (res.userInfo) {
uni.setStorageSync('userInfo', res.userInfo)
uni.setStorageSync('loginAccount', res.userInfo.phone || '微信用户')
} else {
uni.setStorageSync('loginAccount', '微信用户')
}
// tabBar
setTabBar('user')
uni.showToast({
title: '微信登录成功',
icon: 'success',
duration: 1500
})
//
setTimeout(() => {
uni.switchTab({
url: '/pages/index/index',
success: () => {
console.log('跳转到首页成功')
},
fail: () => {
uni.reLaunch({
url: '/pages/index/index'
})
}
})
}, 1500)
} catch (error) {
// request.js Toast
console.error('微信登录失败', error)
}
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '仅支持微信小程序',
icon: 'none'
})
// #endif
}
//
const handleForgotPassword = () => {
uni.showModal({
title: '忘记密码',
content: '请联系管理员重置密码',
showCancel: false,
confirmText: '知道了'
})
}
//
const handleRegister = () => {
if (userRole.value !== 'user') {
uni.showToast({
title: '请切换到普通用户再注册',
icon: 'none'
})
return
}
//
const phone = formData.value.account.trim()
showRegisterDialog.value = true
registerForm.value.phone = phone
registerForm.value.password = ''
registerForm.value.confirmPassword = ''
registerForm.value.nickname = ''
}
//
const closeRegisterDialog = () => {
showRegisterDialog.value = false
}
//
const submitRegister = async () => {
if (registerLoading.value) return
const phone = registerForm.value.phone.trim()
const password = registerForm.value.password.trim()
const confirmPassword = registerForm.value.confirmPassword.trim()
const nickname = registerForm.value.nickname.trim()
if (!phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none'
})
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
uni.showToast({
title: '请输入11位手机号',
icon: 'none'
})
return
}
if (!password || password.length < 4) {
uni.showToast({
title: '密码至少4位',
icon: 'none'
})
return
}
if (password !== confirmPassword) {
uni.showToast({
title: '两次输入的密码不一致',
icon: 'none'
})
return
}
try {
registerLoading.value = true
await userRegister({
phone: String(phone),
password: String(password),
nickname: nickname ? String(nickname) : ''
})
uni.showToast({
title: '注册成功,请登录',
icon: 'success',
duration: 1500
})
//
formData.value.account = phone
formData.value.password = ''
userRole.value = 'user'
showRegisterDialog.value = false
accountFocus.value = false
passwordFocus.value = true
} catch (error) {
// request.js
console.error('注册失败', error)
} finally {
registerLoading.value = false
}
}
//
onMounted(() => {
const rememberedAccount = uni.getStorageSync('rememberedAccount')
if (rememberedAccount) {
formData.value.account = rememberedAccount
rememberPassword.value = true
//
userRole.value = 'user'
}
//
const lastRole = uni.getStorageSync('userRole')
if (lastRole) {
userRole.value = lastRole
}
})
</script>
<style lang="scss" scoped>
.login-container {
min-height: 100vh;
background: linear-gradient(180deg, #e8f4f8 0%, #f5f9fa 100%);
position: relative;
overflow: hidden;
}
.bg-decoration {
position: absolute;
top: -200rpx;
right: -200rpx;
width: 600rpx;
height: 600rpx;
background: radial-gradient(circle, rgba(64, 158, 255, 0.15) 0%, transparent 70%);
border-radius: 50%;
}
.header-section {
padding-top: 120rpx;
text-align: center;
margin-bottom: 80rpx;
}
.logo-wrapper {
margin-bottom: 30rpx;
}
.logo-circle {
width: 160rpx;
height: 160rpx;
margin: 0 auto;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10rpx 30rpx rgba(64, 158, 255, 0.3);
}
.logo-text {
font-size: 80rpx;
}
.app-title {
display: block;
font-size: 48rpx;
font-weight: bold;
color: #303133;
margin-bottom: 10rpx;
}
.app-subtitle {
display: block;
font-size: 26rpx;
color: #909399;
}
.role-tabs {
display: flex;
justify-content: center;
margin: 0 60rpx 60rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 0;
border-radius: 16rpx;
transition: all 0.3s;
position: relative;
}
.tab-item.active {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
}
.tab-icon {
font-size: 36rpx;
margin-bottom: 8rpx;
}
.tab-item.active .tab-icon {
filter: brightness(0) invert(1);
}
.tab-text {
font-size: 26rpx;
color: #606266;
font-weight: 500;
}
.tab-item.active .tab-text {
color: #ffffff;
}
.form-section {
padding: 0 60rpx;
position: relative;
z-index: 1;
}
.form-item {
margin-bottom: 40rpx;
}
.input-wrapper {
display: flex;
align-items: center;
background: #ffffff;
border-radius: 24rpx;
padding: 0 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
height: 96rpx;
}
.input-icon {
font-size: 36rpx;
margin-right: 20rpx;
}
.input-field {
flex: 1;
font-size: 30rpx;
color: #303133;
height: 100%;
}
.clear-btn, .eye-btn {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 10rpx;
text {
font-size: 40rpx;
color: #909399;
}
}
.form-options {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 60rpx;
}
.remember-wrapper {
display: flex;
align-items: center;
}
.checkbox {
width: 32rpx;
height: 32rpx;
border: 2rpx solid #dcdfe6;
border-radius: 6rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12rpx;
transition: all 0.3s;
&.checked {
background: #409eff;
border-color: #409eff;
.check-icon {
color: #ffffff;
font-size: 24rpx;
}
}
}
.remember-text, .forgot-password {
font-size: 26rpx;
color: #606266;
}
.forgot-password {
color: #409eff;
}
.login-btn {
width: 100%;
height: 96rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 48rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
box-shadow: 0 8rpx 20rpx rgba(64, 158, 255, 0.4);
margin-bottom: 40rpx;
&.disabled {
background: #c0c4cc;
box-shadow: none;
}
&::after {
border: none;
}
}
.loading-text {
opacity: 0.8;
}
.divider {
display: flex;
align-items: center;
margin: 40rpx 0;
}
.divider-line {
flex: 1;
height: 1rpx;
background: #e4e7ed;
}
.divider-text {
margin: 0 24rpx;
font-size: 24rpx;
color: #909399;
}
.wechat-login-btn {
width: 100%;
height: 88rpx;
background: #07c160;
color: #ffffff;
border-radius: 44rpx;
font-size: 30rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
&::after {
border: none;
}
}
.wechat-icon {
font-size: 36rpx;
margin-right: 12rpx;
}
.wechat-text {
font-weight: 500;
}
.register-section {
text-align: center;
margin-top: 60rpx;
padding-bottom: 40rpx;
}
.register-text {
font-size: 28rpx;
color: #909399;
margin-right: 12rpx;
}
.register-link {
font-size: 28rpx;
color: #409eff;
font-weight: 500;
}
/* 注册弹窗 */
.register-mask {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.register-dialog {
width: 80%;
background: #ffffff;
border-radius: 24rpx;
padding: 40rpx 32rpx 32rpx;
box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.2);
}
.dialog-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
text-align: center;
margin-bottom: 30rpx;
}
.dialog-body {
max-height: 720rpx;
}
.dialog-item {
margin-bottom: 24rpx;
}
.dialog-label {
display: block;
font-size: 26rpx;
color: #606266;
margin-bottom: 10rpx;
}
.dialog-input {
width: 100%;
height: 80rpx;
background: #f5f7fa;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #303133;
box-sizing: border-box;
}
.dialog-footer {
margin-top: 8rpx;
display: flex;
justify-content: flex-end;
}
.dialog-btn {
min-width: 140rpx;
height: 72rpx;
border-radius: 36rpx;
padding: 0 24rpx;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
margin-left: 20rpx;
}
.dialog-btn.cancel {
background: #f5f7fa;
color: #606266;
}
.dialog-btn.confirm {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
.dialog-btn.confirm.disabled {
opacity: 0.7;
}
.footer-decoration {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 200rpx;
overflow: hidden;
}
.wave {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100%;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 120"><path fill="%23e8f4f8" d="M0,60 Q300,0 600,60 T1200,60 L1200,120 L0,120 Z"/></svg>') repeat-x;
background-size: 600rpx 200rpx;
animation: wave 15s linear infinite;
opacity: 0.5;
}
@keyframes wave {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-600rpx);
}
}
</style>

@ -0,0 +1,366 @@
<template>
<view class="message-container">
<!-- 消息类型选项卡 -->
<view class="message-tabs">
<view
class="tab-item"
v-for="(tab, index) in tabs"
:key="index"
:class="{ active: currentTab === index }"
@click="switchTab(index)"
>
<view class="tab-text-wrapper">
<text class="tab-text">{{ tab.label }}</text>
<text v-if="tab.unread > 0" class="tab-badge">{{ tab.unread }}</text>
</view>
</view>
</view>
<!-- 消息列表 -->
<scroll-view class="message-list" scroll-y>
<view v-if="filteredMessages.length === 0" class="empty-state">
<text class="empty-icon">💬</text>
<text class="empty-text">暂无消息</text>
</view>
<view v-else>
<view
class="message-item"
v-for="(message, index) in filteredMessages"
:key="index"
:class="{ unread: !message.read }"
@click="handleMessageClick(message)"
>
<view class="message-avatar">
<text class="avatar-icon">{{ message.icon }}</text>
</view>
<view class="message-content">
<view class="message-header">
<text class="message-title">{{ message.title }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
<text class="message-desc">{{ message.content }}</text>
</view>
<view v-if="!message.read" class="message-dot"></view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getMessageList, markMessageRead, getNoticeList } from '../../api/message.js'
//
const currentTab = ref(0)
//
const tabs = ref([
{ label: '全部', type: 'all', unread: 0 },
{ label: '系统通知', type: 'system', unread: 0 },
{ label: '订单消息', type: 'order', unread: 0 },
{ label: '设备消息', type: 'device', unread: 0 },
{ label: '服务消息', type: 'service', unread: 0 },
{ label: '活动消息', type: 'activity', unread: 0 }
])
// 使
const items = ref([])
// messageType
const getMessageIcon = (messageType) => {
const map = {
system: '🔔',
order: '📦',
device: '📟',
service: '🛠️'
}
return map[messageType] || '💬'
}
//
const formatTime = (value) => {
if (!value) return ''
if (typeof value === 'string') return value.split('.')[0]
try {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mi}`
} catch (e) {
return ''
}
}
//
const loadMessages = async () => {
try {
const res = await getMessageList()
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
const list = rows.map(m => ({
id: m.messageId,
source: 'message',
type: m.messageType || 'system',
title: m.title,
content: m.content,
time: formatTime(m.createTime),
read: m.isRead === 1,
icon: getMessageIcon(m.messageType)
}))
//
let notices = []
try {
const nRes = await getNoticeList()
const nRows = nRes && nRes.rows ? nRes.rows : (Array.isArray(nRes) ? nRes : [])
notices = nRows.map(n => ({
id: n.noticeId,
source: 'notice',
type: 'activity',
title: n.title,
content: (n.content || '').replace(/<[^>]+>/g, '').slice(0, 50) || '公告',
time: formatTime(n.publishTime),
//
read: false,
icon: '📢'
}))
} catch (e) {
//
console.error('加载公告失败', e)
}
items.value = [...list, ...notices]
updateTabUnread()
} catch (error) {
console.error('加载消息列表失败', error)
items.value = []
updateTabUnread()
}
}
//
const filteredMessages = computed(() => {
const tabType = tabs.value[currentTab.value].type
if (tabType === 'all') {
return items.value
}
return items.value.filter(msg => msg.type === tabType)
})
//
const switchTab = (index) => {
currentTab.value = index
}
//
const handleMessageClick = async (message) => {
//
if (!message.read) {
message.read = true
updateTabUnread()
// message
if (message.source === 'message' && message.id) {
try {
await markMessageRead(message.id)
} catch (error) {
console.error('标记消息已读失败', error)
}
}
}
uni.showToast({
title: message.title,
icon: 'none'
})
}
//
const updateTabUnread = () => {
tabs.value.forEach(tab => {
if (tab.type === 'all') {
tab.unread = items.value.filter(msg => !msg.read).length
} else {
tab.unread = items.value.filter(msg => msg.type === tab.type && !msg.read).length
}
})
}
onMounted(() => {
loadMessages()
})
</script>
<style lang="scss" scoped>
.message-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.message-tabs {
display: flex;
background: #ffffff;
padding: 0 20rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0;
position: relative;
&.active {
.tab-text-wrapper .tab-text {
color: #409eff;
font-weight: bold;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background: #409eff;
border-radius: 2rpx;
}
}
}
.tab-text-wrapper {
position: relative;
display: inline-block;
}
.tab-text {
font-size: 28rpx;
color: #606266;
}
.tab-badge {
position: absolute;
top: -8rpx;
right: -16rpx;
min-width: 32rpx;
height: 32rpx;
background: #f56c6c;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
font-size: 20rpx;
color: #ffffff;
box-sizing: border-box;
white-space: nowrap;
}
.message-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.message-item {
display: flex;
align-items: flex-start;
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
position: relative;
&.unread {
background: #f0f8ff;
}
}
.message-avatar {
width: 100rpx;
height: 100rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.avatar-icon {
font-size: 48rpx;
}
.message-content {
flex: 1;
min-width: 0;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.message-title {
font-size: 30rpx;
font-weight: 500;
color: #303133;
}
.message-time {
font-size: 24rpx;
color: #909399;
}
.message-desc {
font-size: 26rpx;
color: #606266;
line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.message-dot {
position: absolute;
top: 50rpx;
right: 50rpx;
width: 16rpx;
height: 16rpx;
background: #f56c6c;
border-radius: 50%;
}
</style>

@ -0,0 +1,767 @@
<template>
<view class="order-detail-container">
<scroll-view class="detail-content" scroll-y>
<!-- 订单状态 -->
<view class="status-section">
<view class="status-icon-wrapper">
<text class="status-icon">{{ getStatusIcon(orderDetail.status) }}</text>
</view>
<text class="status-text">{{ orderDetail.statusText || '订单详情' }}</text>
<text class="status-desc">{{ getStatusDesc(orderDetail.status) }}</text>
</view>
<!-- 订单基本信息 -->
<view class="info-section">
<view class="section-title">订单信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">服务类型</text>
<text class="info-value">{{ orderDetail.serviceType }}</text>
</view>
<view class="info-item">
<text class="info-label">订单号</text>
<text class="info-value">{{ orderDetail.orderNo }}</text>
</view>
<view class="info-item">
<text class="info-label">订单时间</text>
<text class="info-value">{{ orderDetail.orderTime }}</text>
</view>
<view class="info-item">
<text class="info-label">完成时间</text>
<text class="info-value">{{ orderDetail.completeTime }}</text>
</view>
<view class="info-item" v-if="orderDetail.totalAmount">
<text class="info-label">订单金额</text>
<text class="info-value amount">¥{{ formatPrice(orderDetail.totalAmount) }}</text>
</view>
</view>
</view>
<!-- 订单商品信息 -->
<view class="product-section" v-if="orderDetail.products && orderDetail.products.length > 0">
<view class="section-title">订单商品</view>
<view class="product-list">
<view class="product-item" v-for="(product, index) in orderDetail.products" :key="index">
<view class="product-info">
<text class="product-name">{{ product.productName }}</text>
<view class="product-meta">
<text class="product-quantity">数量{{ product.quantity }}</text>
<text class="product-price">¥{{ formatPrice(product.price) }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 工作人员信息 -->
<view class="staff-section">
<view class="section-title">服务人员</view>
<view class="staff-card">
<view class="staff-avatar">
<text class="avatar-icon">👷</text>
</view>
<view class="staff-info">
<text class="staff-name">{{ orderDetail.staffName }}</text>
<text class="staff-phone">联系电话{{ orderDetail.staffPhone }}</text>
</view>
<button class="call-btn" @click="handleCall">📞</button>
</view>
</view>
<!-- 设备信息 -->
<view class="device-section">
<view class="section-title">服务设备</view>
<view class="device-list">
<view class="device-tag" v-for="(device, index) in orderDetail.devices" :key="index">
<text class="device-icon">📱</text>
<text class="device-text">{{ device }}</text>
</view>
</view>
</view>
<!-- 现场图片仅已完成订单显示 -->
<view v-if="orderDetail.status === 'completed' && orderDetail.images && orderDetail.images.length > 0" class="image-section">
<view class="section-title">现场图片</view>
<view class="image-grid">
<view
class="image-item"
v-for="(image, index) in orderDetail.images"
:key="index"
@click="previewImage(index)"
>
<image class="detail-image" :src="image" mode="aspectFill"></image>
</view>
</view>
</view>
<!-- 服务评价仅已完成订单可评价 -->
<view class="evaluate-section" v-if="orderDetail.status === 'completed'">
<view class="section-title">
<text>服务评价</text>
<text v-if="orderDetail.isEvaluated" class="evaluated-tag"></text>
</view>
<view v-if="!orderDetail.isEvaluated" class="evaluate-form">
<!-- 评分 -->
<view class="rating-wrapper">
<text class="rating-label">服务评分</text>
<view class="stars">
<text
class="star"
v-for="(star, index) in 5"
:key="index"
:class="{ active: index < rating }"
@click="setRating(index + 1)"
>
{{ index < rating ? '⭐' : '☆' }}
</text>
</view>
<text class="rating-text">{{ ratingText }}</text>
</view>
<!-- 评价内容 -->
<view class="comment-wrapper">
<text class="comment-label">评价内容</text>
<textarea
class="comment-input"
v-model="comment"
placeholder="请输入您的评价..."
maxlength="200"
></textarea>
<text class="comment-count">{{ comment.length }}/200</text>
</view>
<!-- 提交评价按钮 -->
<button class="submit-btn" @click="handleSubmitEvaluate"></button>
</view>
<!-- 已评价显示 -->
<view v-else class="evaluated-content">
<view class="evaluated-rating">
<text class="rating-label">评分</text>
<view class="stars-display">
<text class="star" v-for="(star, index) in 5" :key="index">
{{ index < (orderDetail.evaluation.rating || 0) ? '⭐' : '☆' }}
</text>
</view>
<text class="rating-value" v-if="orderDetail.evaluation.rating">
{{ orderDetail.evaluation.rating }}
</text>
</view>
<view class="evaluated-comment" v-if="orderDetail.evaluation.comment">
<text class="comment-label">评价内容</text>
<text class="comment-text">{{ orderDetail.evaluation.comment }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getOrderDetail, evaluateOrder } from '../../api/order.js'
// ID
const orderId = ref(null)
//
const orderDetail = ref({
status: '',
statusText: '',
serviceType: '',
orderNo: '',
orderTime: '',
completeTime: '',
totalAmount: 0, //
staffName: '',
staffPhone: '',
devices: [],
images: [],
products: [], //
isEvaluated: false,
evaluation: {
rating: 0,
comment: ''
}
})
//
const rating = ref(0)
//
const comment = ref('')
//
const ratingText = computed(() => {
const texts = ['', '非常不满意', '不满意', '一般', '满意', '非常满意']
return texts[rating.value]
})
//
const setRating = (value) => {
rating.value = value
}
//
const getStatusIcon = (status) => {
const iconMap = {
unpaid: '⏳', //
pending: '📥', //
accepted: '✅', //
processing: '⚙️', //
completed: '🎉', //
cancelled: '🚫' //
}
return iconMap[status] || '📋'
}
//
const getStatusDesc = (status) => {
const descMap = {
unpaid: '请尽快完成支付,以便工作人员安排服务',
pending: '订单已支付,等待工作人员接单',
accepted: '工作人员已接单,准备为您提供服务',
processing: '服务进行中,请耐心等待',
completed: '服务已完成,欢迎您对本次服务进行评价',
cancelled: '订单已取消,如有疑问请联系客服'
}
return descMap[status] || '查看订单详细信息'
}
//
const previewImage = (index) => {
uni.previewImage({
urls: orderDetail.value.images,
current: index
})
}
//
const handleCall = () => {
const phone = orderDetail.value.staffPhone.replace(/\*/g, '')
uni.makePhoneCall({
phoneNumber: phone,
success: () => {
console.log('拨打电话成功')
},
fail: () => {
uni.showToast({
title: '拨打电话失败',
icon: 'none'
})
}
})
}
//
const handleSubmitEvaluate = async () => {
if (rating.value === 0) {
uni.showToast({
title: '请选择评分',
icon: 'none'
})
return
}
if (!comment.value.trim()) {
uni.showToast({
title: '请输入评价内容',
icon: 'none'
})
return
}
if (!orderId.value) {
uni.showToast({
title: '订单信息异常',
icon: 'none'
})
return
}
try {
await evaluateOrder({
orderId: orderId.value,
rating: rating.value,
content: comment.value.trim(),
images: []
})
//
orderDetail.value.isEvaluated = true
orderDetail.value.evaluation = {
rating: rating.value,
comment: comment.value.trim()
}
uni.showToast({
title: '评价成功,感谢您的反馈',
icon: 'success'
})
} catch (error) {
console.error('评价失败', error)
// request.js
}
}
// null/
const formatDateTime = (value) => {
if (!value) return ''
// 'yyyy-MM-dd HH:mm:ss'
if (typeof value === 'string') return value
try {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const h = String(d.getHours()).padStart(2, '0')
const mi = String(d.getMinutes()).padStart(2, '0')
const s = String(d.getSeconds()).padStart(2, '0')
return `${y}-${m}-${day} ${h}:${mi}:${s}`
} catch (e) {
return ''
}
}
//
const formatPrice = (price) => {
if (!price && price !== 0) return '0.00'
const num = typeof price === 'string' ? parseFloat(price) : price
return num.toFixed(2)
}
//
const loadOrderDetail = async (id) => {
try {
const data = await getOrderDetail(id)
if (!data) {
uni.showToast({
title: '订单不存在',
icon: 'none'
})
return
}
orderDetail.value = {
status: data.status || data.orderStatus || '',
statusText: data.statusText || '',
serviceType: data.serviceTypeText || data.orderTypeText || '订单',
orderNo: data.orderNo || '',
orderTime: formatDateTime(data.createTime || data.appointmentDate),
completeTime: formatDateTime(data.completeTime || data.deliveryCompleteTime),
totalAmount: data.totalAmount || 0, //
staffName: data.staffName || '',
staffPhone: data.staffPhone || '',
devices: data.devices || [], //
images: data.images || [], //
products: data.products || [], //
isEvaluated: data.isEvaluated || false, //
evaluation: data.evaluation || { //
rating: 0,
comment: ''
}
}
} catch (error) {
console.error('加载订单详情失败', error)
// request.js
}
}
//
const loadOrderDetailById = (id) => {
if (id) {
orderId.value = Number(id)
loadOrderDetail(orderId.value)
}
}
//
onMounted(() => {
// ID
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const id = options.orderId
loadOrderDetailById(id)
})
//
onShow(() => {
if (orderId.value) {
loadOrderDetail(orderId.value)
}
})
</script>
<style lang="scss" scoped>
.order-detail-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.detail-content {
flex: 1;
}
.status-section {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
padding: 60rpx 30rpx;
text-align: center;
}
.status-icon-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.5);
}
.status-icon {
font-size: 60rpx;
}
.status-text {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.status-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.info-section,
.product-section,
.staff-section,
.device-section,
.image-section,
.evaluate-section {
background: #ffffff;
margin: 20rpx 30rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.evaluated-tag {
font-size: 24rpx;
color: #67c23a;
padding: 4rpx 16rpx;
background: #f0f9ff;
border-radius: 20rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.info-item {
display: flex;
}
.info-label {
font-size: 28rpx;
color: #909399;
min-width: 160rpx;
}
.info-value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
&.amount {
color: #e6a23c;
font-weight: bold;
font-size: 32rpx;
}
}
.staff-card {
display: flex;
align-items: center;
padding: 20rpx;
background: #f9f9f9;
border-radius: 16rpx;
}
.staff-avatar {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.avatar-icon {
font-size: 40rpx;
}
.staff-info {
flex: 1;
display: flex;
flex-direction: column;
}
.staff-name {
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-bottom: 8rpx;
}
.staff-phone {
font-size: 26rpx;
color: #606266;
}
.call-btn {
width: 80rpx;
height: 80rpx;
line-height: 80rpx;
background: #67c23a;
color: #ffffff;
border-radius: 50%;
font-size: 36rpx;
border: none;
padding: 0;
&::after {
border: none;
}
}
.device-list {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.device-tag {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: #f0f9ff;
border-radius: 40rpx;
border: 1rpx solid #b3d8ff;
}
.device-icon {
font-size: 28rpx;
margin-right: 8rpx;
}
.device-text {
font-size: 26rpx;
color: #409eff;
}
.image-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.image-item {
width: 100%;
aspect-ratio: 1;
border-radius: 12rpx;
overflow: hidden;
}
.detail-image {
width: 100%;
height: 100%;
}
.evaluate-form {
display: flex;
flex-direction: column;
}
.rating-wrapper {
display: flex;
align-items: center;
margin-bottom: 30rpx;
}
.rating-label {
font-size: 28rpx;
color: #606266;
margin-right: 20rpx;
}
.stars {
display: flex;
gap: 12rpx;
margin-right: 20rpx;
}
.star {
font-size: 40rpx;
color: #dcdfe6;
transition: all 0.2s;
&.active {
color: #f7ba2a;
}
}
.rating-text {
font-size: 26rpx;
color: #909399;
}
.comment-wrapper {
display: flex;
flex-direction: column;
margin-bottom: 30rpx;
}
.comment-label {
font-size: 28rpx;
color: #606266;
margin-bottom: 16rpx;
}
.comment-input {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: 1rpx solid #e4e7ed;
}
.comment-count {
align-self: flex-end;
font-size: 24rpx;
color: #909399;
margin-top: 8rpx;
}
.submit-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
}
.evaluated-content {
padding: 20rpx 0;
}
.evaluated-rating {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.stars-display {
display: flex;
gap: 8rpx;
margin-left: 16rpx;
}
.stars-display .star {
font-size: 32rpx;
color: #f7ba2a;
}
.evaluated-comment {
display: flex;
flex-direction: column;
}
.comment-text {
font-size: 28rpx;
color: #606266;
line-height: 1.8;
margin-top: 12rpx;
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.product-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.product-item {
padding: 24rpx;
background: #f9f9f9;
border-radius: 16rpx;
border: 1rpx solid #e4e7ed;
}
.product-info {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.product-name {
font-size: 30rpx;
font-weight: 500;
color: #303133;
}
.product-meta {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-quantity {
font-size: 26rpx;
color: #606266;
}
.product-price {
font-size: 28rpx;
font-weight: bold;
color: #e6a23c;
}
</style>

@ -0,0 +1,641 @@
<template>
<view class="order-progress-container">
<scroll-view class="detail-content" scroll-y>
<!-- 订单状态卡片 -->
<view class="status-section" :style="{ background: getStatusBgColor() }">
<view class="status-icon-wrapper">
<text class="status-icon">{{ getStatusIcon() }}</text>
</view>
<text class="status-text">{{ orderDetail.statusText }}</text>
<text class="status-desc">{{ getStatusDesc() }}</text>
</view>
<!-- 订单基本信息 -->
<view class="info-section">
<view class="section-title">订单信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">订单类型</text>
<text class="info-value">{{ orderDetail.orderType }}</text>
</view>
<view class="info-item">
<text class="info-label">订单号</text>
<text class="info-value">{{ orderDetail.orderNo }}</text>
</view>
<view class="info-item">
<text class="info-label">下单时间</text>
<text class="info-value">{{ orderDetail.orderTime }}</text>
</view>
<view class="info-item">
<text class="info-label">设备编号</text>
<view class="device-tags">
<text class="device-tag" v-for="(device, index) in orderDetail.devices" :key="index">
{{ device }}
</text>
</view>
</view>
</view>
</view>
<!-- 工作人员信息仅已接单显示 -->
<view v-if="orderDetail.status === 'accepted' || orderDetail.status === 'delivering'" class="staff-section">
<view class="section-title">服务人员</view>
<view class="staff-card">
<view class="staff-avatar">
<text class="avatar-icon">👷</text>
</view>
<view class="staff-info">
<text class="staff-name">{{ orderDetail.staffName }}</text>
<text class="staff-phone">联系电话{{ orderDetail.staffPhone }}</text>
</view>
<button class="call-btn" @click="handleCall">📞</button>
</view>
</view>
<!-- 在线聊天 -->
<view class="chat-section">
<view class="section-title">在线沟通</view>
<view class="chat-container">
<scroll-view class="chat-messages" scroll-y :scroll-top="scrollTop" scroll-into-view="bottom">
<view class="message-wrapper" id="bottom">
<view
class="message-item"
v-for="(msg, index) in messages"
:key="index"
:class="msg.type"
>
<view class="message-bubble">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ msg.time }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="chat-input-section">
<input
class="chat-input"
v-model="inputText"
placeholder="请输入消息..."
@confirm="handleSend"
/>
<button class="send-btn" @click="handleSend"></button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
//
const orderDetail = ref({
orderNo: '202401140002',
orderType: '维修',
orderTime: '2024-01-14 15:20:00',
status: 'pending', // pending-, accepted-, delivering-
statusText: '还未接单',
devices: ['JSQ20240115001'],
staffName: '',
staffPhone: ''
})
//
const messages = ref([
{
type: 'system',
content: '您的订单已提交,等待工作人员接单中...',
time: '15:20'
}
])
//
const inputText = ref('')
const scrollTop = ref(0)
//
const getStatusBgColor = () => {
const colorMap = {
pending: 'linear-gradient(135deg, #e6a23c 0%, #ebb563 100%)',
accepted: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)',
delivering: 'linear-gradient(135deg, #67c23a 0%, #85ce61 100%)'
}
return colorMap[orderDetail.value.status] || colorMap.pending
}
//
const getStatusIcon = () => {
const iconMap = {
pending: '⏳',
accepted: '✅',
delivering: '🚚'
}
return iconMap[orderDetail.value.status] || iconMap.pending
}
//
const getStatusDesc = () => {
const descMap = {
pending: '工作人员将在1小时内接单',
accepted: '工作人员已接单,准备上门服务',
delivering: '工作人员正在路上,即将到达'
}
return descMap[orderDetail.value.status] || descMap.pending
}
//
const handleSend = () => {
if (!inputText.value.trim()) return
//
if (orderDetail.value.status === 'pending') {
uni.showToast({
title: '订单未接单,无法发送消息',
icon: 'none'
})
return
}
//
messages.value.push({
type: 'user',
content: inputText.value,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
const userMessage = inputText.value
inputText.value = ''
//
setTimeout(() => {
scrollTop.value = 9999
}, 100)
//
setTimeout(() => {
messages.value.push({
type: 'service',
content: getAutoReply(userMessage),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
setTimeout(() => {
scrollTop.value = 9999
}, 100)
}, 1000)
}
//
const getAutoReply = (message) => {
if (message.includes('什么时候') || message.includes('时间')) {
return '我预计1小时后到达请您保持电话畅通'
} else if (message.includes('地址') || message.includes('在哪')) {
return '我会按照订单上的地址上门服务,如有变动请及时告知'
} else if (message.includes('需要') || message.includes('准备')) {
return '您只需保持设备附近畅通即可,其他工具我会自带'
} else {
return '收到,我会尽快为您处理,请稍候'
}
}
//
const handleCall = () => {
if (!orderDetail.value.staffPhone) {
uni.showToast({
title: '暂无工作人员电话',
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: orderDetail.value.staffPhone,
success: () => {
console.log('拨打电话成功')
},
fail: () => {
uni.showToast({
title: '拨打电话失败',
icon: 'none'
})
}
})
}
//
onMounted(() => {
//
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
const orderNo = options.orderNo
if (orderNo) {
loadOrderDetail(orderNo)
}
// 30
const timer = setInterval(() => {
checkOrderStatus()
}, 30000)
return () => {
clearInterval(timer)
}
})
//
const loadOrderDetail = (orderNo) => {
const orderMap = {
'202401140002': {
orderNo: '202401140002',
orderType: '维修',
orderTime: '2024-01-14 15:20:00',
status: 'pending',
statusText: '还未接单',
devices: ['JSQ20240115001'],
staffName: '',
staffPhone: ''
},
'202401150005': {
orderNo: '202401150005',
orderType: '设备安装',
orderTime: '2024-01-15 10:30:00',
status: 'accepted',
statusText: '已接单',
devices: ['JSQ20240120001', 'JSQ20240120002'],
staffName: '王师傅',
staffPhone: '13700137000'
},
'202401150006': {
orderNo: '202401150006',
orderType: '滤芯更换',
orderTime: '2024-01-15 09:15:00',
status: 'delivering',
statusText: '正在配送',
devices: ['JSQ20240112002'],
staffName: '李师傅',
staffPhone: '13900139000'
}
}
if (orderMap[orderNo]) {
orderDetail.value = orderMap[orderNo]
//
if (orderDetail.value.status !== 'pending') {
messages.value = [
{
type: 'system',
content: `您的订单已被${orderDetail.value.staffName}接单`,
time: orderDetail.value.orderTime.split(' ')[1]
},
{
type: 'service',
content: '您好,我已接单,正在准备上门服务,请保持电话畅通',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
]
}
}
}
//
const checkOrderStatus = () => {
//
if (orderDetail.value.status === 'pending') {
// 30%
if (Math.random() > 0.7) {
orderDetail.value.status = 'accepted'
orderDetail.value.statusText = '已接单'
orderDetail.value.staffName = '张师傅'
orderDetail.value.staffPhone = '13800138000'
messages.value.push({
type: 'system',
content: `您的订单已被${orderDetail.value.staffName}接单`,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
setTimeout(() => {
scrollTop.value = 9999
}, 100)
}
}
//
else if (orderDetail.value.status === 'accepted' && Math.random() > 0.8) {
orderDetail.value.status = 'delivering'
orderDetail.value.statusText = '正在配送'
messages.value.push({
type: 'system',
content: `${orderDetail.value.staffName}已出发,正在前往您的地址`,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
setTimeout(() => {
scrollTop.value = 9999
}, 100)
}
}
</script>
<style lang="scss" scoped>
.order-progress-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.detail-content {
flex: 1;
}
.status-section {
padding: 60rpx 30rpx;
text-align: center;
margin-bottom: 20rpx;
}
.status-icon-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.5);
}
.status-icon {
font-size: 60rpx;
}
.status-text {
display: block;
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.status-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.info-section,
.staff-section,
.chat-section {
background: #ffffff;
margin: 0 30rpx 20rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.info-item {
display: flex;
align-items: flex-start;
padding-bottom: 24rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.info-label {
font-size: 28rpx;
color: #909399;
min-width: 180rpx;
flex-shrink: 0;
}
.info-value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
flex: 1;
}
.device-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.device-tag {
padding: 12rpx 24rpx;
background: #f0f9ff;
color: #409eff;
border-radius: 40rpx;
font-size: 26rpx;
border: 1rpx solid #b3d8ff;
}
.staff-card {
display: flex;
align-items: center;
padding: 20rpx;
background: #f9f9f9;
border-radius: 16rpx;
}
.staff-avatar {
width: 80rpx;
height: 80rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.avatar-icon {
font-size: 40rpx;
}
.staff-info {
flex: 1;
display: flex;
flex-direction: column;
}
.staff-name {
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-bottom: 8rpx;
}
.staff-phone {
font-size: 26rpx;
color: #606266;
}
.call-btn {
width: 80rpx;
height: 80rpx;
line-height: 80rpx;
background: #67c23a;
color: #ffffff;
border-radius: 50%;
font-size: 36rpx;
border: none;
padding: 0;
&::after {
border: none;
}
}
.chat-container {
height: 600rpx;
display: flex;
flex-direction: column;
border: 1rpx solid #e4e7ed;
border-radius: 16rpx;
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: 30rpx;
background: #f9f9f9;
}
.message-wrapper {
display: flex;
flex-direction: column;
}
.message-item {
display: flex;
margin-bottom: 24rpx;
&.user {
justify-content: flex-end;
.message-bubble {
background: #409eff;
.message-text {
color: #ffffff;
}
.message-time {
color: rgba(255, 255, 255, 0.8);
}
}
}
&.service {
justify-content: flex-start;
.message-bubble {
background: #ffffff;
border: 1rpx solid #e4e7ed;
.message-text {
color: #303133;
}
.message-time {
color: #909399;
}
}
}
&.system {
justify-content: center;
.message-bubble {
background: #f0f9ff;
border: 1rpx solid #b3d8ff;
.message-text {
color: #409eff;
font-size: 26rpx;
}
.message-time {
color: #66b1ff;
}
}
}
}
.message-bubble {
max-width: 70%;
padding: 20rpx 24rpx;
border-radius: 20rpx;
display: flex;
flex-direction: column;
}
.message-text {
font-size: 28rpx;
line-height: 1.6;
word-break: break-all;
}
.message-time {
font-size: 22rpx;
margin-top: 8rpx;
align-self: flex-end;
}
.chat-input-section {
display: flex;
align-items: center;
padding: 20rpx;
background: #ffffff;
border-top: 1rpx solid #e4e7ed;
}
.chat-input {
flex: 1;
background: #f5f5f5;
border-radius: 40rpx;
padding: 20rpx 30rpx;
font-size: 28rpx;
margin-right: 20rpx;
border: none;
}
.send-btn {
width: 120rpx;
height: 68rpx;
line-height: 68rpx;
background: #409eff;
color: #ffffff;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,572 @@
<template>
<view class="order-container">
<!-- 订单状态选项卡 -->
<view class="order-tabs">
<view
class="tab-item"
v-for="(tab, index) in tabs"
:key="index"
:class="{ active: currentTab === index }"
@click="switchTab(index)"
>
<text class="tab-text">{{ tab.label }}</text>
<text v-if="tab.count > 0" class="tab-count">{{ tab.count }}</text>
</view>
</view>
<!-- 订单列表 -->
<scroll-view class="order-list" scroll-y>
<view v-if="filteredOrders.length === 0" class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无{{ tabs[currentTab].label }}订单</text>
</view>
<view v-else>
<view class="order-item" v-for="(order, index) in filteredOrders" :key="index" @click="handleOrderClick(order)">
<view class="order-header">
<text class="order-number">订单号{{ order.orderNo }}</text>
<text class="order-status" :class="getStatusClass(order.status)">{{ order.statusText }}</text>
</view>
<view class="order-content">
<view class="order-info">
<text class="order-title">{{ order.title }}</text>
<!-- 显示订单商品信息 -->
<view v-if="order.products && order.products.length > 0" class="order-products">
<text class="product-item" v-for="(product, idx) in order.products" :key="idx">
{{ product.productName }} × {{ product.quantity }}
<text v-if="idx < order.products.length - 1"></text>
</text>
</view>
<text class="order-time">创建时间{{ order.createTime }}</text>
</view>
</view>
<view class="order-footer">
<text class="order-price" v-if="order.price && parseFloat(order.price) > 0">¥{{ order.price }}</text>
<view class="order-actions">
<!-- 待支付状态显示取消和支付按钮 -->
<view v-if="order.status === 'unpaid'" class="action-btn cancel-btn" @click.stop="handleCancel(order)">
<text>取消订单</text>
</view>
<view v-if="order.status === 'unpaid' && parseFloat(order.price) > 0" class="action-btn primary-btn" @click.stop="handlePay(order)">
<text>立即支付</text>
</view>
<!-- 已完成状态显示评价按钮或已评价标签 -->
<view v-if="order.status === 'completed' && order.isEvaluated" class="evaluated-tag">
<text>已评价</text>
</view>
<view v-else-if="order.status === 'completed'" class="action-btn primary-btn" @click.stop="handleEvaluate(order)">
<text>评价</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { getOrderList, cancelOrder, payOrder } from '../../api/order.js'
//
const loading = ref(false)
//
const currentTab = ref(0)
// unpaid/paid/delivering/completed/cancelled
const tabs = ref([
{ label: '全部', status: 'all', count: 0 },
{ label: '待支付', status: 'unpaid', count: 0 },
{ label: '待接单', status: 'paid', count: 0 },
{ label: '配送中', status: 'delivering', count: 0 },
{ label: '已完成', status: 'completed', count: 0 }
])
//
const orders = ref([])
// unpaid/paid/delivering/completed/cancelled
const filteredOrders = computed(() => {
const currentStatus = tabs.value[currentTab.value].status
if (currentStatus === 'all') {
return orders.value
} else if (currentStatus === 'unpaid') {
// unpaid
return orders.value.filter(order => order.status === 'unpaid')
} else if (currentStatus === 'paid') {
// paid
return orders.value.filter(order => order.status === 'paid')
} else if (currentStatus === 'delivering') {
// delivering
return orders.value.filter(order => order.status === 'delivering')
} else if (currentStatus === 'completed') {
// completed
return orders.value.filter(order => order.status === 'completed')
}
return orders.value
})
//
const switchTab = (index) => {
currentTab.value = index
// orders
}
// unpaid/paid/delivering/completed/cancelled
const getStatusClass = (status) => {
const classMap = {
unpaid: 'status-pending',
paid: 'status-processing',
delivering: 'status-processing',
completed: 'status-completed',
cancelled: 'status-cancelled'
}
return classMap[status] || ''
}
//
const getStatusText = (status) => {
const statusMap = {
unpaid: '待支付',
paid: '待接单',
delivering: '配送中',
completed: '已完成',
cancelled: '已取消'
}
return statusMap[status] || status
}
//
const handleOrderClick = (order) => {
// ID
uni.navigateTo({
url: `/pages/order-detail/order-detail?orderId=${order.orderId}`,
fail: () => {
//
uni.showToast({
title: order.title || order.serviceTypeText,
icon: 'none'
})
}
})
}
//
const handleCancel = (order) => {
uni.showModal({
title: '提示',
content: '确定要取消该订单吗?',
editable: true,
placeholderText: '请输入取消原因(可选)',
success: async (res) => {
if (res.confirm) {
try {
uni.showLoading({ title: '取消中...' })
// API
await cancelOrder(order.orderId, res.content || '用户取消')
//
const index = orders.value.findIndex(o => o.orderId === order.orderId)
if (index > -1) {
orders.value[index].status = 'cancelled'
orders.value[index].statusText = '已取消'
}
//
updateTabCounts()
uni.hideLoading()
uni.showToast({
title: '订单已取消',
icon: 'success'
})
} catch (error) {
uni.hideLoading()
console.error('取消订单失败', error)
// request.jsToast
}
}
}
})
}
//
const handlePay = async (order) => {
try {
uni.showLoading({ title: '支付中...' })
// API
await payOrder(order.orderId, 'wechat')
// paid
const index = orders.value.findIndex(o => o.orderId === order.orderId)
if (index > -1) {
orders.value[index].status = 'paid'
orders.value[index].statusText = '待接单'
orders.value[index].paymentStatus = 'paid'
}
//
updateTabCounts()
uni.hideLoading()
uni.showToast({
title: '支付成功',
icon: 'success'
})
//
setTimeout(() => {
uni.navigateTo({
url: `/pages/order-detail/order-detail?orderId=${order.orderId}`
})
}, 1500)
} catch (error) {
uni.hideLoading()
console.error('支付失败', error)
// request.jsToast
}
}
//
const handleEvaluate = (order) => {
//
uni.navigateTo({
url: `/pages/order-detail/order-detail?orderId=${order.orderId}`
})
}
//
const loadOrderList = async () => {
try {
loading.value = true
// Token
const token = uni.getStorageSync('token')
if (!token) {
console.warn('未找到Token无法获取订单列表')
orders.value = []
updateTabCounts()
return
}
// API
const res = await getOrderList({ pageNum: 1, pageSize: 100 })
if (res && res.rows) {
//
orders.value = res.rows.map(order => {
// 使actualAmountamount
const amount = order.actualAmount || order.amount || 0
const price = typeof amount === 'string' ? amount : amount.toFixed(2)
//
const createTime = formatDate(order.createTime || order.submitTime)
//
let displayTitle = '商品订单'
if (order.products && order.products.length > 0) {
//
const productNames = order.products.map(p => p.productName || '').filter(Boolean)
if (productNames.length > 0) {
displayTitle = productNames.join('、')
if (productNames.length > 1) {
displayTitle += `${order.products.length}件商品`
}
}
}
// unpaid/paid/delivering/completed/cancelled
const orderStatus = order.orderStatus || order.status || 'unpaid'
return {
orderId: order.orderId,
orderNo: order.orderNo,
title: displayTitle,
status: orderStatus,
statusText: order.statusText || getStatusText(orderStatus),
price: price,
createTime: createTime,
paymentStatus: order.paymentStatus || 'unpaid',
products: order.products || [], //
isEvaluated: order.isEvaluated || false //
}
})
} else {
orders.value = []
}
//
updateTabCounts()
} catch (error) {
console.error('获取订单列表失败', error)
orders.value = []
updateTabCounts()
} finally {
loading.value = false
}
}
//
const formatDate = (date) => {
if (!date) return '未知'
if (typeof date === 'string') {
//
return date.split(' ').slice(0, 2).join(' ')
}
// DateYYYY-MM-DD HH:mm:ss
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
const seconds = String(d.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// unpaid/paid/delivering/completed
const updateTabCounts = () => {
tabs.value.forEach(tab => {
if (tab.status === 'all') {
tab.count = orders.value.length
} else if (tab.status === 'unpaid') {
tab.count = orders.value.filter(order => order.status === 'unpaid').length
} else if (tab.status === 'paid') {
// paid
tab.count = orders.value.filter(order => order.status === 'paid').length
} else if (tab.status === 'delivering') {
// delivering
tab.count = orders.value.filter(order => order.status === 'delivering').length
} else if (tab.status === 'completed') {
tab.count = orders.value.filter(order => order.status === 'completed').length
} else {
tab.count = 0
}
})
}
//
onPullDownRefresh(() => {
loadOrderList().finally(() => {
uni.stopPullDownRefresh()
})
})
//
onMounted(() => {
loadOrderList()
})
//
onShow(() => {
loadOrderList()
})
</script>
<style lang="scss" scoped>
.order-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.order-tabs {
display: flex;
background: #ffffff;
padding: 0 20rpx;
}
.tab-item {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0;
position: relative;
&.active {
.tab-text {
color: #409eff;
font-weight: bold;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background: #409eff;
border-radius: 2rpx;
}
}
}
.tab-text {
font-size: 28rpx;
color: #606266;
}
.tab-count {
margin-left: 8rpx;
padding: 2rpx 12rpx;
background: #f0f0f0;
border-radius: 20rpx;
font-size: 20rpx;
color: #909399;
}
.order-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.order-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.order-number {
font-size: 26rpx;
color: #909399;
}
.order-status {
font-size: 26rpx;
font-weight: 500;
&.status-pending {
color: #e6a23c;
}
&.status-processing {
color: #409eff;
}
&.status-completed {
color: #67c23a;
}
&.status-cancelled {
color: #909399;
}
}
.order-content {
margin-bottom: 20rpx;
}
.order-title {
display: block;
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 12rpx;
}
.order-products {
margin: 12rpx 0;
font-size: 26rpx;
color: #606266;
}
.product-item {
display: inline;
}
.order-time {
font-size: 24rpx;
color: #909399;
}
.order-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.order-price {
font-size: 32rpx;
font-weight: bold;
color: #e6a23c;
}
.order-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
padding: 12rpx 32rpx;
border-radius: 40rpx;
font-size: 26rpx;
text {
color: #606266;
}
&.cancel-btn {
background: #f0f0f0;
}
&.primary-btn {
background: #409eff;
text {
color: #ffffff;
}
}
}
.evaluated-tag {
padding: 12rpx 32rpx;
border-radius: 40rpx;
font-size: 26rpx;
background: #f0f9ff;
border: 1rpx solid #67c23a;
text {
color: #67c23a;
}
}
</style>

@ -0,0 +1,886 @@
<template>
<view class="payment-container">
<scroll-view class="payment-content" scroll-y>
<!-- 支付金额 -->
<view class="amount-section">
<text class="amount-label">支付金额</text>
<text class="amount-value">¥{{ paymentInfo.amount }}</text>
</view>
<!-- 订单信息 -->
<view class="order-info-section">
<view class="section-title">订单信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">订单号</text>
<text class="info-value">{{ paymentInfo.orderNo || '下单后自动生成' }}</text>
</view>
<view class="info-item">
<text class="info-label">订单类型</text>
<text class="info-value">{{ paymentInfo.orderType }}</text>
</view>
<view class="info-item" v-if="productInfo.productName">
<text class="info-label">商品</text>
<text class="info-value">{{ productInfo.productName }}</text>
</view>
<view class="info-item">
<text class="info-label">订单时间</text>
<text class="info-value">{{ paymentInfo.orderTime }}</text>
</view>
</view>
</view>
<!-- 联系与服务信息 -->
<view class="order-info-section">
<view class="section-title">联系与服务信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">服务地址</text>
<view class="address-select-wrapper" @click="handleSelectAddress">
<view v-if="selectedAddress" class="address-display">
<view class="address-display-header">
<text class="address-display-name">{{ selectedAddress.contactName }}</text>
<text class="address-display-phone">{{ selectedAddress.contactPhone }}</text>
<view v-if="selectedAddress.isDefault === '1'" class="address-display-tag"></view>
</view>
<text class="address-display-detail">{{ getDisplayAddress(selectedAddress.address) }}</text>
</view>
<text v-else class="address-placeholder">请选择收货/安装地址</text>
<text class="address-arrow"></text>
</view>
</view>
<view class="info-item">
<text class="info-label">备注</text>
<textarea
class="info-textarea"
v-model="remark"
placeholder="可填写上门时间、特殊要求等(选填)"
maxlength="200"
></textarea>
</view>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-method-section">
<view class="section-title">选择支付方式</view>
<view class="method-list">
<view
class="method-item"
v-for="(method, index) in paymentMethods"
:key="index"
:class="{ active: selectedMethod === index }"
@click="selectMethod(index)"
>
<view class="method-left">
<text class="method-icon">{{ method.icon }}</text>
<view class="method-info">
<text class="method-name">{{ method.name }}</text>
<text class="method-desc">{{ method.desc }}</text>
</view>
</view>
<view class="method-right">
<view class="radio" :class="{ checked: selectedMethod === index }">
<text v-if="selectedMethod === index" class="radio-dot"></text>
</view>
</view>
</view>
</view>
</view>
<!-- 支付优惠可选 -->
<view class="discount-section">
<view class="discount-item" @click="handleSelectDiscount">
<text class="discount-label">优惠券</text>
<view class="discount-right">
<text class="discount-text">{{ selectedDiscount || '暂无可用优惠券' }}</text>
<text class="discount-arrow"></text>
</view>
</view>
</view>
<!-- 实付金额 -->
<view class="final-amount-section">
<view class="amount-row">
<text class="amount-label-text">订单金额</text>
<text class="amount-value-text">¥{{ paymentInfo.amount }}</text>
</view>
<view class="amount-row">
<text class="amount-label-text">优惠金额</text>
<text class="amount-value-text discount">-¥{{ discountAmount }}</text>
</view>
<view class="amount-divider"></view>
<view class="amount-row final">
<text class="amount-label-text">实付金额</text>
<text class="amount-value-text final-amount">¥{{ finalAmount }}</text>
</view>
</view>
</scroll-view>
<!-- 底部支付按钮 -->
<view class="payment-footer">
<view class="footer-left">
<text class="total-label">合计</text>
<text class="total-amount">¥{{ finalAmount }}</text>
</view>
<button class="pay-btn" :disabled="paying" @click="handlePay">
<text v-if="paying">...</text>
<text v-else></text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getUserInfo } from '../../api/user.js'
import { createOrder, payOrder } from '../../api/order.js'
// product-
const mode = ref('product')
// 使
const productInfo = ref({
productId: null,
productName: '',
price: 0,
quantity: 1
})
// ID
const orderId = ref(null)
//
const paymentInfo = ref({
orderNo: '',
orderType: '商品订购',
amount: '0.00',
orderTime: ''
})
//
const selectedMethod = ref(0)
//
const paymentMethods = ref([
{
name: '微信支付',
desc: '推荐使用',
icon: '💚',
type: 'wechat'
},
{
name: '余额支付',
desc: '账户余额¥256.80',
icon: '💰',
type: 'balance'
},
{
name: '支付宝',
desc: '暂不支持',
icon: '💙',
type: 'alipay',
disabled: true
}
])
//
const selectedDiscount = ref('')
//
const discountAmount = computed(() => {
if (selectedDiscount.value === '满100减10') {
return '10.00'
} else if (selectedDiscount.value === '满50减5') {
return '5.00'
}
return '0.00'
})
//
const finalAmount = computed(() => {
const amount = parseFloat(paymentInfo.value.amount) || 0
const discount = parseFloat(discountAmount.value) || 0
const final = Math.max(0, amount - discount)
return final.toFixed(2)
})
//
const remark = ref('')
//
const selectedAddress = ref(null)
//
const getDisplayAddress = (address) => {
if (!address) return ''
// |
const index = address.indexOf('|')
if (index !== -1) {
return address.substring(0, index)
}
return address
}
//
const userBalance = ref(0)
//
const paying = ref(false)
//
const loadUserInfo = async () => {
try {
const info = await getUserInfo()
if (info) {
//
let bal = 0
if (typeof info.balance === 'string') {
bal = parseFloat(info.balance || '0')
} else {
bal = info.balance || 0
}
if (!Number.isNaN(bal)) {
userBalance.value = bal
//
const balanceMethod = paymentMethods.value.find(m => m.type === 'balance')
if (balanceMethod) {
balanceMethod.desc = `账户余额:¥${userBalance.value.toFixed(2)}`
}
}
}
} catch (error) {
console.error('获取用户信息失败', error)
}
}
//
const selectMethod = (index) => {
if (paymentMethods.value[index].disabled) {
uni.showToast({
title: '该支付方式暂不支持',
icon: 'none'
})
return
}
selectedMethod.value = index
}
//
const handleSelectAddress = () => {
uni.navigateTo({
url: '/pages/profile/address-select'
})
}
//
const setSelectedAddress = (addressData) => {
if (addressData) {
selectedAddress.value = addressData
}
}
//
const handleSelectDiscount = () => {
uni.showActionSheet({
itemList: ['满100减10', '满50减5', '不使用优惠券'],
success: (res) => {
if (res.tapIndex === 0) {
selectedDiscount.value = '满100减10'
} else if (res.tapIndex === 1) {
selectedDiscount.value = '满50减5'
} else {
selectedDiscount.value = ''
}
}
})
}
// +
const handlePay = async () => {
if (paying.value) return
const method = paymentMethods.value[selectedMethod.value]
if (method.disabled) {
uni.showToast({
title: '该支付方式暂不支持',
icon: 'none'
})
return
}
//
if (!selectedAddress.value) {
uni.showToast({
title: '请通过地图选择服务/收货地址',
icon: 'none'
})
return
}
// 使
let finalAddress = selectedAddress.value.address || ''
let finalPhone = selectedAddress.value.contactPhone || ''
//
const extractLocation = (address) => {
if (!address) return { lat: null, lng: null, displayAddress: '' }
const index = address.indexOf('|')
if (index !== -1) {
const displayAddress = address.substring(0, index)
const locationStr = address.substring(index + 1)
const parts = locationStr.split(',')
if (parts.length === 2) {
const lat = parseFloat(parts[0])
const lng = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lng)) {
return { lat, lng, displayAddress }
}
}
return { lat: null, lng: null, displayAddress }
}
return { lat: null, lng: null, displayAddress: address }
}
const locationInfo = extractLocation(finalAddress)
const displayAddress = locationInfo.displayAddress || finalAddress
//
if (!displayAddress.trim()) {
uni.showToast({
title: '地址信息不完整,请重新选择',
icon: 'none'
})
return
}
//
if (!finalPhone || !(finalPhone.length === 11 && /^1[3-9]\d{9}$/.test(finalPhone))) {
uni.showToast({
title: '请选择包含有效联系电话的地址',
icon: 'none'
})
return
}
//
if (method.type === 'balance') {
const balance = userBalance.value || 0
if (parseFloat(finalAmount.value) > balance) {
uni.showToast({
title: '余额不足,请选择其他支付方式',
icon: 'none'
})
return
}
}
try {
paying.value = true
let currentOrderId = orderId.value
//
if (!currentOrderId && mode.value === 'product') {
const createRes = await createOrder({
addressId: selectedAddress.value?.addressId || null,
contactName: selectedAddress.value?.contactName || '',
contactPhone: finalPhone,
deliveryAddress: displayAddress, // 使
locationLat: locationInfo.lat, //
locationLng: locationInfo.lng, //
paymentMethod: method.type,
remark: remark.value || `订购商品:${productInfo.value.productName}`,
products: [
{
productId: productInfo.value.productId,
quantity: productInfo.value.quantity,
price: paymentInfo.value.amount
}
]
})
// createOrder WaterAppOrderController
if (createRes && createRes.orderId) {
currentOrderId = createRes.orderId
orderId.value = createRes.orderId
paymentInfo.value.orderNo = createRes.orderNo || paymentInfo.value.orderNo
// totalAmount
if (createRes.totalAmount != null) {
const total = typeof createRes.totalAmount === 'string'
? parseFloat(createRes.totalAmount || '0')
: (createRes.totalAmount || 0)
paymentInfo.value.amount = total.toFixed(2)
}
}
}
//
if (currentOrderId) {
const payRes = await payOrder(currentOrderId, method.type)
// 使 payRes
if (payRes && payRes.orderNo) {
paymentInfo.value.orderNo = payRes.orderNo
}
}
uni.showToast({
title: '支付成功',
icon: 'success',
duration: 1500
})
//
setTimeout(() => {
uni.switchTab({
url: '/pages/order/order'
})
}, 1500)
} catch (error) {
console.error('支付失败', error)
// request.js
} finally {
paying.value = false
}
}
//
onMounted(() => {
//
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
//
mode.value = options.mode || 'product'
//
if (mode.value === 'product') {
if (options.productId) {
productInfo.value.productId = Number(options.productId)
}
if (options.productName) {
try {
productInfo.value.productName = decodeURIComponent(options.productName)
} catch (e) {
productInfo.value.productName = options.productName
}
}
const amount = options.amount || '0.00'
const numAmount = parseFloat(amount) || 0
productInfo.value.price = numAmount
paymentInfo.value.amount = numAmount.toFixed(2)
paymentInfo.value.orderType = '商品订购'
paymentInfo.value.orderTime = new Date().toLocaleString('zh-CN')
}
//
loadUserInfo()
//
checkSelectedAddress()
})
//
onShow(() => {
checkSelectedAddress()
})
//
const checkSelectedAddress = () => {
try {
const savedAddress = uni.getStorageSync('selectedAddress')
if (savedAddress) {
setSelectedAddress(savedAddress)
//
uni.removeStorageSync('selectedAddress')
}
} catch (e) {
console.error('读取选中地址失败', e)
}
}
</script>
<style lang="scss" scoped>
.payment-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.payment-content {
flex: 1;
padding-bottom: 120rpx;
}
.amount-section {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 60rpx 30rpx;
text-align: center;
}
.amount-label {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 20rpx;
}
.amount-value {
font-size: 72rpx;
font-weight: bold;
color: #ffffff;
}
.order-info-section,
.payment-method-section,
.discount-section,
.final-amount-section {
background: #ffffff;
margin: 20rpx 30rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.info-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.info-item {
display: flex;
}
.info-label {
font-size: 28rpx;
color: #909399;
min-width: 160rpx;
}
.info-value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
}
.method-list {
display: flex;
flex-direction: column;
gap: 0;
}
.method-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.active {
.method-name {
color: #409eff;
}
}
}
.method-left {
display: flex;
align-items: center;
flex: 1;
}
.method-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.method-info {
display: flex;
flex-direction: column;
flex: 1;
}
.method-name {
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 8rpx;
}
.method-desc {
font-size: 24rpx;
color: #909399;
}
.method-right {
margin-left: 20rpx;
}
.radio {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.checked {
border-color: #409eff;
background: #409eff;
}
}
.radio-dot {
width: 16rpx;
height: 16rpx;
background: #ffffff;
border-radius: 50%;
}
.discount-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
}
.discount-label {
font-size: 28rpx;
color: #303133;
}
.discount-right {
display: flex;
align-items: center;
}
.discount-text {
font-size: 28rpx;
color: #909399;
margin-right: 12rpx;
}
.discount-arrow {
font-size: 40rpx;
color: #c0c4cc;
}
.final-amount-section {
background: #ffffff;
}
.amount-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
&.final {
padding-top: 24rpx;
}
}
.amount-label-text {
font-size: 28rpx;
color: #606266;
}
.amount-value-text {
font-size: 28rpx;
color: #303133;
font-weight: 500;
&.discount {
color: #67c23a;
}
&.final-amount {
font-size: 36rpx;
font-weight: bold;
color: #e6a23c;
}
}
.amount-divider {
height: 1rpx;
background: #e4e7ed;
margin: 12rpx 0;
}
.payment-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.footer-left {
flex: 1;
display: flex;
align-items: baseline;
}
.total-label {
font-size: 28rpx;
color: #606266;
margin-right: 12rpx;
}
.total-amount {
font-size: 36rpx;
font-weight: bold;
color: #e6a23c;
}
.pay-btn {
width: 300rpx;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
.info-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: none;
}
.info-textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: none;
}
.address-select-wrapper {
width: 100%;
min-height: 140rpx;
padding: 24rpx;
background: linear-gradient(135deg, #f8fbff 0%, #ffffff 100%);
border: 2rpx solid #e4e7ed;
border-radius: 16rpx;
display: flex;
align-items: center;
position: relative;
box-shadow: 0 2rpx 8rpx rgba(64, 158, 255, 0.08);
cursor: pointer;
}
.address-display {
flex: 1;
min-width: 0;
padding-right: 20rpx;
}
.address-display-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
flex-wrap: wrap;
gap: 12rpx;
}
.address-display-name {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.address-display-phone {
font-size: 26rpx;
color: #606266;
padding: 4rpx 12rpx;
background: #f5f7fa;
border-radius: 8rpx;
}
.address-display-tag {
padding: 4rpx 12rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 8rpx;
font-size: 22rpx;
font-weight: 500;
box-shadow: 0 2rpx 4rpx rgba(64, 158, 255, 0.3);
}
.address-display-detail {
font-size: 26rpx;
color: #606266;
line-height: 1.8;
word-break: break-all;
padding: 12rpx 16rpx;
background: rgba(64, 158, 255, 0.05);
border-radius: 8rpx;
border-left: 4rpx solid #409eff;
}
.address-placeholder {
flex: 1;
font-size: 28rpx;
color: #909399;
display: flex;
align-items: center;
&::before {
content: '📍';
margin-right: 12rpx;
font-size: 32rpx;
}
}
.address-arrow {
font-size: 36rpx;
color: #409eff;
margin-left: 20rpx;
font-weight: bold;
flex-shrink: 0;
}
</style>

@ -0,0 +1,446 @@
<template>
<view class="address-select-container">
<!-- 地址列表 -->
<scroll-view class="address-list" scroll-y>
<view v-if="addressList.length === 0" class="empty-state">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无地址请使用地图选择</text>
<button class="map-btn-empty" @click="handleChooseLocation">📍 使</button>
</view>
<view v-else>
<view
class="address-item"
v-for="(address, index) in addressList"
:key="address.addressId || index"
:class="{ selected: selectedAddressId === address.addressId }"
@click="handleSelect(address)"
>
<view class="address-content">
<view class="address-header">
<text class="address-name">{{ address.contactName || '未命名' }}</text>
<text class="address-phone">{{ address.contactPhone }}</text>
<view v-if="address.isDefault === '1'" class="default-tag"></view>
</view>
<text class="address-detail">{{ getDisplayAddress(address.address) }}</text>
</view>
<view class="address-check">
<view class="check-icon" :class="{ checked: selectedAddressId === address.addressId }">
<text v-if="selectedAddressId === address.addressId"></text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="footer">
<button class="map-select-btn" @click="handleChooseLocation">📍 使</button>
<button
class="confirm-btn"
:disabled="!selectedAddressId"
@click="handleConfirm"
>
确定选择
</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAddressList } from '../../api/address.js'
import { onLoad } from '@dcloudio/uni-app'
//
const addressList = ref([])
// ID
const selectedAddressId = ref(null)
//
const getDisplayAddress = (address) => {
if (!address) return ''
// |
const index = address.indexOf('|')
if (index !== -1) {
return address.substring(0, index)
}
return address
}
//
const loadAddressList = async () => {
try {
const res = await getAddressList()
console.log('地址列表API返回:', res)
//
// TableDataInfo { code: 200, rows: [...], total: ... }
// request.js { rows: [...], total: ... } rows
let list = []
if (res && res.rows) {
// TableDataInfo { rows: [...], total: ... }
list = Array.isArray(res.rows) ? res.rows : []
} else if (Array.isArray(res)) {
//
list = res
}
console.log('处理后的地址列表:', list)
addressList.value = list.map(item => ({
addressId: item.addressId,
contactName: item.contactName || '',
contactPhone: item.contactPhone || '',
address: item.address || '',
// isDefault
isDefault: item.isDefault === true || item.isDefault === '1' || item.isDefault === 1 ? '1' : '0'
}))
console.log('最终地址列表:', addressList.value)
//
const defaultAddress = addressList.value.find(addr => addr.isDefault === '1')
if (defaultAddress) {
selectedAddressId.value = defaultAddress.addressId
} else if (addressList.value.length > 0) {
//
selectedAddressId.value = addressList.value[0].addressId
}
} catch (error) {
console.error('加载地址列表失败', error)
uni.showToast({
title: '加载地址失败',
icon: 'none'
})
addressList.value = []
}
}
//
const handleSelect = (address) => {
selectedAddressId.value = address.addressId
}
// 使
const handleChooseLocation = () => {
uni.chooseLocation({
success: (res) => {
// +
let addressText = ''
if (res.name && res.address) {
addressText = `${res.name}${res.address}`
} else if (res.name) {
addressText = res.name
} else if (res.address) {
addressText = res.address
}
// |lat,lng
if (res.latitude && res.longitude) {
addressText += `|${res.latitude},${res.longitude}`
}
//
const mapSelectedAddress = {
addressId: null, // ID
contactName: '', //
contactPhone: '', //
address: addressText, //
isDefault: '0',
latitude: res.latitude,
longitude: res.longitude
}
//
uni.showModal({
title: '填写联系信息',
editable: true,
placeholderText: '请输入联系人姓名',
success: (nameRes) => {
if (nameRes.confirm && nameRes.content) {
mapSelectedAddress.contactName = nameRes.content.trim()
//
uni.showModal({
title: '联系电话',
editable: true,
placeholderText: '请输入联系电话',
success: (phoneRes) => {
if (phoneRes.confirm && phoneRes.content) {
const phone = phoneRes.content.trim()
//
if (!/^1[3-9]\d{9}$/.test(phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return
}
mapSelectedAddress.contactPhone = phone
//
uni.setStorageSync('selectedAddress', mapSelectedAddress)
//
uni.navigateBack()
}
}
})
}
}
})
},
fail: (err) => {
console.error('选择位置失败', err)
if (err.errMsg && err.errMsg.includes('requiredPrivateInfos')) {
uni.showToast({
title: '请在设置中开启位置权限',
icon: 'none',
duration: 3000
})
} else {
uni.showToast({
title: '选择位置失败,请重试',
icon: 'none'
})
}
}
})
}
// 使
const handleAdd = () => {
//
uni.navigateTo({
url: '/pages/profile/address?mode=add',
events: {
//
addressAdded: (data) => {
//
loadAddressList()
}
}
})
}
//
const handleConfirm = () => {
if (!selectedAddressId.value) {
uni.showToast({
title: '请选择地址',
icon: 'none'
})
return
}
const selectedAddress = addressList.value.find(addr => addr.addressId === selectedAddressId.value)
if (!selectedAddress) {
uni.showToast({
title: '地址不存在',
icon: 'none'
})
return
}
//
uni.setStorageSync('selectedAddress', selectedAddress)
//
uni.navigateBack()
}
//
onLoad(() => {
loadAddressList()
})
//
import { onShow } from '@dcloudio/uni-app'
onShow(() => {
loadAddressList()
})
</script>
<style lang="scss" scoped>
.address-select-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.address-list {
flex: 1;
padding: 20rpx 30rpx;
padding-bottom: 160rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 40rpx;
}
.empty-text {
font-size: 28rpx;
color: #909399;
margin-bottom: 40rpx;
}
.map-btn-empty {
width: 300rpx;
height: 80rpx;
line-height: 80rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 40rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
&::after {
border: none;
}
}
.address-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
border: 2rpx solid transparent;
transition: all 0.3s;
&.selected {
border-color: #409eff;
background: #ecf5ff;
}
}
.address-content {
flex: 1;
min-width: 0;
}
.address-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.address-name {
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-right: 20rpx;
}
.address-phone {
font-size: 28rpx;
color: #606266;
margin-right: 20rpx;
}
.default-tag {
padding: 4rpx 12rpx;
background: #409eff;
color: #ffffff;
border-radius: 8rpx;
font-size: 20rpx;
}
.address-detail {
font-size: 26rpx;
color: #909399;
line-height: 1.6;
word-break: break-all;
}
.address-check {
margin-left: 20rpx;
flex-shrink: 0;
}
.check-icon {
width: 48rpx;
height: 48rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
color: #ffffff;
transition: all 0.3s;
&.checked {
border-color: #409eff;
background: #409eff;
}
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
gap: 20rpx;
}
.map-select-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 28rpx;
font-weight: 500;
border: none;
margin-right: 20rpx;
&::after {
border: none;
}
}
.confirm-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
color: #ffffff;
}
}
</style>

@ -0,0 +1,723 @@
<template>
<view class="address-container">
<!-- 地址列表 -->
<scroll-view class="address-list" scroll-y>
<view v-if="addressList.length === 0" class="empty-state">
<text class="empty-icon">📍</text>
<text class="empty-text">暂无地址请添加</text>
</view>
<view v-else>
<view class="address-item" v-for="(address, index) in addressList" :key="address.addressId || index">
<view class="address-content" @click="handleEdit(address)">
<view class="address-header">
<text class="address-name">{{ address.contactName || '未命名' }}</text>
<text class="address-phone">{{ address.contactPhone }}</text>
<view v-if="address.isDefault" class="default-tag"></view>
</view>
<text class="address-detail">{{ getDisplayAddress(address.address) }}</text>
</view>
<view class="address-actions">
<view class="action-btn" @click.stop="handleSetDefault(address)">
<text :class="address.isDefault ? 'default-icon active' : 'default-icon'">
{{ address.isDefault ? '✓' : '○' }}
</text>
<text class="action-text">设为默认</text>
</view>
<view class="action-btn" @click.stop="handleEdit(address)">
<text class="edit-icon"></text>
<text class="action-text">编辑</text>
</view>
<view class="action-btn" @click.stop="handleDelete(address)">
<text class="delete-icon">🗑</text>
<text class="action-text">删除</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部添加按钮 -->
<view class="footer">
<button class="add-btn" @click="handleAdd">+ </button>
</view>
<!-- 编辑地址弹窗 -->
<view v-if="showEditDialog" class="dialog-mask" @click="closeEditDialog">
<view class="dialog-content" @click.stop>
<view class="dialog-header">
<text class="dialog-title">{{ editingAddress.addressId ? '编辑地址' : '新增地址' }}</text>
<text class="dialog-close" @click="closeEditDialog">×</text>
</view>
<view class="dialog-body">
<view class="form-item">
<text class="form-label">联系人</text>
<input
class="form-input"
type="text"
v-model="editingAddress.contactName"
placeholder="请输入联系人姓名"
maxlength="20"
/>
</view>
<view class="form-item">
<text class="form-label">联系电话</text>
<input
class="form-input"
type="text"
v-model="editingAddress.contactPhone"
placeholder="请输入联系电话"
maxlength="11"
/>
</view>
<view class="form-item">
<text class="form-label">详细地址</text>
<view class="address-select-wrapper">
<view v-if="editingAddress.address" class="address-display-box">
<text class="address-display-text">{{ getDisplayAddress(editingAddress.address) }}</text>
<button class="rechoose-btn" @click="handleChooseLocation"></button>
</view>
<button v-else class="location-select-btn" @click="handleChooseLocation">
<text class="location-icon">📍</text>
<text class="location-text">请使用地图选择地址</text>
</button>
</view>
</view>
<view class="form-item">
<view class="checkbox-wrapper" @click="toggleDefault">
<text class="checkbox" :class="{ checked: editingAddress.isDefault }">
{{ editingAddress.isDefault ? '✓' : '' }}
</text>
<text class="checkbox-label">设为默认地址</text>
</view>
</view>
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel-btn" @click="closeEditDialog"></button>
<button class="dialog-btn confirm-btn" @click="handleSave" :disabled="saving">
{{ saving ? '保存中...' : '保存' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getAddressList, createAddress, updateAddress, deleteAddress, setDefaultAddress } from '../../api/address.js'
//
const addressList = ref([])
//
const showEditDialog = ref(false)
const editingAddress = ref({
addressId: null,
contactName: '',
contactPhone: '',
address: '',
isDefault: false
})
//
const saving = ref(false)
//
const handleChooseLocation = () => {
uni.chooseLocation({
success: (res) => {
// res.name: ""
// res.address: "xxx123"
// res.latitude:
// res.longitude:
// +
let addressText = ''
if (res.name && res.address) {
//
addressText = `${res.name}${res.address}`
} else if (res.name) {
//
addressText = res.name
} else if (res.address) {
//
addressText = res.address
}
// |lat,lng
if (res.latitude && res.longitude) {
addressText += `|${res.latitude},${res.longitude}`
}
editingAddress.value.address = addressText
uni.showToast({
title: '定位成功',
icon: 'success',
duration: 1500
})
},
fail: (err) => {
console.error('选择位置失败', err)
//
if (err.errMsg && !err.errMsg.includes('cancel')) {
if (err.errMsg && err.errMsg.includes('requiredPrivateInfos')) {
uni.showToast({
title: '请在设置中开启位置权限',
icon: 'none',
duration: 3000
})
} else {
uni.showToast({
title: '选择位置失败,请重试',
icon: 'none',
duration: 2000
})
}
}
}
})
}
//
const getDisplayAddress = (address) => {
if (!address) return ''
// |
const index = address.indexOf('|')
if (index !== -1) {
return address.substring(0, index)
}
return address
}
//
const extractLocation = (address) => {
if (!address) return { lat: null, lng: null }
const index = address.indexOf('|')
if (index !== -1) {
const locationStr = address.substring(index + 1)
const parts = locationStr.split(',')
if (parts.length === 2) {
const lat = parseFloat(parts[0])
const lng = parseFloat(parts[1])
if (!isNaN(lat) && !isNaN(lng)) {
return { lat, lng }
}
}
}
return { lat: null, lng: null }
}
//
const loadAddressList = async () => {
try {
const res = await getAddressList()
//
const list = res && res.data ? res.data : (res && res.rows ? res.rows : (Array.isArray(res) ? res : []))
addressList.value = list
} catch (error) {
console.error('加载地址列表失败', error)
addressList.value = []
}
}
//
const handleAdd = () => {
editingAddress.value = {
addressId: null,
contactName: '',
contactPhone: '',
address: '',
isDefault: false
}
showEditDialog.value = true
}
//
const handleEdit = (address) => {
editingAddress.value = {
addressId: address.addressId,
contactName: address.contactName || '',
contactPhone: address.contactPhone || '',
address: address.address || '',
isDefault: address.isDefault || false
}
showEditDialog.value = true
}
//
const handleDelete = (address) => {
uni.showModal({
title: '提示',
content: '确定要删除该地址吗?',
success: async (res) => {
if (res.confirm) {
try {
await deleteAddress(address.addressId)
uni.showToast({
title: '删除成功',
icon: 'success'
})
loadAddressList()
} catch (error) {
console.error('删除地址失败', error)
}
}
}
})
}
//
const handleSetDefault = async (address) => {
if (address.isDefault) {
return
}
try {
await setDefaultAddress(address.addressId)
uni.showToast({
title: '设置成功',
icon: 'success'
})
loadAddressList()
} catch (error) {
console.error('设置默认地址失败', error)
}
}
//
const toggleDefault = () => {
editingAddress.value.isDefault = !editingAddress.value.isDefault
}
//
const closeEditDialog = () => {
showEditDialog.value = false
editingAddress.value = {
addressId: null,
contactName: '',
contactPhone: '',
address: '',
isDefault: false
}
}
//
const handleSave = async () => {
//
if (!editingAddress.value.contactName || !editingAddress.value.contactName.trim()) {
uni.showToast({
title: '请输入联系人姓名',
icon: 'none'
})
return
}
if (!editingAddress.value.contactPhone || !editingAddress.value.contactPhone.trim()) {
uni.showToast({
title: '请输入联系电话',
icon: 'none'
})
return
}
if (!/^1[3-9]\d{9}$/.test(editingAddress.value.contactPhone.trim())) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return
}
if (!editingAddress.value.address || !editingAddress.value.address.trim()) {
uni.showToast({
title: '请使用地图选择地址',
icon: 'none'
})
return
}
try {
saving.value = true
const data = {
contactName: editingAddress.value.contactName.trim(),
contactPhone: editingAddress.value.contactPhone.trim(),
address: editingAddress.value.address.trim(),
isDefault: editingAddress.value.isDefault ? '1' : '0'
}
if (editingAddress.value.addressId) {
//
await updateAddress(editingAddress.value.addressId, data)
} else {
//
await createAddress(data)
}
uni.showToast({
title: '保存成功',
icon: 'success'
})
closeEditDialog()
loadAddressList()
} catch (error) {
console.error('保存地址失败', error)
} finally {
saving.value = false
}
}
//
onMounted(() => {
loadAddressList()
})
//
onShow(() => {
loadAddressList()
})
</script>
<style lang="scss" scoped>
.address-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.address-list {
flex: 1;
padding: 20rpx 30rpx;
padding-bottom: 120rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.address-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.address-content {
margin-bottom: 20rpx;
}
.address-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.address-name {
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-right: 20rpx;
}
.address-phone {
font-size: 28rpx;
color: #606266;
margin-right: 20rpx;
}
.default-tag {
padding: 4rpx 16rpx;
background: #e6f7ff;
color: #409eff;
border-radius: 20rpx;
font-size: 22rpx;
}
.address-detail {
font-size: 28rpx;
color: #606266;
line-height: 1.6;
}
.address-actions {
display: flex;
justify-content: flex-end;
gap: 30rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
}
.action-btn {
display: flex;
align-items: center;
gap: 8rpx;
}
.default-icon,
.edit-icon,
.delete-icon {
font-size: 32rpx;
}
.default-icon {
color: #909399;
&.active {
color: #409eff;
}
}
.action-text {
font-size: 26rpx;
color: #606266;
}
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
}
.add-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
}
.dialog-mask {
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;
}
.dialog-content {
width: 90%;
max-width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.dialog-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.dialog-close {
font-size: 48rpx;
color: #909399;
line-height: 1;
}
.dialog-body {
padding: 30rpx;
}
.form-item {
margin-bottom: 30rpx;
}
.form-label {
display: block;
font-size: 28rpx;
color: #606266;
margin-bottom: 12rpx;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: 1rpx solid #e4e7ed;
}
.address-select-wrapper {
width: 100%;
}
.address-display-box {
width: 100%;
padding: 24rpx;
background: linear-gradient(135deg, #f8fbff 0%, #ffffff 100%);
border: 2rpx solid #e4e7ed;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.address-display-text {
font-size: 28rpx;
color: #303133;
line-height: 1.6;
word-break: break-all;
padding: 12rpx 16rpx;
background: rgba(64, 158, 255, 0.05);
border-radius: 8rpx;
border-left: 4rpx solid #409eff;
}
.rechoose-btn {
align-self: flex-end;
height: 64rpx;
padding: 0 32rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 32rpx;
font-size: 26rpx;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
&::after {
border: none;
}
}
.location-select-btn {
width: 100%;
height: 160rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 16rpx;
font-size: 28rpx;
border: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(64, 158, 255, 0.3);
&::after {
border: none;
}
}
.location-icon {
font-size: 48rpx;
}
.location-text {
font-size: 28rpx;
font-weight: 500;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 12rpx;
}
.checkbox {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #ffffff;
&.checked {
background: #409eff;
border-color: #409eff;
}
}
.checkbox-label {
font-size: 28rpx;
color: #606266;
}
.dialog-footer {
display: flex;
padding: 20rpx 30rpx;
border-top: 1rpx solid #f0f0f0;
gap: 20rpx;
}
.dialog-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
font-size: 30rpx;
border: none;
&::after {
border: none;
}
&.cancel-btn {
background: #f0f0f0;
color: #606266;
}
&.confirm-btn {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
&:disabled {
opacity: 0.6;
}
}
}
</style>

@ -0,0 +1,206 @@
<template>
<view class="change-password-page">
<view class="form-card">
<view class="form-item">
<text class="label">当前密码</text>
<input
class="input"
:password="!showOldPwd"
v-model="form.oldPassword"
placeholder="请输入当前密码"
/>
<text class="eye-icon" @click="showOldPwd = !showOldPwd">
{{ showOldPwd ? '👁️' : '🙈' }}
</text>
</view>
<view class="form-item">
<text class="label">新密码</text>
<input
class="input"
:password="!showNewPwd"
v-model="form.newPassword"
placeholder="请输入新密码不少于6位"
/>
<text class="eye-icon" @click="showNewPwd = !showNewPwd">
{{ showNewPwd ? '👁️' : '🙈' }}
</text>
</view>
<view class="form-item">
<text class="label">确认新密码</text>
<input
class="input"
:password="!showConfirmPwd"
v-model="form.confirmPassword"
placeholder="请再次输入新密码"
/>
<text class="eye-icon" @click="showConfirmPwd = !showConfirmPwd">
{{ showConfirmPwd ? '👁️' : '🙈' }}
</text>
</view>
</view>
<view class="tips-card">
<text class="tips-title">密码安全提示</text>
<text class="tips-text">1. 建议使用数字和字母组合的密码长度不少于6位</text>
<text class="tips-text">2. 请勿与其他网站使用相同密码</text>
</view>
<view class="bottom-bar">
<button class="save-btn" :disabled="submitting" @click="handleSubmit">
<text v-if="submitting">...</text>
<text v-else></text>
</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { changePassword } from '../../api/auth.js'
const form = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const showOldPwd = ref(false)
const showNewPwd = ref(false)
const showConfirmPwd = ref(false)
const submitting = ref(false)
const handleSubmit = async () => {
if (submitting.value) return
if (!form.value.oldPassword) {
uni.showToast({ title: '请输入当前密码', icon: 'none' })
return
}
if (!form.value.newPassword || form.value.newPassword.length < 6) {
uni.showToast({ title: '新密码不少于6位', icon: 'none' })
return
}
if (form.value.newPassword !== form.value.confirmPassword) {
uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' })
return
}
if (form.value.newPassword === form.value.oldPassword) {
uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' })
return
}
try {
submitting.value = true
await changePassword(form.value.oldPassword, form.value.newPassword)
uni.showToast({
title: '修改成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
console.error('修改密码失败', error)
// request.js
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.change-password-page {
min-height: 100vh;
background: #f5f5f5;
padding: 30rpx 30rpx 120rpx;
box-sizing: border-box;
}
.form-card {
background: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
}
.form-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.label {
font-size: 28rpx;
color: #606266;
min-width: 180rpx;
}
.input {
flex: 1;
font-size: 28rpx;
color: #303133;
}
.eye-icon {
font-size: 32rpx;
margin-left: 16rpx;
color: #909399;
}
.tips-card {
background: #ffffff;
border-radius: 20rpx;
padding: 24rpx 26rpx;
color: #606266;
font-size: 26rpx;
}
.tips-title {
display: block;
margin-bottom: 12rpx;
font-weight: 600;
}
.tips-text {
display: block;
margin-bottom: 6rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-sizing: border-box;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
</style>

@ -0,0 +1,376 @@
<template>
<view class="profile-container">
<!-- 用户信息头部 -->
<view class="profile-header">
<view class="user-info" @click="handleUserInfo">
<view class="avatar-wrapper">
<image
v-if="avatarUrl"
class="avatar-image"
:src="avatarUrl"
mode="aspectFill"
/>
<text v-else class="avatar-icon">{{ avatarEmoji }}</text>
</view>
<view class="user-details">
<text class="user-name">{{ userName }}</text>
<text class="user-role">{{ userRoleText }}</text>
</view>
<text class="arrow-icon"></text>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-group">
<view class="menu-item" v-for="(item, index) in menuItems" :key="index" @click="handleMenuItem(item)">
<view class="menu-left">
<text class="menu-icon">{{ item.icon }}</text>
<text class="menu-text">{{ item.label }}</text>
</view>
<view class="menu-right">
<text v-if="item.badge" class="menu-badge">{{ item.badge }}</text>
<text class="arrow-icon"></text>
</view>
</view>
</view>
</view>
<!-- 工作人员专属功能 -->
<view v-if="isStaff" class="menu-section">
<view class="section-title">工作人员功能</view>
<view class="menu-group">
<view class="menu-item" v-for="(item, index) in staffMenuItems" :key="index" @click="handleMenuItem(item)">
<view class="menu-left">
<text class="menu-icon">{{ item.icon }}</text>
<text class="menu-text">{{ item.label }}</text>
</view>
<view class="menu-right">
<text v-if="item.badge" class="menu-badge">{{ item.badge }}</text>
<text class="arrow-icon"></text>
</view>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="handleLogout">退</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUserInfo } from '../../api/user.js'
import request from '../../utils/request.js'
//
const userRole = ref('user')
const userName = ref('用户')
const userRoleText = computed(() => userRole.value === 'user' ? '普通用户' : '工作人员')
const isStaff = computed(() => userRole.value === 'staff')
const avatar = ref('')
const avatarUrl = computed(() => {
const v = avatar.value || ''
if (!v) return ''
// URL
if (v.startsWith('http')) {
return v
}
// /profile/upload/...
if (v.startsWith('/')) {
// /profile/ 使 /prod-api
if (v.startsWith('/profile/')) {
return `https://tjkhnu.com${v}`
}
// 使 BASE_URL
return `${request.BASE_URL}${v}`
}
return ''
})
const avatarEmoji = computed(() => {
if (!avatarUrl.value && avatar.value) {
return avatar.value
}
return '👤'
})
//
const menuItems = ref([
{ label: '我的订单', icon: '📋', path: '', badge: '' },
{ label: '我的地址', icon: '📍', path: '', badge: '' },
{ label: '充值中心', icon: '💳', path: '', badge: '' },
{ label: '账户安全', icon: '🔒', path: '', badge: '' },
{ label: '帮助中心', icon: '❓', path: '', badge: '' },
{ label: '关于我们', icon: '', path: '', badge: '' }
])
//
const staffMenuItems = ref([
{ label: '工单管理', icon: '📝', path: '', badge: '12' },
{ label: '客户管理', icon: '👥', path: '', badge: '' },
{ label: '数据统计', icon: '📊', path: '', badge: '' },
{ label: '系统设置', icon: '⚙️', path: '', badge: '' }
])
//
const handleUserInfo = () => {
uni.navigateTo({
url: '/pages/profile/user-info'
})
}
//
const handleMenuItem = (item) => {
// tab
if (item.label === '我的订单') {
uni.switchTab({
url: '/pages/order/order'
})
return
}
//
if (item.label === '账户安全') {
uni.navigateTo({
url: '/pages/profile/security'
})
return
}
//
if (item.label === '我的地址') {
uni.navigateTo({
url: '/pages/profile/address'
})
return
}
//
if (item.label === '充值中心') {
uni.navigateTo({
url: '/pages/profile/recharge'
})
return
}
//
uni.showToast({
title: item.label,
icon: 'none'
})
}
// 退
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
//
uni.removeStorageSync('userRole')
uni.removeStorageSync('loginAccount')
uni.removeStorageSync('rememberedAccount')
//
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
}
//
const initUserInfo = async () => {
const role = uni.getStorageSync('userRole')
const account = uni.getStorageSync('loginAccount')
if (role) {
userRole.value = role
}
// 使
if (userRole.value === 'user') {
try {
const info = await getUserInfo()
if (info) {
avatar.value = info.avatar || ''
if (info.nickname) {
userName.value = info.nickname
} else if (info.phone) {
//
const p = info.phone
userName.value = p && p.length === 11 ? p.substring(0, 3) + '****' + p.substring(7) : p
} else if (account) {
userName.value = '用户 ' + account.substring(account.length - 4)
}
}
} catch (e) {
// 退
if (account) {
userName.value = '用户 ' + account.substring(account.length - 4)
}
}
} else {
// 使
if (account) {
userName.value = '工作人员 ' + account
} else {
userName.value = '工作人员'
}
}
}
//
onMounted(() => {
initUserInfo()
})
</script>
<style lang="scss" scoped>
.profile-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.profile-header {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 60rpx 30rpx 40rpx;
}
.user-info {
display: flex;
align-items: center;
}
.avatar-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.5);
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-icon {
font-size: 60rpx;
}
.user-details {
flex: 1;
display: flex;
flex-direction: column;
}
.user-name {
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 8rpx;
}
.user-role {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.arrow-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.8);
}
.menu-section {
margin-top: 30rpx;
padding: 0 30rpx;
}
.section-title {
font-size: 28rpx;
color: #909399;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.menu-group {
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
font-size: 40rpx;
margin-right: 24rpx;
}
.menu-text {
font-size: 30rpx;
color: #303133;
}
.menu-right {
display: flex;
align-items: center;
}
.menu-badge {
padding: 4rpx 16rpx;
background: #f56c6c;
border-radius: 20rpx;
font-size: 20rpx;
color: #ffffff;
margin-right: 12rpx;
}
.logout-section {
margin-top: 60rpx;
padding: 0 30rpx;
}
.logout-btn {
width: 100%;
height: 96rpx;
background: #ffffff;
color: #f56c6c;
border-radius: 48rpx;
font-size: 32rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,519 @@
<template>
<view class="recharge-container">
<scroll-view class="recharge-content" scroll-y>
<!-- 当前余额 -->
<view class="balance-section">
<text class="balance-label">当前余额</text>
<text class="balance-value">¥{{ balance.toFixed(2) }}</text>
</view>
<!-- 充值金额选择 -->
<view class="amount-section">
<view class="section-title">选择充值金额</view>
<view class="amount-grid">
<view
class="amount-item"
v-for="(amount, index) in presetAmounts"
:key="index"
:class="{ active: selectedAmount === amount }"
@click="selectAmount(amount)"
>
<text class="amount-text">¥{{ amount }}</text>
</view>
<view
class="amount-item custom"
:class="{ active: isCustomAmount }"
@click="handleCustomAmount"
>
<text class="amount-text">自定义</text>
</view>
</view>
<!-- 自定义金额输入 -->
<view v-if="isCustomAmount" class="custom-amount-input">
<text class="input-label">输入金额</text>
<input
class="amount-input"
type="digit"
v-model="customAmount"
placeholder="请输入充值金额"
maxlength="8"
@input="handleCustomInput"
/>
<text class="currency-symbol"></text>
</view>
</view>
<!-- 支付方式 -->
<view class="payment-section">
<view class="section-title">选择支付方式</view>
<view class="payment-methods">
<view
class="payment-item"
v-for="(method, index) in paymentMethods"
:key="index"
:class="{ active: selectedPaymentMethod === index }"
@click="selectPaymentMethod(index)"
>
<view class="payment-left">
<text class="payment-icon">{{ method.icon }}</text>
<view class="payment-info">
<text class="payment-name">{{ method.name }}</text>
<text class="payment-desc">{{ method.desc }}</text>
</view>
</view>
<view class="payment-right">
<view class="radio" :class="{ checked: selectedPaymentMethod === index }">
<text v-if="selectedPaymentMethod === index" class="radio-dot"></text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部支付按钮 -->
<view class="recharge-footer">
<view class="footer-info">
<text class="footer-label">充值金额</text>
<text class="footer-amount">¥{{ finalAmount.toFixed(2) }}</text>
</view>
<button
class="recharge-btn"
:disabled="recharging || finalAmount <= 0"
@click="handleRecharge"
>
<text v-if="recharging">...</text>
<text v-else></text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getUserInfo, recharge } from '../../api/user.js'
//
const balance = ref(0)
//
const presetAmounts = ref([50, 100, 200, 500, 1000])
//
const selectedAmount = ref(0)
const isCustomAmount = ref(false)
const customAmount = ref('')
//
const paymentMethods = ref([
{
name: '微信支付',
desc: '推荐使用',
icon: '💚',
type: 'wechat'
},
{
name: '支付宝',
desc: '暂不支持',
icon: '💙',
type: 'alipay',
disabled: true
}
])
//
const selectedPaymentMethod = ref(0)
//
const recharging = ref(false)
//
const finalAmount = computed(() => {
if (isCustomAmount.value) {
const amount = parseFloat(customAmount.value) || 0
return Math.max(0, amount)
}
return selectedAmount.value || 0
})
//
const loadBalance = async () => {
try {
const info = await getUserInfo()
if (info && info.balance !== undefined) {
const bal = typeof info.balance === 'string'
? parseFloat(info.balance || '0')
: (info.balance || 0)
if (!Number.isNaN(bal)) {
balance.value = bal
}
}
} catch (error) {
console.error('获取余额失败', error)
}
}
//
const selectAmount = (amount) => {
selectedAmount.value = amount
isCustomAmount.value = false
customAmount.value = ''
}
//
const handleCustomAmount = () => {
isCustomAmount.value = true
selectedAmount.value = 0
}
//
const handleCustomInput = () => {
const value = parseFloat(customAmount.value) || 0
if (value < 0) {
customAmount.value = ''
}
}
//
const selectPaymentMethod = (index) => {
if (paymentMethods.value[index].disabled) {
uni.showToast({
title: '该支付方式暂不支持',
icon: 'none'
})
return
}
selectedPaymentMethod.value = index
}
//
const handleRecharge = async () => {
if (recharging.value) return
//
if (finalAmount.value <= 0) {
uni.showToast({
title: '请输入充值金额',
icon: 'none'
})
return
}
//
if (finalAmount.value < 1) {
uni.showToast({
title: '充值金额不能少于1元',
icon: 'none'
})
return
}
//
if (finalAmount.value > 10000) {
uni.showToast({
title: '单次充值金额不能超过10000元',
icon: 'none'
})
return
}
const method = paymentMethods.value[selectedPaymentMethod.value]
if (method.disabled) {
uni.showToast({
title: '该支付方式暂不支持',
icon: 'none'
})
return
}
try {
recharging.value = true
//
const res = await recharge({
amount: finalAmount.value,
paymentMethod: method.type
})
uni.showToast({
title: '充值成功',
icon: 'success',
duration: 1500
})
//
await loadBalance()
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
console.error('充值失败', error)
// request.js
} finally {
recharging.value = false
}
}
//
onMounted(() => {
loadBalance()
})
//
onShow(() => {
loadBalance()
})
</script>
<style lang="scss" scoped>
.recharge-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.recharge-content {
flex: 1;
padding-bottom: 120rpx;
}
.balance-section {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 60rpx 30rpx;
text-align: center;
}
.balance-label {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 20rpx;
}
.balance-value {
font-size: 72rpx;
font-weight: bold;
color: #ffffff;
}
.amount-section,
.payment-section {
background: #ffffff;
margin: 20rpx 30rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.amount-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.amount-item {
height: 100rpx;
background: #f5f7fa;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: 2rpx solid transparent;
transition: all 0.3s;
&.active {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-color: #409eff;
.amount-text {
color: #ffffff;
font-weight: bold;
}
}
&.custom {
background: #f0f9ff;
border-color: #b3d8ff;
}
}
.amount-text {
font-size: 30rpx;
color: #606266;
font-weight: 500;
}
.custom-amount-input {
display: flex;
align-items: center;
margin-top: 30rpx;
padding: 20rpx;
background: #f5f7fa;
border-radius: 12rpx;
}
.input-label {
font-size: 28rpx;
color: #606266;
margin-right: 16rpx;
}
.amount-input {
flex: 1;
height: 60rpx;
padding: 0 20rpx;
background: #ffffff;
border-radius: 8rpx;
font-size: 30rpx;
color: #303133;
border: 1rpx solid #e4e7ed;
}
.currency-symbol {
font-size: 28rpx;
color: #606266;
margin-left: 12rpx;
}
.payment-methods {
display: flex;
flex-direction: column;
gap: 0;
}
.payment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.active {
.payment-name {
color: #409eff;
}
}
}
.payment-left {
display: flex;
align-items: center;
flex: 1;
}
.payment-icon {
font-size: 48rpx;
margin-right: 24rpx;
}
.payment-info {
display: flex;
flex-direction: column;
flex: 1;
}
.payment-name {
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 8rpx;
}
.payment-desc {
font-size: 24rpx;
color: #909399;
}
.payment-right {
margin-left: 20rpx;
}
.radio {
width: 40rpx;
height: 40rpx;
border: 2rpx solid #dcdfe6;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&.checked {
border-color: #409eff;
background: #409eff;
}
}
.radio-dot {
width: 16rpx;
height: 16rpx;
background: #ffffff;
border-radius: 50%;
}
.recharge-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-shadow: 0 -4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.footer-info {
flex: 1;
display: flex;
align-items: baseline;
}
.footer-label {
font-size: 28rpx;
color: #606266;
margin-right: 12rpx;
}
.footer-amount {
font-size: 36rpx;
font-weight: bold;
color: #e6a23c;
}
.recharge-btn {
width: 300rpx;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
</style>

@ -0,0 +1,427 @@
<template>
<view class="security-page">
<!-- 修改密码部分 -->
<view class="section-card">
<view class="section-title">修改密码</view>
<view class="form-item">
<text class="label">当前密码</text>
<input
class="input"
:password="!showOldPwd"
v-model="passwordForm.oldPassword"
placeholder="请输入当前密码"
/>
<text class="eye-icon" @click="showOldPwd = !showOldPwd">
{{ showOldPwd ? '👁️' : '🙈' }}
</text>
</view>
<view class="form-item">
<text class="label">新密码</text>
<input
class="input"
:password="!showNewPwd"
v-model="passwordForm.newPassword"
placeholder="请输入新密码不少于6位"
/>
<text class="eye-icon" @click="showNewPwd = !showNewPwd">
{{ showNewPwd ? '👁️' : '🙈' }}
</text>
</view>
<view class="form-item">
<text class="label">确认新密码</text>
<input
class="input"
:password="!showConfirmPwd"
v-model="passwordForm.confirmPassword"
placeholder="请再次输入新密码"
/>
<text class="eye-icon" @click="showConfirmPwd = !showConfirmPwd">
{{ showConfirmPwd ? '👁️' : '🙈' }}
</text>
</view>
<button class="submit-btn" :disabled="changingPassword" @click="handleChangePassword">
{{ changingPassword ? '提交中...' : '确认修改密码' }}
</button>
</view>
<!-- 手机号绑定部分 -->
<view class="section-card">
<view class="section-title">手机号绑定</view>
<view class="phone-status" v-if="userPhone">
<text class="status-label">当前绑定手机号</text>
<text class="phone-number">{{ userPhone }}</text>
</view>
<view class="phone-status" v-else>
<text class="status-label">暂未绑定手机号</text>
</view>
<view class="form-item">
<text class="label">手机号</text>
<input
class="input"
type="number"
v-model="phoneForm.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="form-item">
<text class="label">验证码</text>
<input
class="input code-input"
type="number"
v-model="phoneForm.code"
placeholder="请输入验证码"
maxlength="6"
/>
<button
class="code-btn"
:disabled="codeCountdown > 0 || sendingCode"
@click="handleSendCode"
>
{{ codeCountdown > 0 ? `${codeCountdown}` : (sendingCode ? '发送中...' : '获取验证码') }}
</button>
</view>
<button class="submit-btn" :disabled="bindingPhone" @click="handleBindPhone">
{{ bindingPhone ? '绑定中...' : (userPhone ? '更换手机号' : '绑定手机号') }}
</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { changePassword } from '../../api/auth.js'
import { getUserInfo, sendPhoneCode, bindPhone } from '../../api/user.js'
//
const userPhone = ref('')
//
const passwordForm = ref({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const showOldPwd = ref(false)
const showNewPwd = ref(false)
const showConfirmPwd = ref(false)
const changingPassword = ref(false)
//
const phoneForm = ref({
phone: '',
code: ''
})
const sendingCode = ref(false)
const bindingPhone = ref(false)
const codeCountdown = ref(0)
let countdownTimer = null
//
const loadUserInfo = async () => {
try {
const res = await getUserInfo()
if (res && res.phone) {
userPhone.value = res.phone
}
} catch (error) {
console.error('获取用户信息失败', error)
}
}
//
const handleSendCode = async () => {
if (!phoneForm.value.phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none'
})
return
}
//
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(phoneForm.value.phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return
}
//
if (userPhone.value && phoneForm.value.phone === userPhone.value) {
uni.showToast({
title: '该手机号已绑定',
icon: 'none'
})
return
}
try {
sendingCode.value = true
await sendPhoneCode(phoneForm.value.phone)
uni.showToast({
title: '验证码已发送',
icon: 'success'
})
//
codeCountdown.value = 60
countdownTimer = setInterval(() => {
codeCountdown.value--
if (codeCountdown.value <= 0) {
clearInterval(countdownTimer)
countdownTimer = null
}
}, 1000)
} catch (error) {
console.error('发送验证码失败', error)
// request.js
} finally {
sendingCode.value = false
}
}
//
const handleBindPhone = async () => {
if (!phoneForm.value.phone) {
uni.showToast({
title: '请输入手机号',
icon: 'none'
})
return
}
//
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(phoneForm.value.phone)) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return
}
if (!phoneForm.value.code) {
uni.showToast({
title: '请输入验证码',
icon: 'none'
})
return
}
if (phoneForm.value.code.length !== 6) {
uni.showToast({
title: '验证码为6位数字',
icon: 'none'
})
return
}
try {
bindingPhone.value = true
await bindPhone({
phone: phoneForm.value.phone,
code: phoneForm.value.code
})
uni.showToast({
title: userPhone.value ? '更换成功' : '绑定成功',
icon: 'success'
})
//
userPhone.value = phoneForm.value.phone
//
phoneForm.value.phone = ''
phoneForm.value.code = ''
//
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
codeCountdown.value = 0
} catch (error) {
console.error('绑定手机号失败', error)
// request.js
} finally {
bindingPhone.value = false
}
}
//
const handleChangePassword = async () => {
if (changingPassword.value) return
if (!passwordForm.value.oldPassword) {
uni.showToast({ title: '请输入当前密码', icon: 'none' })
return
}
if (!passwordForm.value.newPassword || passwordForm.value.newPassword.length < 6) {
uni.showToast({ title: '新密码不少于6位', icon: 'none' })
return
}
if (passwordForm.value.newPassword !== passwordForm.value.confirmPassword) {
uni.showToast({ title: '两次输入的新密码不一致', icon: 'none' })
return
}
if (passwordForm.value.newPassword === passwordForm.value.oldPassword) {
uni.showToast({ title: '新密码不能与旧密码相同', icon: 'none' })
return
}
try {
changingPassword.value = true
await changePassword(passwordForm.value.oldPassword, passwordForm.value.newPassword)
uni.showToast({
title: '修改成功',
icon: 'success'
})
//
passwordForm.value.oldPassword = ''
passwordForm.value.newPassword = ''
passwordForm.value.confirmPassword = ''
} catch (error) {
console.error('修改密码失败', error)
// request.js
} finally {
changingPassword.value = false
}
}
//
onMounted(() => {
loadUserInfo()
})
//
onUnmounted(() => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
})
</script>
<style lang="scss" scoped>
.security-page {
min-height: 100vh;
background: #f5f5f5;
padding: 30rpx 30rpx 60rpx;
box-sizing: border-box;
}
.section-card {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx 24rpx;
margin-bottom: 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 30rpx;
}
.form-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
position: relative;
&:last-of-type {
border-bottom: none;
margin-bottom: 30rpx;
}
}
.label {
font-size: 28rpx;
color: #606266;
min-width: 160rpx;
}
.input {
flex: 1;
font-size: 28rpx;
color: #303133;
}
.code-input {
margin-right: 20rpx;
}
.eye-icon {
font-size: 32rpx;
margin-left: 16rpx;
color: #909399;
}
.code-btn {
min-width: 160rpx;
height: 60rpx;
line-height: 60rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 30rpx;
font-size: 24rpx;
border: none;
padding: 0 20rpx;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
.submit-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
margin-top: 20rpx;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
.phone-status {
margin-bottom: 30rpx;
padding: 20rpx;
background: #f5f7fa;
border-radius: 12rpx;
}
.status-label {
font-size: 26rpx;
color: #909399;
margin-right: 12rpx;
}
.phone-number {
font-size: 28rpx;
color: #303133;
font-weight: 500;
}
</style>

@ -0,0 +1,397 @@
<template>
<view class="user-info-page">
<!-- 头像与基础信息 -->
<view class="header-card">
<view class="avatar-wrapper" @click="handleAvatarClick">
<image
v-if="isImageAvatar"
class="avatar-image"
:src="avatarUrl"
mode="aspectFill"
/>
<text v-else class="avatar-icon">{{ avatarEmoji }}</text>
</view>
<view class="basic-info">
<text class="phone-text">{{ user.phone || '未绑定手机号' }}</text>
<text class="sub-text">设备{{ user.deviceCount }} 订单{{ user.orderCount }} </text>
</view>
</view>
<!-- 可编辑信息 -->
<view class="form-card">
<view class="form-item">
<text class="label">昵称</text>
<input
class="input"
type="text"
v-model="user.nickname"
placeholder="请输入昵称"
maxlength="20"
/>
</view>
<view class="form-item">
<text class="label">头像设置</text>
<view class="avatar-actions">
<button class="mini-btn" @click="handleAvatarClick"></button>
<button class="mini-btn secondary" @click="openBuiltInAvatarPicker"></button>
</view>
</view>
</view>
<!-- 账户信息 -->
<view class="form-card">
<view class="form-item readonly">
<text class="label">账户余额</text>
<text class="value">¥{{ user.balance }}</text>
</view>
</view>
<!-- 保存按钮 -->
<view class="bottom-bar">
<button class="save-btn" :disabled="saving" @click="handleSave">
<text v-if="saving">...</text>
<text v-else></text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getUserInfo, updateUserInfo } from '../../api/user.js'
import request from '../../utils/request.js'
const user = ref({
phone: '',
nickname: '',
avatar: '',
balance: 0,
deviceCount: 0,
orderCount: 0
})
const saving = ref(false)
//
const avatarUrl = computed(() => {
const v = user.value.avatar || ''
if (!v) return ''
// URL
if (v.startsWith('http')) {
return v
}
// /profile/upload/xxx
if (v.startsWith('/')) {
return `${request.BASE_URL}${v}`
}
// Emoji URL
return ''
})
const isImageAvatar = computed(() => !!avatarUrl.value)
const avatarEmoji = computed(() => {
// 使使 Emoji
if (!isImageAvatar.value && user.value.avatar) {
return user.value.avatar
}
return '👤'
})
// Emoji
const builtInAvatars = ['💧', '🌊', '🚰', '😊', '😄', '🤖']
const loadUser = async () => {
try {
const data = await getUserInfo()
if (data) {
user.value.phone = data.phone || ''
user.value.nickname = data.nickname || ''
user.value.avatar = data.avatar || ''
user.value.balance = data.balance != null ? data.balance : 0
user.value.deviceCount = data.deviceCount != null ? data.deviceCount : 0
user.value.orderCount = data.orderCount != null ? data.orderCount : 0
}
} catch (error) {
console.error('获取用户信息失败', error)
}
}
//
const handleAvatarClick = () => {
uni.showActionSheet({
itemList: ['从相册选择', '拍照上传', '选择内置头像'],
success: (res) => {
if (res.tapIndex === 0) {
chooseLocalAvatar(['album'])
} else if (res.tapIndex === 1) {
chooseLocalAvatar(['camera'])
} else if (res.tapIndex === 2) {
openBuiltInAvatarPicker()
}
}
})
}
// /
const chooseLocalAvatar = (sourceType) => {
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType,
success: (res) => {
const filePath = res.tempFilePaths && res.tempFilePaths[0]
if (filePath) {
uploadAvatar(filePath)
}
}
})
}
//
const uploadAvatar = (filePath) => {
const token = request.getToken()
uni.showLoading({
title: '上传中...',
mask: true
})
uni.uploadFile({
url: `${request.BASE_URL}/water/app/user/avatar`,
filePath,
name: 'file',
header: {
'Authorization': token ? `Bearer ${token}` : ''
},
success: (res) => {
uni.hideLoading()
try {
const data = JSON.parse(res.data || '{}')
if (data.code === 200) {
const avatarPath = data.url || data.fileName || ''
if (avatarPath) {
user.value.avatar = avatarPath
}
uni.showToast({
title: '上传成功',
icon: 'success'
})
} else {
uni.showToast({
title: data.msg || '上传失败',
icon: 'none'
})
}
} catch (e) {
uni.showToast({
title: '上传失败',
icon: 'none'
})
}
},
fail: () => {
uni.hideLoading()
uni.showToast({
title: '上传失败',
icon: 'none'
})
}
})
}
// Emoji
const openBuiltInAvatarPicker = () => {
uni.showActionSheet({
itemList: builtInAvatars,
success: (res) => {
const emoji = builtInAvatars[res.tapIndex]
if (emoji) {
user.value.avatar = emoji
}
}
})
}
const handleSave = async () => {
if (saving.value) return
saving.value = true
try {
await updateUserInfo({
nickname: user.value.nickname,
avatar: user.value.avatar
})
uni.showToast({
title: '保存成功',
icon: 'success'
})
} catch (error) {
console.error('更新用户信息失败', error)
// request.js
} finally {
saving.value = false
}
}
onMounted(() => {
loadUser()
})
</script>
<style lang="scss" scoped>
.user-info-page {
min-height: 100vh;
background: #f5f5f5;
padding: 30rpx 30rpx 120rpx;
box-sizing: border-box;
}
.header-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 24rpx;
padding: 40rpx 34rpx;
display: flex;
align-items: center;
margin-bottom: 30rpx;
color: #ffffff;
box-shadow: 0 18rpx 40rpx -20rpx rgba(64, 158, 255, 0.8);
}
.avatar-wrapper {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-icon {
font-size: 64rpx;
}
.basic-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.phone-text {
font-size: 32rpx;
font-weight: 600;
}
.sub-text {
font-size: 24rpx;
opacity: 0.9;
}
.form-card {
background: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
}
.form-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.form-item.readonly {
justify-content: space-between;
}
.label {
font-size: 28rpx;
color: #606266;
min-width: 160rpx;
}
.input {
flex: 1;
font-size: 28rpx;
color: #303133;
text-align: right;
}
.avatar-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.mini-btn {
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #409eff;
color: #ffffff;
border: none;
&::after {
border: none;
}
}
.mini-btn.secondary {
background: #ecf5ff;
color: #409eff;
}
.value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-sizing: border-box;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
</style>

@ -0,0 +1,360 @@
<template>
<view class="purchase-container">
<!-- 商品分类 -->
<view class="category-section">
<scroll-view class="category-scroll" scroll-x>
<view
class="category-item"
v-for="(category, index) in categories"
:key="index"
:class="{ active: currentCategory === index }"
@click="switchCategory(index)"
>
<text class="category-text">{{ category.name }}</text>
</view>
</scroll-view>
</view>
<!-- 商品列表 -->
<scroll-view class="product-list" scroll-y>
<view v-if="filteredProducts.length === 0" class="empty-state">
<text class="empty-text">暂无商品</text>
</view>
<view v-else class="product-grid">
<view
class="product-item"
v-for="(product, index) in filteredProducts"
:key="index"
@click="handleProductClick(product)"
>
<view class="product-image-wrapper">
<text class="product-icon">{{ product.icon }}</text>
</view>
<view class="product-info">
<text class="product-name">{{ product.name }}</text>
<text class="product-desc">{{ product.description }}</text>
<view class="product-footer">
<text class="product-price">¥{{ product.price }}</text>
<button class="add-cart-btn" @click.stop="handleAddToCart(product)">订购</button>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getProductList } from '../../api/product.js'
import { getUserInfo } from '../../api/user.js'
//
const currentCategory = ref(0)
//
const categories = ref([
{ name: '全部', type: 'all' },
{ name: '净水器', type: 'purifier' },
{ name: '水表', type: 'meter' },
{ name: '配件', type: 'parts' },
{ name: '服务', type: 'service' }
])
//
const products = ref([])
//
const loading = ref(false)
//
const userPhone = ref('')
//
const isLoggedIn = ref(false)
// icon使
const getCategoryIcon = (category) => {
const map = {
purifier: '💧',
meter: '📊',
parts: '🔧',
service: '🔬'
}
return map[category] || '🛒'
}
//
const loadProducts = async () => {
try {
loading.value = true
//
const res = await getProductList({
pageNum: 1,
pageSize: 100
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
products.value = rows.map(item => {
const priceNumber = typeof item.price === 'string'
? parseFloat(item.price || '0')
: (item.price || 0)
return {
productId: item.productId,
name: item.productName,
description: item.description || '',
price: priceNumber.toFixed(2),
category: item.category,
icon: getCategoryIcon(item.category),
stock: item.stock
}
})
} catch (error) {
console.error('加载商品列表失败', error)
products.value = []
} finally {
loading.value = false
}
}
//
const loadUserInfo = async () => {
try {
const info = await getUserInfo()
if (info) {
isLoggedIn.value = true
if (info.phone) {
userPhone.value = info.phone
}
}
} catch (error) {
console.error('获取用户信息失败', error)
// token
const token = uni.getStorageSync('token')
const userRole = uni.getStorageSync('userRole')
if (token && userRole === 'user') {
isLoggedIn.value = true
} else {
isLoggedIn.value = false
}
}
}
//
const filteredProducts = computed(() => {
if (categories.value[currentCategory.value].type === 'all') {
return products.value
}
return products.value.filter(product => product.category === categories.value[currentCategory.value].type)
})
//
const switchCategory = (index) => {
currentCategory.value = index
}
//
const handleProductClick = (product) => {
uni.showToast({
title: product.name,
icon: 'none'
})
}
// /
const handleAddToCart = (product) => {
//
if (!isLoggedIn.value) {
// tokenuserRole
const token = uni.getStorageSync('token')
const userRole = uni.getStorageSync('userRole')
if (!token || userRole !== 'user') {
uni.showToast({
title: '请先登录后再订购',
icon: 'none'
})
setTimeout(() => {
uni.reLaunch({
url: '/pages/login/login'
})
}, 1500)
return
} else {
// token
isLoggedIn.value = true
}
}
// /
let encodedName = ''
try {
encodedName = encodeURIComponent(product.name || '')
} catch (e) {
encodedName = product.name || ''
}
uni.navigateTo({
url: `/pages/payment/payment?mode=product&productId=${product.productId}&productName=${encodedName}&amount=${product.price}`
})
}
//
const checkLoginStatus = () => {
const token = uni.getStorageSync('token')
const userRole = uni.getStorageSync('userRole')
if (token && userRole === 'user') {
isLoggedIn.value = true
} else {
isLoggedIn.value = false
}
}
//
onMounted(() => {
checkLoginStatus()
loadUserInfo()
loadProducts()
})
//
onShow(() => {
checkLoginStatus()
loadUserInfo()
})
</script>
<style lang="scss" scoped>
.purchase-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.category-section {
background: #ffffff;
padding: 20rpx 0;
}
.category-scroll {
white-space: nowrap;
}
.category-item {
display: inline-block;
padding: 20rpx 40rpx;
margin: 0 10rpx;
border-radius: 50rpx;
background: #f5f5f5;
&.active {
background: #409eff;
.category-text {
color: #ffffff;
font-weight: bold;
}
}
}
.category-text {
font-size: 28rpx;
color: #606266;
}
.product-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
text-align: center;
padding: 200rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.product-grid {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.product-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
display: flex;
align-items: center;
}
.product-image-wrapper {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
flex-shrink: 0;
}
.product-icon {
font-size: 60rpx;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
display: block;
font-size: 30rpx;
font-weight: bold;
color: #303133;
margin-bottom: 8rpx;
}
.product-desc {
display: block;
font-size: 24rpx;
color: #909399;
margin-bottom: 16rpx;
}
.product-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.product-price {
font-size: 32rpx;
font-weight: bold;
color: #e6a23c;
}
.add-cart-btn {
height: 56rpx;
line-height: 56rpx;
padding: 0 32rpx;
background: #409eff;
color: #ffffff;
border-radius: 28rpx;
font-size: 26rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,945 @@
<template>
<view class="delivery-detail-container">
<scroll-view class="detail-content" scroll-y :scroll-top="scrollTop" scroll-with-animation>
<!-- 加载状态 -->
<view v-if="loading" class="loading-wrapper">
<text class="loading-text">加载中...</text>
</view>
<!-- 配送状态卡片 -->
<view class="status-card" :style="{ background: getStatusGradient() }">
<view class="status-icon-wrapper">
<text class="status-icon">{{ getStatusIcon() }}</text>
</view>
<text class="status-text">{{ deliveryDetail.statusText }}</text>
<text class="delivery-number">配送单号{{ deliveryDetail.number }}</text>
</view>
<!-- 配送基本信息 -->
<view class="info-section">
<view class="section-title">配送信息</view>
<view class="info-list">
<view class="info-item">
<text class="info-label">配送类型</text>
<text class="info-value">{{ deliveryDetail.title }}</text>
</view>
<view class="info-item" v-if="deliveryDetail.products && deliveryDetail.products.length > 0">
<text class="info-label">配送商品</text>
<view class="info-value">
<text v-for="(product, index) in deliveryDetail.products" :key="index" class="product-tag">
{{ product.productName }} × {{ product.quantity }}
</text>
</view>
</view>
<view class="info-item">
<text class="info-label">收货地址</text>
<view class="info-value-wrapper">
<text class="info-value">{{ deliveryDetail.address }}</text>
<text
v-if="deliveryDetail.latitude && deliveryDetail.longitude"
class="location-btn"
@click="handleViewLocation"
>📍 查看定位</text>
</view>
</view>
<view class="info-item">
<text class="info-label">联系人</text>
<text class="info-value">{{ deliveryDetail.contact || '未填写' }}</text>
</view>
<view class="info-item">
<text class="info-label">联系电话</text>
<text class="info-value">{{ deliveryDetail.phone || '未填写' }}</text>
<text v-if="deliveryDetail.phone" class="contact-btn" @click="handleCall">📞 </text>
</view>
<view class="info-item">
<text class="info-label">创建时间</text>
<text class="info-value">{{ deliveryDetail.deliveryTime }}</text>
</view>
</view>
</view>
<!-- 在线聊天 -->
<view class="chat-section">
<view class="section-title">在线聊天</view>
<view class="chat-container">
<scroll-view
class="chat-messages"
scroll-y
:scroll-top="chatScrollTop"
scroll-with-animation
>
<view class="messages-wrapper">
<view
class="message-item"
v-for="(msg, index) in messages"
:key="index"
:class="msg.type"
>
<view class="message-content" :class="msg.type">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ msg.time }}</text>
</view>
</view>
</view>
</scroll-view>
<view class="chat-input-area">
<input
v-model="inputText"
class="chat-input"
placeholder="输入消息..."
@confirm="handleSend"
:disabled="deliveryDetail.status === 'pending'"
/>
<button class="send-btn" @click="handleSend" :disabled="deliveryDetail.status === 'pending' || !inputText.trim()">
发送
</button>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<button
v-if="deliveryDetail.status === 'pending' || deliveryDetail.status === 'accepted'"
class="action-btn start-delivery-btn"
@click="handleStartDelivery"
:disabled="loading"
>
开始配送
</button>
<button
v-if="deliveryDetail.status === 'in_progress' || deliveryDetail.status === 'delivering'"
class="action-btn complete-btn"
@click="handleCompleteDelivery"
:disabled="loading"
>
完成配送
</button>
<button
class="action-btn feedback-btn"
@click="handleFeedback"
>
向客服反馈
</button>
</view>
</scroll-view>
<!-- 反馈弹窗 -->
<view v-if="showFeedbackModal" class="modal-overlay" @click="closeFeedbackModal">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">向客服反馈</text>
<text class="modal-close" @click="closeFeedbackModal"></text>
</view>
<view class="modal-body">
<textarea
v-model="feedbackContent"
class="feedback-input"
placeholder="请输入反馈内容..."
maxlength="500"
></textarea>
<text class="char-count">{{ feedbackContent.length }}/500</text>
</view>
<view class="modal-footer">
<button class="modal-btn cancel-btn" @click="closeFeedbackModal"></button>
<button class="modal-btn submit-btn" @click="submitFeedback"></button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getDeliveryDetail, startDelivery, completeDelivery } from '../../api/staff.js'
//
const deliveryDetail = ref({
orderId: null,
number: '',
title: '',
address: '',
contact: '',
phone: '',
status: '',
statusText: '',
deliveryTime: '',
products: [],
latitude: null,
longitude: null
})
//
const loading = ref(false)
//
const messages = ref([
{
type: 'system',
content: '您好,我是配送员,正在为您配送商品',
time: '09:30'
}
])
//
const inputText = ref('')
const scrollTop = ref(0)
const chatScrollTop = ref(0)
//
const showFeedbackModal = ref(false)
const feedbackContent = ref('')
//
onLoad((options) => {
// tabBar
setTabBar('staff')
// ID
const orderId = options.orderId
if (orderId) {
loadDeliveryDetail(orderId)
} else {
uni.showToast({
title: '配送单号不存在',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
})
//
onShow(() => {
if (deliveryDetail.value.orderId) {
loadDeliveryDetail(deliveryDetail.value.orderId)
}
})
//
const loadDeliveryDetail = async (orderId) => {
try {
loading.value = true
const data = await getDeliveryDetail(orderId)
if (!data) {
uni.showToast({
title: '配送任务不存在',
icon: 'none'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
return
}
//
let title = '商品配送'
if (data.products && data.products.length > 0) {
const productNames = data.products.map(p => p.productName || '').filter(Boolean)
if (productNames.length > 0) {
title = productNames.join('、')
if (productNames.length > 1) {
title += `${data.products.length}件商品`
}
}
}
// accepted -> pending
let frontendStatus = data.status
if (data.status === 'accepted') {
frontendStatus = 'pending'
} else if (data.status === 'delivering') {
frontendStatus = 'in_progress'
}
deliveryDetail.value = {
orderId: data.orderId,
number: data.orderNo || `DL${data.orderId}`,
title: title,
address: data.address || '地址未填写',
contact: data.customerName || '',
phone: data.customerPhone || '',
status: frontendStatus,
statusText: data.statusText || '未知',
deliveryTime: formatTime(data.createTime),
products: data.products || [],
latitude: data.latitude || null,
longitude: data.longitude || null
}
} catch (error) {
console.error('加载配送详情失败', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
//
const formatTime = (timeStr) => {
if (!timeStr) return ''
if (typeof timeStr === 'string') {
return timeStr.split(' ').slice(0, 2).join(' ')
}
return timeStr
}
//
const getStatusGradient = () => {
const colorMap = {
pending: 'linear-gradient(135deg, #e6a23c 0%, #ebb563 100%)',
accepted: 'linear-gradient(135deg, #e6a23c 0%, #ebb563 100%)', //
in_progress: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)',
delivering: 'linear-gradient(135deg, #409eff 0%, #66b1ff 100%)', // 使
completed: 'linear-gradient(135deg, #67c23a 0%, #85ce61 100%)'
}
return colorMap[deliveryDetail.value.status] || colorMap.pending
}
//
const getStatusIcon = () => {
const iconMap = {
pending: '⏳',
accepted: '⏳', //
in_progress: '🚚',
delivering: '🚚', // 使
completed: '✅'
}
return iconMap[deliveryDetail.value.status] || iconMap.pending
}
//
const handleCall = () => {
const phone = deliveryDetail.value.phone
if (!phone) {
uni.showToast({
title: '联系电话不存在',
icon: 'none'
})
return
}
// *
const cleanPhone = phone.replace(/\*/g, '').replace(/\D/g, '')
if (cleanPhone.length < 11) {
uni.showToast({
title: '联系电话格式不正确',
icon: 'none'
})
return
}
uni.makePhoneCall({
phoneNumber: cleanPhone,
fail: () => {
uni.showToast({
title: '无法拨打电话',
icon: 'none'
})
}
})
}
//
const handleViewLocation = () => {
if (!deliveryDetail.value.latitude || !deliveryDetail.value.longitude) {
uni.showToast({
title: '地址定位信息不存在',
icon: 'none'
})
return
}
uni.openLocation({
latitude: deliveryDetail.value.latitude,
longitude: deliveryDetail.value.longitude,
name: deliveryDetail.value.address || '配送地址',
address: deliveryDetail.value.address || '',
success: () => {
console.log('打开地图成功')
},
fail: (err) => {
console.error('打开地图失败', err)
uni.showToast({
title: '打开地图失败',
icon: 'none'
})
}
})
}
//
const handleSend = () => {
if (!inputText.value.trim()) return
//
if (deliveryDetail.value.status === 'pending') {
uni.showToast({
title: '请先开始配送',
icon: 'none'
})
return
}
//
messages.value.push({
type: 'staff',
content: inputText.value,
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
const userMessage = inputText.value
inputText.value = ''
//
setTimeout(() => {
chatScrollTop.value = Date.now()
}, 100)
//
setTimeout(() => {
messages.value.push({
type: 'user',
content: getAutoReply(userMessage),
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
setTimeout(() => {
chatScrollTop.value = Date.now()
}, 100)
}, 1000)
}
//
const getAutoReply = (message) => {
if (message.includes('什么时候') || message.includes('时间') || message.includes('多久')) {
return '我预计30分钟内到达请您保持电话畅通'
} else if (message.includes('地址') || message.includes('在哪')) {
return '我会按照订单上的地址配送,如有变动请及时告知'
} else if (message.includes('需要') || message.includes('准备')) {
return '您只需保持收货地址有人即可'
} else {
return '好的,收到'
}
}
//
const handleStartDelivery = async () => {
if (!deliveryDetail.value.orderId) {
uni.showToast({
title: '配送数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认开始配送',
content: `确定要开始配送单 ${deliveryDetail.value.number} 吗?`,
success: async (res) => {
if (res.confirm) {
try {
await startDelivery(deliveryDetail.value.orderId)
//
deliveryDetail.value.status = 'in_progress'
deliveryDetail.value.statusText = '配送中'
//
messages.value.push({
type: 'system',
content: '配送员已开始配送,正在前往目的地',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
uni.showToast({
title: '已开始配送',
icon: 'success'
})
//
loadDeliveryDetail(deliveryDetail.value.orderId)
} catch (error) {
console.error('开始配送失败', error)
// request.js
}
}
}
})
}
//
const handleCompleteDelivery = async () => {
if (!deliveryDetail.value.orderId) {
uni.showToast({
title: '配送数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认完成配送',
content: `确定配送单 ${deliveryDetail.value.number} 已完成吗?`,
success: async (res) => {
if (res.confirm) {
try {
await completeDelivery({
orderId: deliveryDetail.value.orderId,
signature: '',
images: []
})
//
deliveryDetail.value.status = 'completed'
deliveryDetail.value.statusText = '已完成'
//
messages.value.push({
type: 'system',
content: '配送已完成,感谢您的配合',
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
})
uni.showToast({
title: '配送已完成',
icon: 'success'
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
console.error('完成配送失败', error)
// request.js
}
}
}
})
}
//
const handleFeedback = () => {
showFeedbackModal.value = true
feedbackContent.value = ''
}
//
const closeFeedbackModal = () => {
showFeedbackModal.value = false
feedbackContent.value = ''
}
//
const submitFeedback = () => {
if (!feedbackContent.value.trim()) {
uni.showToast({
title: '请输入反馈内容',
icon: 'none'
})
return
}
//
uni.showLoading({
title: '提交中...'
})
setTimeout(() => {
uni.hideLoading()
uni.showToast({
title: '反馈已提交',
icon: 'success'
})
closeFeedbackModal()
}, 1000)
}
</script>
<style lang="scss" scoped>
.delivery-detail-container {
min-height: 100vh;
background: #f5f5f5;
}
.detail-content {
height: calc(100vh - 88rpx);
padding: 20rpx 30rpx;
padding-bottom: 200rpx;
}
.status-card {
border-radius: 20rpx;
padding: 40rpx;
margin-bottom: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.status-icon-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 60rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.status-icon {
font-size: 60rpx;
}
.status-text {
font-size: 32rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.delivery-number {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.info-section,
.chat-section {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
padding-bottom: 16rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.info-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.info-item {
display: flex;
align-items: center;
}
.info-label {
font-size: 26rpx;
color: #909399;
min-width: 140rpx;
flex-shrink: 0;
}
.info-value-wrapper {
flex: 1;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.info-value {
font-size: 26rpx;
color: #303133;
flex: 1;
min-width: 0;
}
.contact-btn,
.location-btn {
margin-left: 20rpx;
padding: 8rpx 20rpx;
background: #409eff;
color: #ffffff;
border-radius: 20rpx;
font-size: 24rpx;
white-space: nowrap;
}
.product-tag {
display: inline-block;
margin-right: 16rpx;
margin-bottom: 8rpx;
padding: 8rpx 16rpx;
background: #f0f9ff;
color: #409eff;
border-radius: 8rpx;
font-size: 24rpx;
}
/* 聊天样式 */
.chat-container {
display: flex;
flex-direction: column;
}
.chat-messages {
height: 600rpx;
background: #f9f9f9;
border-radius: 12rpx;
box-sizing: border-box;
}
.messages-wrapper {
padding: 20rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
min-height: 100%;
justify-content: flex-end;
}
.message-item {
display: flex;
width: 100%;
&.staff {
justify-content: flex-end;
}
&.user {
justify-content: flex-start;
}
&.system {
justify-content: center;
}
}
.message-content {
max-width: 60%;
padding: 16rpx 20rpx;
border-radius: 16rpx;
display: flex;
flex-direction: column;
&.staff {
background: #409eff;
color: #ffffff;
align-items: flex-end;
}
&.user {
background: #ffffff;
color: #303133;
border: 1rpx solid #e4e7ed;
align-items: flex-start;
}
&.system {
background: transparent;
color: #909399;
font-size: 24rpx;
max-width: 100%;
}
}
.message-text {
font-size: 28rpx;
line-height: 1.5;
word-wrap: break-word;
margin-bottom: 8rpx;
}
.message-time {
font-size: 20rpx;
opacity: 0.6;
}
.chat-input-area {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
align-items: center;
}
.chat-input {
flex: 1;
height: 68rpx;
padding: 0 20rpx;
background: #f9f9f9;
border-radius: 34rpx;
font-size: 28rpx;
border: 1rpx solid #e4e7ed;
}
.send-btn {
width: 120rpx;
height: 68rpx;
line-height: 68rpx;
background: #409eff;
color: #ffffff;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
color: #ffffff;
}
}
.action-section {
display: flex;
flex-direction: column;
gap: 20rpx;
padding-bottom: 40rpx;
}
.action-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 500;
border: none;
&::after {
border: none;
}
}
.start-delivery-btn {
background: linear-gradient(135deg, #409eff 0%, #36a3f7 100%);
color: #ffffff;
}
.complete-btn {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #ffffff;
}
.feedback-btn {
background: #ffffff;
color: #409eff;
border: 2rpx solid #409eff;
}
/* 反馈弹窗样式 */
.modal-overlay {
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: 999;
}
.modal-content {
width: 600rpx;
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.modal-close {
font-size: 40rpx;
color: #909399;
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
padding: 30rpx;
}
.feedback-input {
width: 100%;
min-height: 200rpx;
padding: 20rpx;
border: 2rpx solid #e4e7ed;
border-radius: 12rpx;
font-size: 28rpx;
background: #f9f9f9;
}
.char-count {
display: block;
text-align: right;
margin-top: 12rpx;
font-size: 24rpx;
color: #909399;
}
.modal-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.modal-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
text-align: center;
font-size: 30rpx;
border: none;
border-radius: 0;
&::after {
border: none;
}
}
.cancel-btn {
background: #ffffff;
color: #606266;
border-right: 1rpx solid #f0f0f0;
}
.submit-btn {
background: #409eff;
color: #ffffff;
}
.loading-wrapper {
padding: 100rpx 0;
text-align: center;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
</style>

@ -0,0 +1,864 @@
<template>
<view class="delivery-task-container">
<!-- 地图区域 -->
<view class="map-section">
<map
:latitude="latitude"
:longitude="longitude"
:markers="markers"
:scale="14"
:show-location="true"
:enable-scroll="true"
:enable-zoom="true"
class="map-view"
@tap="handleMapTap"
></map>
<view class="map-actions">
<button class="map-btn" @click="handleLocate">📍 </button>
<button class="map-btn" @click="handleNavigation">🧭 </button>
<button class="map-btn primary-btn" @click="handlePlanRoute">🚀 </button>
</view>
</view>
<!-- 工单列表区域 -->
<view class="workorder-section">
<view class="section-header">
<view class="header-left">
<text class="section-title">处理中的工单</text>
<text class="workorder-count">{{ allInProgressItems.length }}</text>
<text v-if="routePlanResult" class="plan-info">
预计{{ routePlanResult.estimatedDuration }}分钟 · {{ routePlanResult.totalDistance.toFixed(1) }}公里
</text>
</view>
<button v-if="routePlanResult" class="reset-btn" @click="resetRoutePlan"></button>
</view>
<scroll-view class="workorder-list" scroll-y>
<view v-if="loading" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="allInProgressItems.length === 0" class="empty-state">
<text class="empty-icon">🚚</text>
<text class="empty-text">暂无处理中的工单</text>
</view>
<view
v-else
v-for="(item, index) in allInProgressItems"
:key="item.orderId || index"
class="workorder-item"
:class="{ 'route-ordered': routePlanResult }"
@click="handleItemDetail(item)"
>
<view class="workorder-header">
<view class="workorder-header-left">
<text v-if="routePlanResult" class="route-number">{{ getRouteIndex(item.orderId) }}</text>
<text class="workorder-id">{{ item.id }}</text>
</view>
<text class="workorder-status" :class="getStatusClass(item.status)">
{{ item.statusText }}
</text>
</view>
<view class="workorder-content">
<text class="workorder-title">{{ item.title }}</text>
<view v-if="item.products && item.products.length > 0" class="workorder-products">
<text class="product-item" v-for="(product, idx) in item.products" :key="idx">
{{ product.productName }} × {{ product.quantity }}
<text v-if="idx < item.products.length - 1"></text>
</text>
</view>
<text class="workorder-address">📍 {{ item.address }}</text>
<text class="workorder-contact">👤 {{ item.contact || '未填写' }} {{ item.phone || '' }}</text>
</view>
<view class="workorder-footer">
<text class="workorder-time">创建时间{{ item.submitTime }}</text>
<button class="workorder-btn" @click.stop="handleViewOnMap(item)">查看位置</button>
</view>
</view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getDeliveryList, planDeliveryRoute, getWorkorderList } from '../../api/staff.js'
//
const latitude = ref(39.908823) //
const longitude = ref(116.397470)
const markers = ref([])
//
const deliveries = ref([])
//
const workorders = ref([])
const loading = ref(false)
//
const routePlanResult = ref(null)
//
const inProgressDeliveries = computed(() => {
const filtered = deliveries.value.filter(delivery => {
// pending in_progress/delivering
return delivery.status === 'pending' ||
delivery.status === 'in_progress' ||
delivery.status === 'delivering'
})
//
if (routePlanResult.value && routePlanResult.value.route && routePlanResult.value.route.length > 0) {
const routeOrderIds = routePlanResult.value.route.map(r => r.orderId)
return filtered.sort((a, b) => {
const indexA = routeOrderIds.indexOf(a.orderId)
const indexB = routeOrderIds.indexOf(b.orderId)
//
if (indexA === -1) return 1
if (indexB === -1) return -1
return indexA - indexB
})
}
return filtered
})
// processing
const inProgressWorkorders = computed(() => {
return workorders.value.filter(workorder => {
return workorder.status === 'processing'
})
})
// +
const allInProgressItems = computed(() => {
return [...inProgressDeliveries.value, ...inProgressWorkorders.value]
})
//
onLoad(() => {
setTabBar('staff')
initMap()
loadDeliveries()
loadWorkorders()
})
//
onShow(() => {
loadDeliveries()
loadWorkorders()
})
//
const loadDeliveries = async () => {
if (loading.value) return
loading.value = true
try {
//
const res = await getDeliveryList({
status: '', //
pageNum: 1,
pageSize: 100
})
const list = res.rows || []
deliveries.value = list.map(item => {
// accepted -> pending
let frontendStatus = item.status
let frontendStatusText = item.statusText
if (item.status === 'accepted') {
frontendStatus = 'pending'
frontendStatusText = '待配送'
} else if (item.status === 'delivering') {
frontendStatus = 'in_progress'
frontendStatusText = '配送中'
}
//
let title = '商品配送'
if (item.products && item.products.length > 0) {
const productNames = item.products.map(p => p.productName).filter(Boolean)
if (productNames.length > 0) {
title = productNames.join('、')
if (productNames.length > 1) {
title += `${productNames.length}件商品`
}
}
}
return {
orderId: item.orderId,
id: item.orderNo || `订单${item.orderId}`,
title: title,
address: item.address || '',
contact: item.customerName || '',
phone: item.customerPhone || '',
status: frontendStatus,
statusText: frontendStatusText,
submitTime: formatDateTime(item.createTime),
latitude: item.latitude || null,
longitude: item.longitude || null,
products: item.products || [],
type: 'delivery' //
}
})
//
updateMarkers()
} catch (error) {
console.error('加载配送任务失败', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
//
const initMap = () => {
//
uni.getLocation({
type: 'gcj02',
success: (res) => {
latitude.value = res.latitude
longitude.value = res.longitude
updateMarkers()
},
fail: () => {
// 使
updateMarkers()
}
})
}
//
const loadWorkorders = async () => {
try {
// processing
const res = await getWorkorderList({
orderStatus: 'processing',
pageNum: 1,
pageSize: 100
})
const list = res.rows || []
workorders.value = list.map(item => {
// location
let lat = null
let lng = null
if (item.location && item.location.latitude && item.location.longitude) {
lat = item.location.latitude
lng = item.location.longitude
} else if (item.latitude && item.longitude) {
lat = item.latitude
lng = item.longitude
}
return {
orderId: item.orderId,
id: item.orderNo || `工单${item.orderId}`,
title: item.serviceTypeText || item.serviceType || '工单',
address: item.address || '',
contact: item.customerName || '',
phone: item.customerPhone || '',
status: item.status || 'processing',
statusText: item.statusText || '处理中',
submitTime: formatDateTime(item.createTime),
latitude: lat,
longitude: lng,
products: [],
type: 'workorder' //
}
})
//
updateMarkers()
} catch (error) {
console.error('加载工单列表失败', error)
}
}
//
const updateMarkers = () => {
// +
const itemsWithLocation = allInProgressItems.value.filter(item =>
item.latitude && item.longitude
)
markers.value = itemsWithLocation.map((item, index) => ({
id: index,
latitude: item.latitude,
longitude: item.longitude,
iconPath: item.type === 'workorder' ? '/static/workorder-marker.png' : '/static/logo.png',
width: 30,
height: 30,
title: item.title,
callout: {
content: `${item.title}\n${item.address}`,
color: '#333',
fontSize: 12,
borderRadius: 4,
bgColor: '#fff',
padding: 8,
display: 'BYCLICK'
}
}))
//
if (itemsWithLocation.length > 0) {
latitude.value = itemsWithLocation[0].latitude
longitude.value = itemsWithLocation[0].longitude
}
}
//
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return ''
const date = new Date(dateTimeString)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
//
const handleMapTap = (e) => {
//
}
//
const handleLocate = () => {
uni.getLocation({
type: 'gcj02',
success: (res) => {
latitude.value = res.latitude
longitude.value = res.longitude
uni.showToast({
title: '定位成功',
icon: 'success'
})
},
fail: () => {
uni.showToast({
title: '定位失败',
icon: 'none'
})
}
})
}
//
const handleNavigation = () => {
if (allInProgressItems.value.length === 0) {
uni.showToast({
title: '暂无需要导航的任务',
icon: 'none'
})
return
}
//
const firstItem = allInProgressItems.value[0]
if (firstItem.latitude && firstItem.longitude) {
// #ifdef MP-WEIXIN
uni.openLocation({
latitude: firstItem.latitude,
longitude: firstItem.longitude,
name: firstItem.address,
address: firstItem.address,
success: () => {
uni.showToast({
title: '已打开地图导航',
icon: 'success'
})
},
fail: () => {
uni.showToast({
title: '打开地图失败',
icon: 'none'
})
}
})
// #endif
// #ifndef MP-WEIXIN
uni.showToast({
title: '请使用地图APP导航到' + firstItem.address,
icon: 'none',
duration: 3000
})
// #endif
} else {
// 使
uni.showToast({
title: '该地址暂无位置信息,请手动导航',
icon: 'none',
duration: 3000
})
}
}
//
const handleViewOnMap = (item) => {
if (!item.latitude || !item.longitude) {
uni.showToast({
title: '该地址暂无位置信息',
icon: 'none'
})
return
}
//
uni.openLocation({
latitude: item.latitude,
longitude: item.longitude,
name: item.title || '工单位置',
address: item.address || '',
success: () => {
console.log('打开地图成功')
},
fail: (err) => {
console.error('打开地图失败', err)
uni.showToast({
title: '打开地图失败',
icon: 'none'
})
}
})
}
//
const getStatusClass = (status) => {
const classMap = {
pending: 'status-pending',
in_progress: 'status-processing',
delivering: 'status-processing',
processing: 'status-processing'
}
return classMap[status] || ''
}
//
const getRouteIndex = (orderId) => {
if (!routePlanResult.value || !routePlanResult.value.route) {
return ''
}
const index = routePlanResult.value.route.findIndex(r => r.orderId === orderId)
return index >= 0 ? index + 1 : ''
}
//
const handlePlanRoute = async () => {
try {
//
const locationRes = await new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: resolve,
fail: reject
})
})
const currentLat = locationRes.latitude
const currentLng = locationRes.longitude
//
const result = await planDeliveryRoute({
startLat: currentLat,
startLng: currentLng
})
if (result && result.route) {
routePlanResult.value = result
//
updateMarkersWithRoute(result.route)
uni.showToast({
title: `已规划${result.route.length}个配送点,预计${result.estimatedDuration}分钟`,
icon: 'success',
duration: 3000
})
}
} catch (error) {
console.error('路径规划失败', error)
if (error.message && error.message.includes('暂无')) {
// ""
uni.showToast({
title: error.message,
icon: 'none'
})
} else {
uni.showToast({
title: '路径规划失败,请重试',
icon: 'none'
})
}
}
}
//
const resetRoutePlan = () => {
routePlanResult.value = null
updateMarkers() //
uni.showToast({
title: '已重置为原始顺序',
icon: 'success'
})
}
//
const updateMarkersWithRoute = (route) => {
if (!route || route.length === 0) {
updateMarkers()
return
}
markers.value = route.map((item, index) => ({
id: index,
latitude: item.latitude,
longitude: item.longitude,
iconPath: '/static/logo.png',
width: 30,
height: 30,
title: `${index + 1}站:${item.address}`,
callout: {
content: `${index + 1}\n${item.address}`,
color: '#333',
fontSize: 12,
borderRadius: 4,
bgColor: '#fff',
padding: 8,
display: 'BYCLICK'
},
label: {
content: String(index + 1),
color: '#ffffff',
bgColor: '#409eff',
borderRadius: 50,
padding: 5,
fontSize: 12
}
}))
//
if (route.length > 0) {
latitude.value = route[0].latitude
longitude.value = route[0].longitude
}
}
//
const handleItemDetail = (item) => {
if (!item || !item.orderId) {
uni.showToast({
title: '数据异常',
icon: 'none'
})
return
}
//
if (item.type === 'workorder') {
//
uni.navigateTo({
url: `/pages/staff/workorder-detail?orderId=${item.orderId}`,
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
} else {
//
uni.navigateTo({
url: `/pages/staff/delivery-detail?orderId=${item.orderId}`,
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
}
onMounted(() => {
setTabBar('staff')
})
</script>
<style lang="scss" scoped>
.delivery-task-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.map-section {
width: 100%;
height: 500rpx;
position: relative;
background: #e5e5e5;
}
.map-view {
width: 100%;
height: 100%;
}
.map-actions {
position: absolute;
right: 20rpx;
top: 20rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
z-index: 999;
}
.map-btn {
width: 120rpx;
height: 64rpx;
line-height: 64rpx;
background: rgba(255, 255, 255, 0.95);
color: #409eff;
border-radius: 32rpx;
font-size: 24rpx;
border: none;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.15);
&::after {
border: none;
}
}
.map-btn.primary-btn {
background: rgba(64, 158, 255, 0.95);
color: #ffffff;
font-weight: 500;
}
.workorder-section {
flex: 1;
background: #ffffff;
border-radius: 20rpx 20rpx 0 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.header-left {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 20rpx;
flex: 1;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.workorder-count {
font-size: 26rpx;
color: #409eff;
background: #ecf5ff;
padding: 8rpx 20rpx;
border-radius: 20rpx;
}
.plan-info {
font-size: 24rpx;
color: #67c23a;
background: #f0f9ff;
padding: 6rpx 16rpx;
border-radius: 16rpx;
}
.reset-btn {
font-size: 24rpx;
color: #909399;
background: transparent;
border: 1rpx solid #dcdfe6;
padding: 8rpx 20rpx;
border-radius: 20rpx;
line-height: 1;
}
.workorder-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
.workorder-products {
margin-top: 8rpx;
margin-bottom: 8rpx;
}
.product-item {
font-size: 24rpx;
color: #606266;
}
.workorder-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
border: 2rpx solid #f0f0f0;
}
.workorder-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.workorder-header-left {
display: flex;
align-items: center;
gap: 12rpx;
}
.route-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48rpx;
height: 48rpx;
background: #409eff;
color: #ffffff;
border-radius: 50%;
font-size: 24rpx;
font-weight: 600;
flex-shrink: 0;
}
.workorder-item.route-ordered {
border-left: 6rpx solid #409eff;
}
.workorder-id {
font-size: 26rpx;
color: #909399;
}
.workorder-status {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.status-pending {
background: #f0f9ff;
color: #409eff;
}
&.status-processing {
background: #f0f9ff;
color: #67c23a;
}
}
.workorder-content {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-bottom: 20rpx;
}
.workorder-title {
font-size: 30rpx;
font-weight: 500;
color: #303133;
}
.workorder-address,
.workorder-contact {
font-size: 26rpx;
color: #606266;
}
.workorder-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
}
.workorder-time {
font-size: 24rpx;
color: #909399;
}
.workorder-btn {
height: 56rpx;
line-height: 56rpx;
padding: 0 24rpx;
background: #409eff;
color: #ffffff;
border-radius: 28rpx;
font-size: 24rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,366 @@
<template>
<view class="delivery-container">
<!-- 配送列表 -->
<scroll-view class="delivery-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="handleRefresh">
<view v-if="filteredDeliveries.length === 0" class="empty-state">
<text class="empty-icon">🚚</text>
<text class="empty-text">暂无处理中的工单</text>
</view>
<view v-else>
<view class="delivery-item" v-for="(delivery, index) in filteredDeliveries" :key="delivery.orderId || index" @click="handleDeliveryDetail(delivery)">
<view class="delivery-header">
<text class="delivery-number">配送单号{{ delivery.number }}</text>
<text class="delivery-status" :class="getStatusClass(delivery.status)">{{ delivery.statusText }}</text>
</view>
<view class="delivery-content">
<view class="delivery-info">
<text class="delivery-title">{{ delivery.title }}</text>
<text class="delivery-address">📍 收货地址{{ delivery.address }}</text>
<text class="delivery-contact">👤 联系人{{ delivery.contact || '未填写' }}</text>
<text class="delivery-phone">📱 联系电话{{ delivery.phone || '未填写' }}</text>
</view>
</view>
<view class="delivery-time">
<text>创建时间{{ delivery.deliveryTime }}</text>
</view>
<view class="delivery-actions">
<button class="action-btn detail-btn" @click.stop="handleDeliveryDetail(delivery)">
查看详情
</button>
</view>
</view>
</view>
</scroll-view>
<!-- 配送任务浮窗 -->
<view class="task-float-btn" @click="handleGoToDeliveryTask">
<text class="float-icon">🚚</text>
<text class="float-text">配送任务</text>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getWorkorderList } from '../../api/staff.js'
//
const loading = ref(false)
const refreshing = ref(false)
//
const processingWorkorders = ref([])
//
const filteredDeliveries = computed(() => {
//
return processingWorkorders.value.map(wo => ({
orderId: wo.orderId,
number: wo.orderNo || `WO${wo.orderId}`,
title: wo.serviceTypeText || wo.serviceType || '工单',
address: wo.address || wo.serviceAddress || '地址未填写',
contact: wo.customerName || '',
phone: wo.customerPhone || '',
status: 'pending', // pending
statusText: '处理中',
deliveryTime: formatTime(wo.createTime),
products: [],
isWorkorder: true //
}))
})
//
const loadDeliveries = async () => {
try {
loading.value = true
await loadProcessingWorkorders()
} catch (error) {
console.error('加载工单列表失败', error)
processingWorkorders.value = []
} finally {
loading.value = false
}
}
//
const loadProcessingWorkorders = async () => {
try {
const res = await getWorkorderList({
status: 'processing',
pageNum: 1,
pageSize: 100
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
processingWorkorders.value = rows.map(item => ({
orderId: item.orderId,
orderNo: item.orderNo || `WO${item.orderId}`,
serviceType: item.serviceType || '',
serviceTypeText: item.serviceTypeText || item.serviceType || '工单',
address: item.address || item.serviceAddress || '',
customerName: item.customerName || '',
customerPhone: item.customerPhone || '',
status: item.status || 'processing',
createTime: item.createTime || ''
}))
} catch (error) {
console.error('加载处理中工单失败', error)
processingWorkorders.value = []
}
}
//
const formatTime = (timeStr) => {
if (!timeStr) return ''
if (typeof timeStr === 'string') {
return timeStr.split(' ').slice(0, 2).join(' ')
}
return timeStr
}
//
const getStatusClass = (status) => {
const classMap = {
pending: 'status-pending',
in_progress: 'status-in-progress',
delivering: 'status-in-progress',
completed: 'status-completed'
}
return classMap[status] || ''
}
//
const handleDeliveryDetail = (delivery) => {
if (!delivery || !delivery.orderId) {
uni.showToast({
title: '数据异常',
icon: 'none'
})
return
}
//
uni.navigateTo({
url: `/pages/staff/workorder-detail?orderId=${delivery.orderId}`,
fail: () => {
//
uni.navigateTo({
url: '/pages/staff/workorder',
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
})
}
//
const handleGoToDeliveryTask = () => {
uni.navigateTo({
url: '/pages/staff/delivery-task',
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
//
const handleRefresh = () => {
refreshing.value = true
loadDeliveries().finally(() => {
refreshing.value = false
})
}
//
onMounted(() => {
setTabBar('staff')
loadDeliveries()
})
//
onShow(() => {
loadDeliveries()
})
</script>
<style lang="scss" scoped>
.delivery-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.delivery-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.delivery-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.delivery-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.delivery-number {
font-size: 26rpx;
color: #909399;
}
.delivery-status {
font-size: 26rpx;
font-weight: 500;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.status-pending {
background: #fef0f0;
color: #e6a23c;
}
&.status-in-progress {
background: #f0f9ff;
color: #409eff;
}
&.status-completed {
background: #f0f9ff;
color: #67c23a;
}
}
.delivery-content {
margin-bottom: 20rpx;
}
.delivery-title {
display: block;
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 12rpx;
}
.delivery-address,
.delivery-contact,
.delivery-phone {
display: block;
font-size: 26rpx;
color: #606266;
margin-bottom: 8rpx;
}
.delivery-time {
padding: 16rpx 0;
border-top: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 20rpx;
}
.delivery-time text {
font-size: 26rpx;
color: #606266;
}
.delivery-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.start-btn {
background: #409eff;
color: #ffffff;
}
.complete-btn {
background: #67c23a;
color: #ffffff;
}
.detail-btn {
background: #f0f0f0;
color: #606266;
}
.task-float-btn {
position: fixed;
right: 30rpx;
bottom: 200rpx;
width: 140rpx;
height: 140rpx;
background: linear-gradient(135deg, #409eff 0%, #36a3f7 100%);
border-radius: 70rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(64, 158, 255, 0.4);
z-index: 999;
}
.float-icon {
font-size: 48rpx;
margin-bottom: 8rpx;
}
.float-text {
font-size: 22rpx;
color: #ffffff;
font-weight: 500;
}
</style>

@ -0,0 +1,640 @@
<template>
<view class="staff-index-container">
<!-- 顶部统计卡片 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">{{ stats.today }}</text>
<text class="stat-label">今日工单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.pending }}</text>
<text class="stat-label">待处理</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.completed }}</text>
<text class="stat-label">已完成</text>
</view>
</view>
</view>
<!-- 快捷操作 -->
<view class="quick-actions">
<view class="section-title">快捷操作</view>
<view class="actions-grid">
<view class="action-item" v-for="(action, index) in quickActions" :key="index" @click="handleAction(action)">
<view class="action-icon-wrapper" :style="{ background: action.color }">
<text class="action-icon">{{ action.icon }}</text>
</view>
<text class="action-name">{{ action.name }}</text>
<text v-if="action.badge" class="action-badge">{{ action.badge }}</text>
</view>
</view>
</view>
<!-- 待处理工单列表 -->
<view class="workorder-section">
<view class="section-header">
<text class="section-title">待处理工单</text>
<text class="more-link" @click="handleViewAll"> </text>
</view>
<view class="workorder-list">
<view v-if="pendingOrders.length === 0" class="empty-state">
<text class="empty-text">暂无待处理工单</text>
</view>
<view v-else>
<view class="workorder-item" v-for="(order, index) in pendingOrders" :key="index" @click="handleOrderDetail(order)">
<view class="order-header">
<text class="order-id">工单号{{ order.id }}</text>
<text class="order-priority" :class="getPriorityClass(order.priority)">{{ order.priorityText }}</text>
</view>
<view class="order-content">
<text class="order-title">{{ order.title }}</text>
<text class="order-address">📍 {{ order.address }}</text>
<text class="order-time">提交时间{{ order.time }}</text>
</view>
<view class="order-footer">
<button class="accept-btn" @click.stop="handleAccept(order)">接单</button>
<button class="detail-btn" @click.stop="handleOrderDetail(order)">详情</button>
</view>
</view>
</view>
</view>
</view>
<!-- 工作动态 -->
<view class="activity-section">
<view class="section-title">工作动态</view>
<view class="activity-list">
<view class="activity-item" v-for="(activity, index) in activities" :key="index">
<view class="activity-dot"></view>
<view class="activity-content">
<text class="activity-text">{{ activity.content }}</text>
<text class="activity-time">{{ activity.time }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getWorkorderList, acceptWorkorder, getStaffStatistics } from '../../api/staff.js'
import { getDeliveryList } from '../../api/staff.js'
//
const stats = ref({
today: 0,
pending: 0,
completed: 0
})
//
const loading = ref(false)
//
const quickActions = ref([
{ name: '工单', icon: '✅', color: '#409eff', badge: '0', path: '/pages/staff/workorder' },
{ name: '配送', icon: '🚚', color: '#67c23a', badge: '0', path: '/pages/staff/delivery' },
{ name: '我的', icon: '👤', color: '#e6a23c', path: '/pages/staff/profile' },
{ name: '统计', icon: '📊', color: '#f56c6c', path: '/pages/staff/stats' }
])
//
const pendingOrders = ref([])
//
const activities = ref([])
//
const loadStatistics = async () => {
try {
const res = await getStaffStatistics()
if (res) {
stats.value.today = res.todayOrders || 0
stats.value.completed = res.completedOrders || 0
// pendingOrders使使
if (res.pendingOrders !== undefined) {
stats.value.pending = res.pendingOrders || 0
}
}
} catch (error) {
console.error('加载统计数据失败', error)
}
}
//
const loadPendingOrders = async () => {
try {
loading.value = true
const res = await getWorkorderList({
status: 'pending',
pageNum: 1,
pageSize: 5 // 5
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
pendingOrders.value = rows.map(item => ({
id: item.orderNo || `WO${item.orderId}`,
orderId: item.orderId,
title: item.serviceTypeText || item.serviceType || '工单',
address: item.address || item.serviceAddress || '地址未填写',
contact: item.customerName || '',
phone: item.customerPhone || item.contactPhone || '',
description: item.description || '',
priority: 'normal', //
priorityText: '普通',
status: item.status || 'pending',
statusText: item.statusText || '待接单',
submitTime: item.createTime || item.appointmentDate || '',
time: formatTime(item.createTime || item.appointmentDate),
acceptTime: ''
}))
//
stats.value.pending = pendingOrders.value.length
//
quickActions.value[0].badge = String(stats.value.pending)
} catch (error) {
console.error('加载待处理工单失败', error)
pendingOrders.value = []
} finally {
loading.value = false
}
}
//
const loadDeliveryCount = async () => {
try {
const res = await getDeliveryList({
status: 'pending',
pageNum: 1,
pageSize: 1
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
const total = res && res.total ? res.total : rows.length
quickActions.value[1].badge = String(total || 0)
} catch (error) {
console.error('加载配送任务数量失败', error)
}
}
//
const formatTime = (timeStr) => {
if (!timeStr) return ''
if (typeof timeStr === 'string') {
//
return timeStr.split(' ').slice(0, 2).join(' ')
}
return timeStr
}
//
const loadActivities = async () => {
try {
//
const res = await getWorkorderList({
status: 'completed',
pageNum: 1,
pageSize: 3
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
activities.value = rows.map(item => ({
content: `完成了工单 ${item.orderNo || `WO${item.orderId}`} - ${item.serviceTypeText || ''}`,
time: formatRelativeTime(item.completeTime || item.createTime)
}))
} catch (error) {
console.error('加载工作动态失败', error)
activities.value = []
}
}
//
const formatRelativeTime = (timeStr) => {
if (!timeStr) return ''
try {
const time = new Date(timeStr)
const now = new Date()
const diff = now - time
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return formatTime(timeStr)
} catch (e) {
return formatTime(timeStr)
}
}
//
const handleAction = (action) => {
if (action.path) {
//
uni.navigateTo({
url: action.path,
fail: () => {
// navigateTo 使 redirectTo
uni.redirectTo({
url: action.path
})
}
})
} else {
//
uni.showToast({
title: action.name + '功能开发中',
icon: 'none'
})
}
}
//
const handleAccept = async (order) => {
if (!order.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认接单',
content: `确定要接取工单 ${order.id} 吗?`,
success: async (res) => {
if (res.confirm) {
try {
await acceptWorkorder(order.orderId)
//
const index = pendingOrders.value.findIndex(o => o.orderId === order.orderId)
if (index > -1) {
pendingOrders.value.splice(index, 1)
stats.value.pending--
quickActions.value[0].badge = String(stats.value.pending)
}
uni.showToast({
title: '接单成功',
icon: 'success'
})
//
loadStatistics()
} catch (error) {
console.error('接单失败', error)
// request.js
}
}
}
})
}
//
const handleViewAll = () => {
uni.navigateTo({
url: '/pages/staff/workorder',
fail: () => {
uni.redirectTo({
url: '/pages/staff/workorder'
})
}
})
}
//
const handleOrderDetail = (order) => {
if (!order || !order.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
// 使orderId
uni.navigateTo({
url: `/pages/staff/workorder-detail?orderId=${order.orderId}`,
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
//
const getPriorityClass = (priority) => {
return priority === 'high' ? 'priority-high' : 'priority-normal'
}
//
onMounted(() => {
loadStatistics()
loadPendingOrders()
loadDeliveryCount()
loadActivities()
})
//
onShow(() => {
loadStatistics()
loadPendingOrders()
loadDeliveryCount()
loadActivities()
})
</script>
<style lang="scss" scoped>
.staff-index-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.stats-section {
padding: 30rpx;
}
.stats-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 20rpx;
padding: 40rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 8rpx 20rpx rgba(64, 158, 255, 0.3);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.3);
}
.quick-actions {
padding: 0 30rpx;
margin-bottom: 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.actions-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 30rpx;
background: #ffffff;
padding: 40rpx 30rpx;
border-radius: 20rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.action-icon-wrapper {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.action-icon {
font-size: 48rpx;
}
.action-name {
font-size: 24rpx;
color: #606266;
}
.action-badge {
position: absolute;
top: -10rpx;
right: -10rpx;
min-width: 32rpx;
height: 32rpx;
background: #f56c6c;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 8rpx;
font-size: 20rpx;
color: #ffffff;
border: 2rpx solid #ffffff;
}
.workorder-section {
padding: 0 30rpx;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.more-link {
font-size: 26rpx;
color: #409eff;
}
.workorder-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.empty-state {
background: #ffffff;
border-radius: 20rpx;
padding: 80rpx 0;
text-align: center;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.workorder-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
}
.order-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.order-id {
font-size: 26rpx;
color: #909399;
}
.order-priority {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.priority-high {
background: #fef0f0;
color: #f56c6c;
}
&.priority-normal {
background: #f0f9ff;
color: #409eff;
}
}
.order-content {
margin-bottom: 24rpx;
}
.order-title {
display: block;
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 12rpx;
}
.order-address {
display: block;
font-size: 26rpx;
color: #606266;
margin-bottom: 8rpx;
}
.order-time {
font-size: 24rpx;
color: #909399;
}
.order-footer {
display: flex;
gap: 20rpx;
}
.accept-btn, .detail-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.accept-btn {
background: #409eff;
color: #ffffff;
}
.detail-btn {
background: #f0f0f0;
color: #606266;
}
.activity-section {
padding: 0 30rpx;
margin-top: 30rpx;
}
.activity-list {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
}
.activity-item {
display: flex;
align-items: flex-start;
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
}
.activity-dot {
width: 12rpx;
height: 12rpx;
background: #409eff;
border-radius: 50%;
margin-right: 20rpx;
margin-top: 8rpx;
flex-shrink: 0;
}
.activity-content {
flex: 1;
display: flex;
flex-direction: column;
}
.activity-text {
font-size: 28rpx;
color: #303133;
margin-bottom: 8rpx;
}
.activity-time {
font-size: 24rpx;
color: #909399;
}
</style>

@ -0,0 +1,454 @@
<template>
<view class="staff-profile-container">
<!-- 工作人员信息头部 -->
<view class="profile-header">
<view class="staff-info" @click="handleStaffInfo">
<view class="avatar-wrapper">
<image
v-if="avatarUrl"
class="avatar-image"
:src="avatarUrl"
mode="aspectFill"
/>
<text v-else class="avatar-icon">{{ avatarEmoji }}</text>
</view>
<view class="staff-details">
<text class="staff-name">{{ staffName }}</text>
<text class="staff-id">工号{{ staffId }}</text>
<text class="staff-dept">部门{{ staffDept }}</text>
</view>
<text class="arrow-icon"></text>
</view>
</view>
<!-- 工作统计 -->
<view class="stats-section">
<view class="stats-card">
<view class="stat-item">
<text class="stat-value">{{ stats.monthWorkorders }}</text>
<text class="stat-label">本月工单</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.completionRate }}%</text>
<text class="stat-label">完成率</text>
</view>
<view class="stat-divider"></view>
<view class="stat-item">
<text class="stat-value">{{ stats.rating }}</text>
<text class="stat-label">评分</text>
</view>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-wrapper">
<text class="loading-text">加载中...</text>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-group">
<view class="menu-item" v-for="(item, index) in menuItems" :key="index" @click="handleMenuItem(item)">
<view class="menu-left">
<text class="menu-icon">{{ item.icon }}</text>
<text class="menu-text">{{ item.label }}</text>
</view>
<view class="menu-right">
<text v-if="item.badge" class="menu-badge">{{ item.badge }}</text>
<text class="arrow-icon"></text>
</view>
</view>
</view>
</view>
<!-- 退出登录 -->
<view class="logout-section">
<button class="logout-btn" @click="handleLogout">退</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getStaffStatistics } from '../../api/staff.js'
import { logout } from '../../api/auth.js'
import request from '../../utils/request.js'
//
const loading = ref(false)
//
const staffName = ref('工作人员')
const staffId = ref('')
const staffDept = ref('')
const staffPhone = ref('')
const staffAvatar = ref('')
//
const avatarUrl = computed(() => {
const v = staffAvatar.value || ''
if (!v) return ''
if (v.startsWith('http')) {
return v
}
if (v.startsWith('/')) {
return `${request.BASE_URL}${v}`
}
return ''
})
const avatarEmoji = computed(() => {
if (!avatarUrl.value && staffAvatar.value) {
return staffAvatar.value
}
return '👷'
})
//
const stats = ref({
monthWorkorders: 0,
completionRate: 0,
rating: '0.0'
})
//
const menuItems = ref([
{ label: '我的工单', icon: '📋', path: '/pages/staff/workorder', badge: '' },
{ label: '配送记录', icon: '🚚', path: '/pages/staff/delivery', badge: '' },
{ label: '工作统计', icon: '📊', path: '/pages/staff/stats', badge: '' },
{ label: '账户设置', icon: '⚙️', path: '/pages/profile/change-password', badge: '' },
{ label: '帮助中心', icon: '❓', path: '', badge: '' }
])
//
const loadStaffInfo = () => {
try {
//
const staffInfo = uni.getStorageSync('staffInfo')
if (staffInfo) {
staffName.value = staffInfo.staffName || '工作人员'
staffId.value = staffInfo.staffNo || staffInfo.staffId || ''
staffDept.value = staffInfo.roleText || staffInfo.role || '工作人员'
staffPhone.value = staffInfo.phone || ''
staffAvatar.value = staffInfo.avatar || ''
} else {
// 使
const account = uni.getStorageSync('loginAccount')
if (account) {
staffId.value = account
staffName.value = '工作人员 ' + account
staffDept.value = '工作人员'
}
}
} catch (error) {
console.error('加载员工信息失败', error)
const account = uni.getStorageSync('loginAccount')
if (account) {
staffId.value = account
staffName.value = '工作人员 ' + account
}
}
}
//
const loadStatistics = async () => {
if (loading.value) return
loading.value = true
try {
const res = await getStaffStatistics()
//
const monthWorkorders = res.monthOrders || res.monthWorkorders || 0
//
const completedOrders = res.completedOrders || 0
// / 使
const totalOrders = res.totalOrders || monthWorkorders
const completionRate = totalOrders > 0 ? Math.round((completedOrders / totalOrders) * 100) : 0
//
const averageRating = res.averageRating || 0
const ratingValue = typeof averageRating === 'number' ? averageRating : parseFloat(averageRating) || 0
const ratingText = ratingValue.toFixed(1)
stats.value = {
monthWorkorders: monthWorkorders,
completionRate: completionRate,
rating: ratingText
}
} catch (error) {
console.error('加载统计数据失败', error)
//
} finally {
loading.value = false
}
}
//
const handleStaffInfo = () => {
uni.navigateTo({
url: '/pages/staff/staff-info'
})
}
//
const handleMenuItem = (item) => {
if (item.path) {
if (item.path === '/pages/profile/change-password') {
//
uni.navigateTo({
url: item.path
})
} else {
uni.navigateTo({
url: item.path,
fail: () => {
uni.redirectTo({
url: item.path
})
}
})
}
} else {
uni.showToast({
title: item.label + '功能开发中',
icon: 'none'
})
}
}
// 退
const handleLogout = () => {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
success: async (res) => {
if (res.confirm) {
try {
// 退API
await logout()
} catch (error) {
console.error('退出登录失败', error)
}
//
uni.removeStorageSync('userRole')
uni.removeStorageSync('loginAccount')
uni.removeStorageSync('rememberedAccount')
uni.removeStorageSync('staffInfo')
uni.removeStorageSync('token')
//
uni.reLaunch({
url: '/pages/login/login'
})
}
}
})
}
//
onMounted(() => {
setTabBar('staff')
loadStaffInfo()
loadStatistics()
})
//
onShow(() => {
loadStaffInfo()
loadStatistics()
})
</script>
<style lang="scss" scoped>
.staff-profile-container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 120rpx;
}
.profile-header {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
padding: 60rpx 30rpx 40rpx;
}
.staff-info {
display: flex;
align-items: center;
}
.avatar-wrapper {
width: 120rpx;
height: 120rpx;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
border: 4rpx solid rgba(255, 255, 255, 0.5);
overflow: hidden;
}
.avatar-icon {
font-size: 60rpx;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.staff-details {
flex: 1;
display: flex;
flex-direction: column;
}
.staff-name {
font-size: 36rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 8rpx;
}
.staff-id,
.staff-dept {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
margin-bottom: 4rpx;
}
.arrow-icon {
font-size: 40rpx;
color: rgba(255, 255, 255, 0.8);
}
.stats-section {
padding: 30rpx;
}
.stats-card {
background: #ffffff;
border-radius: 20rpx;
padding: 40rpx;
display: flex;
align-items: center;
justify-content: space-around;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #409eff;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 26rpx;
color: #909399;
}
.stat-divider {
width: 2rpx;
height: 80rpx;
background: #f0f0f0;
}
.menu-section {
margin-top: 30rpx;
padding: 0 30rpx;
}
.menu-group {
background: #ffffff;
border-radius: 20rpx;
overflow: hidden;
}
.menu-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.menu-left {
display: flex;
align-items: center;
}
.menu-icon {
font-size: 40rpx;
margin-right: 24rpx;
}
.menu-text {
font-size: 30rpx;
color: #303133;
}
.menu-right {
display: flex;
align-items: center;
}
.menu-badge {
padding: 4rpx 16rpx;
background: #f56c6c;
border-radius: 20rpx;
font-size: 20rpx;
color: #ffffff;
margin-right: 12rpx;
}
.logout-section {
margin-top: 60rpx;
padding: 0 30rpx;
}
.logout-btn {
width: 100%;
height: 96rpx;
background: #ffffff;
color: #f56c6c;
border-radius: 48rpx;
font-size: 32rpx;
border: none;
&::after {
border: none;
}
}
.loading-wrapper {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
</style>

@ -0,0 +1,362 @@
<template>
<view class="staff-info-page">
<!-- 头像与基础信息 -->
<view class="header-card">
<view class="avatar-wrapper" @click="handleAvatarClick">
<image
v-if="isImageAvatar"
class="avatar-image"
:src="avatarUrl"
mode="aspectFill"
/>
<text v-else class="avatar-icon">{{ avatarEmoji }}</text>
</view>
<view class="basic-info">
<text class="phone-text">{{ staff.phone || '未绑定手机号' }}</text>
<text class="sub-text">工号{{ staff.staffNo }} 部门{{ staff.dept }}</text>
</view>
</view>
<!-- 可编辑信息 -->
<view class="form-card">
<view class="form-item">
<text class="label">姓名</text>
<input
class="input"
type="text"
v-model="staff.staffName"
placeholder="请输入姓名"
maxlength="20"
/>
</view>
<view class="form-item">
<text class="label">头像设置</text>
<view class="avatar-actions">
<button class="mini-btn secondary" @click="openBuiltInAvatarPicker"></button>
</view>
</view>
</view>
<!-- 账户信息只读 -->
<view class="form-card">
<view class="form-item readonly">
<text class="label">工号</text>
<text class="value">{{ staff.staffNo }}</text>
</view>
<view class="form-item readonly">
<text class="label">部门</text>
<text class="value">{{ staff.dept }}</text>
</view>
<view class="form-item readonly">
<text class="label">手机号</text>
<text class="value">{{ staff.phone }}</text>
</view>
</view>
<!-- 保存按钮 -->
<view class="bottom-bar">
<button class="save-btn" :disabled="saving" @click="handleSave">
<text v-if="saving">...</text>
<text v-else></text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getStaffInfo, updateStaffInfo } from '../../api/staff.js'
import request from '../../utils/request.js'
const staff = ref({
staffId: '',
staffNo: '',
staffName: '',
phone: '',
dept: '',
avatar: ''
})
const saving = ref(false)
// Emoji使
const avatarUrl = computed(() => {
return '' // 使
})
const isImageAvatar = computed(() => false) // 使Emoji
const avatarEmoji = computed(() => {
// 使使 Emoji
if (!isImageAvatar.value && staff.value.avatar) {
return staff.value.avatar
}
return '👷'
})
// Emoji
const builtInAvatars = ['👷', '💧', '🌊', '🚰', '😊', '😄', '🤖', '👨‍🔧']
const loadStaff = async () => {
try {
//
const staffInfo = uni.getStorageSync('staffInfo')
if (staffInfo) {
staff.value.staffId = staffInfo.staffId || ''
staff.value.staffNo = staffInfo.staffNo || ''
staff.value.staffName = staffInfo.staffName || ''
staff.value.phone = staffInfo.phone || ''
staff.value.dept = staffInfo.roleText || staffInfo.role || ''
staff.value.avatar = staffInfo.avatar || ''
}
// API
const data = await getStaffInfo()
if (data) {
staff.value.staffId = data.staffId || staff.value.staffId
staff.value.staffNo = data.staffNo || data.employeeNo || staff.value.staffNo
staff.value.staffName = data.staffName || staff.value.staffName
staff.value.phone = data.phone || staff.value.phone
staff.value.dept = data.dept || data.position || staff.value.dept
staff.value.avatar = data.avatar || staff.value.avatar
//
uni.setStorageSync('staffInfo', {
...staffInfo,
...data
})
}
} catch (error) {
console.error('获取员工信息失败', error)
}
}
//
const handleAvatarClick = () => {
openBuiltInAvatarPicker()
}
// Emoji
const openBuiltInAvatarPicker = () => {
uni.showActionSheet({
itemList: builtInAvatars,
success: (res) => {
const emoji = builtInAvatars[res.tapIndex]
if (emoji) {
staff.value.avatar = emoji
}
}
})
}
const handleSave = async () => {
if (saving.value) return
//
if (!staff.value.staffName || !staff.value.staffName.trim()) {
uni.showToast({
title: '请输入姓名',
icon: 'none'
})
return
}
saving.value = true
try {
//
await updateStaffInfo({
staffName: staff.value.staffName.trim()
// avatar
})
//
const staffInfo = uni.getStorageSync('staffInfo') || {}
staffInfo.staffName = staff.value.staffName.trim()
staffInfo.avatar = staff.value.avatar //
uni.setStorageSync('staffInfo', staffInfo)
uni.showToast({
title: '保存成功',
icon: 'success'
})
//
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (error) {
console.error('更新员工信息失败', error)
// request.js
} finally {
saving.value = false
}
}
onMounted(() => {
loadStaff()
})
onShow(() => {
loadStaff()
})
</script>
<style lang="scss" scoped>
.staff-info-page {
min-height: 100vh;
background: #f5f5f5;
padding: 30rpx 30rpx 120rpx;
box-sizing: border-box;
}
.header-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 24rpx;
padding: 40rpx 34rpx;
display: flex;
align-items: center;
margin-bottom: 30rpx;
color: #ffffff;
box-shadow: 0 18rpx 40rpx -20rpx rgba(64, 158, 255, 0.8);
}
.avatar-wrapper {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
}
.avatar-icon {
font-size: 64rpx;
}
.basic-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.phone-text {
font-size: 32rpx;
font-weight: 600;
}
.sub-text {
font-size: 24rpx;
opacity: 0.9;
}
.form-card {
background: #ffffff;
border-radius: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 24rpx;
}
.form-item {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.form-item.readonly {
justify-content: space-between;
}
.label {
font-size: 28rpx;
color: #606266;
min-width: 160rpx;
}
.input {
flex: 1;
font-size: 28rpx;
color: #303133;
text-align: right;
}
.avatar-actions {
flex: 1;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.mini-btn {
height: 64rpx;
line-height: 64rpx;
padding: 0 24rpx;
border-radius: 32rpx;
font-size: 24rpx;
background: #409eff;
color: #ffffff;
border: none;
&::after {
border: none;
}
}
.mini-btn.secondary {
background: #ecf5ff;
color: #409eff;
}
.value {
font-size: 28rpx;
color: #303133;
font-weight: 500;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
box-sizing: border-box;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
&[disabled] {
background: #c0c4cc;
}
}
</style>

@ -0,0 +1,553 @@
<template>
<view class="stats-container">
<scroll-view class="stats-content" scroll-y>
<!-- 综合评价卡片 -->
<view class="evaluation-card">
<view class="card-header">
<text class="card-title">综合评价</text>
<view class="evaluation-badge" :class="getEvaluationClass()">
<text>{{ evaluationLevel }}</text>
</view>
</view>
<view class="score-display">
<text class="score-value">{{ statsData.averageRating.toFixed(1) }}</text>
<view class="stars-display">
<text class="star" v-for="(star, index) in 5" :key="index">
{{ index < Math.round(statsData.averageRating) ? '⭐' : '☆' }}
</text>
</view>
</view>
<view class="evaluation-desc">{{ evaluationDesc }}</view>
</view>
<!-- 数据统计 -->
<view class="data-stats-section">
<view class="section-title">数据统计</view>
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ statsData.totalOrders }}</text>
<text class="stat-label">总订单数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ statsData.completedOrders }}</text>
<text class="stat-label">已完成</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ statsData.completionRate }}%</text>
<text class="stat-label">完成率</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ statsData.averageTime }}</text>
<text class="stat-label">平均时长</text>
</view>
</view>
</view>
<!-- 配送效率 -->
<view class="efficiency-section">
<view class="section-title">配送效率</view>
<view class="efficiency-card">
<view class="efficiency-item">
<view class="efficiency-header">
<text class="efficiency-label">本月配送</text>
<text class="efficiency-value">{{ statsData.monthDeliveries }}</text>
</view>
<view class="progress-bar">
<view class="progress-fill" :style="{ width: statsData.efficiencyPercent + '%' }"></view>
</view>
</view>
<view class="efficiency-item">
<view class="efficiency-header">
<text class="efficiency-label">准时送达率</text>
<text class="efficiency-value">{{ statsData.onTimeRate }}%</text>
</view>
<view class="progress-bar">
<view class="progress-fill ontime" :style="{ width: statsData.onTimeRate + '%' }"></view>
</view>
</view>
</view>
</view>
<!-- 用户评价 -->
<view class="reviews-section">
<view class="section-title">最近评价</view>
<view class="reviews-list">
<view v-if="loading && reviews.length === 0" class="empty-state">
<text class="empty-text">加载中...</text>
</view>
<view v-else-if="reviews.length === 0" class="empty-state">
<text class="empty-text">暂无评价</text>
</view>
<view v-else>
<view class="review-item" v-for="(review, index) in reviews" :key="index">
<view class="review-header">
<view class="review-user">
<text class="user-icon">👤</text>
<text class="user-name">{{ review.userName }}</text>
</view>
<view class="review-rating">
<text class="star" v-for="(star, idx) in 5" :key="idx">
{{ idx < review.rating ? '⭐' : '☆' }}
</text>
</view>
</view>
<text class="review-content">{{ review.comment }}</text>
<view class="review-footer">
<text class="review-time">{{ review.time }}</text>
<text v-if="review.orderNo" class="review-order">{{ review.orderNo }}</text>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getStaffStatistics, getStaffEvaluationList } from '../../api/staff.js'
//
const loading = ref(false)
//
const statsData = ref({
totalOrders: 0,
completedOrders: 0,
completionRate: 0,
averageTime: '0小时',
averageRating: 0,
monthDeliveries: 0,
efficiencyPercent: 0,
onTimeRate: 0
})
//
const reviews = ref([])
//
const loadStatistics = async () => {
if (loading.value) return
loading.value = true
try {
const res = await getStaffStatistics()
// 使
const totalOrders = res.monthOrders || 0
//
const completedOrders = res.completedOrders || 0
//
const completionRate = totalOrders > 0 ? Math.round((completedOrders / totalOrders) * 100) : 0
//
const averageRating = res.averageRating || 0
const ratingValue = typeof averageRating === 'number' ? averageRating : parseFloat(averageRating) || 0
// 使
const monthDeliveries = res.monthOrders || 0
//
const efficiencyPercent = completionRate
// 使
const onTimeRate = completionRate
// 使 actualDuration
const averageTime = '2.5小时'
statsData.value = {
totalOrders: totalOrders,
completedOrders: completedOrders,
completionRate: completionRate,
averageTime: averageTime,
averageRating: ratingValue,
monthDeliveries: monthDeliveries,
efficiencyPercent: efficiencyPercent,
onTimeRate: onTimeRate
}
} catch (error) {
console.error('加载统计数据失败', error)
uni.showToast({
title: '加载统计数据失败',
icon: 'none'
})
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
//
const loadReviews = async () => {
try {
const res = await getStaffEvaluationList()
const list = res.rows || []
reviews.value = list.map(item => {
// 使
let userName = item.customerName || ''
if (!userName && item.orderNo) {
userName = `用户${item.orderNo.substring(item.orderNo.length - 4)}`
} else if (!userName) {
userName = '匿名用户'
}
//
const rating = item.rating ? Math.round(parseFloat(item.rating)) : 5
//
const time = formatDate(item.createTime)
return {
userName: userName,
rating: rating,
comment: item.content || '暂无评价内容',
time: time,
orderNo: item.orderNo || ''
}
})
//
if (list.length > 0) {
const totalRating = list.reduce((sum, item) => {
const rating = item.rating ? parseFloat(item.rating) : 0
return sum + rating
}, 0)
const avgRating = totalRating / list.length
statsData.value.averageRating = parseFloat(avgRating.toFixed(1))
}
} catch (error) {
console.error('加载评价列表失败', error)
reviews.value = []
}
}
//
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
//
const evaluationLevel = computed(() => {
const rating = statsData.value.averageRating
if (rating >= 4.5) {
return '标杆配送员'
} else if (rating >= 4.0) {
return '优秀配送员'
} else {
return '普通配送员'
}
})
//
const evaluationDesc = computed(() => {
const rating = statsData.value.averageRating
if (rating >= 4.5) {
return '您的工作表现优秀,是团队的标杆,继续加油!'
} else if (rating >= 4.0) {
return '您的工作表现良好,继续保持优秀的工作状态!'
} else {
return '还有提升空间,继续努力,争取更好的评价!'
}
})
//
const getEvaluationClass = () => {
const rating = statsData.value.averageRating
if (rating >= 4.5) {
return 'badge-excellent'
} else if (rating >= 4.0) {
return 'badge-good'
} else {
return 'badge-normal'
}
}
//
const loadAllData = () => {
loadStatistics()
loadReviews()
}
//
onMounted(() => {
setTabBar('staff')
loadAllData()
})
//
onShow(() => {
loadAllData()
})
//
onPullDownRefresh(() => {
loadAllData()
})
</script>
<style lang="scss" scoped>
.stats-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.stats-content {
flex: 1;
padding-bottom: 40rpx;
}
.evaluation-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
margin: 30rpx;
padding: 40rpx;
border-radius: 20rpx;
box-shadow: 0 8rpx 20rpx rgba(64, 158, 255, 0.3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #ffffff;
}
.evaluation-badge {
padding: 8rpx 24rpx;
border-radius: 40rpx;
background: rgba(255, 255, 255, 0.3);
text {
font-size: 24rpx;
color: #ffffff;
font-weight: bold;
}
&.badge-excellent {
background: rgba(103, 194, 58, 0.3);
}
&.badge-good {
background: rgba(230, 162, 60, 0.3);
}
&.badge-normal {
background: rgba(144, 147, 153, 0.3);
}
}
.score-display {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
}
.score-value {
font-size: 80rpx;
font-weight: bold;
color: #ffffff;
margin-right: 30rpx;
}
.stars-display {
display: flex;
gap: 8rpx;
}
.star {
font-size: 36rpx;
}
.evaluation-desc {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.9);
text-align: center;
}
.data-stats-section,
.efficiency-section,
.reviews-section {
background: #ffffff;
margin: 0 30rpx 20rpx;
padding: 30rpx;
border-radius: 20rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
margin-bottom: 24rpx;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.stat-card {
background: #f9f9f9;
padding: 40rpx 30rpx;
border-radius: 16rpx;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #409eff;
margin-bottom: 12rpx;
}
.stat-label {
font-size: 26rpx;
color: #909399;
}
.efficiency-card {
display: flex;
flex-direction: column;
gap: 30rpx;
}
.efficiency-item {
display: flex;
flex-direction: column;
}
.efficiency-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.efficiency-label {
font-size: 28rpx;
color: #606266;
}
.efficiency-value {
font-size: 28rpx;
font-weight: bold;
color: #409eff;
}
.progress-bar {
width: 100%;
height: 20rpx;
background: #e4e7ed;
border-radius: 10rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #409eff 0%, #66b1ff 100%);
border-radius: 10rpx;
transition: width 0.3s;
&.ontime {
background: linear-gradient(90deg, #67c23a 0%, #85ce61 100%);
}
}
.reviews-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.empty-state {
text-align: center;
padding: 100rpx 0;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.review-item {
padding: 30rpx;
background: #f9f9f9;
border-radius: 16rpx;
border-left: 4rpx solid #409eff;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16rpx;
}
.review-user {
display: flex;
align-items: center;
}
.user-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.user-name {
font-size: 28rpx;
font-weight: 500;
color: #303133;
}
.review-rating {
display: flex;
gap: 4rpx;
}
.review-rating .star {
font-size: 24rpx;
}
.review-content {
font-size: 28rpx;
color: #606266;
line-height: 1.8;
margin-bottom: 16rpx;
display: block;
}
.review-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.review-time,
.review-order {
font-size: 24rpx;
color: #909399;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,610 @@
<template>
<view class="workorder-container">
<!-- 工单状态选项卡 -->
<view class="workorder-tabs">
<view
class="tab-item"
v-for="(tab, index) in tabs"
:key="index"
:class="{ active: currentTab === index }"
@click="switchTab(index)"
>
<text class="tab-text">{{ tab.label }}</text>
<text v-if="tab.count > 0" class="tab-count">{{ tab.count }}</text>
</view>
</view>
<!-- 工单列表 -->
<scroll-view class="workorder-list" scroll-y :refresher-enabled="true" :refresher-triggered="loading" @refresherrefresh="loadWorkorders">
<view v-if="loading && workorders.length === 0" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="filteredWorkorders.length === 0" class="empty-state">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无{{ tabs[currentTab].label }}工单</text>
</view>
<view v-else>
<view class="workorder-item" v-for="(workorder, index) in filteredWorkorders" :key="workorder.orderId || index" @click="handleWorkorderDetail(workorder)">
<view class="workorder-header">
<view class="header-left">
<text class="workorder-id">工单号{{ workorder.id }}</text>
<text v-if="workorder.priorityText" class="workorder-priority" :class="getPriorityClass(workorder.priority)">{{ workorder.priorityText }}</text>
</view>
<text class="workorder-status" :class="getStatusClass(workorder.status)">{{ workorder.statusText }}</text>
</view>
<view class="workorder-content">
<text class="workorder-title">{{ workorder.title }}</text>
<text v-if="workorder.address" class="workorder-address">📍 {{ workorder.address }}</text>
<text v-if="workorder.contact || workorder.phone" class="workorder-contact">👤 {{ workorder.contact || '' }} {{ workorder.phone || '' }}</text>
<text v-if="workorder.description" class="workorder-description">{{ workorder.description }}</text>
</view>
<view class="workorder-time">
<text>提交时间{{ workorder.submitTime }}</text>
<text v-if="workorder.acceptTime"> | {{ workorder.acceptTime }}</text>
</view>
<view class="workorder-actions">
<button v-if="workorder.status === 'pending'" class="action-btn accept-btn" @click.stop="handleAccept(workorder)">
接单
</button>
<button v-if="workorder.status === 'accepted'" class="action-btn process-btn" @click.stop="handleProcess(workorder)">
开始处理
</button>
<button v-if="workorder.status === 'processing'" class="action-btn complete-btn" @click.stop="handleComplete(workorder)">
完成
</button>
<button class="action-btn detail-btn" @click.stop="handleWorkorderDetail(workorder)">
详情
</button>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { setTabBar } from '../../utils/tabBar.js'
import { getWorkorderList, acceptWorkorder, startWorkorder, completeWorkorder } from '../../api/staff.js'
//
const currentTab = ref(0)
//
const loading = ref(false)
//
const tabs = ref([
{ label: '全部', status: 'all', count: 0 },
{ label: '待接单', status: 'pending', count: 0 },
{ label: '已接单', status: 'accepted', count: 0 },
{ label: '处理中', status: 'processing', count: 0 },
{ label: '已完成', status: 'completed', count: 0 }
])
//
const workorders = ref([])
//
const filteredWorkorders = computed(() => {
if (tabs.value[currentTab.value].status === 'all') {
return workorders.value
}
return workorders.value.filter(wo => wo.status === tabs.value[currentTab.value].status)
})
//
const loadWorkorders = async () => {
if (loading.value) return
loading.value = true
try {
const status = tabs.value[currentTab.value].status === 'all' ? '' : tabs.value[currentTab.value].status
const res = await getWorkorderList({
status: status,
pageNum: 1,
pageSize: 100 //
})
const list = res.rows || []
console.log('工单列表API返回:', res) //
console.log('工单列表数据:', list) //
workorders.value = list.map(item => {
//
let priority = item.priority || 'normal'
let priorityText = priority === 'high' ? '紧急' : '普通'
//
let serviceTypeText = item.serviceTypeText || ''
// serviceTypeText serviceType
if (!serviceTypeText && item.serviceType) {
const serviceTypeMap = {
'quality_check': '水质检测',
'repair': '维修',
'install': '安装',
'filter_replace': '滤芯更换',
'order': '商品订购',
'product_order': '商品订购'
}
serviceTypeText = serviceTypeMap[item.serviceType] || item.serviceType
}
// 使
if (!serviceTypeText) {
serviceTypeText = '服务工单'
}
console.log('工单项数据:', item) //
console.log('服务类型文本:', serviceTypeText) //
return {
orderId: item.orderId,
id: item.orderNo || `订单${item.orderId}`,
title: serviceTypeText, //
address: item.address || item.serviceAddress || '',
contact: item.customerName || '',
phone: item.customerPhone || '',
description: item.description || '',
priority: priority,
priorityText: priorityText,
status: item.status,
statusText: item.statusText || getStatusText(item.status),
submitTime: formatDateTime(item.createTime || item.submitTime),
acceptTime: formatDateTime(item.acceptTime)
}
})
console.log('处理后的工单列表:', workorders.value) //
updateTabCounts()
} catch (error) {
console.error('加载工单列表失败', error)
uni.showToast({
title: '加载失败',
icon: 'none'
})
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
//
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return ''
const date = new Date(dateTimeString)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
const hours = date.getHours().toString().padStart(2, '0')
const minutes = date.getMinutes().toString().padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
//
const getStatusText = (status) => {
const statusMap = {
'pending': '待接单',
'accepted': '已接单',
'processing': '处理中',
'completed': '已完成',
'cancelled': '已取消'
}
return statusMap[status] || status
}
//
const switchTab = (index) => {
currentTab.value = index
loadWorkorders() //
}
//
const getPriorityClass = (priority) => {
return priority === 'high' ? 'priority-high' : 'priority-normal'
}
//
const getStatusClass = (status) => {
const classMap = {
pending: 'status-pending',
accepted: 'status-accepted',
processing: 'status-processing',
completed: 'status-completed'
}
return classMap[status] || ''
}
//
const handleAccept = async (workorder) => {
if (!workorder.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认接单',
content: `确定要接取工单 ${workorder.id} 吗?`,
success: async (res) => {
if (res.confirm) {
try {
await acceptWorkorder(workorder.orderId)
uni.showToast({
title: '接单成功',
icon: 'success'
})
loadWorkorders() //
} catch (error) {
console.error('接单失败', error)
}
}
}
})
}
//
const handleProcess = async (workorder) => {
if (!workorder.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认开始处理',
content: `确定要开始处理工单 ${workorder.id} 吗?`,
success: async (res) => {
if (res.confirm) {
try {
await startWorkorder(workorder.orderId)
uni.showToast({
title: '开始处理',
icon: 'success'
})
loadWorkorders() //
} catch (error) {
console.error('开始处理失败', error)
}
}
}
})
}
//
const handleComplete = async (workorder) => {
if (!workorder.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
uni.showModal({
title: '确认完成',
content: `确定工单 ${workorder.id} 已完成吗?`,
success: async (res) => {
if (res.confirm) {
try {
await completeWorkorder({
orderId: workorder.orderId,
result: '工单已完成',
images: []
})
uni.showToast({
title: '工单已完成',
icon: 'success'
})
loadWorkorders() //
} catch (error) {
console.error('完成工单失败', error)
}
}
}
})
}
//
const handleWorkorderDetail = (workorder) => {
if (!workorder || !workorder.orderId) {
uni.showToast({
title: '工单数据异常',
icon: 'none'
})
return
}
// 使 orderId
uni.navigateTo({
url: `/pages/staff/workorder-detail?orderId=${workorder.orderId}`,
fail: () => {
uni.showToast({
title: '跳转失败',
icon: 'none'
})
}
})
}
//
const updateTabCounts = () => {
tabs.value.forEach(tab => {
if (tab.status === 'all') {
tab.count = workorders.value.length
} else {
tab.count = workorders.value.filter(wo => wo.status === tab.status).length
}
})
}
//
onMounted(() => {
setTabBar('staff')
loadWorkorders()
})
//
onShow(() => {
loadWorkorders()
})
//
onPullDownRefresh(() => {
loadWorkorders()
})
</script>
<style lang="scss" scoped>
.workorder-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.workorder-tabs {
display: flex;
background: #ffffff;
padding: 0 10rpx;
overflow-x: auto;
white-space: nowrap;
}
.tab-item {
min-width: 120rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 20rpx;
position: relative;
&.active {
.tab-text {
color: #409eff;
font-weight: bold;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background: #409eff;
border-radius: 2rpx;
}
}
}
.tab-text {
font-size: 26rpx;
color: #606266;
}
.tab-count {
margin-left: 8rpx;
padding: 2rpx 12rpx;
background: #f0f0f0;
border-radius: 20rpx;
font-size: 20rpx;
color: #909399;
}
.workorder-list {
flex: 1;
padding: 20rpx 30rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 0;
}
.loading-text {
font-size: 28rpx;
color: #909399;
}
.workorder-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.workorder-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.header-left {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.workorder-id {
font-size: 26rpx;
color: #909399;
}
.workorder-priority {
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
width: fit-content;
&.priority-high {
background: #fef0f0;
color: #f56c6c;
}
&.priority-normal {
background: #f0f9ff;
color: #409eff;
}
}
.workorder-status {
font-size: 26rpx;
font-weight: 500;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.status-pending {
background: #fef0f0;
color: #e6a23c;
}
&.status-accepted {
background: #f0f9ff;
color: #409eff;
}
&.status-processing {
background: #f0f9ff;
color: #409eff;
}
&.status-completed {
background: #f0f9ff;
color: #67c23a;
}
}
.workorder-content {
margin-bottom: 20rpx;
}
.workorder-title {
display: block;
font-size: 30rpx;
font-weight: 500;
color: #303133;
margin-bottom: 12rpx;
}
.workorder-address,
.workorder-contact {
display: block;
font-size: 26rpx;
color: #606266;
margin-bottom: 8rpx;
}
.workorder-description {
display: block;
font-size: 26rpx;
color: #909399;
line-height: 1.6;
padding: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
margin-top: 12rpx;
}
.workorder-time {
padding: 16rpx 0;
border-top: 1rpx solid #f0f0f0;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 20rpx;
}
.workorder-time text {
font-size: 24rpx;
color: #909399;
}
.workorder-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.accept-btn {
background: #409eff;
color: #ffffff;
}
.process-btn {
background: #67c23a;
color: #ffffff;
}
.complete-btn {
background: #67c23a;
color: #ffffff;
}
.detail-btn {
background: #f0f0f0;
color: #606266;
}
</style>

@ -0,0 +1,282 @@
<template>
<view class="alert-page">
<view class="notice-bar">
<text class="notice-icon">💧</text>
<text class="notice-text">以下设备近期水质指标异常请及时复检或联系工作人员</text>
</view>
<scroll-view class="alert-list" scroll-y>
<view v-if="alerts.length === 0" class="empty-state">
<text class="empty-icon"></text>
<text class="empty-text">暂无水质预警</text>
</view>
<view
v-else
class="alert-card"
v-for="(item, index) in alerts"
:key="index"
>
<view class="card-header">
<text class="device-name">{{ item.name }}</text>
<text class="badge" :class="getBadgeClass(item.tagType)">{{ item.level || item.tag }}</text>
</view>
<view class="card-content">
<text class="row">设备编号{{ item.sn }}</text>
<text class="row">最近检测值{{ item.description }}</text>
<text class="row">最近检测时间{{ item.lastChecked || '暂无记录' }}</text>
</view>
<view class="card-actions">
<button class="action-btn detail-btn" @click="goDeviceDetail(item.sn)"></button>
<button class="action-btn report-btn" @click="goWaterQualityDetail(item)"></button>
<button class="action-btn contact-btn" @click="contactStaff(item)"></button>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getQualityAlertList } from '../../api/quality.js'
const alerts = ref([])
//
const loadAlerts = async () => {
try {
const res = await getQualityAlertList()
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
// /
const abnormal = rows.filter(item => item.qualityLevel === '一般' || item.qualityLevel === '较差')
alerts.value = abnormal.map(item => {
const level = item.qualityLevel
const tagType = level === '较差' ? 'danger' : 'warning'
const lastChecked = formatDate(item.testTime)
//
let desc = ''
if (item.turbidity != null) {
desc += `浊度 ${item.turbidity} NTU; `
}
if (item.residualChlorine != null) {
desc += `余氯 ${item.residualChlorine} mg/L; `
}
if (item.tds != null) {
desc += `TDS ${item.tds} mg/L`
}
return {
name: item.deviceName || `设备-${item.deviceId || ''}`,
sn: item.deviceSn || '',
level,
tagType,
description: desc || (item.qualityDesc || '水质指标存在异常,请关注'),
lastChecked
}
})
} catch (e) {
console.error('加载水质预警失败', e)
alerts.value = []
}
}
const formatDate = (value) => {
if (!value) return ''
if (typeof value === 'string') return value.split(' ')[0]
try {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
} catch (e) {
return ''
}
}
onMounted(() => {
loadAlerts()
})
const goDeviceDetail = (sn) => {
if (!sn) {
uni.showToast({
title: '设备信息缺失',
icon: 'none'
})
return
}
const url = `/pages/device-detail/device-detail?sn=${sn}`
uni.navigateTo({
url,
fail: () => {
uni.redirectTo({ url })
}
})
}
const goWaterQualityDetail = (item) => {
// qualityId
if (!item || !item.qualityId) {
uni.showToast({
title: '暂无检测记录',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/water-quality-detail/water-quality-detail?qualityId=${item.qualityId}`
})
}
const contactStaff = (item) => {
uni.showToast({
title: '已通知工作人员跟进',
icon: 'none'
})
}
const getBadgeClass = (type) => {
if (type === 'danger') return 'badge-danger'
if (type === 'warning') return 'badge-warning'
return 'badge-info'
}
</script>
<style lang="scss" scoped>
.alert-page {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.notice-bar {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
background: #ecf5ff;
color: #409eff;
}
.notice-icon {
font-size: 32rpx;
margin-right: 12rpx;
}
.notice-text {
font-size: 26rpx;
}
.alert-list {
flex: 1;
padding: 20rpx 30rpx 40rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
color: #909399;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
}
.alert-card {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.device-name {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.badge {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 24rpx;
}
.badge-danger {
background: rgba(245, 108, 108, 0.15);
color: #f56c6c;
}
.badge-warning {
background: rgba(230, 162, 60, 0.15);
color: #e6a23c;
}
.badge-info {
background: rgba(64, 158, 255, 0.15);
color: #409eff;
}
.card-content {
display: flex;
flex-direction: column;
gap: 12rpx;
font-size: 26rpx;
color: #606266;
}
.row {
font-size: 26rpx;
}
.card-actions {
display: flex;
gap: 16rpx;
}
.action-btn {
flex: 1;
height: 72rpx;
line-height: 72rpx;
border-radius: 36rpx;
font-size: 26rpx;
border: none;
}
.detail-btn {
background: #ffffff;
color: #409eff;
border: 2rpx solid #409eff;
}
.report-btn {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
.contact-btn {
background: #fef0f0;
color: #f56c6c;
}
</style>

@ -0,0 +1,537 @@
<template>
<view class="detail-container">
<view v-if="record" class="content">
<view class="header-card">
<view class="status-row">
<view class="status-badge" :class="getStatusClass(record.status)">
<text>{{ record.statusText || '检测中' }}</text>
</view>
<text class="report-date">{{ record.date }}</text>
</view>
<view class="quality-row">
<text class="quality-label">水质等级</text>
<text class="quality-value" :style="{ color: getQualityColor(record.level) }">
{{ record.level || '待评估' }}
</text>
</view>
<view class="address-row">
<text class="address-icon">📍</text>
<text class="address-text">{{ record.address || '暂无地址信息' }}</text>
</view>
</view>
<view class="section-card">
<view class="section-header">
<text class="section-title">检测指标</text>
<text class="section-subtitle">采样时间{{ record.date }}</text>
</view>
<view v-if="hasResult" class="indicator-grid">
<view class="indicator-item" v-for="(item, index) in record.result" :key="index">
<text class="indicator-name">{{ item.name }}</text>
<text class="indicator-value">{{ item.value }}</text>
<text class="indicator-unit">{{ item.unit }}</text>
<text class="indicator-standard">标准范围{{ getStandardRange(item.name) }}</text>
</view>
</view>
<view v-else class="empty-indicator">
<text>检测数据生成中请稍后查看</text>
</view>
</view>
<view class="section-card">
<view class="section-header">
<text class="section-title">检测结论</text>
</view>
<view class="conclusion-block">
<text class="conclusion-text">
{{ record.conclusion || defaultConclusion }}
</text>
</view>
<view v-if="tips.length" class="tips-list">
<view class="tips-item" v-for="(tip, index) in tips" :key="index">
<text class="tips-icon">💡</text>
<text class="tips-text">{{ tip }}</text>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header">
<text class="section-title">服务操作</text>
</view>
<view class="action-group">
<button class="action-btn primary" @click="handleDownload"></button>
<button class="action-btn secondary" @click="handleContact"></button>
</view>
</view>
</view>
<view v-else class="empty-state">
<text class="empty-icon">📄</text>
<text class="empty-title">未找到检测报告</text>
<text class="empty-desc">请返回上一页重新选择检测记录</text>
<button class="back-btn" @click="handleBack"></button>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getQualityDetail } from '../../api/quality.js'
const record = ref(null)
//
const loadDetail = async (qualityId) => {
try {
const data = await getQualityDetail(qualityId)
if (!data) {
uni.showToast({
title: '检测记录不存在',
icon: 'none'
})
return
}
// 使
record.value = {
id: data.qualityId,
deviceId: data.deviceId,
userId: data.userId,
status: data.status === '1' ? 'completed' : 'processing',
statusText: data.status === '1' ? '已完成' : '检测中',
date: formatDate(data.testTime),
address: data.testAddress || '',
level: data.qualityLevel || '',
conclusion: data.qualityDesc || '',
result: buildResultList(data)
}
} catch (error) {
console.error('加载水质检测详情失败', error)
}
}
//
const buildResultList = (data) => {
const list = []
if (data.phValue != null) {
list.push({ name: 'pH值', value: data.phValue, unit: '' })
}
if (data.turbidity != null) {
list.push({ name: '浊度', value: data.turbidity, unit: 'NTU' })
}
if (data.residualChlorine != null) {
list.push({ name: '余氯', value: data.residualChlorine, unit: 'mg/L' })
}
if (data.totalHardness != null) {
list.push({ name: '总硬度', value: data.totalHardness, unit: 'mg/L' })
}
if (data.tds != null) {
list.push({ name: 'TDS', value: data.tds, unit: 'mg/L' })
}
if (data.cod != null) {
list.push({ name: 'COD', value: data.cod, unit: 'mg/L' })
}
if (data.ammoniaNitrogen != null) {
list.push({ name: '氨氮', value: data.ammoniaNitrogen, unit: 'mg/L' })
}
return list
}
//
const formatDate = (value) => {
if (!value) return ''
if (typeof value === 'string') return value.split(' ')[0]
try {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
} catch (e) {
return ''
}
}
onLoad((options) => {
const id = options && options.qualityId ? Number(options.qualityId) : null
if (!id) {
uni.showToast({
title: '缺少检测ID',
icon: 'none'
})
return
}
loadDetail(id)
})
const indicatorStandards = {
'pH值': {
range: '6.5 - 8.5'
},
'浊度': {
range: '≤ 1 NTU出厂 / ≤ 3 NTU末梢'
},
'余氯': {
range: '0.3 - 0.5 mg/L'
},
'总硬度': {
range: '≤ 450 mg/L以CaCO₃计'
},
'TDS': {
range: '≤ 1000 mg/L'
}
}
const hasResult = computed(() => {
return record.value && Array.isArray(record.value.result) && record.value.result.length > 0
})
const defaultConclusion = '水质整体表现良好,符合日常饮用和使用需求。如需进一步检测或设备维护,请联系工作人员。'
const tips = computed(() => {
if (!record.value) {
return []
}
const list = []
if (record.value.level === '优质') {
list.push('持续保持当前设备状态,建议每月查看一次检测结果。')
}
if (record.value.level === '良好') {
list.push('建议关注余氯与浊度变化,适时预约滤芯检测。')
}
if (record.value.level === '一般' || record.value.level === '较差') {
list.push('建议尽快预约设备维护,检查滤芯或供水管路。')
list.push('如有异味或水色异常,请立即停止饮用并联系工作人员。')
}
if (!list.length) {
list.push('检测结果将用于优化设备运行,请保持关注。')
}
return list
})
const getStatusClass = (status) => {
if (status === 'completed') {
return 'status-completed'
}
if (status === 'processing') {
return 'status-processing'
}
return 'status-pending'
}
const getQualityColor = (level) => {
const colorMap = {
'优质': '#67c23a',
'良好': '#409eff',
'一般': '#e6a23c',
'较差': '#f56c6c'
}
return colorMap[level] || '#909399'
}
const getStandardRange = (name) => {
return indicatorStandards[name]?.range || '参考标准更新中'
}
const handleDownload = () => {
uni.showToast({
title: '正在生成报告...',
icon: 'none'
})
setTimeout(() => {
uni.showToast({
title: '报告生成成功',
icon: 'success'
})
}, 800)
}
const handleContact = () => {
uni.showModal({
title: '联系工作人员',
content: '是否拨打客服热线 400-800-1234',
success: (res) => {
if (res.confirm) {
uni.makePhoneCall({
phoneNumber: '4008001234',
fail: () => {
uni.showToast({
title: '拨号失败,请稍后重试',
icon: 'none'
})
}
})
}
}
})
}
const handleBack = () => {
uni.navigateBack({
fail: () => {
uni.reLaunch({
url: '/pages/water-quality/water-quality'
})
}
})
}
</script>
<style lang="scss" scoped>
.detail-container {
min-height: 100vh;
background: #f5f5f5;
padding: 30rpx 30rpx 120rpx;
box-sizing: border-box;
}
.content {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.header-card {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
border-radius: 24rpx;
padding: 40rpx 34rpx;
color: #ffffff;
box-shadow: 0 18rpx 40rpx -20rpx rgba(64, 158, 255, 0.8);
}
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.status-badge {
padding: 12rpx 24rpx;
border-radius: 28rpx;
font-size: 24rpx;
background: rgba(255, 255, 255, 0.2);
}
.status-badge.status-completed {
background: rgba(103, 194, 58, 0.2);
color: #67c23a;
}
.status-badge.status-processing {
background: rgba(230, 162, 60, 0.2);
color: #e6a23c;
}
.status-badge.status-pending {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
}
.report-date {
font-size: 26rpx;
}
.quality-row {
display: flex;
align-items: baseline;
gap: 20rpx;
margin-bottom: 20rpx;
}
.quality-label {
font-size: 26rpx;
opacity: 0.8;
}
.quality-value {
font-size: 48rpx;
font-weight: bold;
}
.address-row {
display: flex;
align-items: center;
gap: 12rpx;
font-size: 26rpx;
opacity: 0.9;
}
.section-card {
background: #ffffff;
border-radius: 20rpx;
padding: 34rpx 30rpx;
box-shadow: 0 10rpx 20rpx -16rpx rgba(0, 0, 0, 0.12);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.section-subtitle {
font-size: 24rpx;
color: #909399;
}
.indicator-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
}
.indicator-item {
background: #f5f7fa;
border-radius: 16rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.indicator-name {
font-size: 26rpx;
color: #606266;
}
.indicator-value {
font-size: 36rpx;
font-weight: bold;
color: #303133;
}
.indicator-unit {
font-size: 24rpx;
color: #909399;
}
.indicator-standard {
font-size: 24rpx;
color: #606266;
}
.empty-indicator {
text-align: center;
font-size: 26rpx;
color: #909399;
padding: 40rpx 20rpx;
}
.conclusion-block {
background: linear-gradient(135deg, rgba(64, 158, 255, 0.08) 0%, rgba(102, 177, 255, 0.12) 100%);
border-radius: 18rpx;
padding: 30rpx;
margin-bottom: 24rpx;
}
.conclusion-text {
font-size: 28rpx;
color: #303133;
line-height: 1.6;
}
.tips-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.tips-item {
display: flex;
align-items: center;
gap: 16rpx;
background: #f9fafc;
border-radius: 14rpx;
padding: 20rpx 24rpx;
}
.tips-icon {
font-size: 32rpx;
}
.tips-text {
font-size: 26rpx;
color: #606266;
}
.action-group {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.action-btn {
height: 88rpx;
line-height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
}
.action-btn.primary {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
}
.action-btn.secondary {
background: #ecf5ff;
color: #409eff;
}
.empty-state {
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
color: #909399;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 10rpx;
}
.empty-title {
font-size: 32rpx;
color: #303133;
}
.empty-desc {
font-size: 26rpx;
margin-bottom: 20rpx;
}
.back-btn {
width: 260rpx;
height: 80rpx;
line-height: 80rpx;
border-radius: 40rpx;
background: #409eff;
color: #ffffff;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
</style>

@ -0,0 +1,758 @@
<template>
<view class="quality-container">
<!-- 检测记录列表 -->
<scroll-view class="record-list" scroll-y>
<view v-if="records.length === 0" class="empty-state">
<text class="empty-icon">💧</text>
<text class="empty-text">暂无检测记录</text>
<button class="test-btn" @click="handleNewTest"></button>
</view>
<view v-else>
<view
class="record-item"
v-for="(record, index) in records"
:key="index"
@click="handleRecordClick(record)"
>
<view class="record-header">
<text class="record-date">{{ record.date }}</text>
<view class="record-status" :class="getStatusClass(record.status)">
<text>{{ record.statusText }}</text>
</view>
</view>
<view class="record-content">
<view class="quality-item">
<text class="quality-label">检测地址</text>
<text class="quality-value">{{ record.address }}</text>
</view>
<view class="quality-item">
<text class="quality-label">水质等级</text>
<text class="quality-value" :style="{ color: getQualityColor(record.level) }">
{{ record.level }}
</text>
</view>
</view>
<view v-if="record.result" class="record-result">
<view class="result-item" v-for="(item, idx) in record.result" :key="idx">
<text class="result-label">{{ item.name }}</text>
<text class="result-value">{{ item.value }} {{ item.unit }}</text>
</view>
</view>
<view class="record-actions">
<button class="action-btn" @click.stop="handleViewReport(record)">查看报告</button>
</view>
</view>
</view>
</scroll-view>
<!-- 底部申请按钮 -->
<view class="bottom-section">
<button class="apply-btn" @click="handleNewTest"></button>
</view>
<!-- 申请检测弹窗 -->
<view v-if="showApplyDialog" class="dialog-mask" @click="closeApplyDialog">
<view class="dialog-content" @click.stop>
<view class="dialog-header">
<text class="dialog-title">申请水质检测</text>
<text class="dialog-close" @click="closeApplyDialog">×</text>
</view>
<view class="dialog-body">
<view class="form-item">
<text class="form-label">选择设备</text>
<view class="device-select-wrapper" @click="handleSelectDevice">
<view v-if="selectedDevice" class="device-display">
<view class="device-display-header">
<text class="device-display-name">{{ selectedDevice.name }}</text>
<text class="device-display-sn">编号{{ selectedDevice.sn }}</text>
</view>
<text class="device-display-detail">{{ selectedDevice.address || '地址未填写' }}</text>
</view>
<text v-else class="device-placeholder">请选择检测设备</text>
<text class="device-arrow"></text>
</view>
</view>
<view class="form-item">
<text class="form-label">联系电话</text>
<input
class="form-input"
type="number"
v-model="contactPhone"
placeholder="请输入联系电话"
maxlength="11"
/>
</view>
<view class="form-item">
<text class="form-label">备注</text>
<textarea
class="form-textarea"
v-model="applyRemark"
placeholder="可填写特殊要求等(选填)"
maxlength="200"
></textarea>
</view>
</view>
<view class="dialog-footer">
<button class="dialog-btn cancel-btn" @click="closeApplyDialog"></button>
<button class="dialog-btn confirm-btn" @click="handleSubmitApply" :disabled="submitting">
{{ submitting ? '提交中...' : '提交申请' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { onLoad, onShow } from '@dcloudio/uni-app'
import { getQualityList, applyQualityCheck } from '../../api/quality.js'
import { getDeviceList } from '../../api/device.js'
const records = ref([])
const currentDeviceId = ref(null)
//
const showApplyDialog = ref(false)
const selectedDevice = ref(null)
const contactPhone = ref('') //
const applyRemark = ref('')
const submitting = ref(false)
//
const devices = ref([])
//
const loadRecords = async (deviceId) => {
try {
const params = {}
if (deviceId) {
params.deviceId = deviceId
}
const res = await getQualityList(params)
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
records.value = rows.map(item => {
return {
id: item.qualityId,
deviceId: item.deviceId,
userId: item.userId,
date: formatDate(item.testTime),
address: item.testAddress || '',
status: item.status === '1' ? 'completed' : 'processing',
statusText: item.status === '1' ? '已完成' : '检测中',
level: item.qualityLevel || '-',
//
result: [
item.phValue != null ? { name: 'pH值', value: item.phValue, unit: '' } : null,
item.turbidity != null ? { name: '浊度', value: item.turbidity, unit: 'NTU' } : null,
item.residualChlorine != null ? { name: '余氯', value: item.residualChlorine, unit: 'mg/L' } : null
].filter(Boolean)
}
})
} catch (error) {
console.error('加载水质检测记录失败', error)
records.value = []
}
}
//
const formatDate = (value) => {
if (!value) return ''
if (typeof value === 'string') {
return value.split(' ')[0]
}
try {
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
} catch (e) {
return ''
}
}
onLoad((options) => {
// deviceId
if (options && options.deviceId) {
currentDeviceId.value = Number(options.deviceId)
}
loadRecords(currentDeviceId.value)
})
//
const getStatusClass = (status) => {
return status === 'completed' ? 'status-completed' : 'status-processing'
}
//
const getQualityColor = (level) => {
const colorMap = {
'优质': '#67c23a',
'良好': '#409eff',
'一般': '#e6a23c',
'较差': '#f56c6c'
}
return colorMap[level] || '#909399'
}
//
const handleRecordClick = (record) => {
if (record.status === 'completed') {
handleViewReport(record)
}
}
const handleViewReport = (record) => {
if (!record || !record.id) {
uni.showToast({
title: '检测记录不存在',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/water-quality-detail/water-quality-detail?qualityId=${record.id}`
})
}
//
const loadDevices = async () => {
try {
const res = await getDeviceList({
pageNum: 1,
pageSize: 100
})
const rows = res && res.rows ? res.rows : (Array.isArray(res) ? res : [])
devices.value = rows.map(item => ({
deviceId: item.deviceId,
name: item.deviceName || '未命名设备',
sn: item.deviceSn || item.sn || '', // deviceSn
address: item.installAddress || '',
type: item.deviceType || 'purifier',
status: item.status || '0'
}))
} catch (error) {
console.error('加载设备列表失败', error)
devices.value = []
}
}
//
const handleNewTest = async () => {
//
await loadDevices()
if (devices.value.length === 0) {
uni.showToast({
title: '您还没有绑定设备,请先绑定设备',
icon: 'none',
duration: 2000
})
//
setTimeout(() => {
uni.navigateTo({
url: '/pages/device/device'
})
}, 2000)
return
}
showApplyDialog.value = true
selectedDevice.value = null
contactPhone.value = ''
applyRemark.value = ''
}
//
const closeApplyDialog = () => {
showApplyDialog.value = false
selectedDevice.value = null
contactPhone.value = ''
applyRemark.value = ''
}
//
const handleSelectDevice = () => {
if (devices.value.length === 0) {
uni.showToast({
title: '您还没有绑定设备',
icon: 'none'
})
return
}
//
const deviceNames = devices.value.map(d => d.name + '' + d.sn + '')
uni.showActionSheet({
itemList: deviceNames,
success: (res) => {
selectedDevice.value = devices.value[res.tapIndex]
}
})
}
//
onShow(() => {
//
if (showApplyDialog.value) {
loadDevices()
}
})
//
const handleSubmitApply = async () => {
if (!selectedDevice.value) {
uni.showToast({
title: '请选择检测设备',
icon: 'none'
})
return
}
//
if (!selectedDevice.value.sn) {
uni.showToast({
title: '设备信息不完整,请重新选择',
icon: 'none'
})
return
}
//
if (!contactPhone.value || contactPhone.value.trim() === '') {
uni.showToast({
title: '请输入联系电话',
icon: 'none'
})
return
}
// 111
if (!/^1[3-9]\d{9}$/.test(contactPhone.value.trim())) {
uni.showToast({
title: '请输入正确的手机号',
icon: 'none'
})
return
}
// 10
const today = new Date()
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const appointmentDate = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`
const appointmentTime = '10:00'
if (submitting.value) return
try {
submitting.value = true
await applyQualityCheck({
deviceSn: selectedDevice.value.sn, // 使
appointmentDate: appointmentDate,
appointmentTime: appointmentTime,
contactPhone: contactPhone.value.trim(),
address: selectedDevice.value.address || '',
remark: applyRemark.value || ''
})
uni.showToast({
title: '申请成功,工作人员将联系您',
icon: 'success'
})
//
closeApplyDialog()
//
loadRecords(currentDeviceId.value)
} catch (error) {
console.error('申请水质检测失败', error)
// request.js
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.quality-container {
min-height: 100vh;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
.record-list {
flex: 1;
padding: 20rpx 30rpx;
padding-bottom: 120rpx;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
}
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #909399;
margin-bottom: 40rpx;
}
.test-btn {
width: 300rpx;
height: 80rpx;
line-height: 80rpx;
background: #409eff;
color: #ffffff;
border-radius: 40rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.record-item {
background: #ffffff;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 20rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.record-date {
font-size: 30rpx;
font-weight: bold;
color: #303133;
}
.record-status {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
&.status-completed {
background: #f0f9ff;
color: #67c23a;
}
&.status-processing {
background: #fef0f0;
color: #e6a23c;
}
}
.record-content {
margin-bottom: 20rpx;
}
.quality-item {
display: flex;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
.quality-label {
font-size: 26rpx;
color: #909399;
}
.quality-value {
font-size: 26rpx;
color: #606266;
font-weight: 500;
}
.record-result {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.result-item {
display: flex;
justify-content: space-between;
margin-bottom: 8rpx;
&:last-child {
margin-bottom: 0;
}
}
.result-label {
font-size: 26rpx;
color: #606266;
}
.result-value {
font-size: 26rpx;
color: #303133;
font-weight: 500;
}
.record-actions {
display: flex;
}
.action-btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
background: #409eff;
color: #ffffff;
border-radius: 34rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
}
.bottom-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 30rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
}
.apply-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-radius: 44rpx;
font-size: 32rpx;
font-weight: bold;
border: none;
&::after {
border: none;
}
}
.dialog-mask {
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: 999;
}
.dialog-content {
width: 90%;
max-width: 600rpx;
background: #ffffff;
border-radius: 24rpx;
overflow: hidden;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.dialog-title {
font-size: 32rpx;
font-weight: bold;
color: #303133;
}
.dialog-close {
font-size: 48rpx;
color: #909399;
line-height: 1;
}
.dialog-body {
padding: 30rpx;
max-height: 60vh;
overflow-y: auto;
}
.form-item {
margin-bottom: 30rpx;
&:last-child {
margin-bottom: 0;
}
}
.form-label {
display: block;
font-size: 28rpx;
color: #606266;
margin-bottom: 16rpx;
font-weight: 500;
}
.form-input {
width: 100%;
height: 80rpx;
padding: 0 20rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: none;
}
.form-textarea {
width: 100%;
min-height: 120rpx;
padding: 20rpx;
background: #f5f5f5;
border-radius: 12rpx;
font-size: 28rpx;
color: #303133;
border: none;
}
.device-select-wrapper {
width: 100%;
min-height: 140rpx;
padding: 24rpx;
background: linear-gradient(135deg, #f8fbff 0%, #ffffff 100%);
border: 2rpx solid #e4e7ed;
border-radius: 16rpx;
display: flex;
align-items: center;
position: relative;
box-shadow: 0 2rpx 8rpx rgba(64, 158, 255, 0.08);
}
.device-display {
flex: 1;
min-width: 0;
padding-right: 20rpx;
}
.device-display-header {
display: flex;
align-items: center;
margin-bottom: 12rpx;
flex-wrap: wrap;
gap: 12rpx;
}
.device-display-name {
font-size: 30rpx;
font-weight: 600;
color: #303133;
}
.device-display-sn {
font-size: 26rpx;
color: #606266;
padding: 4rpx 12rpx;
background: #f5f7fa;
border-radius: 8rpx;
}
.device-display-detail {
font-size: 26rpx;
color: #606266;
line-height: 1.8;
word-break: break-all;
padding: 12rpx 16rpx;
background: rgba(64, 158, 255, 0.05);
border-radius: 8rpx;
border-left: 4rpx solid #409eff;
}
.device-placeholder {
flex: 1;
font-size: 28rpx;
color: #909399;
display: flex;
align-items: center;
}
.device-arrow {
font-size: 36rpx;
color: #409eff;
margin-left: 20rpx;
font-weight: bold;
flex-shrink: 0;
}
.dialog-footer {
display: flex;
border-top: 1rpx solid #f0f0f0;
}
.dialog-btn {
flex: 1;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
border: none;
border-radius: 0;
&::after {
border: none;
}
}
.cancel-btn {
background: #ffffff;
color: #606266;
border-right: 1rpx solid #f0f0f0;
}
.confirm-btn {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
font-weight: bold;
&[disabled] {
background: #c0c4cc;
color: #ffffff;
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@ -0,0 +1,13 @@
uni.addInterceptor({
returnValue (res) {
if (!(!!res && (typeof res === "object" || typeof res === "function") && typeof res.then === "function")) {
return res;
}
return new Promise((resolve, reject) => {
res.then((res) => {
if (!res) return resolve(res)
return res[0] ? reject(res[0]) : resolve(res[1])
});
});
},
});

@ -0,0 +1,76 @@
/**
* uni-app
*
* uni-app https://ext.dcloud.net.cn使
* 使scss使 import 便App
*
*/
/**
* App使
*
* 使scss scss 使 import
*/
/* 颜色变量 */
/* 行为相关颜色 */
$uni-color-primary: #007aff;
$uni-color-success: #4cd964;
$uni-color-warning: #f0ad4e;
$uni-color-error: #dd524d;
/* 文字基本颜色 */
$uni-text-color:#333;//
$uni-text-color-inverse:#fff;//
$uni-text-color-grey:#999;//
$uni-text-color-placeholder: #808080;
$uni-text-color-disable:#c0c0c0;
/* 背景颜色 */
$uni-bg-color:#ffffff;
$uni-bg-color-grey:#f8f8f8;
$uni-bg-color-hover:#f1f1f1;//
$uni-bg-color-mask:rgba(0, 0, 0, 0.4);//
/* 边框颜色 */
$uni-border-color:#c8c7cc;
/* 尺寸变量 */
/* 文字尺寸 */
$uni-font-size-sm:12px;
$uni-font-size-base:14px;
$uni-font-size-lg:16px;
/* 图片尺寸 */
$uni-img-size-sm:20px;
$uni-img-size-base:26px;
$uni-img-size-lg:40px;
/* Border Radius */
$uni-border-radius-sm: 2px;
$uni-border-radius-base: 3px;
$uni-border-radius-lg: 6px;
$uni-border-radius-circle: 50%;
/* 水平间距 */
$uni-spacing-row-sm: 5px;
$uni-spacing-row-base: 10px;
$uni-spacing-row-lg: 15px;
/* 垂直间距 */
$uni-spacing-col-sm: 4px;
$uni-spacing-col-base: 8px;
$uni-spacing-col-lg: 12px;
/* 透明度 */
$uni-opacity-disabled: 0.3; //
/* 文章场景相关 */
$uni-color-title: #2C405A; //
$uni-font-size-title:20px;
$uni-color-subtitle: #555555; //
$uni-font-size-subtitle:26px;
$uni-color-paragraph: #3F536E; //
$uni-font-size-paragraph:15px;

@ -0,0 +1 @@
{"version":3,"file":"app.js","sources":["App.vue","main.js"],"sourcesContent":["<script setup>\r\nimport { onLaunch, onShow, onHide } from '@dcloudio/uni-app'\r\nimport { setTabBar } from './utils/tabBar.js'\r\n\r\nonLaunch(() => {\r\n\tconsole.log('App Launch - 数智水管家')\r\n\t// 根据存储的角色设置 tabBar\r\n\tconst userRole = uni.getStorageSync('userRole') || 'user'\r\n\tsetTabBar(userRole)\r\n})\r\n\r\nonShow(() => {\r\n\tconsole.log('App Show')\r\n})\r\n\r\nonHide(() => {\r\n\tconsole.log('App Hide')\r\n})\r\n</script>\r\n\r\n<style>\r\n/* 全局样式 */\r\npage {\r\n\tbackground-color: #f5f5f5;\r\n\tfont-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;\r\n}\r\n</style>\r\n","import App from './App'\n\n// #ifndef VUE3\nimport Vue from 'vue'\nimport './uni.promisify.adaptor'\nVue.config.productionTip = false\nApp.mpType = 'app'\nconst app = new Vue({\n ...App\n})\napp.$mount()\n// #endif\n\n// #ifdef VUE3\nimport { createSSRApp } from 'vue'\nexport function createApp() {\n const app = createSSRApp(App)\n return {\n app\n }\n}\n// #endif"],"names":["onLaunch","uni","setTabBar","onShow","onHide","createSSRApp","App"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAIAA,kBAAAA,SAAS,MAAM;AACdC,oBAAAA,MAAA,MAAA,OAAA,gBAAY,oBAAoB;AAEhC,YAAM,WAAWA,cAAG,MAAC,eAAe,UAAU,KAAK;AACnDC,mBAAAA,UAAU,QAAQ;AAAA,IACnB,CAAC;AAEDC,kBAAAA,OAAO,MAAM;AACZF,oBAAAA,MAAY,MAAA,OAAA,iBAAA,UAAU;AAAA,IACvB,CAAC;AAEDG,kBAAAA,OAAO,MAAM;AACZH,oBAAAA,MAAY,MAAA,OAAA,iBAAA,UAAU;AAAA,IACvB,CAAC;;;;;ACFM,SAAS,YAAY;AAC1B,QAAM,MAAMI,cAAY,aAACC,SAAG;AAC5B,SAAO;AAAA,IACL;AAAA,EACD;AACH;;;"}

@ -0,0 +1 @@
{"version":3,"file":"assets.js","sources":["static/logo.png"],"sourcesContent":["export default \"__VITE_ASSET__46719607__\""],"names":[],"mappings":";AAAA,MAAe,aAAA;;"}

File diff suppressed because one or more lines are too long

@ -0,0 +1 @@
{"version":3,"file":"device-problem.js","sources":["pages/device-problem/device-problem.vue","../../HBuilderX/plugins/uniapp-cli-vite/uniPage:/cGFnZXMvZGV2aWNlLXByb2JsZW0vZGV2aWNlLXByb2JsZW0udnVl"],"sourcesContent":["<template>\r\n\t<view class=\"problem-container\">\r\n\t\t<!-- 说明条 -->\r\n\t\t<view class=\"notice-bar\">\r\n\t\t\t<text class=\"notice-icon\">⚠️</text>\r\n\t\t\t<text class=\"notice-text\">以下为检测到的异常/离线设备,请及时处理</text>\r\n\t\t</view>\r\n\r\n\t\t<!-- 问题设备列表 -->\r\n\t\t<scroll-view class=\"problem-list\" scroll-y>\r\n\t\t\t<view v-if=\"problemDevices.length === 0\" class=\"empty-state\">\r\n\t\t\t\t<text class=\"empty-icon\">✅</text>\r\n\t\t\t\t<text class=\"empty-text\">暂无问题设备</text>\r\n\t\t\t</view>\r\n\t\t\t\r\n\t\t\t<view v-else>\r\n\t\t\t\t<view \r\n\t\t\t\t\tclass=\"problem-item\" \r\n\t\t\t\t\tv-for=\"(d, index) in problemDevices\" \r\n\t\t\t\t\t:key=\"index\"\r\n\t\t\t\t\t@click=\"goDetail(d)\"\r\n\t\t\t\t>\r\n\t\t\t\t\t<view class=\"item-header\">\r\n\t\t\t\t\t\t<text class=\"device-name\">{{ d.name }}</text>\r\n\t\t\t\t\t\t<text class=\"status-badge\">{{ d.statusText || '异常' }}</text>\r\n\t\t\t\t\t</view>\r\n\t\t\t\t\t<view class=\"item-content\">\r\n\t\t\t\t\t\t<text class=\"row\">设备编号:{{ d.sn }}</text>\r\n\t\t\t\t\t\t<text class=\"row\">安装地址:{{ d.address }}</text>\r\n\t\t\t\t\t\t<text class=\"row\">绑定时间:{{ d.bindTime }}</text>\r\n\t\t\t\t\t</view>\r\n\t\t\t\t\t<view class=\"item-actions\">\r\n\t\t\t\t\t\t<button class=\"action-btn fix-btn\" @click.stop=\"handleFix(d)\">一键诊断</button>\r\n\t\t\t\t\t\t<button class=\"action-btn detail-btn\" @click.stop=\"goDetail(d)\">查看详情</button>\r\n\t\t\t\t\t</view>\r\n\t\t\t\t</view>\r\n\t\t\t</view>\r\n\t\t</scroll-view>\r\n\t</view>\r\n</template>\r\n\r\n<script setup>\r\nimport { ref, onMounted } from 'vue'\r\n\r\nconst problemDevices = ref([])\r\n\r\nonMounted(() => {\r\n\ttry {\r\n\t\tconst list = uni.getStorageSync('problemDevices') || []\r\n\t\tproblemDevices.value = Array.isArray(list) ? list : []\r\n\t} catch (e) {\r\n\t\tproblemDevices.value = []\r\n\t}\r\n})\r\n\r\nconst goDetail = (device) => {\r\n\tuni.navigateTo({\r\n\t\turl: `/pages/device-detail/device-detail?sn=${device.sn}`\r\n\t})\r\n}\r\n\r\nconst handleFix = (device) => {\r\n\tuni.showLoading({ title: '诊断中...' })\r\n\tsetTimeout(() => {\r\n\t\tuni.hideLoading()\r\n\t\tuni.showToast({ title: '诊断完成,建议检查网络', icon: 'none' })\r\n\t}, 1000)\r\n}\r\n</script>\r\n\r\n<style lang=\"scss\" scoped>\r\n.problem-container {\r\n\tmin-height: 100vh;\r\n\tbackground: #f5f5f5;\r\n\tdisplay: flex;\r\n\tflex-direction: column;\r\n}\r\n\r\n.notice-bar {\r\n\tdisplay: flex;\r\n\talign-items: center;\r\n\tbackground: #fef0f0;\r\n\tcolor: #f56c6c;\r\n\tpadding: 20rpx 30rpx;\r\n}\r\n\r\n.notice-icon { font-size: 32rpx; margin-right: 12rpx; }\r\n.notice-text { font-size: 26rpx; }\r\n\r\n.problem-list {\r\n\tflex: 1;\r\n\tpadding: 20rpx 30rpx;\r\n}\r\n\r\n.empty-state { align-items: center; justify-content: center; display: flex; flex-direction: column; padding: 200rpx 0; }\r\n.empty-icon { font-size: 120rpx; margin-bottom: 30rpx; opacity: 0.5; }\r\n.empty-text { font-size: 28rpx; color: #909399; }\r\n\r\n.problem-item {\r\n\tbackground: #ffffff;\r\n\tborder-radius: 20rpx;\r\n\tpadding: 30rpx;\r\n\tmargin-bottom: 20rpx;\r\n\tborder: 2rpx solid #f0f0f0;\r\n}\r\n\r\n.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; }\r\n.device-name { font-size: 30rpx; font-weight: 600; color: #303133; }\r\n.status-badge { font-size: 22rpx; color: #f56c6c; background: #fde2e2; padding: 4rpx 16rpx; border-radius: 20rpx; }\r\n\r\n.item-content { display: flex; flex-direction: column; gap: 8rpx; margin-bottom: 16rpx; }\r\n.row { font-size: 26rpx; color: #606266; }\r\n\r\n.item-actions { display: flex; gap: 20rpx; }\r\n.action-btn { flex: 1; height: 64rpx; line-height: 64rpx; border-radius: 32rpx; font-size: 26rpx; border: none; }\r\n.fix-btn { background: #f56c6c; color: #fff; }\r\n.detail-btn { background: #f0f0f0; color: #606266; }\r\n</style>\r\n\r\n","import MiniProgramPage from 'D:/hbuilder/hbuilderproject/数智水管家/pages/device-problem/device-problem.vue'\nwx.createPage(MiniProgramPage)"],"names":["ref","onMounted","uni"],"mappings":";;;;;AA4CA,UAAM,iBAAiBA,cAAG,IAAC,EAAE;AAE7BC,kBAAAA,UAAU,MAAM;AACf,UAAI;AACH,cAAM,OAAOC,cAAG,MAAC,eAAe,gBAAgB,KAAK,CAAE;AACvD,uBAAe,QAAQ,MAAM,QAAQ,IAAI,IAAI,OAAO,CAAE;AAAA,MACtD,SAAQ,GAAG;AACX,uBAAe,QAAQ,CAAE;AAAA,MACzB;AAAA,IACF,CAAC;AAED,UAAM,WAAW,CAAC,WAAW;AAC5BA,oBAAAA,MAAI,WAAW;AAAA,QACd,KAAK,yCAAyC,OAAO,EAAE;AAAA,MACzD,CAAE;AAAA,IACF;AAEA,UAAM,YAAY,CAAC,WAAW;AAC7BA,oBAAAA,MAAI,YAAY,EAAE,OAAO,SAAQ,CAAE;AACnC,iBAAW,MAAM;AAChBA,sBAAAA,MAAI,YAAa;AACjBA,sBAAG,MAAC,UAAU,EAAE,OAAO,eAAe,MAAM,QAAQ;AAAA,MACpD,GAAE,GAAI;AAAA,IACR;;;;;;;;;;;;;;;;;;;;;;;AClEA,GAAG,WAAW,eAAe;"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

Loading…
Cancel
Save