42 Commits
Author SHA1 Message Date
bot_pm f035fa274f docs: v2.0 详细设计规格书 (含全部模块DB/API/组件/数据流/Issue对照表) 2026-06-14 17:48:01 +08:00
bot_dev2 69fd9d7c41 feat(wm-revenue): #57 微信网厅(微信支付+AI客服+意图匹配)
- WxPayService: 统一下单/支付回调/退款/订单查询
- FaqService: FAQ CRUD/关键词搜索/热门推荐/分类查询
- IntentService: 意图识别(正则匹配)/批量解析/规则管理
- 3个Controller + 15+ API端点 (/api/revenue/wxpay|faq|intent/*)
- Entity: WxPayOrder, FaqItem, IntentRule
- DDL: rev_wx_pay_order/rev_faq_item/rev_intent_rule + 8个索引
- 8个单元测试
2026-06-14 16:43:00 +08:00
bot_dev2 8e1b9d8dd2 Merge remote-tracking branch 'origin/feature/issue-66'
# Conflicts:
#	wm-production/pom.xml
#	wm-production/src/main/java/com/water/production/controller/QualityLedgerController.java
#	wm-production/src/main/java/com/water/production/dto/QualityQueryRequest.java
#	wm-production/src/main/java/com/water/production/dto/QualityStatVO.java
#	wm-production/src/main/java/com/water/production/entity/QualityStandard.java
#	wm-production/src/main/java/com/water/production/entity/QualityTestPlan.java
#	wm-production/src/main/java/com/water/production/entity/QualityTestRecord.java
#	wm-production/src/main/java/com/water/production/mapper/QualityTestRecordMapper.java
#	wm-production/src/main/java/com/water/production/service/QualityLedgerService.java
#	wm-production/src/main/java/com/water/production/service/QualityStandardService.java
#	wm-production/src/main/java/com/water/production/service/QualityTestPlanService.java
#	wm-production/src/main/resources/db/V4__quality_ledger.sql
#	wm-production/src/main/resources/mapper/QualityTestRecordMapper.xml
#	wm-production/src/test/java/com/water/production/service/QualityLedgerServiceTest.java
2026-06-14 16:35:45 +08:00
bot_dev2 91740fb44c feat(wm-production): #66 水质检测台账管理
- 水质检测记录 CRUD(浊度/pH/余氯/色度/嗅味/大肠杆菌等)
- 根据 GB5749-2022 国标自动合格判定,支持自定义标准
- 检测计划管理(日检/周检/月检),自动计算下次检测日期
- 多维度台账查询(时间/区域/检测类型/合格状态)
- 统计分析(合格率趋势/各指标分布/不合格项追踪)
- Excel 导出检测报告
- 3个 Entity + 2个 DTO + 3个 Mapper(含XML) + 3个 Service + 1个 Controller(26端点)
- DDL 含3张表 + 索引 + GB5749-2022 默认标准数据
- 18个单元测试
2026-06-14 16:34:58 +08:00
bot_dev2 b445b089c8 feat(wm-revenue): #55 客服知识库+公告板+KPI看板完整实现
- Entity: KbArticle(知识库文章), Announcement(公告), KpiDashboard(KPI看板VO)
- Mapper: KbArticleMapper, AnnouncementMapper (MyBatis-Plus)
- Service: KnowledgeBaseService(知识库CRUD+搜索+分类+点赞+热门),
           AnnouncementService(公告发布/编辑/按类型筛选/按范围推送/撤回),
           KpiService(KPI聚合计算:待处理量/时效/满意率/趋势/排行)
- Controller: CsSupportController (/api/revenue/cs/*)
- SQL DDL: V_cs_support.sql (cs_kb_article + cs_announcement 表+示例数据)
- Frontend: KnowledgeBaseView.vue(列表/卡片/Markdown编辑器),
            AnnouncementView.vue(类型标签/状态切换/时间范围),
            KpiDashboardView.vue(ECharts趋势图+饼图+排行)
- Unit Test: CsSupportServiceTest (知识库/公告/KPI三组测试)
- Router: 新增 /cs/knowledge, /cs/announcement, /cs/kpi 路由
2026-06-14 16:28:05 +08:00
bot_dev2 98f91e5d74 feat(wm-production): #65 全工艺药剂投加监控 2026-06-14 16:24:11 +08:00
bot_dev2 fddf330ab2 feat(wm-production): #64 GIS地图展示后端服务
- GisService: 点位CRUD/空间查询(矩形+圆形)/管网数据/热力图/统计
- GisController: 11个API端点 (/api/production/gis/*)
- 支持流量/压力/液位/水质/阀门5类监测点位
- Haversine距离计算 + 网格聚合热力图
- DDL: prod_gis_point/pipeline/area + 6个索引
2026-06-14 16:17:31 +08:00
bot_dev2 dcb412c7e4 feat(wm-production): #63 视频监控集成与AI人员闯入检测
- VideoMonitorService: 摄像头CRUD/视频流管理/状态监控/录像回放/统计
- IntrusionDetectionService: AI闯入检测(模拟)/事件处理/分页查询/统计
- VideoController: 20+ API端点 (/api/production/video/*)
- DDL: prod_video_recording + prod_intrusion_event + 索引
2026-06-14 16:15:50 +08:00
bot_dev2 21cf0e97af feat(wm-production): #62 在线监测列表与多维筛选 2026-06-14 16:12:59 +08:00
bot_dev2 4a82e90fcb feat(wm-production): #61 总览大屏后端服务 2026-06-14 16:11:02 +08:00
bot_dev2 f240059d1e feat(wm-dma): #59 DMA分区计量与漏损分析
- 新增 wm-dma 模块
- Entity: DmaZone, DmaMeter, DmaFlowRecord, DmaLeakageAnalysis, WaterBalance
- Mapper + Service + Controller(18个端点)
- DMA分区管理: 分区层级定义(CRUD) + 区域划分 + 关联设备 + 树形结构
- 分区计量: 各分区进出水量汇总 + 最小夜间流量(MNF)分析 + 流量趋势
- 漏损分析: 产销差计算 + 漏损率评估 + 趋势分析 + 报警
- 水平衡表: 供水量/售水量/漏损量平衡分析 + IWA组成
- DDL: dma_ddl.sql
- 单元测试: 5个Service测试类(25+测试用例)
2026-06-14 16:06:49 +08:00
bot_dev2 08649f027e feat(wm-data-engine): #71 历史数据回溯与报表生成
- HistoryDataService: 水量/水质历史数据分页查询 + 导出
- ReportService: 日报/周报/月报/年报自动生成 + 发布 + 模板管理
- StatisticsService: 同比/环比/趋势分析 + 综合看板
- 5个Entity + 5个Mapper + 3个Service + 1个Controller(12+端点)
- DDL: 4张表 + 5个索引
- 单元测试: 3个测试类
2026-06-14 15:38:41 +08:00
bot_dev2 a26a626d21 feat(wm-dispatch): #70 应急推演(爆管模拟+水质异常+演练管理)
- 爆管模拟: 影响范围/用户/水量损失/修复时间/关阀方案
- 水质异常: 事件上报/严重度评估/预案匹配/响应流程/处置归档
- 应急演练: 计划创建/执行/完成/评估打分
- 4个Entity + 4个Mapper + 3个Service + 1个Controller(15端点)
- DDL: 4张表 + 4个索引
- 单元测试: 3个测试类
2026-06-14 15:38:22 +08:00
bot_dev2 4a0fc1bf42 feat(wm-config): #72 阈值管理+信息发布+设备管理 2026-06-14 15:37:16 +08:00
bot_dev2 1870171e45 feat(wm-production): #67 报警规则引擎与报警管理中心
- DDL: V2__alert_engine.sql (prod_alert_rule, prod_alert_record, prod_alert_notification, prod_alert_rule_device)
- Entity: AlertRule(规则定义), AlertRecord(全生命周期增强), AlertNotification(通知记录)
- Mapper: AlertRuleMapper, AlertRecordMapper(自定义关联SQL), AlertNotificationMapper
- Service: AlertRuleService(规则CRUD + AND/OR组合条件评估引擎 + 阈值触发 + 多级别报警)
- Service: AlertCenterService(报警全生命周期: 确认/派单/处理/归档 + 统计看板)
- Controller: AlertController 23个RESTful端点 (/api/production/alert/*)
- 单元测试: AlertRuleServiceTest(12个) + AlertCenterServiceTest(5个) = 17个测试
2026-06-14 15:31:14 +08:00
bot_dev2 6c6db59ba9 feat(wm-production): #69 调度指令管理完整实现
- 实体: DispatchCommand/DispatchExecution/DispatchTracking
- Mapper: MyBatis-Plus + XML (含台账分页/详情/统计)
- Service: 完整状态机 (draft→issued→received→executing→completed/rejected)
- Controller: /api/production/dispatch-command (全生命周期API)
- SQL DDL: 三表+索引
- 前端: CommandList/CommandDetail/CommandCreate (Vue3+TS+Element Plus)
- 单元测试: DispatchCommandServiceTest + DispatchTrackingServiceTest
2026-06-14 15:30:52 +08:00
bot_dev2 21fa7cffd2 feat(wm-dispatch): #69 调度指令全生命周期管理
- 增强 DispatchCommand 实体,支持全生命周期状态流转
- 新增 CommandExecutionRecord(执行记录)、CommandTracking(过程追踪)实体
- 新增 DTO: CommandCreateRequest, CommandQueryRequest, ExecutionRequest, CommandStatVO
- 新增 Mapper: CommandExecutionRecordMapper, CommandTrackingMapper, 增强 DispatchCommandMapper(分页+统计)
- 新增 Service:
  - CommandLifecycleService: DRAFT→ISSUED→RECEIVED→EXECUTING→COMPLETED/REJECTED/CANCELLED 全流程
  - CommandLedgerService: 指令台账(多维度查询/统计/导出)
  - CommandTrackingService: 全过程追踪时间线
- 新增 DispatchCommandController: 18个 RESTful 端点 (/api/dispatch/command/*)
- DDL: V2__command_lifecycle.sql(3张表 + 索引)
- 单元测试: CommandLifecycleServiceTest(13个), CommandTrackingServiceTest(4个)
- 同步修复 DispatchBizService/DispatchController 适配新实体字段
2026-06-14 15:24:37 +08:00
bot_dev2 51c292f25b feat(wm-system): #74 系统管理(角色/用户/部门/日志/字典)
- 增强 SysRole: 5级角色(level 1-5), 数据权限(dataScope), 权限配置(permissions JSON)
- 增强 SysUser: 角色关联(roleId), 最后登录记录, 分页查询+多维度筛选
- 增强 SysDepartment: 树结构(ancestors), 子部门查询, 树形构建
- 增强 SysLog: 状态/错误信息字段, 分页+多条件筛选(类型/用户/模块/时间/状态)
- 新增 DataDictionary + DataDictionaryItem: 字典CRUD+字典项CRUD, 按编码查询
- 5个专用Controller: RoleController/UserController/DepartmentController/LogController/DictionaryController
- 28个RESTful API端点于 /api/system/*
- 5个专用Service接口+实现: RoleService/UserService/DepartmentService/LogService/DictionaryService
- DDL: V2__system_enhance.sql — 新增表+索引+初始数据
- 单元测试: 5个Service测试类, 覆盖核心业务逻辑
- DTO: 请求参数校验(jakarta.validation)
- 保持SysService向后兼容
2026-06-14 15:23:34 +08:00
bot_dev2 520b4084eb chore: 添加 wm-dispatch, wm-system 模块到 parent pom 2026-06-14 13:53:15 +08:00
bot_dev2 ce1f69c9bb Merge remote-tracking branch 'origin/feature/issue-16' 2026-06-14 13:53:11 +08:00
bot_dev2 f88eca848b Merge remote-tracking branch 'origin/feature/issue-14' 2026-06-14 13:53:11 +08:00
bot_dev2 653a50f737 Merge remote-tracking branch 'origin/feature/issue-12' 2026-06-14 13:53:11 +08:00
bot_dev2 429aaedba9 Merge remote-tracking branch 'origin/feature/issue-10' 2026-06-14 13:53:11 +08:00
bot_dev2 cf9798be32 feat(wm-mobile-app): #16 移动APP三合一后端服务
- 统一登录: 供水/巡检/收费三模块身份认证
- 供水API聚合: /mobile/water/overview
- 巡检API聚合: /mobile/patrol/tasks
- 收费API聚合: /mobile/billing/summary
- 消息推送: 报警推送/待办提醒/公告通知
- 版本检查: APP更新检测
- Entities: MobileUser/PushNotification/AppVersion
- DDL: 3 张表
2026-06-14 13:53:03 +08:00
bot_dev2 c84bf79d9d feat(wm-system): #14 文档管理与系统管理
文档管理:
- 文档 CRUD / 分类管理 / 版本控制
- DocService + DocController (/system/doc/*)

系统管理:
- 角色管理(5级角色预设)
- 用户管理(新增/编辑/停用)
- 菜单管理 / 部门管理
- 日志管理(登录/操作/异常日志)
- SysService + SysController (/system/*)

DDL: 8 张表 (document/category/version/role/user/menu/department/log)
2026-06-14 13:52:57 +08:00
bot_dev2 bc2fca4e97 feat(wm-dispatch): #12 调度工作台与调度业务管理
- 值班管理: 今日值班/值班日志/交接班
- 指令台账: 创建/下发/跟踪/完成
- 工单管理: 创建/状态流转/优先级
- 调度策略: 常态化/专项应急策略配置
- 应急调度: 预案管理/应急模拟推演
- Entities: DutySchedule/DispatchCommand/WorkOrder/DutyLog/DispatchStrategy/EmergencyPlan
- DDL: 6 张表
2026-06-14 13:52:50 +08:00
bot_dev2 542c038801 feat(wm-production): #10 供水生产总览与在线监测
- DashboardService: 区域定位/进出水量/昨日供水量/设备概况
- WaterQualityService: 水质监测(原水/出厂水/末梢水)/台账
- AlertEngine: 实时报警/确认/派单
- DispatchService: 今日值班/指令下发/爆管模拟
- DataCenterService: 历史数据/报表/阈值管理
- VideoService: 视频监控汇聚
- Entities: WaterQualityRecord/FlowPressureMonitor/DeviceStatus/AlertRecord/VideoCamera
- DDL: prod_water_quality/flow_pressure/device_status/alert_record/video_camera
2026-06-14 13:52:43 +08:00
bot_dev2 c2295fba2c feat(wm-bpm-engine): #2 业务流程引擎
- 流程定义 CRUD + 版本管理 + 发布
- 流程实例启动/列表/状态跟踪
- 待办/已办任务管理(审批/驳回/转办)
- 流程模板快速创建
- 统计评估(完成率/运行中实例)
- DDL: bpm_process_definition/instance/task_item/template
- 增强 wm-bpm 模块(新增 FormTemplate/Orchestration/ProcessNode/ProcessStat/TodoTask)
2026-06-14 13:52:36 +08:00
bot_dev2 f808efdc80 feat(wm-data-engine): #4 数据汇聚引擎完整实现
DE-01 数据采集: 实时流(Kafka)+批量采集, WebSocket推送
DE-02 数据接入: RESTful API/数据库直连/文件接入/数据源管理
DE-03 数据存储: TDengine时序+PostgreSQL关系+MinIO对象
DE-04 数据集成: 全量/增量同步, 数据合并聚合, 血缘追踪

新增实体: DataSource/CollectTask/CollectRecord/StorageConfig/QualityRule/SyncTask/DataLineage
新增服务: DataCollectService/DataIngestService/DataStorageService/DataIntegrationService/DataGovernanceService
新增控制器: DataCollectController/DataIngestController/DataStorageController/DataIntegrationController/DataGovernanceController
新增配置: KafkaConfig/WebSocketConfig/MyBatisPlusConfig
新增DDL: V1__data_engine.sql (12张业务表)
新增测试: 5个Service测试类覆盖核心业务逻辑
2026-06-14 13:38:16 +08:00
bot_pm 8290b813f1 Phase 2 #10 #11 #12 #13 #14 #15: 供水生产管理平台 + 巡检管理系统
#10 总览+在线监测:
- DashboardService: 今日进出水量/设备概况/能耗药耗/实时监测列表(多维筛选)
- VideoService: 视频监控点位+AI人员闯入检测(YOLO mock)

#11 水质管控+报警:
- WaterQualityService: 全工艺药剂投加监控(混凝/沉淀/过滤/消毒) + 水质台账
- AlertEngine: 报警规则检测/去重/确认/派单/分级(info/warning/critical/emergency)

#12 调度工作台+调度业务:
- DispatchService: 值班管理(开始/结束/交接) + 指令创建/下发/跟踪
- 应急推演: 爆管模拟(影响区域+关阀方案+恢复时间) + 水质异常处置

#13 数据中心+配置:
- DataCenterService: 历史数据查看/报表生成(水量/水质/报警) + 阈值管理 + 信息发布

#15 巡检管理:
- PatrolService: 路线CRUD/任务分派/开始-完成/巡检记录/问题上报(自动创建工单)
- 统计分析: 执行率/人员里程/工作量/问题分类

ProductionController + PatrolController: 完整 REST API
2026-06-14 13:27:31 +08:00
bot_pm 4268f8df6b Phase 2 #6 #7 #8 #9: 营业收费系统完整实现
#6 营收管理平台+报装:
- RevenueBaseService: SSO登录/应用接入/运维审计
- InstallService: 预受理→工程申请→派单→进度查询→统计报表

#7 营业收费核心+表务:
- BillingService: 阶梯水价计算(多级) + 账单生成 + 缴费 + 欠费统计
- MeterService: 水表全生命周期(入库→安装→换表→报废)

#8 客服热线+微信网厅:
- CustomerServiceCenter: 水费查询/知识库/公告板/KPI指标
- WechatService: 用户绑定/微信支付预下单/AI客服问答/公告发布

#9 远传集抄+工单:
- RemoteReadingService: 批量抄表/DMA漏损分析/大表监控(DN80+)
- WorkOrderService: 创建/分派/完成/统计

Controllers: RevenueController + WechatController + MeterWorkController
2026-06-14 13:26:15 +08:00
bot_pm 919f75cf9e Phase 2 #4 #5: 数据引擎 — 汇聚/治理/服务全管道
#4 数据汇聚:
- DataCollectService: 多源数据接入入口(iot/manual/api) → Kafka路由
- Kafka Consumer: iot.raw.generic → 解析指标 → 写入TDengine时序库
- 批量接入 API (batchIngest)

#5 数据治理:
- standardize(): 水利数据对象标准字段映射(LL流量/YL压力/SW水位/ZD浊度等)
- clean(): 缺失值填充(-9999标记)/异常值检测(负值标记)
- qualityCheck(): 数据质控打分(完整性-10/异常-20)
- buildLineage(): 数据血缘关联记录
- DataController: /ingest 接入 /pipeline 标准化管道演示
2026-06-14 13:25:02 +08:00
bot_pm 28dcea5fb6 Phase 2 #1 #2: 物联网平台协议适配器 + 流程引擎
#1 物联网平台:
- ProtocolAdapter 接口: 策略模式统一协议适配(parseTelemetry/encodeCommand/authenticate)
- MqttAdapter: JSON 遥测数据解析 + 指令下发
- ModbusAdapter: RTU/TCP 帧解析 + 寄存器映射
- AdapterFactory: 自动注册协议适配器(按protocol名查找)
- DeviceShadowService: Redis 设备影子(上报/期望/差异) + TTL 24h
- OtaService: 固件升级任务创建/设备查询升级

#2 业务流程引擎:
- BpmProcessDefinition: 流程定义(BPMN XML + 表单Schema)
- BpmProcessInstance: 流程实例(发起人/当前节点/状态)
- BpmApprovalRecord: 审批记录(通过/驳回/转办/委派)
- ProcessEngine: 完整流程引擎 启动/审批/完成/待办/查询
- ProcessController: REST API 发起流程/审批/待办列表/详情
2026-06-14 13:24:38 +08:00
bot_pm 778726f943 Phase 1 #24: Flutter 移动端框架(三合一 APP 骨架)
- pubspec.yaml: dio/provider/shared_preferences/flutter_map/geolocator
- main.dart: Provider 状态管理 + 登录守卫
- AuthService: Token 管理 + Dio HTTP + SharedPreferences 持久化
- LoginPage: Material Design 登录页
- HomePage: 三合一 BottomTab 导航(供水/巡检/营收)
- 预留依赖: flutter_local_notifications/image_picker/permission_handler
2026-06-14 13:19:18 +08:00
bot_pm 0b8bad8879 Phase 1 #21 #22 #25 #26: GIS + IoT + DevOps + Notify
#21 GIS 引擎集成:
- GeoServer init 脚本(自动创建工作区/数据源)
- Leaflet 地图组件 (Vue3 MapView: 点位/弹窗/OSM底图)
- GisService: PostGIS 空间查询(附近设备/片区统计/GeoJSON)
- GisController: /nearby /device-stats /geojson API

#22 IoT 设备接入层:
- Kafka Consumer: iot.telemetry + iot.event 消费
- DeviceController: 设备列表/详情/注册/指令下发 REST API

#26 消息通知:
- NotifyService: 短信/WebSocket/APP Push/多渠道分发
- NotifyController: SMS/Push API

#25 DevOps:
- 10个微服务 Dockerfile (Eclipse Temurin JRE17)
- CI build.sh: Maven构建 + Docker镜像打包
- Frontend Nginx 反向代理配置
2026-06-14 13:18:19 +08:00
bot_pm 575b2138c1 Phase 1 #20 #23: 通用模块 + Vue3 前端框架搭建
#20 通用模块:
- MinioService: MinIO 文件上传/下载/桶管理
- @DataScope 注解: 数据权限切片
- ExcelUtils: EasyExcel 导出
- IdUtils: Snowflake ID 生成器
- BaseEntity: MyBatis-Plus 基础实体

#23 前端框架:
- Vue3 + TypeScript + Vite + Element Plus + ECharts + Pinia
- 路由系统: 登录守卫 + 动态路由
- 登录页: 渐变背景 + Axios+Sa-Token 认证
- 仪表盘: 统计卡片 + 供水趋势折线图 + 片区饼图
- MainLayout: 侧边栏菜单 + 顶栏用户下拉(退出)
- API 层: Axios 请求/响应拦截器 + Token 自动注入
- 系统管理骨架: 用户/角色/菜单/部门 列表页
2026-06-14 13:17:40 +08:00
bot_pm 9a7e0481ef Phase 1 #19: RBAC 认证授权系统 + SSO 单点登录
- Entity: SysUser/SysRole/SysMenu/SysDept (MyBatis-Plus)
- Mapper: 含自定义SQL(角色权限查询/数据范围查询/菜单按角色查询)
- Service: 登录验证(BCrypt)/Token创建(Sa-Token)/菜单树/部门树构建
- Controller: AuthController(登录/登出/用户信息/Token校验)
  + SysUserController + SysRoleController + SysMenuController + SysDeptController (CRUD)
- Config: SaToken拦截器(排除认证+Swagger) + MyBatis-Plus分页插件 + Knife4j Swagger
- 支持5级角色(admin/leader/manager/operator/tech) + 数据权限(datas_scope: ALL/DEPT/SELF)
2026-06-14 13:16:39 +08:00
bot_pm f337ebedfd Phase 1 #18: PostgreSQL + TDengine 完整 DDL 建表脚本
- 15+张核心表: sys_*(部门/用户/角色/菜单/日志/字典/通知/文件)
- iot_*: 设备模型/设备实例/设备影子/设备事件/站点(PostGIS空间坐标)
- rev_*: 用水户/水价阶梯/水表档案/水表操作日志/抄表/账单/缴费/报装
- production: alert_rule(报警规则)/alert_event(报警事件)/dispatch_command(调度)/duty(值班)/patrol(巡检)/water_quality(水质)
- TDengine: iot_telemetry超级表(6类设备子表)+事件表+小时聚合
- 种子数据: admin管理员/5个角色/6个片区/基础字典
2026-06-14 13:15:38 +08:00
bot_pm e929f65948 Phase 1 #17: Spring Cloud Alibaba 微服务框架搭建
- Maven 父工程 wm-parent (Spring Boot 3.3.5 + Cloud 2023.0.3 + Alibaba 2023.0.1)
- 12个子模块: wm-common/gateway/base/iot/data-engine/bpm/production/revenue/patrol/bi/notify/job
- wm-common: 统一响应 R<T> + 全局异常处理 + BusinessException
- wm-gateway: Spring Cloud Gateway 路由配置(7个微服务路由)
- 集成: Nacos(注册/配置) + PostgreSQL + Redis + Kafka + Sa-Token + MyBatis-Plus + Knife4j
- docker-compose.yml: 一键启动10个中间件(PG+PostGIS/TDengine/Redis/Kafka/EMQX/Nacos/ES/Kibana/MinIO/GeoServer)
2026-06-14 13:14:07 +08:00
bot_pm 57e4898e87 新增系统设计文档:技术选型/架构/数据库/接口/部署/安全/决策记录 2026-06-14 12:12:44 +08:00
bot_pm 85eca0c867 完善需求规格说明书:补全全部子系统功能需求编号与详细描述 2026-06-14 12:10:29 +08:00
bot_pm a76d818580 初始化项目:需求规格清单、工作量评估、报价 2026-06-14 12:08:11 +08:00
580 changed files with 39731 additions and 6814 deletions
+20 -288
View File
@@ -1,299 +1,31 @@
# 水务管理系统 - 数据接入层
# 智慧水务管理系统
**精河县供水工程综合管理平台**
## 项目概述
本项目是水务管理系统中的数据接入层,实现了多源数据接入、实时WebSocket推送和批量数据导入功能。
覆盖物联网接入→数据治理→生产管理→营业收费→巡检运维→移动端的全栈智慧水务解决方案。
## 功能特性
## 核心系统
### 1. REST API 数据接入
- **IoT设备数据接入**:支持实时接收传感器数据
- **手动数据录入**:支持人工录入数据
- **批量API导入**:支持通过API批量导入数据
- **数据查询接口**:提供按数据类型、时间范围等条件的数据查询
- **统计分析接口**:提供数据统计和分析功能
- **物联网平台** — 多协议设备统一接入与感知层标准
- **数据引擎** — 数据汇聚/治理/服务全生命周期
- **供水生产管理平台** — 在线监测/GIS/水质/调度/报警/数据中心
- **营业收费系统** — 营收/报装/收费/表务/客服/微信网厅/远传集抄/工单
- **巡检管理系统** — 巡检任务/路线/统计分析/巡检APP
- **移动APP** — 供水+巡检+营业收费三合一
- **大数据分析系统** — BI决策支持
- **业务流程引擎** — 跨系统流程统一编排
### 2. WebSocket 实时推送
- **实时数据推送**:传感器数据实时推送到客户端
- **连接管理**:支持多客户端连接和订阅管理
- **数据历史**:新连接客户端可以获取历史数据
- **警报推送**:支持实时警报推送
- **心跳检测**:支持连接状态监控
## 适用场景
### 3. 批量数据导入
- **多格式支持**:支持CSV、Excel、JSON格式导入
- **数据验证**:内置数据验证和错误处理
- **字段映射**:支持水利行业标准字段映射
- **单位转换**:自动进行单位转换和标准化
- **批量处理**:支持大批量数据处理
精河县及6个片区(精芒、八家户、托里、大镇阿合其、托托)供水管理。
## 技术栈
## 技术架构
- **后端框架**:FastAPI
- **WebSocket**:websockets
- **数据处理**:pandas, numpy
- **异步处理**:asyncio
- **数据验证**:pydantic
- **文件处理**:aiofiles
架构选型详见 [docs/architecture.md](docs/architecture.md)
## 项目结构
## 文档
```
water-management-system/
├── src/
│ ├── api/ # REST API模块
│ │ └── rest_api.py # 主API服务器
│ ├── websocket/ # WebSocket模块
│ │ └── websocket_server.py # WebSocket服务器
│ ├── batch/ # 批量导入模块
│ │ └── batch_import.py # 批量导入功能
│ ├── utils/ # 工具模块
│ │ └── data_utils.py # 数据处理工具
│ └── models/ # 数据模型
│ └── models.py # 数据模型定义
├── main.py # 主程序入口
├── requirements.txt # 依赖文件
├── config.json # 配置文件
└── README.md # 项目说明
```
## API 接口文档
### REST API 端点
#### 1. IoT 数据接收
```
POST /api/iot/data
Content-Type: application/json
{
"device_id": "device_001",
"data_type": "LL",
"value": 25.5,
"timestamp": "2024-01-01T12:00:00",
"location": "A区"
}
```
#### 2. 手动数据录入
```
POST /api/manual/data
Content-Type: application/json
{
"source": "manual",
"data_type": "YL",
"value": 0.8,
"timestamp": "2024-01-01T12:00:00",
"operator": "张三",
"notes": "定期数据录入"
}
```
#### 3. 批量导入
```
POST /api/batch/import
Content-Type: application/json
{
"batch_id": "batch_001",
"data_source": "system_import",
"records": [
{
"device_id": "device_002",
"data_type": "SW",
"value": 5.2,
"timestamp": "2024-01-01T12:00:00",
"location": "B区"
}
]
}
```
#### 4. 数据查询
```
GET /api/data/{data_type}?limit=100&offset=0
GET /api/data/recent?hours=24&limit=100
GET /api/stats
```
### WebSocket 连接
#### 连接端点
```
ws://localhost:8765
```
#### 消息格式
**发送消息**:
```json
{
"type": "subscribe",
"subscription": "LL" // 订阅特定类型数据,"all"订阅所有
}
```
**接收消息**:
```json
{
"type": "sensor_data",
"data_type": "LL",
"device_id": "device_001",
"value": 25.5,
"location": "A区",
"timestamp": "2024-01-01T12:00:00Z"
}
```
## 安装和运行
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 启动系统
```bash
# 普通模式
python main.py
# 演示模式(自动生成数据)
python main.py --demo
# 指定配置文件
python main.py --config custom_config.json
```
### 3. 初始化项目
```bash
python main.py --init
```
## 配置文件
创建 `config.json` 文件:
```json
{
"api": {
"host": "0.0.0.0",
"port": 8000
},
"websocket": {
"host": "0.0.0.0",
"port": 8765
},
"batch": {
"max_file_size_mb": 100,
"supported_formats": ["csv", "excel", "json"]
},
"demo_mode": true,
"logging": {
"level": "INFO",
"file": "water_management.log"
}
}
```
## 数据类型说明
支持的水利行业标准数据类型:
| 数据类型 | 描述 | 单位 |
|---------|------|------|
| LL | 流量 | m³/h |
| YL | 压力 | MPa |
| SW | 水位 | m |
| ZD | 浊度 | NTU |
| PH | pH值 | - |
| WD | 温度 | °C |
| DD | 电导率 | μS/cm |
| YD | 硬度 | mg/L |
## 开发规范
### 代码结构
- 遵循模块化设计,功能解耦
- 使用异步编程提高性能
- 统一的错误处理机制
- 完整的日志记录
### 数据处理
- 数据验证和清洗
- 标准化字段映射
- 单位自动转换
- 质量评分机制
### 安全考虑
- 输入数据验证
- 文件大小限制
- 连接状态监控
- 错误信息脱敏
## 测试和验证
### 数据验证
```python
from src.utils.data_utils import data_converter
# 验证传感器数据
validation_result = data_converter.validate_sensor_data({
"device_id": "device_001",
"data_type": "LL",
"value": 25.5,
"location": "A区"
})
print(validation_result)
```
### 批量导入测试
```python
import asyncio
from src.batch.batch_import import batch_manager
async def test_import():
result = await batch_manager.import_file(
file_path="data.csv",
batch_id="test_batch",
data_source="test"
)
print(result)
asyncio.run(test_import())
```
## 部署建议
### 1. 生产环境配置
- 使用反向代理(Nginx)
- 配置SSL证书
- 设置防火墙规则
- 监控系统资源使用
### 2. 性能优化
- 数据库连接池
- 缓存机制
- 异步处理优化
- 连接数限制
### 3. 监控和日志
- 应用性能监控
- 错误日志收集
- 性能指标统计
- 告警机制
## 许可证
本项目遵循 MIT 许可证。
## 贡献指南
欢迎提交 Issue 和 Pull Request来贡献代码。
## 联系方式
如有问题,请通过以下方式联系:
- 邮箱:bot_dev1@xayunmei.com
- 项目地址:http://git.xayunmei.com/bot_ym/water-management-system
- [需求规格清单](docs/requirements.md)
- [工作量评估](docs/estimation.md)
+89
View File
@@ -0,0 +1,89 @@
-- 药剂投加监控 DDL
-- 全工艺药剂投加监控(混凝→沉淀→过滤→消毒)
-- 1. 药剂投加记录表
CREATE TABLE IF NOT EXISTS prod_chemical_dosing (
id BIGSERIAL PRIMARY KEY,
process_stage VARCHAR(32) NOT NULL, -- 工艺段: coagulation/sedimentation/filtration/disinfection
chemical_name VARCHAR(64) NOT NULL, -- 药剂名称
chemical_code VARCHAR(32), -- 药剂编码
dosing_amount DECIMAL(12,4), -- 投加量(kg)
dosing_rate DECIMAL(10,4), -- 投加速率(kg/h)
concentration DECIMAL(10,4), -- 投加浓度(mg/L)
flow_rate DECIMAL(12,4), -- 当时流量(m³/h)
station VARCHAR(64), -- 站点/水厂
operator VARCHAR(32), -- 操作员
status VARCHAR(16) DEFAULT 'active', -- active/paused/stopped
remark VARCHAR(255),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_chemical_dosing IS '药剂投加监控记录';
COMMENT ON COLUMN prod_chemical_dosing.process_stage IS '工艺段: coagulation(混凝)/sedimentation(沉淀)/filtration(过滤)/disinfection(消毒)';
-- 2. 投加历史记录表
CREATE TABLE IF NOT EXISTS prod_dosing_record (
id BIGSERIAL PRIMARY KEY,
dosing_id BIGINT, -- 关联投加记录
process_stage VARCHAR(32) NOT NULL,
chemical_name VARCHAR(64) NOT NULL,
dosing_amount DECIMAL(12,4),
dosing_rate DECIMAL(10,4),
concentration DECIMAL(10,4),
flow_rate DECIMAL(12,4),
station VARCHAR(64),
record_time TIMESTAMP NOT NULL, -- 记录时间
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_dosing_record IS '投加历史记录(用于趋势分析)';
-- 3. 药剂库存表
CREATE TABLE IF NOT EXISTS prod_chemical_stock (
id BIGSERIAL PRIMARY KEY,
chemical_name VARCHAR(64) NOT NULL,
chemical_code VARCHAR(32),
current_stock DECIMAL(12,4) NOT NULL, -- 当前库存(kg)
max_stock DECIMAL(12,4), -- 最大库存
min_stock DECIMAL(12,4), -- 安全库存(低于此值预警)
unit VARCHAR(16) DEFAULT 'kg',
warehouse VARCHAR(64), -- 仓库位置
supplier VARCHAR(128), -- 供应商
station VARCHAR(64),
status VARCHAR(16) DEFAULT 'normal', -- normal/low/out
last_inbound TIMESTAMP, -- 最近入库时间
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_chemical_stock IS '药剂库存管理';
-- 4. 投加策略表
CREATE TABLE IF NOT EXISTS prod_dosing_strategy (
id BIGSERIAL PRIMARY KEY,
strategy_name VARCHAR(64) NOT NULL,
process_stage VARCHAR(32) NOT NULL,
chemical_name VARCHAR(64) NOT NULL,
strategy_type VARCHAR(32), -- auto/manual/semi-auto
base_dosing_rate DECIMAL(10,4), -- 基础投加速率
min_dosing_rate DECIMAL(10,4), -- 最小投加速率
max_dosing_rate DECIMAL(10,4), -- 最大投加速率
turbidity_threshold DECIMAL(10,4), -- 浊度阈值联动
flow_threshold DECIMAL(12,4), -- 流量阈值联动
ph_threshold_min DECIMAL(6,2), -- pH下限
ph_threshold_max DECIMAL(6,2), -- pH上限
formula VARCHAR(255), -- 投加公式
enabled BOOLEAN DEFAULT true,
station VARCHAR(64),
remark VARCHAR(255),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_dosing_strategy IS '自动投加策略配置(基于原水水质/流量联动)';
-- 索引
CREATE INDEX IF NOT EXISTS idx_dosing_stage ON prod_chemical_dosing(process_stage);
CREATE INDEX IF NOT EXISTS idx_dosing_station ON prod_chemical_dosing(station);
CREATE INDEX IF NOT EXISTS idx_dosing_created ON prod_chemical_dosing(created_time);
CREATE INDEX IF NOT EXISTS idx_record_stage ON prod_dosing_record(process_stage);
CREATE INDEX IF NOT EXISTS idx_record_time ON prod_dosing_record(record_time);
CREATE INDEX IF NOT EXISTS idx_stock_station ON prod_chemical_stock(station);
CREATE INDEX IF NOT EXISTS idx_strategy_stage ON prod_dosing_strategy(process_stage);
+136
View File
@@ -0,0 +1,136 @@
-- =====================================================
-- DMA分区计量与漏损分析 DDL
-- 数据库: PostgreSQL
-- =====================================================
-- DMA分区表
CREATE TABLE IF NOT EXISTS dma_zone (
id BIGSERIAL PRIMARY KEY,
zone_name VARCHAR(100) NOT NULL,
zone_code VARCHAR(50) NOT NULL UNIQUE,
parent_id BIGINT REFERENCES dma_zone(id),
zone_level INTEGER NOT NULL DEFAULT 1,
area VARCHAR(100),
area_size NUMERIC(10, 2),
population INTEGER,
pipe_length NUMERIC(10, 2),
status VARCHAR(20) DEFAULT 'active',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_zone IS 'DMA分区表';
COMMENT ON COLUMN dma_zone.zone_level IS '分区层级: 1=一级/2=二级/3=三级';
COMMENT ON COLUMN dma_zone.status IS '状态: active/inactive';
-- DMA计量表
CREATE TABLE IF NOT EXISTS dma_meter (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT REFERENCES dma_zone(id),
meter_code VARCHAR(50) NOT NULL UNIQUE,
meter_name VARCHAR(100),
meter_type VARCHAR(20) NOT NULL,
location VARCHAR(200),
longitude NUMERIC(12, 8),
latitude NUMERIC(12, 8),
caliber INTEGER,
brand VARCHAR(100),
status VARCHAR(20) DEFAULT 'online',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_meter IS 'DMA计量表';
COMMENT ON COLUMN dma_meter.meter_type IS '表计类型: inlet=进水表/outlet=出水表/boundary=边界表';
COMMENT ON COLUMN dma_meter.status IS '状态: online/offline/fault';
CREATE INDEX IF NOT EXISTS idx_meter_zone ON dma_meter(zone_id);
-- DMA流量记录表
CREATE TABLE IF NOT EXISTS dma_flow_record (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
meter_id BIGINT NOT NULL REFERENCES dma_meter(id),
instant_flow NUMERIC(12, 4),
total_flow NUMERIC(14, 4),
pressure NUMERIC(8, 4),
collect_time TIMESTAMP NOT NULL,
data_quality VARCHAR(20) DEFAULT 'good',
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_flow_record IS 'DMA流量记录表';
COMMENT ON COLUMN dma_flow_record.instant_flow IS '瞬时流量(m³/h)';
COMMENT ON COLUMN dma_flow_record.total_flow IS '累计流量(m³)';
COMMENT ON COLUMN dma_flow_record.pressure IS '压力(MPa)';
COMMENT ON COLUMN dma_flow_record.data_quality IS '数据质量: good/bad/missing';
CREATE INDEX IF NOT EXISTS idx_flow_zone_time ON dma_flow_record(zone_id, collect_time);
CREATE INDEX IF NOT EXISTS idx_flow_meter_time ON dma_flow_record(meter_id, collect_time);
-- DMA漏损分析表
CREATE TABLE IF NOT EXISTS dma_leakage_analysis (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
analysis_date DATE NOT NULL,
supply_volume NUMERIC(14, 4),
sale_volume NUMERIC(14, 4),
leakage_volume NUMERIC(14, 4),
nrw_rate NUMERIC(8, 2),
leakage_rate NUMERIC(8, 2),
mnf NUMERIC(10, 4),
mnf_time VARCHAR(20),
background_leakage NUMERIC(10, 4),
burst_leakage NUMERIC(10, 4),
alarm_level VARCHAR(20) DEFAULT 'normal',
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_leakage_analysis IS 'DMA漏损分析表';
COMMENT ON COLUMN dma_leakage_analysis.nrw_rate IS '产销差率(%)';
COMMENT ON COLUMN dma_leakage_analysis.leakage_rate IS '漏损率(%)';
COMMENT ON COLUMN dma_leakage_analysis.mnf IS '最小夜间流量(m³/h)';
COMMENT ON COLUMN dma_leakage_analysis.alarm_level IS '报警级别: normal/warning/critical';
CREATE INDEX IF NOT EXISTS idx_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date);
CREATE UNIQUE INDEX IF NOT EXISTS uk_leakage_zone_date ON dma_leakage_analysis(zone_id, analysis_date) WHERE deleted = 0;
-- 水平衡表
CREATE TABLE IF NOT EXISTS dma_water_balance (
id BIGSERIAL PRIMARY KEY,
zone_id BIGINT NOT NULL REFERENCES dma_zone(id),
period VARCHAR(20) NOT NULL,
stat_date DATE NOT NULL,
total_supply NUMERIC(14, 4),
total_sale NUMERIC(14, 4),
billing_sale NUMERIC(14, 4),
free_supply NUMERIC(14, 4),
apparent_loss NUMERIC(14, 4),
real_loss NUMERIC(14, 4),
background_loss NUMERIC(14, 4),
burst_loss NUMERIC(14, 4),
total_loss NUMERIC(14, 4),
nrw_rate NUMERIC(8, 2),
leakage_rate NUMERIC(8, 2),
remark VARCHAR(500),
deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE dma_water_balance IS '水平衡表';
COMMENT ON COLUMN dma_water_balance.period IS '统计周期: daily/monthly/yearly';
COMMENT ON COLUMN dma_water_balance.apparent_loss IS '表观漏损(m³) - 计量误差+偷水';
COMMENT ON COLUMN dma_water_balance.real_loss IS '实际漏损(m³) - 物理漏损';
CREATE INDEX IF NOT EXISTS idx_balance_zone_date ON dma_water_balance(zone_id, stat_date);
CREATE INDEX IF NOT EXISTS idx_balance_period ON dma_water_balance(period);
+151
View File
@@ -0,0 +1,151 @@
-- =====================================================
-- GIS 地图展示模块 DDL
-- 包含: 监测点位表、管网线段表、区域表
-- 数据库: PostgreSQL
-- =====================================================
-- 1. GIS 监测点位表
CREATE TABLE IF NOT EXISTS prod_gis_point (
id BIGSERIAL PRIMARY KEY,
point_code VARCHAR(50) NOT NULL UNIQUE,
point_name VARCHAR(200) NOT NULL,
point_type VARCHAR(20) NOT NULL, -- flow/pressure/level/quality/valve
area VARCHAR(100),
lng DECIMAL(12, 8) NOT NULL, -- 经度
lat DECIMAL(12, 8) NOT NULL, -- 纬度
elevation DECIMAL(8, 2), -- 海拔高度(米)
device_id BIGINT, -- 关联 prod_monitor_device.id
address VARCHAR(500),
status VARCHAR(20) DEFAULT 'online', -- online/offline/fault
properties JSONB, -- 扩展属性(JSON)
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_gis_point IS 'GIS 监测点位表';
COMMENT ON COLUMN prod_gis_point.point_type IS '点位类型: flow-流量/pressure-压力/level-液位/quality-水质/valve-阀门';
COMMENT ON COLUMN prod_gis_point.lng IS '经度';
COMMENT ON COLUMN prod_gis_point.lat IS '纬度';
COMMENT ON COLUMN prod_gis_point.device_id IS '关联设备ID(prod_monitor_device.id)';
COMMENT ON COLUMN prod_gis_point.properties IS '扩展属性(JSON,存储不同类型点位的特有属性)';
-- 索引
CREATE INDEX IF NOT EXISTS idx_gis_point_type ON prod_gis_point(point_type);
CREATE INDEX IF NOT EXISTS idx_gis_point_area ON prod_gis_point(area);
CREATE INDEX IF NOT EXISTS idx_gis_point_status ON prod_gis_point(status);
CREATE INDEX IF NOT EXISTS idx_gis_point_device_id ON prod_gis_point(device_id);
CREATE INDEX IF NOT EXISTS idx_gis_point_lng_lat ON prod_gis_point(lng, lat);
-- 2. GIS 管网线段表
CREATE TABLE IF NOT EXISTS prod_gis_pipeline (
id BIGSERIAL PRIMARY KEY,
pipeline_code VARCHAR(50) NOT NULL UNIQUE,
pipeline_name VARCHAR(200),
pipeline_type VARCHAR(30), -- supply/distribution/drainage/raw_water
material VARCHAR(30), -- ductile_iron/pvc/pe/steel
diameter DECIMAL(8, 2), -- 管径(mm)
start_lng DECIMAL(12, 8) NOT NULL, -- 起点经度
start_lat DECIMAL(12, 8) NOT NULL, -- 起点纬度
end_lng DECIMAL(12, 8) NOT NULL, -- 终点经度
end_lat DECIMAL(12, 8) NOT NULL, -- 终点纬度
length DECIMAL(10, 2), -- 长度(米)
start_node_id BIGINT, -- 起点节点ID(关联 prod_gis_point.id)
end_node_id BIGINT, -- 终点节点ID(关联 prod_gis_point.id)
area VARCHAR(100),
burial_depth DECIMAL(6, 2), -- 埋深(米)
build_year INTEGER,
status VARCHAR(20) DEFAULT 'normal', -- normal/leakage/damaged/maintenance
properties JSONB,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_gis_pipeline IS 'GIS 管网线段表';
COMMENT ON COLUMN prod_gis_pipeline.pipeline_type IS '管线类型: supply-供水/distribution-配水/drainage-排水/raw_water-原水';
COMMENT ON COLUMN prod_gis_pipeline.material IS '材质: ductile_iron-球墨铸铁/pvc/pe/steel-钢管';
COMMENT ON COLUMN prod_gis_pipeline.status IS '状态: normal-正常/leakage-渗漏/damaged-损坏/maintenance-维护中';
-- 索引
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_type ON prod_gis_pipeline(pipeline_type);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_area ON prod_gis_pipeline(area);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_status ON prod_gis_pipeline(status);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_node ON prod_gis_pipeline(start_node_id);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_node ON prod_gis_pipeline(end_node_id);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_start_coord ON prod_gis_pipeline(start_lng, start_lat);
CREATE INDEX IF NOT EXISTS idx_gis_pipeline_end_coord ON prod_gis_pipeline(end_lng, end_lat);
-- 3. GIS 区域表
CREATE TABLE IF NOT EXISTS prod_gis_area (
id BIGSERIAL PRIMARY KEY,
area_code VARCHAR(50) NOT NULL UNIQUE,
area_name VARCHAR(200) NOT NULL,
area_type VARCHAR(30), -- water_plant/supply_zone/dma/admin_district
center_lng DECIMAL(12, 8),
center_lat DECIMAL(12, 8),
area_size DECIMAL(10, 4), -- 面积(平方公里)
boundary TEXT, -- 边界 GeoJSON (Polygon/MultiPolygon)
parent_id BIGINT, -- 上级区域ID
device_count INTEGER DEFAULT 0,
online_count INTEGER DEFAULT 0,
alert_count INTEGER DEFAULT 0,
population DECIMAL(10, 4), -- 供水人口(万人)
status VARCHAR(20) DEFAULT 'active', -- active/inactive
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE prod_gis_area IS 'GIS 区域表';
COMMENT ON COLUMN prod_gis_area.area_type IS '区域类型: water_plant-水厂/supply_zone-供水片区/dma-独立计量区/admin_district-行政区';
COMMENT ON COLUMN prod_gis_area.boundary IS '区域边界(GeoJSON 格式)';
COMMENT ON COLUMN prod_gis_area.population IS '供水人口(万人)';
-- 索引
CREATE INDEX IF NOT EXISTS idx_gis_area_type ON prod_gis_area(area_type);
CREATE INDEX IF NOT EXISTS idx_gis_area_status ON prod_gis_area(status);
CREATE INDEX IF NOT EXISTS idx_gis_area_parent ON prod_gis_area(parent_id);
CREATE INDEX IF NOT EXISTS idx_gis_area_center ON prod_gis_area(center_lng, center_lat);
-- =====================================================
-- 初始数据 (示例)
-- =====================================================
-- 示例区域
INSERT INTO prod_gis_area (area_code, area_name, area_type, center_lng, center_lat, area_size, device_count, online_count, alert_count, population, status)
VALUES
('AREA-001', '一体化水厂', 'water_plant', 82.07100000, 44.84500000, 2.5, 15, 12, 1, 5.0, 'active'),
('AREA-002', '管网一区', 'supply_zone', 82.08500000, 44.85500000, 8.0, 25, 20, 3, 12.0, 'active'),
('AREA-003', '管网二区', 'supply_zone', 82.09500000, 44.86000000, 6.5, 18, 15, 2, 8.5, 'active'),
('AREA-004', 'DMA-001', 'dma', 82.08000000, 44.85000000, 1.2, 8, 7, 0, 2.0, 'active'),
('AREA-005', 'DMA-002', 'dma', 82.09000000, 44.85800000, 1.8, 10, 8, 1, 3.5, 'active')
ON CONFLICT (area_code) DO NOTHING;
-- 示例监测点位
INSERT INTO prod_gis_point (point_code, point_name, point_type, area, lng, lat, elevation, device_id, address, status)
VALUES
('GIS-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', 82.07123456, 44.84567890, 350.5, 1, '一号泵站出口', 'online'),
('GIS-FLOW-002', '二号泵站流量计', 'flow', '一体化水厂', 82.07234567, 44.84678901, 348.2, 2, '二号泵站', 'online'),
('GIS-PRES-001', '管网压力监测点A', 'pressure', '管网一区', 82.08567890, 44.85512345, 340.0, 3, '人民路与建设路交叉口', 'online'),
('GIS-PRES-002', '管网压力监测点B', 'pressure', '管网一区', 82.08678901, 44.85623456, 338.5, 4, '中山路与解放路交叉口', 'offline'),
('GIS-LEV-001', '清水池液位计', 'level', '一体化水厂', 82.07012345, 44.84456789, 355.0, NULL, '清水池', 'online'),
('GIS-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', 82.07345678, 44.84789012, 345.0, 5, '出厂水管', 'fault'),
('GIS-VALV-001', '主干管阀门V01', 'valve', '管网一区', 82.08456789, 44.85401234, 342.0, NULL, '主干管起点', 'online'),
('GIS-VALV-002', '主干管阀门V02', 'valve', '管网二区', 82.09512345, 44.86023456, 335.0, NULL, '主干管末端', 'online'),
('GIS-FLOW-003', 'DMA-001入口流量计', 'flow', 'DMA-001', 82.08045678, 44.85067890, 341.5, NULL, 'DMA-001入口', 'online'),
('GIS-PRES-003', '管网压力监测点C', 'pressure', '管网二区', 82.09623456, 44.86134567, 333.0, NULL, '建设路与和平路交叉口', 'online')
ON CONFLICT (point_code) DO NOTHING;
-- 示例管线
INSERT INTO prod_gis_pipeline (pipeline_code, pipeline_name, pipeline_type, material, diameter, start_lng, start_lat, end_lng, end_lat, length, start_node_id, end_node_id, area, burial_depth, build_year, status)
VALUES
('PIPE-001', '出厂水主干管', 'supply', 'ductile_iron', 600.00, 82.07123456, 44.84567890, 82.08456789, 44.85401234, 1500.00, 1, 7, '一体化水厂', 1.5, 2020, 'normal'),
('PIPE-002', '管网一区主干管', 'distribution', 'ductile_iron', 400.00, 82.08456789, 44.85401234, 82.08678901, 44.85623456, 800.00, 7, 4, '管网一区', 1.2, 2020, 'normal'),
('PIPE-003', '管网二区主干管', 'distribution', 'pvc', 300.00, 82.08678901, 44.85623456, 82.09512345, 44.86023456, 1200.00, 4, 8, '管网二区', 1.0, 2021, 'normal'),
('PIPE-004', 'DMA-001入口管', 'distribution', 'pe', 200.00, 82.08456789, 44.85401234, 82.08045678, 44.85067890, 500.00, 7, 9, 'DMA-001', 0.8, 2022, 'normal'),
('PIPE-005', '管网二区支管', 'distribution', 'pe', 150.00, 82.09512345, 44.86023456, 82.09623456, 44.86134567, 300.00, 8, 10, '管网二区', 0.8, 2022, 'maintenance')
ON CONFLICT (pipeline_code) DO NOTHING;
+210
View File
@@ -0,0 +1,210 @@
-- =============================================
-- 智慧水务管理系统 - PostgreSQL DDL
-- 版本: V1
-- 描述: 核心业务表建表脚本
-- =============================================
-- ==================== 系统管理 ====================
-- 部门表
CREATE TABLE IF NOT EXISTS sys_dept (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT,
dept_name VARCHAR(100) NOT NULL,
dept_type VARCHAR(20) NOT NULL DEFAULT 'water_company', -- water_bureau/water_company/ops
sort_order INT DEFAULT 0,
leader VARCHAR(50),
phone VARCHAR(20),
status SMALLINT DEFAULT 1,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_dept IS '部门表';
COMMENT ON COLUMN sys_dept.dept_type IS '部门类型: water_bureau(水利局)/water_company(水务公司)/ops(运维单位)';
-- 用户表
CREATE TABLE IF NOT EXISTS sys_user (
id BIGSERIAL PRIMARY KEY,
dept_id BIGINT REFERENCES sys_dept(id),
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
real_name VARCHAR(50),
nickname VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
avatar VARCHAR(500),
gender SMALLINT DEFAULT 0, -- 0:未知 1:男 2:女
role_type VARCHAR(30) DEFAULT 'operator', -- admin/leader/manager/operator/tech
status SMALLINT DEFAULT 1, -- 0:停用 1:启用
login_ip VARCHAR(50),
login_at TIMESTAMP,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_user IS '用户表';
COMMENT ON COLUMN sys_user.role_type IS '角色类型: admin/leader/manager/operator/tech';
-- 角色表
CREATE TABLE IF NOT EXISTS sys_role (
id BIGSERIAL PRIMARY KEY,
role_name VARCHAR(50) UNIQUE NOT NULL,
role_key VARCHAR(50) UNIQUE NOT NULL,
role_sort INT DEFAULT 0,
data_scope VARCHAR(20) DEFAULT 'SELF', -- ALL/DEPT/CUSTOM/SELF
status SMALLINT DEFAULT 1,
remark VARCHAR(500),
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_role IS '角色表';
COMMENT ON COLUMN sys_role.role_key IS '角色标识: admin/supervisor/biz_manager/field_ops/tech_maintain';
COMMENT ON COLUMN sys_role.data_scope IS '数据权限范围: ALL/DEPT/CUSTOM/SELF';
-- 菜单表
CREATE TABLE IF NOT EXISTS sys_menu (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT DEFAULT 0,
menu_name VARCHAR(50) NOT NULL,
menu_type CHAR(1) DEFAULT 'M', -- M:目录 C:菜单 F:按钮
path VARCHAR(200),
component VARCHAR(255),
perms VARCHAR(100),
icon VARCHAR(100),
sort_order INT DEFAULT 0,
visible SMALLINT DEFAULT 1,
status SMALLINT DEFAULT 1,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_menu IS '菜单表';
-- 角色-菜单关联
CREATE TABLE IF NOT EXISTS sys_role_menu (
role_id BIGINT NOT NULL REFERENCES sys_role(id),
menu_id BIGINT NOT NULL REFERENCES sys_menu(id),
PRIMARY KEY (role_id, menu_id)
);
COMMENT ON TABLE sys_role_menu IS '角色菜单关联表';
-- 用户-角色关联
CREATE TABLE IF NOT EXISTS sys_user_role (
user_id BIGINT NOT NULL REFERENCES sys_user(id),
role_id BIGINT NOT NULL REFERENCES sys_role(id),
PRIMARY KEY (user_id, role_id)
);
COMMENT ON TABLE sys_user_role IS '用户角色关联表';
-- 操作日志表
CREATE TABLE IF NOT EXISTS sys_oper_log (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
username VARCHAR(50),
module VARCHAR(50),
operation VARCHAR(50),
method VARCHAR(200),
request_method VARCHAR(10),
request_url VARCHAR(500),
request_params TEXT,
response_result TEXT,
ip VARCHAR(50),
location VARCHAR(100),
duration BIGINT,
status SMALLINT DEFAULT 1,
error_msg TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_oper_log IS '操作日志表';
-- 登录日志表
CREATE TABLE IF NOT EXISTS sys_login_log (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50),
ip VARCHAR(50),
location VARCHAR(100),
browser VARCHAR(100),
os VARCHAR(100),
status SMALLINT DEFAULT 1, -- 0:失败 1:成功
msg VARCHAR(500),
login_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_login_log IS '登录日志表';
-- ==================== 数据字典 ====================
CREATE TABLE IF NOT EXISTS sys_dict_type (
id BIGSERIAL PRIMARY KEY,
dict_name VARCHAR(100) NOT NULL,
dict_key VARCHAR(100) UNIQUE NOT NULL,
status SMALLINT DEFAULT 1,
remark VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_dict_type IS '字典类型表';
CREATE TABLE IF NOT EXISTS sys_dict_data (
id BIGSERIAL PRIMARY KEY,
dict_type_id BIGINT REFERENCES sys_dict_type(id),
dict_label VARCHAR(100) NOT NULL,
dict_value VARCHAR(100) NOT NULL,
css_class VARCHAR(100),
list_class VARCHAR(100),
sort_order INT DEFAULT 0,
status SMALLINT DEFAULT 1,
remark VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_dict_data IS '字典数据表';
-- ==================== 通知方案 ====================
CREATE TABLE IF NOT EXISTS sys_notify_scheme (
id BIGSERIAL PRIMARY KEY,
scheme_name VARCHAR(100) NOT NULL,
channels VARCHAR(200), -- sms,app_push,websocket,wechat (逗号分隔)
template_id VARCHAR(50),
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_notify_scheme IS '通知方案表';
CREATE TABLE IF NOT EXISTS sys_notify_record (
id BIGSERIAL PRIMARY KEY,
scheme_id BIGINT REFERENCES sys_notify_scheme(id),
target_user_id BIGINT,
target_phone VARCHAR(20),
channel VARCHAR(20),
title VARCHAR(200),
content TEXT,
status VARCHAR(20) DEFAULT 'pending', -- pending/sent/failed/read
sent_at TIMESTAMP,
read_at TIMESTAMP,
error_msg VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_notify_record IS '通知记录表';
-- ==================== 文件管理 ====================
CREATE TABLE IF NOT EXISTS sys_file (
id BIGSERIAL PRIMARY KEY,
file_name VARCHAR(200) NOT NULL,
original_name VARCHAR(200),
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
mime_type VARCHAR(100),
file_ext VARCHAR(20),
storage_type VARCHAR(20) DEFAULT 'minio',
bucket VARCHAR(100) DEFAULT 'water-management',
module VARCHAR(50),
biz_id BIGINT,
upload_by BIGINT,
deleted SMALLINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE sys_file IS '文件管理表';
+101
View File
@@ -0,0 +1,101 @@
-- =============================================
-- 智慧水务管理系统 - 物联网设备相关 DDL
-- 版本: V1
-- =============================================
-- 扩展
CREATE EXTENSION IF NOT EXISTS postgis;
-- 设备模型定义
CREATE TABLE IF NOT EXISTS iot_device_model (
id BIGSERIAL PRIMARY KEY,
model_key VARCHAR(50) UNIQUE NOT NULL,
model_name VARCHAR(100) NOT NULL,
vendor VARCHAR(100),
protocol VARCHAR(20) NOT NULL, -- MQTT/Modbus/CoAP/HTTP
properties JSONB, -- [{key, name, unit, data_type, range_min, range_max}]
commands JSONB, -- [{key, name, params: [{key, name, type}]}]
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_device_model IS '设备模型定义表';
COMMENT ON COLUMN iot_device_model.properties IS '属性定义JSON数组';
COMMENT ON COLUMN iot_device_model.commands IS '支持指令JSON数组';
-- 设备实例
CREATE TABLE IF NOT EXISTS iot_device (
id BIGSERIAL PRIMARY KEY,
device_sn VARCHAR(100) UNIQUE NOT NULL,
device_name VARCHAR(200) NOT NULL,
model_id BIGINT REFERENCES iot_device_model(id),
device_type VARCHAR(30) NOT NULL, -- flow_meter/pressure_sensor/valve/water_quality/level_sensor/pump
position_type VARCHAR(20), -- water_plant/pressure_station/pipe_network/village
label VARCHAR(50), -- 设备标签(便于搜索)
loc_lng DOUBLE PRECISION,
loc_lat DOUBLE PRECISION,
geom GEOMETRY(Point, 4326),
area VARCHAR(50) NOT NULL, -- 所属片区
station_id BIGINT, -- 所属站点
address VARCHAR(300),
install_date DATE,
warranty_until DATE,
status VARCHAR(20) DEFAULT 'offline', -- online/offline/maintenance/fault
firmware_version VARCHAR(20),
last_report_time TIMESTAMP,
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_device IS '设备实例表';
COMMENT ON COLUMN iot_device.geom IS 'PostGIS空间坐标点';
COMMENT ON COLUMN iot_device.metadata IS '扩展元数据';
-- 空间索引
CREATE INDEX IF NOT EXISTS idx_iot_device_geom ON iot_device USING GIST(geom);
CREATE INDEX IF NOT EXISTS idx_iot_device_area ON iot_device(area);
CREATE INDEX IF NOT EXISTS idx_iot_device_type ON iot_device(device_type);
CREATE INDEX IF NOT EXISTS idx_iot_device_status ON iot_device(status);
-- 设备影子
CREATE TABLE IF NOT EXISTS iot_device_shadow (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT UNIQUE REFERENCES iot_device(id),
reported_state JSONB, -- 设备上报的最新状态
desired_state JSONB, -- 期望状态(云端下发)
delta_state JSONB, -- 差异状态
version BIGINT DEFAULT 1,
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_device_shadow IS '设备影子表';
-- 设备事件
CREATE TABLE IF NOT EXISTS iot_device_event (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT REFERENCES iot_device(id),
device_sn VARCHAR(100) NOT NULL,
event_type VARCHAR(30) NOT NULL, -- online/offline/fault/recovery/ota
event_data JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_device_event IS '设备事件表';
CREATE INDEX IF NOT EXISTS idx_iot_device_event_sn ON iot_device_event(device_sn, created_at DESC);
-- 站点管理(水厂/调压站等)
CREATE TABLE IF NOT EXISTS iot_station (
id BIGSERIAL PRIMARY KEY,
station_name VARCHAR(100) NOT NULL,
station_type VARCHAR(30) NOT NULL, -- water_plant/pressure_station/pump_station
area VARCHAR(50) NOT NULL,
loc_lng DOUBLE PRECISION,
loc_lat DOUBLE PRECISION,
geom GEOMETRY(Point, 4326),
address VARCHAR(300),
manager VARCHAR(50),
manager_phone VARCHAR(20),
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE iot_station IS '站点管理表';
CREATE INDEX IF NOT EXISTS idx_iot_station_geom ON iot_station USING GIST(geom);
+189
View File
@@ -0,0 +1,189 @@
-- =============================================
-- 智慧水务管理系统 - 供水生产 + 巡检 + 报警 DDL
-- 版本: V1
-- =============================================
-- ==================== 报警规则 ====================
CREATE TABLE IF NOT EXISTS alert_rule (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL,
device_type VARCHAR(30),
metric_key VARCHAR(50) NOT NULL,
alert_level VARCHAR(10) NOT NULL, -- info/warning/critical/emergency
condition_expr VARCHAR(200) NOT NULL, -- "> 0.5" 或 "ABS_DELTA > 10"
threshold_value DECIMAL(12,4),
debounce_sec INT DEFAULT 300, -- 去重窗口(秒)
notify_scheme_id BIGINT REFERENCES sys_notify_scheme(id),
enabled SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE alert_rule IS '报警规则表';
COMMENT ON COLUMN alert_rule.alert_level IS '报警等级: info/warning/critical/emergency';
CREATE TABLE IF NOT EXISTS alert_event (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES alert_rule(id),
device_id BIGINT,
device_sn VARCHAR(100) NOT NULL,
device_name VARCHAR(200),
area VARCHAR(50),
metric_key VARCHAR(50) NOT NULL,
metric_value DECIMAL(12,4),
threshold_value VARCHAR(50),
alert_level VARCHAR(10) NOT NULL,
title VARCHAR(200),
message TEXT,
confirmed_by BIGINT,
confirmed_at TIMESTAMP,
dispatched SMALLINT DEFAULT 0, -- 是否已派单
resolved_by BIGINT,
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE alert_event IS '报警事件表';
CREATE INDEX IF NOT EXISTS idx_alert_event_time ON alert_event(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_alert_event_device ON alert_event(device_sn, created_at DESC);
-- ==================== 调度指令 ====================
CREATE TABLE IF NOT EXISTS dispatch_command (
id BIGSERIAL PRIMARY KEY,
command_no VARCHAR(30) UNIQUE NOT NULL,
command_type VARCHAR(30) NOT NULL, -- routine/emergency
command_title VARCHAR(200) NOT NULL,
command_content TEXT NOT NULL,
source VARCHAR(50), -- 来源: 平台/SCADA/上级
issuer_id BIGINT,
issuer_name VARCHAR(50),
target_type VARCHAR(30), -- station/device_area/person
target_ids JSONB, -- 目标站点/设备ID数组
priority VARCHAR(10) DEFAULT 'normal', -- low/normal/high/urgent
status VARCHAR(20) DEFAULT 'draft', -- draft/issued/received/executing/completed/rejected
issued_at TIMESTAMP,
received_at TIMESTAMP,
completed_at TIMESTAMP,
result_remark TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE dispatch_command IS '调度指令表';
CREATE TABLE IF NOT EXISTS dispatch_log (
id BIGSERIAL PRIMARY KEY,
command_id BIGINT REFERENCES dispatch_command(id),
action VARCHAR(20) NOT NULL, -- issue/receive/execute/complete/reject
operator_id BIGINT,
operator_name VARCHAR(50),
remark TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE dispatch_log IS '调度指令操作日志';
-- ==================== 值班管理 ====================
CREATE TABLE IF NOT EXISTS duty_schedule (
id BIGSERIAL PRIMARY KEY,
schedule_name VARCHAR(100),
dept_id BIGINT REFERENCES sys_dept(id),
station_id BIGINT,
shift_type VARCHAR(20), -- day/night/full
start_time TIME NOT NULL,
end_time TIME NOT NULL,
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE duty_schedule IS '值班班次表';
CREATE TABLE IF NOT EXISTS duty_record (
id BIGSERIAL PRIMARY KEY,
schedule_id BIGINT REFERENCES duty_schedule(id),
duty_date DATE NOT NULL,
user_id BIGINT REFERENCES sys_user(id),
shift_type VARCHAR(20),
on_duty_at TIMESTAMP,
off_duty_at TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending', -- pending/on_duty/off_duty
handover_remark TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE duty_record IS '值班记录表';
-- ==================== 巡检管理 ====================
CREATE TABLE IF NOT EXISTS patrol_route (
id BIGSERIAL PRIMARY KEY,
route_name VARCHAR(100) NOT NULL,
area VARCHAR(50) NOT NULL,
route_points JSONB, -- [{seq, lng, lat, name, device_ids}]
estim_duration INT, -- 预计时长(分钟)
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_route IS '巡检路线表';
CREATE TABLE IF NOT EXISTS patrol_task (
id BIGSERIAL PRIMARY KEY,
route_id BIGINT REFERENCES patrol_route(id),
assignee_id BIGINT REFERENCES sys_user(id),
task_name VARCHAR(200),
task_date DATE NOT NULL,
plan_start TIMESTAMP,
plan_end TIMESTAMP,
actual_start TIMESTAMP,
actual_end TIMESTAMP,
status VARCHAR(20) DEFAULT 'pending', -- pending/in_progress/completed/expired/cancelled
distance DECIMAL(8,2), -- 实际巡检距离(km)
score INT, -- 评分
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_task IS '巡检任务表';
CREATE INDEX IF NOT EXISTS idx_patrol_task_date ON patrol_task(task_date);
CREATE TABLE IF NOT EXISTS patrol_record (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT REFERENCES patrol_task(id),
point_seq INT,
device_id BIGINT,
device_name VARCHAR(200),
check_items JSONB, -- [{item, result, value, photo_urls, remark}]
gps_lng DOUBLE PRECISION,
gps_lat DOUBLE PRECISION,
geom GEOMETRY(Point, 4326),
record_time TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE patrol_record IS '巡检记录表';
CREATE INDEX IF NOT EXISTS idx_patrol_record_geom ON patrol_record USING GIST(geom);
-- ==================== 水质检测 ====================
CREATE TABLE IF NOT EXISTS water_quality_record (
id BIGSERIAL PRIMARY KEY,
test_type VARCHAR(20) NOT NULL, -- manual/auto
test_point VARCHAR(100) NOT NULL, -- 检测点位名称
point_type VARCHAR(20), -- source_water/plant_out/pipe_network/terminal
area VARCHAR(50),
station_id BIGINT,
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
test_date DATE NOT NULL,
test_time TIME,
tester VARCHAR(50),
turbidity DECIMAL(8,2), -- 浊度(NTU)
ph DECIMAL(5,2),
residual_chlorine DECIMAL(8,2), -- 余氯(mg/L)
total_chlorine DECIMAL(8,2),
cod DECIMAL(8,2), -- 化学需氧量(mg/L)
ammonia DECIMAL(8,2), -- 氨氮(mg/L)
total_bacteria INT, -- 总大肠菌群(CFU/100mL)
extra_data JSONB, -- 扩展指标
is_qualified SMALLINT,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE water_quality_record IS '水质检测记录表';
CREATE INDEX IF NOT EXISTS idx_wq_record_date ON water_quality_record(test_date);
CREATE INDEX IF NOT EXISTS idx_wq_record_area ON water_quality_record(area);
+164
View File
@@ -0,0 +1,164 @@
-- =============================================
-- 智慧水务管理系统 - 营业收费相关 DDL
-- 版本: V1
-- =============================================
-- 用水户
CREATE TABLE IF NOT EXISTS rev_customer (
id BIGSERIAL PRIMARY KEY,
customer_no VARCHAR(30) UNIQUE NOT NULL,
customer_name VARCHAR(100) NOT NULL,
customer_type VARCHAR(20) NOT NULL, -- residential/business/enterprise/institution
area VARCHAR(50) NOT NULL,
address VARCHAR(300),
phone VARCHAR(20),
id_card VARCHAR(18),
contract_no VARCHAR(50),
open_date DATE,
meter_count INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'active', -- active/suspended/closed
remark VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_customer IS '用水户表';
-- 水价阶梯
CREATE TABLE IF NOT EXISTS rev_water_price (
id BIGSERIAL PRIMARY KEY,
customer_type VARCHAR(20) NOT NULL, -- residential/business/enterprise/institution
tier_no INT NOT NULL, -- 第几阶梯
range_start DECIMAL(12,2) DEFAULT 0, -- 起始水量(立方米)
range_end DECIMAL(12,2), -- 结束水量(null=无上限)
water_price DECIMAL(10,4) NOT NULL, -- 水价(元/立方米)
sewage_price DECIMAL(10,4) DEFAULT 0, -- 污水处理费
effective_date DATE NOT NULL,
status SMALLINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_water_price IS '水价阶梯配置表';
-- 水表档案
CREATE TABLE IF NOT EXISTS rev_meter (
id BIGSERIAL PRIMARY KEY,
meter_no VARCHAR(50) UNIQUE NOT NULL,
customer_id BIGINT REFERENCES rev_customer(id),
device_id BIGINT, -- 关联 IoT 设备
caliber VARCHAR(10), -- DN15/DN20/DN40/DN80+
meter_type VARCHAR(20), -- mechanical/ultrasonic/electromagnetic
manufacturer VARCHAR(100),
max_reading DECIMAL(10,2) DEFAULT 99999,
initial_reading DECIMAL(10,2) DEFAULT 0,
current_reading DECIMAL(10,2) DEFAULT 0,
install_date DATE,
install_address VARCHAR(300),
status VARCHAR(20) DEFAULT 'active', -- active/dismantled/scrapped/repaired/warehouse
remark VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_meter IS '水表档案表';
-- 水表操作记录(全生命周期)
CREATE TABLE IF NOT EXISTS rev_meter_log (
id BIGSERIAL PRIMARY KEY,
meter_id BIGINT REFERENCES rev_meter(id),
operation_type VARCHAR(30) NOT NULL, -- install/dismantle/repair/change/scrap/calibrate/refurbish
old_reading DECIMAL(10,2),
new_reading DECIMAL(10,2),
new_meter_no VARCHAR(50),
operator_id BIGINT,
operator_name VARCHAR(50),
photos JSONB, -- 现场照片URL数组
remark VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_meter_log IS '水表操作记录表(全生命周期)';
-- 抄表记录
CREATE TABLE IF NOT EXISTS rev_reading (
id BIGSERIAL PRIMARY KEY,
meter_id BIGINT REFERENCES rev_meter(id),
reading_date DATE NOT NULL,
reading_period VARCHAR(10), -- 2026-06
prev_reading DECIMAL(10,2),
curr_reading DECIMAL(10,2),
consumption DECIMAL(10,2),
read_type VARCHAR(20) DEFAULT 'manual', -- manual/remote/estimate
reader_id BIGINT,
reader_name VARCHAR(50),
photo_urls JSONB, -- 拍照图片URL数组
abnormal_flag SMALLINT DEFAULT 0, -- 0:正常 1:异常
verified SMALLINT DEFAULT 0, -- 0:未审核 1:已审核
verified_by BIGINT,
verified_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_reading IS '抄表记录表';
CREATE INDEX IF NOT EXISTS idx_reading_period ON rev_reading(reading_period);
-- 水费账单
CREATE TABLE IF NOT EXISTS rev_bill (
id BIGSERIAL PRIMARY KEY,
bill_no VARCHAR(30) UNIQUE NOT NULL,
customer_id BIGINT REFERENCES rev_customer(id),
meter_id BIGINT REFERENCES rev_meter(id),
reading_id BIGINT REFERENCES rev_reading(id),
bill_period VARCHAR(10) NOT NULL, -- 2026-06
prev_reading DECIMAL(10,2),
curr_reading DECIMAL(10,2),
consumption DECIMAL(10,2),
water_fee DECIMAL(10,2),
sewage_fee DECIMAL(10,2),
other_fee DECIMAL(10,2) DEFAULT 0, -- 污水处理费/垃圾处理费等
total_fee DECIMAL(10,2),
paid_fee DECIMAL(10,2) DEFAULT 0,
discount_fee DECIMAL(10,2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending', -- pending/partial/paid/overdue/cancelled
due_date DATE,
paid_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_bill IS '水费账单表';
CREATE INDEX IF NOT EXISTS idx_bill_customer ON rev_bill(customer_id, bill_period);
CREATE INDEX IF NOT EXISTS idx_bill_status ON rev_bill(status);
-- 缴费记录
CREATE TABLE IF NOT EXISTS rev_payment (
id BIGSERIAL PRIMARY KEY,
bill_id BIGINT REFERENCES rev_bill(id),
customer_id BIGINT REFERENCES rev_customer(id),
payment_no VARCHAR(50) UNIQUE NOT NULL,
amount DECIMAL(10,2) NOT NULL,
pay_method VARCHAR(20), -- wechat/alipay/cash/bank_transfer/pos
pay_channel VARCHAR(30), -- counter/app/wechat_mini/third_party
transaction_id VARCHAR(100),
operator_id BIGINT,
remark VARCHAR(500),
paid_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_payment IS '缴费记录表';
-- 报装申请
CREATE TABLE IF NOT EXISTS rev_install (
id BIGSERIAL PRIMARY KEY,
application_no VARCHAR(30) UNIQUE NOT NULL,
applicant_name VARCHAR(50) NOT NULL,
applicant_phone VARCHAR(20) NOT NULL,
applicant_id_card VARCHAR(18),
area VARCHAR(50),
address VARCHAR(300),
customer_type VARCHAR(20), -- residential/business/enterprise
caliber VARCHAR(10), -- 申请管径
purpose VARCHAR(200),
status VARCHAR(20) DEFAULT 'pre_apply', -- pre_apply/engineering/pending_review/approved/rejected/completed
survey_date DATE,
survey_result TEXT,
approved_by BIGINT,
approved_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE rev_install IS '报装申请表';
+122
View File
@@ -0,0 +1,122 @@
-- =============================================
-- 智慧水务管理系统 - 报警规则引擎 + 报警管理中心 DDL
-- 版本: V2
-- =============================================
-- ==================== 报警规则定义 ====================
CREATE TABLE IF NOT EXISTS prod_alert_rule (
id BIGSERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL,
rule_code VARCHAR(50) UNIQUE,
description TEXT,
device_type VARCHAR(30),
metric_key VARCHAR(50) NOT NULL,
alert_level VARCHAR(10) NOT NULL DEFAULT 'general', -- general/important/urgent
condition_expr TEXT NOT NULL, -- JSON: {"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8},...]}
threshold_value DECIMAL(12,4), -- 简单阈值(向后兼容)
debounce_sec INT DEFAULT 300,
notify_channels VARCHAR(200), -- 逗号分隔: sms,wechat,app,email
notify_template VARCHAR(500), -- 通知模板
enabled SMALLINT DEFAULT 1,
priority INT DEFAULT 0, -- 规则优先级
effective_start TIME, -- 生效开始时间
effective_end TIME, -- 生效结束时间
created_by BIGINT,
updated_by BIGINT,
deleted SMALLINT DEFAULT 0,
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_rule IS '报警规则定义表';
COMMENT ON COLUMN prod_alert_rule.alert_level IS '报警等级: general(一般)/important(重要)/urgent(紧急)';
COMMENT ON COLUMN prod_alert_rule.condition_expr IS '条件表达式JSON: 支持AND/OR组合条件';
-- ==================== 报警记录(全生命周期) ====================
CREATE TABLE IF NOT EXISTS prod_alert_record (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES prod_alert_rule(id),
rule_name VARCHAR(100),
device_id BIGINT,
device_sn VARCHAR(100),
device_name VARCHAR(200),
area VARCHAR(50),
metric_key VARCHAR(50) NOT NULL,
metric_value DECIMAL(12,4),
threshold_value VARCHAR(50),
alert_level VARCHAR(10) NOT NULL DEFAULT 'general',
title VARCHAR(200),
message TEXT,
-- 生命周期状态: 0=活跃 1=已确认 2=已派单 3=处理中 4=已处理 5=已归档
status INT DEFAULT 0,
confirmed_by BIGINT,
confirmed_time TIMESTAMP,
dispatch_time TIMESTAMP,
assignee_id BIGINT,
assignee_name VARCHAR(50),
handler_id BIGINT,
handler_name VARCHAR(50),
handle_result TEXT,
handle_time TIMESTAMP,
archive_time TIMESTAMP,
archive_reason VARCHAR(500),
resolved_at TIMESTAMP,
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted SMALLINT DEFAULT 0
);
COMMENT ON TABLE prod_alert_record IS '报警记录表(全生命周期)';
CREATE INDEX IF NOT EXISTS idx_alert_record_time ON prod_alert_record(created_time DESC);
CREATE INDEX IF NOT EXISTS idx_alert_record_device ON prod_alert_record(device_sn, created_time DESC);
CREATE INDEX IF NOT EXISTS idx_alert_record_status ON prod_alert_record(status);
CREATE INDEX IF NOT EXISTS idx_alert_record_level ON prod_alert_record(alert_level);
CREATE INDEX IF NOT EXISTS idx_alert_record_area ON prod_alert_record(area);
-- ==================== 报警通知记录 ====================
CREATE TABLE IF NOT EXISTS prod_alert_notification (
id BIGSERIAL PRIMARY KEY,
alert_record_id BIGINT REFERENCES prod_alert_record(id),
rule_id BIGINT,
channel VARCHAR(30) NOT NULL, -- sms/wechat/app/email
recipient VARCHAR(100) NOT NULL, -- 接收人标识
recipient_name VARCHAR(50),
title VARCHAR(200),
content TEXT,
status INT DEFAULT 0, -- 0=待发送 1=已发送 2=发送失败 3=已读
send_time TIMESTAMP,
read_time TIMESTAMP,
retry_count INT DEFAULT 0,
error_msg VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_notification IS '报警通知记录表';
CREATE INDEX IF NOT EXISTS idx_alert_notif_record ON prod_alert_notification(alert_record_id);
CREATE INDEX IF NOT EXISTS idx_alert_notif_status ON prod_alert_notification(status);
-- ==================== 报警规则-设备关联(可选) ====================
CREATE TABLE IF NOT EXISTS prod_alert_rule_device (
id BIGSERIAL PRIMARY KEY,
rule_id BIGINT REFERENCES prod_alert_rule(id),
device_id BIGINT,
device_sn VARCHAR(100),
area VARCHAR(50),
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_alert_rule_device IS '报警规则-设备/区域关联表';
-- ==================== 初始规则数据 ====================
INSERT INTO prod_alert_rule (rule_name, rule_code, metric_key, alert_level, condition_expr, threshold_value, debounce_sec, description, enabled) VALUES
('管网压力过高报警', 'RULE_PRESSURE_HIGH', 'pressure', 'urgent',
'{"op":"AND","conditions":[{"metric":"pressure","operator":">","threshold":0.8}]}',
0.8000, 300, '管网压力超过0.8MPa时触发紧急报警', 1),
('管网压力过低报警', 'RULE_PRESSURE_LOW', 'pressure', 'important',
'{"op":"OR","conditions":[{"metric":"pressure","operator":"<","threshold":0.2}]}',
0.2000, 300, '管网压力低于0.2MPa时触发重要报警', 1),
('水质浊度超标', 'RULE_TURBIDITY_HIGH', 'turbidity', 'urgent',
'{"op":"AND","conditions":[{"metric":"turbidity","operator":">","threshold":1.0}]}',
1.0000, 600, '水质浊度超过1.0NTU触发紧急报警', 1),
('余氯偏低报警', 'RULE_CHLORINE_LOW', 'residual_chlorine', 'general',
'{"op":"AND","conditions":[{"metric":"residual_chlorine","operator":"<","threshold":0.1}]}',
0.1000, 600, '余氯低于0.1mg/L触发一般报警', 1),
('流量异常波动', 'RULE_FLOW_ANOMALY', 'flow', 'important',
'{"op":"OR","conditions":[{"metric":"flow","operator":">","threshold":100},{"metric":"flow","operator":"<","threshold":5}]}',
NULL, 120, '流量异常偏高或偏低时触发报警', 1);
+90
View File
@@ -0,0 +1,90 @@
-- =============================================
-- 智慧水务管理系统 - 在线监测列表 DDL
-- 版本: V3
-- 功能: 在线监测设备 + 实时数据 + 多维筛选
-- =============================================
-- ==================== 在线监测设备 ====================
CREATE TABLE IF NOT EXISTS prod_monitor_device (
id BIGSERIAL PRIMARY KEY,
device_code VARCHAR(50) NOT NULL UNIQUE, -- 设备编号
device_name VARCHAR(200) NOT NULL, -- 设备名称
device_type VARCHAR(30) NOT NULL, -- 设备类型: flow/pressure/level/quality
area VARCHAR(50) NOT NULL, -- 所属区域
location VARCHAR(300), -- 安装位置描述
lng DECIMAL(12, 8), -- 经度
lat DECIMAL(12, 8), -- 纬度
status VARCHAR(20) NOT NULL DEFAULT 'offline', -- 设备状态: online/offline/fault/abnormal
last_report_time TIMESTAMP, -- 最后上报时间
brand VARCHAR(100), -- 品牌/型号
install_time TIMESTAMP, -- 安装时间
remark VARCHAR(500), -- 备注
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_monitor_device IS '在线监测设备表';
COMMENT ON COLUMN prod_monitor_device.device_type IS '设备类型: flow(流量计)/pressure(压力计)/level(液位计)/quality(水质仪)';
COMMENT ON COLUMN prod_monitor_device.status IS '设备状态: online(在线)/offline(离线)/fault(故障)/abnormal(数据异常)';
CREATE INDEX IF NOT EXISTS idx_monitor_device_area ON prod_monitor_device(area);
CREATE INDEX IF NOT EXISTS idx_monitor_device_type ON prod_monitor_device(device_type);
CREATE INDEX IF NOT EXISTS idx_monitor_device_status ON prod_monitor_device(status);
CREATE INDEX IF NOT EXISTS idx_monitor_device_report ON prod_monitor_device(last_report_time DESC);
CREATE INDEX IF NOT EXISTS idx_monitor_device_code_name ON prod_monitor_device(device_code, device_name);
-- ==================== 监测实时数据 ====================
CREATE TABLE IF NOT EXISTS prod_monitor_realtime_data (
id BIGSERIAL PRIMARY KEY,
device_id BIGINT NOT NULL REFERENCES prod_monitor_device(id), -- 关联设备
device_code VARCHAR(50) NOT NULL, -- 设备编号(冗余)
metric_key VARCHAR(50) NOT NULL, -- 参数类型: flow/pressure/level/turbidity/ph/residual_chlorine/temperature
metric_value DECIMAL(14, 4) NOT NULL, -- 实时值
unit VARCHAR(20), -- 单位
threshold_high DECIMAL(14, 4), -- 阈值上限
threshold_low DECIMAL(14, 4), -- 阈值下限
is_abnormal SMALLINT DEFAULT 0, -- 是否异常: 0正常 1异常
collect_time TIMESTAMP NOT NULL, -- 采集时间
created_time TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE prod_monitor_realtime_data IS '监测实时数据表';
COMMENT ON COLUMN prod_monitor_realtime_data.metric_key IS '参数类型: flow(流量)/pressure(压力)/level(液位)/turbidity(浊度)/ph(pH值)/residual_chlorine(余氯)/temperature(温度)';
CREATE INDEX IF NOT EXISTS idx_realtime_device_id ON prod_monitor_realtime_data(device_id);
CREATE INDEX IF NOT EXISTS idx_realtime_device_code ON prod_monitor_realtime_data(device_code);
CREATE INDEX IF NOT EXISTS idx_realtime_metric_key ON prod_monitor_realtime_data(metric_key);
CREATE INDEX IF NOT EXISTS idx_realtime_collect_time ON prod_monitor_realtime_data(collect_time DESC);
CREATE INDEX IF NOT EXISTS idx_realtime_device_metric ON prod_monitor_realtime_data(device_id, metric_key, collect_time DESC);
-- ==================== 初始化数据(示例) ====================
INSERT INTO prod_monitor_device (device_code, device_name, device_type, area, location, lng, lat, status, last_report_time, brand)
VALUES
('MON-FLOW-001', '一号泵站出口流量计', 'flow', '一体化水厂', '一号泵站出口', 82.07123456, 44.84567890, 'online', NOW(), 'E+H Promag 50'),
('MON-FLOW-002', '二号泵站出口流量计', 'flow', '一体化水厂', '二号泵站出口', 82.07234567, 44.84678901, 'online', NOW(), 'E+H Promag 50'),
('MON-PRES-001', '管网压力监测点A', 'pressure', '管网一区', '人民路DN300', 82.06890123, 44.84234567, 'online', NOW(), 'WIKA S-20'),
('MON-PRES-002', '管网压力监测点B', 'pressure', '管网一区', '建设路DN200', 82.06901234, 44.84345678, 'offline', NOW() - INTERVAL '2 hours', 'WIKA S-20'),
('MON-LEV-001', '清水池液位计', 'level', '一体化水厂', '清水池', 82.07156789, 44.84501234, 'online', NOW(), 'VEGA VEGAPULS 64'),
('MON-LEV-002', '沉淀池液位计', 'level', '一体化水厂', '沉淀池', 82.07167890, 44.84512345, 'fault', NOW() - INTERVAL '30 minutes', 'VEGA VEGAPULS 64'),
('MON-QUAL-001', '出厂水质监测仪', 'quality', '一体化水厂', '出厂水口', 82.07178901, 44.84523456, 'online', NOW(), 'HACH sc200'),
('MON-QUAL-002', '管网末梢水质仪', 'quality', '管网二区', '末梢检测点', 82.06543210, 44.83987654, 'abnormal',NOW(), 'HACH sc200'),
('MON-FLOW-003', '三号泵站流量计', 'flow', '管网二区', '三号泵站', 82.06654321, 44.84098765, 'online', NOW(), 'E+H Promag 10'),
('MON-PRES-003', '高位水池压力计', 'pressure', '管网三区', '高位水池出口', 82.07345678, 44.84789012, 'online', NOW(), 'WIKA S-20')
ON CONFLICT (device_code) DO NOTHING;
INSERT INTO prod_monitor_realtime_data (device_id, device_code, metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal, collect_time)
SELECT d.id, d.device_code, m.metric_key, m.metric_value, m.unit, m.threshold_high, m.threshold_low, m.is_abnormal, NOW()
FROM prod_monitor_device d
CROSS JOIN (VALUES
('flow', 125.50, 'm³/h', 200.0, 10.0, 0),
('pressure', 0.35, 'MPa', 0.6, 0.15, 0),
('level', 3.80, 'm', 5.0, 0.5, 0),
('turbidity', 0.45, 'NTU', 1.0, NULL, 0),
('ph', 7.20, '', 8.5, 6.5, 0),
('residual_chlorine', 0.35, 'mg/L', 0.8, 0.05, 0)
) AS m(metric_key, metric_value, unit, threshold_high, threshold_low, is_abnormal)
WHERE d.status = 'online'
ON CONFLICT DO NOTHING;
+122
View File
@@ -0,0 +1,122 @@
-- =============================================
-- 智慧水务管理系统 - 视频监控集成 + AI人员闯入检测 DDL
-- 版本: V3
-- =============================================
-- ==================== 视频监控摄像头 ====================
CREATE TABLE IF NOT EXISTS prod_video_camera (
id BIGSERIAL PRIMARY KEY,
camera_id VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
area VARCHAR(50),
stream_url_rtsp VARCHAR(500),
stream_url_hls VARCHAR(500),
stream_url_flv VARCHAR(500),
status INTEGER DEFAULT 0,
manufacturer VARCHAR(50),
model VARCHAR(50),
lng DOUBLE PRECISION,
lat DOUBLE PRECISION,
install_location VARCHAR(200),
install_date DATE,
last_online_time TIMESTAMP,
ai_enabled INTEGER DEFAULT 0,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_video_camera IS '视频监控摄像头表';
COMMENT ON COLUMN prod_video_camera.camera_id IS '摄像头唯一编号';
COMMENT ON COLUMN prod_video_camera.status IS '状态: 0=离线, 1=在线, 2=故障';
COMMENT ON COLUMN prod_video_camera.ai_enabled IS '是否启用AI检测: 0=未启用, 1=已启用';
COMMENT ON COLUMN prod_video_camera.stream_url_rtsp IS 'RTSP视频流地址';
COMMENT ON COLUMN prod_video_camera.stream_url_hls IS 'HLS视频流地址';
COMMENT ON COLUMN prod_video_camera.stream_url_flv IS 'FLV视频流地址';
CREATE INDEX IF NOT EXISTS idx_video_camera_area ON prod_video_camera(area);
CREATE INDEX IF NOT EXISTS idx_video_camera_status ON prod_video_camera(status);
-- ==================== AI闯入检测事件 ====================
CREATE TABLE IF NOT EXISTS prod_intrusion_event (
id BIGSERIAL PRIMARY KEY,
camera_id BIGINT NOT NULL,
camera_name VARCHAR(100),
area VARCHAR(50),
event_type VARCHAR(30) NOT NULL,
confidence NUMERIC(6, 4),
snapshot_url VARCHAR(500),
video_clip_url VARCHAR(500),
alert_level VARCHAR(20),
alert_status INTEGER DEFAULT 0,
detected_at TIMESTAMP NOT NULL,
handle_result TEXT,
handled_by BIGINT,
handler_name VARCHAR(50),
handled_time TIMESTAMP,
alert_record_id BIGINT,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_intrusion_event IS 'AI人员闯入检测事件表';
COMMENT ON COLUMN prod_intrusion_event.event_type IS '事件类型: person_intrusion=人员闯入, person_loitering=人员徘徊, zone_breach=区域越界';
COMMENT ON COLUMN prod_intrusion_event.confidence IS 'AI识别置信度(0~1)';
COMMENT ON COLUMN prod_intrusion_event.alert_level IS '报警等级: info, warning, critical';
COMMENT ON COLUMN prod_intrusion_event.alert_status IS '报警状态: 0=待处理, 1=已确认, 2=已处理, 3=已忽略';
CREATE INDEX IF NOT EXISTS idx_intrusion_camera ON prod_intrusion_event(camera_id);
CREATE INDEX IF NOT EXISTS idx_intrusion_area ON prod_intrusion_event(area);
CREATE INDEX IF NOT EXISTS idx_intrusion_detected_at ON prod_intrusion_event(detected_at DESC);
CREATE INDEX IF NOT EXISTS idx_intrusion_alert_status ON prod_intrusion_event(alert_status);
-- ==================== 视频录像记录 ====================
CREATE TABLE IF NOT EXISTS prod_video_recording (
id BIGSERIAL PRIMARY KEY,
camera_id BIGINT NOT NULL,
camera_name VARCHAR(100),
area VARCHAR(50),
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP,
duration_sec INTEGER,
file_size_mb NUMERIC(10, 2),
storage_path VARCHAR(500),
playback_url VARCHAR(500),
record_type VARCHAR(20) NOT NULL,
event_id BIGINT,
remark VARCHAR(500),
created_time TIMESTAMP DEFAULT NOW(),
updated_time TIMESTAMP DEFAULT NOW(),
deleted INTEGER DEFAULT 0
);
COMMENT ON TABLE prod_video_recording IS '视频录像记录表';
COMMENT ON COLUMN prod_video_recording.record_type IS '录像类型: scheduled=计划录像, event_triggered=事件触发, manual=手动录像';
COMMENT ON COLUMN prod_video_recording.event_id IS '关联闯入事件ID(事件触发时有值)';
CREATE INDEX IF NOT EXISTS idx_recording_camera ON prod_video_recording(camera_id);
CREATE INDEX IF NOT EXISTS idx_recording_start_time ON prod_video_recording(start_time DESC);
CREATE INDEX IF NOT EXISTS idx_recording_record_type ON prod_video_recording(record_type);
CREATE INDEX IF NOT EXISTS idx_recording_event ON prod_video_recording(event_id);
-- ==================== 初始化测试数据 ====================
INSERT INTO prod_video_camera (camera_id, name, area, stream_url_rtsp, stream_url_hls, stream_url_flv,
status, manufacturer, model, lng, lat, install_location, install_date, ai_enabled, last_online_time)
VALUES
('CAM-001', '一体化水厂-沉淀池', '一体化水厂', 'rtsp://192.168.1.100/stream1', 'http://192.168.1.100/hls/stream1.m3u8', 'http://192.168.1.100/flv/stream1.flv',
1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5712, 43.7928, '一体化水厂沉淀池北侧', '2024-03-15', 1, NOW()),
('CAM-002', '一体化水厂-清水池', '一体化水厂', 'rtsp://192.168.1.101/stream1', 'http://192.168.1.101/hls/stream1.m3u8', 'http://192.168.1.101/flv/stream1.flv',
1, '海康威视', 'DS-2CD2T26FWDA3-IS', 87.5715, 43.7930, '一体化水厂清水池入口', '2024-03-15', 1, NOW()),
('CAM-003', '查村调压站-入口', '八家户片区', 'rtsp://192.168.1.102/stream1', 'http://192.168.1.102/hls/stream1.m3u8', 'http://192.168.1.102/flv/stream1.flv',
1, '大华', 'DH-IPC-HFW5442T-ASE', 87.5680, 43.7890, '查村调压站大门', '2024-04-10', 1, NOW()),
('CAM-004', '精芒片区-管网节点1', '精芒片区', 'rtsp://192.168.1.103/stream1', 'http://192.168.1.103/hls/stream1.m3u8', 'http://192.168.1.103/flv/stream1.flv',
0, '大华', 'DH-IPC-HFW5442T-ASE', 87.5650, 43.7860, '精芒片区管网节点井', '2024-05-20', 0, '2025-06-10 08:30:00'),
('CAM-005', '八家户泵站-机房', '八家户片区', 'rtsp://192.168.1.104/stream1', 'http://192.168.1.104/hls/stream1.m3u8', 'http://192.168.1.104/flv/stream1.flv',
2, '宇视', 'IPC3612SB-ADZK-I0', 87.5670, 43.7880, '八家户泵站机房入口', '2024-06-01', 1, '2025-06-01 12:00:00');
+109
View File
@@ -0,0 +1,109 @@
-- ============================================================
-- V4__quality_ledger.sql
-- 水质检测台账模块 DDL
-- 包含: 检测记录、水质标准、检测计划
-- ============================================================
-- 1. 水质检测记录表
CREATE TABLE IF NOT EXISTS prod_quality_test_record (
id BIGSERIAL PRIMARY KEY,
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special/complaint
water_type VARCHAR(20) NOT NULL DEFAULT 'treated', -- raw/treated/network
sampling_point VARCHAR(100), -- 采样点
area VARCHAR(50), -- 所属区域
test_date DATE NOT NULL, -- 检测日期
test_time TIME, -- 检测时间
tester VARCHAR(50), -- 检测人
turbidity NUMERIC(10,2), -- 浊度 (NTU)
ph NUMERIC(5,2), -- pH值
residual_chlorine NUMERIC(6,3), -- 余氯 (mg/L)
color NUMERIC(8,2), -- 色度 (度)
odor NUMERIC(4,1), -- 嗅味 (级)
ecoli NUMERIC(10,2), -- 大肠杆菌 (CFU/100mL)
colony_count NUMERIC(10,2), -- 菌落总数 (CFU/mL)
compliance_status VARCHAR(20) NOT NULL DEFAULT 'pending', -- qualified/unqualified/pending
unqualified_items TEXT, -- 不合格项 (JSON)
remark VARCHAR(500), -- 备注
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_record_type ON prod_quality_test_record(test_type);
CREATE INDEX IF NOT EXISTS idx_quality_record_water_type ON prod_quality_test_record(water_type);
CREATE INDEX IF NOT EXISTS idx_quality_record_area ON prod_quality_test_record(area);
CREATE INDEX IF NOT EXISTS idx_quality_record_date ON prod_quality_test_record(test_date);
CREATE INDEX IF NOT EXISTS idx_quality_record_compliance ON prod_quality_test_record(compliance_status);
CREATE INDEX IF NOT EXISTS idx_quality_record_deleted ON prod_quality_test_record(deleted);
COMMENT ON TABLE prod_quality_test_record IS '水质检测记录表';
COMMENT ON COLUMN prod_quality_test_record.test_type IS '检测类型: routine-常规/special-专项/complaint-投诉';
COMMENT ON COLUMN prod_quality_test_record.water_type IS '水样类型: raw-原水/treated-出厂水/network-管网末梢水';
COMMENT ON COLUMN prod_quality_test_record.compliance_status IS '合格状态: qualified-合格/unqualified-不合格/pending-待判定';
-- 2. 水质标准表 (GB5749-2022)
CREATE TABLE IF NOT EXISTS prod_quality_standard (
id BIGSERIAL PRIMARY KEY,
standard_name VARCHAR(100) NOT NULL,
standard_code VARCHAR(50) NOT NULL DEFAULT 'GB5749-2022',
param_name VARCHAR(50) NOT NULL, -- 参数编码
param_label VARCHAR(50), -- 参数显示名
param_unit VARCHAR(20), -- 单位
min_value NUMERIC(12,4), -- 最小值 (NULL=无下限)
max_value NUMERIC(12,4), -- 最大值 (NULL=无上限)
water_type VARCHAR(20) NOT NULL DEFAULT 'all', -- 适用水样类型
enabled INTEGER NOT NULL DEFAULT 1,
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_standard_code ON prod_quality_standard(standard_code);
CREATE INDEX IF NOT EXISTS idx_quality_standard_param ON prod_quality_standard(param_name);
CREATE INDEX IF NOT EXISTS idx_quality_standard_deleted ON prod_quality_standard(deleted);
COMMENT ON TABLE prod_quality_standard IS '水质标准表 (基于GB5749-2022)';
-- 初始化 GB5749-2022 默认标准
INSERT INTO prod_quality_standard (standard_name, standard_code, param_name, param_label, param_unit, min_value, max_value, water_type)
VALUES
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 1.0, 'treated'),
('生活饮用水卫生标准', 'GB5749-2022', 'turbidity', '浊度', 'NTU', NULL, 3.0, 'network'),
('生活饮用水卫生标准', 'GB5749-2022', 'ph', 'pH', '', 6.5, 8.5, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.3, 2.0, 'treated'),
('生活饮用水卫生标准', 'GB5749-2022', 'residual_chlorine', '余氯', 'mg/L', 0.05, 2.0, 'network'),
('生活饮用水卫生标准', 'GB5749-2022', 'color', '色度', '度', NULL, 15.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'odor', '嗅味', '级', NULL, 2.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'ecoli', '大肠杆菌', 'CFU/100mL', NULL, 0.0, 'all'),
('生活饮用水卫生标准', 'GB5749-2022', 'colony_count', '菌落总数', 'CFU/mL', NULL, 100.0, 'all')
ON CONFLICT DO NOTHING;
-- 3. 水质检测计划表
CREATE TABLE IF NOT EXISTS prod_quality_test_plan (
id BIGSERIAL PRIMARY KEY,
plan_name VARCHAR(100) NOT NULL,
test_type VARCHAR(20) NOT NULL DEFAULT 'routine', -- routine/special
water_type VARCHAR(20) NOT NULL DEFAULT 'treated',
sampling_point VARCHAR(100),
area VARCHAR(50),
frequency VARCHAR(20) NOT NULL DEFAULT 'daily', -- daily/weekly/monthly
test_params VARCHAR(200), -- 检测参数 (逗号分隔)
start_date DATE NOT NULL,
end_date DATE, -- NULL=长期
next_test_date DATE,
status VARCHAR(20) NOT NULL DEFAULT 'active', -- active/paused/completed
execution_count INTEGER NOT NULL DEFAULT 0,
remark VARCHAR(500),
deleted INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_quality_plan_status ON prod_quality_test_plan(status);
CREATE INDEX IF NOT EXISTS idx_quality_plan_frequency ON prod_quality_test_plan(frequency);
CREATE INDEX IF NOT EXISTS idx_quality_plan_next_date ON prod_quality_test_plan(next_test_date);
CREATE INDEX IF NOT EXISTS idx_quality_plan_deleted ON prod_quality_test_plan(deleted);
COMMENT ON TABLE prod_quality_test_plan IS '水质检测计划表';
COMMENT ON COLUMN prod_quality_test_plan.frequency IS '检测频率: daily-日检/weekly-周检/monthly-月检';
COMMENT ON COLUMN prod_quality_test_plan.status IS '计划状态: active-启用/paused-暂停/completed-已完成';
+79
View File
@@ -0,0 +1,79 @@
-- =============================================
-- 种子数据:管理员 + 基础角色 + 字典
-- =============================================
-- 插入部门
INSERT INTO sys_dept (id, parent_id, dept_name, dept_type, sort_order) VALUES
(1, NULL, '精河县水利局', 'water_bureau', 1),
(2, NULL, '安阜清源水务公司', 'water_company', 2),
(3, 2, '一体化水厂', 'water_company', 1),
(4, 2, '精芒片区', 'water_company', 2),
(5, 2, '八家户片区', 'water_company', 3),
(6, 2, '托里片区', 'water_company', 4),
(7, 2, '大镇阿合其片区', 'water_company', 5),
(8, 2, '托托片区', 'water_company', 6),
(9, 2, '运维中心', 'ops', 7)
ON CONFLICT DO NOTHING;
-- 重置序列
SELECT setval('sys_dept_id_seq', (SELECT MAX(id) FROM sys_dept));
-- 插入角色
INSERT INTO sys_role (id, role_name, role_key, role_sort, data_scope, remark) VALUES
(1, '系统管理员', 'admin', 1, 'ALL', '最高权限,管理全部功能'),
(2, '水务分管领导', 'leader', 2, 'ALL', '查看全部数据、BI看板'),
(3, '水务业务管理人员', 'manager', 3, 'DEPT', '调度、营收、水质管理'),
(4, '现场运维操作人员', 'operator', 4, 'DEPT', '巡检、值班、工单处理'),
(5, '系统技术维护人员', 'tech', 5, 'DEPT', '配置管理、设备维护')
ON CONFLICT (role_key) DO NOTHING;
-- 插入管理员用户 (密码: admin123, BCrypt加密)
INSERT INTO sys_user (id, dept_id, username, password, real_name, role_type, status) VALUES
(1, 1, 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt6Z5Eh', '系统管理员', 'admin', 1)
ON CONFLICT (username) DO NOTHING;
-- 管理员关联角色
INSERT INTO sys_user_role (user_id, role_id) VALUES (1, 1) ON CONFLICT DO NOTHING;
-- 插入字典类型
INSERT INTO sys_dict_type (id, dict_key, dict_name) VALUES
(1, 'device_type', '设备类型'),
(2, 'area', '片区'),
(3, 'customer_type', '用水户类型'),
(4, 'alert_level', '报警等级'),
(5, 'meter_status', '水表状态')
ON CONFLICT (dict_key) DO NOTHING;
-- 插入字典数据
INSERT INTO sys_dict_data (dict_type_id, dict_label, dict_value, sort_order) VALUES
-- 设备类型
(1, '流量计', 'flow_meter', 1),
(1, '压力传感器', 'pressure_sensor', 2),
(1, '电动阀门', 'valve', 3),
(1, '水质监测仪', 'water_quality', 4),
(1, '液位计', 'level_sensor', 5),
(1, '水泵', 'pump', 6),
-- 片区
(2, '一体化水厂', '一体化水厂', 1),
(2, '精芒片区', '精芒片区', 2),
(2, '八家户片区', '八家户片区', 3),
(2, '托里片区', '托里片区', 4),
(2, '大镇阿合其片区', '大镇阿合其片区', 5),
(2, '托托片区', '托托片区', 6),
-- 用水户类型
(3, '居民', 'residential', 1),
(3, '商业', 'business', 2),
(3, '企业', 'enterprise', 3),
(3, '事业单位', 'institution', 4),
-- 报警等级
(4, '提示', 'info', 1),
(4, '一般', 'warning', 2),
(4, '严重', 'critical', 3),
(4, '紧急', 'emergency', 4),
-- 水表状态
(5, '正常使用', 'active', 1),
(5, '已拆除', 'dismantled', 2),
(5, '已报废', 'scrapped', 3),
(5, '维修中', 'repaired', 4),
(5, '库存', 'warehouse', 5)
ON CONFLICT DO NOTHING;
+68
View File
@@ -0,0 +1,68 @@
-- =============================================
-- 智慧水务管理系统 - TDengine 建表脚本
-- 用于物联网设备遥测数据存储
-- =============================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS water_iot
KEEP 365
DURATION 30
BUFFER 256
PAGES 256
PAGESIZE 4
WAL_LEVEL 1;
USE water_iot;
-- 遥测数据超级表
CREATE STABLE IF NOT EXISTS iot_telemetry (
ts TIMESTAMP,
device_sn NCHAR(100),
metric_key NCHAR(50),
metric_value DOUBLE,
quality TINYINT
) TAGS (
device_type NCHAR(30),
area NCHAR(50),
station NCHAR(100)
);
-- 按设备类型创建子表(模板,实际运行时动态创建)
CREATE TABLE IF NOT EXISTS telemetry_flow_meter USING iot_telemetry TAGS('flow_meter', 'default', 'default');
CREATE TABLE IF NOT EXISTS telemetry_pressure USING iot_telemetry TAGS('pressure_sensor', 'default', 'default');
CREATE TABLE IF NOT EXISTS telemetry_valve USING iot_telemetry TAGS('valve', 'default', 'default');
CREATE TABLE IF NOT EXISTS telemetry_water_quality USING iot_telemetry TAGS('water_quality', 'default', 'default');
CREATE TABLE IF NOT EXISTS telemetry_level USING iot_telemetry TAGS('level_sensor', 'default', 'default');
CREATE TABLE IF NOT EXISTS telemetry_pump USING iot_telemetry TAGS('pump', 'default', 'default');
-- 设备事件超级表
CREATE STABLE IF NOT EXISTS iot_device_event (
ts TIMESTAMP,
device_sn NCHAR(100),
event_type NCHAR(30),
event_data NCHAR(2000)
) TAGS (
device_type NCHAR(30),
area NCHAR(50)
);
-- 小时聚合超级表(自动聚合)
CREATE STABLE IF NOT EXISTS iot_telemetry_hourly (
ts TIMESTAMP,
device_sn NCHAR(100),
metric_key NCHAR(50),
min_value DOUBLE,
max_value DOUBLE,
avg_value DOUBLE,
count_value INT
) TAGS (
device_type NCHAR(30),
area NCHAR(50)
);
-- 日聚合视图(示例)
-- CREATE STREAM IF NOT EXISTS hourly_stream INTO iot_telemetry_hourly AS
-- SELECT _wstart as ts, device_sn, metric_key,
-- MIN(metric_value), MAX(metric_value), AVG(metric_value), COUNT(*)
-- FROM iot_telemetry
-- INTERVAL(1h);
+184
View File
@@ -0,0 +1,184 @@
version: '3.8'
services:
# ==================== 基础设施 ====================
postgres:
image: postgis/postgis:16-3.4
container_name: wm-postgres
environment:
POSTGRES_DB: water_management
POSTGRES_USER: water
POSTGRES_PASSWORD: water123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U water"]
interval: 10s
timeout: 5s
retries: 5
tdengine:
image: tdengine/tdengine:3.3.3.0
container_name: wm-tdengine
ports:
- "6030:6030"
- "6041:6041"
- "6043:6043"
environment:
TAOS_FQDN: tdengine
volumes:
- tddata:/var/lib/taos
healthcheck:
test: ["CMD", "taos", "-s", "show databases"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: wm-redis
ports:
- "6379:6379"
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# ==================== 消息队列 ====================
kafka:
image: bitnami/kafka:3.8
container_name: wm-kafka
ports:
- "9092:9092"
environment:
KAFKA_CFG_NODE_ID: 0
KAFKA_CFG_PROCESS_ROLES: controller,broker
KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093
KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://${KAFKA_ADVERTISED:-localhost}:9092
KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "true"
volumes:
- kafkadata:/bitnami/kafka
healthcheck:
test: ["CMD-SHELL", "kafka-topics.sh --bootstrap-server localhost:9092 --list"]
interval: 15s
timeout: 10s
retries: 5
# ==================== 物联网 MQTT ====================
emqx:
image: emqx/emqx:5.7.2
container_name: wm-emqx
ports:
- "1883:1883"
- "8083:8083"
- "18083:18083"
environment:
EMQX_DASHBOARD__DEFAULT_USERNAME: admin
EMQX_DASHBOARD__DEFAULT_PASSWORD: public
volumes:
- emqxdata:/opt/emqx/data
healthcheck:
test: ["CMD", "emqx", "ping"]
interval: 10s
timeout: 5s
retries: 5
# ==================== 服务注册/配置 ====================
nacos:
image: nacos/nacos-server:v2.3.2
container_name: wm-nacos
environment:
MODE: standalone
PREFER_HOST_MODE: hostname
ports:
- "8848:8848"
- "9848:9848"
volumes:
- nacosdata:/home/nacos/data
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:8848/nacos/v1/console/health/readiness"]
interval: 15s
timeout: 5s
retries: 5
# ==================== 搜索引擎 ====================
elasticsearch:
image: elasticsearch:8.15.0
container_name: wm-es
environment:
discovery.type: single-node
xpack.security.enabled: "false"
"ES_JAVA_OPTS": "-Xms512m -Xmx512m"
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
kibana:
image: kibana:8.15.0
container_name: wm-kibana
ports:
- "5601:5601"
environment:
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
depends_on:
- elasticsearch
# ==================== 对象存储 ====================
minio:
image: minio/minio:latest
container_name: wm-minio
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- miniodata:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
# ==================== GIS 地图服务 ====================
geoserver:
image: docker.osgeo.org/geoserver:2.25.2
container_name: wm-geoserver
ports:
- "8081:8080"
environment:
INSTALL_EXTENSIONS: "true"
STABLE_EXTENSIONS: "wps,csw"
GEOSERVER_ADMIN_USER: admin
GEOSERVER_ADMIN_PASSWORD: geoserver
volumes:
- geoserverdata:/opt/geoserver_data
profiles:
- gis
healthcheck:
test: ["CMD-SHELL", "curl -s http://localhost:8080/geoserver/web/"]
interval: 20s
timeout: 10s
retries: 3
volumes:
pgdata:
tddata:
redisdata:
kafkadata:
emqxdata:
nacosdata:
esdata:
miniodata:
geoserverdata:
+7
View File
@@ -0,0 +1,7 @@
#!/bin/bash
set -e
mvn clean package -DskipTests
for mod in wm-{base,iot,data-engine,bpm,production,revenue,patrol,bi,notify,job}; do
docker build -t water/$mod -f docker/$mod/Dockerfile .
done
echo "Build done"
+8
View File
@@ -0,0 +1,8 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / { try_files $uri /index.html; }
location /api/ { proxy_pass http://wm-gateway:8080/; }
}
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# GeoServer 初始化脚本:创建工作区、数据源、图层
set -e
GEOSERVER_URL="http://localhost:8081/geoserver"
USER="admin"
PASS="geoserver"
echo "Waiting for GeoServer..."
until curl -s -u $USER:$PASS "$GEOSERVER_URL/rest/about/version.xml" > /dev/null; do sleep 2; done
# Create workspace
curl -s -u $USER:$PASS -X POST "$GEOSERVER_URL/rest/workspaces" -H "Content-Type: text/xml" -d '<workspace><name>water_management</name></workspace>' || true
echo "GeoServer init done"
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-base/target/wm-base-*.jar app.jar
EXPOSE 8081
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-bi/target/wm-bi-*.jar app.jar
EXPOSE 8088
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-bpm/target/wm-bpm-*.jar app.jar
EXPOSE 8084
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-data-engine/target/wm-data-engine-*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-iot/target/wm-iot-*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-job/target/wm-job-*.jar app.jar
EXPOSE 8090
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-notify/target/wm-notify-*.jar app.jar
EXPOSE 8089
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-patrol/target/wm-patrol-*.jar app.jar
EXPOSE 8087
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-production/target/wm-production-*.jar app.jar
EXPOSE 8085
ENTRYPOINT ["java", "-jar", "app.jar"]
+5
View File
@@ -0,0 +1,5 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY wm-revenue/target/wm-revenue-*.jar app.jar
EXPOSE 8086
ENTRYPOINT ["java", "-jar", "app.jar"]
+570
View File
@@ -0,0 +1,570 @@
# 智慧水务管理系统 — 系统设计文档
> 版本:v1.0 | 日期:2026-06-14 | 作者:bot_pm
---
## 一、技术选型
### 1.1 总体技术栈
| 层次 | 技术 | 版本要求 | 选型理由 |
|------|------|----------|----------|
| 后端框架 | Spring Cloud Alibaba | 2024.x | 微服务生态成熟,Nacos 服务治理 |
| 单体可回退 | Spring Boot 3.x | 3.3+ | 小规模可单体启动,渐进式拆分 |
| 前端框架 | Vue 3 + TypeScript | 3.5+ | 生态丰富,GIS 组件支持好 |
| 前端 UI | Element Plus + ECharts | 最新 | 大屏可视化 + 中后台表单 |
| 移动端 | Flutter 3.x | 3.24+ | 一套代码 Android/iOS |
| 网关 | Spring Cloud Gateway | 匹配 SC | 统一入口、限流、鉴权 |
| 服务注册/配置 | Nacos | 2.4+ | 服务发现 + 配置中心一体化 |
| 认证授权 | Sa-Token + OAuth2 | 最新 | 轻量级 RBAC,支持 SSO |
| 数据库 | PostgreSQL 16 + PostGIS | 16+ | 空间数据原生支持,替代 Oracle Spatial |
| 时序数据库 | TDengine 3.x | 3.3+ | 物联网时序数据写入性能优,SQL 兼容 |
| 缓存 | Redis 7 + Redisson | 7.2+ | 缓存 + 分布式锁 |
| 消息队列 | Apache Kafka | 3.8+ | IoT 高吞吐实时流 |
| 搜索引擎 | Elasticsearch | 8.x | 日志/文档全文检索 |
| 流程引擎 | Camunda 7 / Flowable | 最新 | BPMN 2.0,用户量大 |
| 对象存储 | MinIO / 阿里云 OSS | 最新 | 文件、视频存储 |
| 容器编排 | Docker Compose → K8s | 最新 | 分步过渡 |
### 1.2 选型对比与决策
#### GIS 引擎选型
| 方案 | 优势 | 劣势 | 推荐场景 |
|------|------|------|----------|
| **超图 iServer** | 国内水利项目标配,二三维一体化 | 授权费高(15-30万) | 预算充足 |
| **开源 MapServer+GeoServer** | 免费 | 功能较弱,三维支持差 | 预算有限 |
| **Leaflet + Cesium** | 前端轻量三维 | 瓦片服务需另配 | 推荐混合方案 |
**推荐**:短期 GeoServer + Leaflet/Cesium;长期按甲方要求替换为超图。
#### 视频 AI 引擎
| 方案 | 优势 | 劣势 |
|------|------|------|
| **自研训练(YOLOv8)** | 可定制 | 需标注数据+3-5人月训练 |
| **采购成熟产品** | 开箱即用 | 10-20万授权费 |
| **对接现有视频平台 SDK** | 复用已有投资 | 依赖厂商API |
**推荐**:优先对接甲方现有视频平台;人员闯入模型采用预训练 YOLO + 少量迁移学习。
---
## 二、系统架构
### 2.1 整体架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 接入层 (Gateway) │
│ Nginx → Spring Cloud Gateway (限流/鉴权/路由) │
│ 统一入口: Web / Flutter APP / 微信小程序 / OpenAPI │
├─────────────────────────────────────────────────────────────┤
│ 应用服务层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │供水生产 │ │营业收费 │ │巡检管理 │ │大数据分析 │ │
│ │管理服务 │ │服务 │ │服务 │ │服务 │ │
│ ├──────────┤ ├──────────┤ ├──────────┤ ├──────────────┤ │
│ │总览/监测 │ │营收/报装 │ │任务/轨迹 │ │BI看板/报表 │ │
│ │水质/调度 │ │表务/客服 │ │统计/设置 │ │决策模型 │ │
│ │报警/数据 │ │微信/集抄 │ │巡检APP │ │大屏展示 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 流程引擎服务 │ │
│ │ Camunda BPMN → 流程定义/编排/监控 │ │
│ └──────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 能力层 (中台) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │物联网平台│ │数据引擎 │ │工单引擎 │ │消息通知 │ │
│ │设备接入 │ │汇聚/治理 │ │任务流转 │ │短信/推送/WS │ │
│ │协议适配 │ │服务/目录 │ │跟踪/统计 │ │邮件/订阅 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 基础服务平台 │ │
│ │ 认证授权(Sa-Token) / 用户管理 / 部门管理 / 日志 │ │
│ └──────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 数据存储层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
│ │PostgreSQL│ │ TDengine │ │ Redis │ │Elasticsearch │ │
│ │+PostGIS │ │ 时序数据 │ │ 缓存/锁 │ │ 搜索/日志 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ MinIO │ │ Kafka │ │
│ │ 文件存储 │ │ 消息队列 │ │
│ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────────────┤
│ 外部集成层 │
│ ┌────────┐ ┌────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ │
│ │ SCADA │ │ 视频 │ │ 支付宝 │ │ 微信 │ │GIS地图 │ │
│ │ 对接 │ │ 平台 │ │ 生活缴费 │ │ 支付 │ │ 服务 │ │
│ └────────┘ └────────┘ └──────────┘ └────────┘ └────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 微服务拆分
| 微服务 | 端口 | 职责 | 拆分优先级 |
|--------|:----:|------|:----------:|
| `wm-gateway` | 8080 | API 网关/鉴权/限流 | P0 |
| `wm-base` | 8081 | 用户/角色/菜单/部门/日志 | P0 |
| `wm-iot` | 8082 | 物联网平台:设备接入/协议适配/设备管理 | P0 |
| `wm-data-engine` | 8083 | 数据引擎:汇聚/治理/服务/目录 | P0 |
| `wm-bpm` | 8084 | 流程引擎:Camunda 封装 | P0 |
| `wm-production` | 8085 | 供水生产管理平台全部模块 | P1 |
| `wm-revenue` | 8086 | 营业收费系统全部模块 | P1 |
| `wm-patrol` | 8087 | 巡检管理系统 | P2 |
| `wm-bi` | 8088 | 大数据分析/BI | P2 |
| `wm-notify` | 8089 | 消息通知:短信/极光推送/WebSocket | P1 |
| `wm-job` | 8090 | 定时任务调度(XXL-Job) | P1 |
---
## 三、关键模块设计
### 3.1 物联网平台
```
设备 → [MQTT Broker(EMQX)] → [Kafka] → [wm-iot]
↑ 设备认证 ↑ 规则引擎路由 ↑ 设备管理/影子/OTA
```
**协议适配器模式**:
```
wm-iot/adapters/
├── MqttAdapter.java # MQTT 适配
├── CoapAdapter.java # CoAP 适配
├── ModbusAdapter.java # Modbus TCP/RTU
├── HttpAdapter.java # HTTP 直连
└── DeviceModelMapper.java # 统一设备模型转换
```
- **设备影子**:Redis 存储设备期望/上报状态,断线自动同步
- **OTA 升级**:MinIO 存储固件,设备心跳拉取更新
- **数据路由**:EMQX 规则引擎 → Kafka Topic 分流(`iot.raw.{deviceType}`)
### 3.2 数据引擎
**数据流转**:
```
数据源 → 采集适配 → Kafka → 实时流(Flink) → TDengine(时序)
↘ ↘
批量(Spark) → PostgreSQL(业务数据)
→ Elasticsearch(全文)
```
**治理流水线**:
```
原始数据 → 标准化 → 清洗 → 质控 → 关联 → 入仓
(水利标准映射) (缺失/异常) (质量评分) (血缘) (增量/全量)
```
### 3.3 GIS 集成
```
前端: Vue3 + Leaflet(2D) / Cesium(3D)
↕ WMS/WMTS/TMS
后端: GeoServer(瓦片服务) ← PostGIS(空间数据库)
↑
空间数据: 管网矢量(shp→PostGIS)、监测点位(动态图层)
```
### 3.4 报警引擎
```
IoT数据 → Kafka → wm-iot(数据消费)
↓
规则引擎(阈值判断)
↓
Redis(去重窗口)
↓
wm-notify(通知分发)
↓
┌───────┼────────┐
短信 APP推送 WebSocket(实时弹窗)
```
**报警等级**:🟢 提示 → 🟡 一般 → 🟠 严重 → 🔴 紧急
**去重策略**:同一设备+同一类型+N分钟内只告警一次。
### 3.5 调度推演引擎
```
预案模板库 → 参数填充 → 管网水力模型(EPANET) → 调度策略生成
↓
指令下发 → 执行跟踪 → 效果评估
```
- 爆管模拟:阀门关闭策略 → 影响区域分析 → 应急供水方案
- 水质异常:污染物扩散模拟 → 排放方案
---
## 四、数据库设计(核心表)
### 4.1 用户权限体系
```sql
-- 部门表
CREATE TABLE sys_dept (
id BIGINT PRIMARY KEY,
parent_id BIGINT,
dept_name VARCHAR(100),
dept_type VARCHAR(20), -- water_bureau(水利局)/water_company(水务公司)/ops(运维)
sort_order INT,
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
-- 用户表
CREATE TABLE sys_user (
id BIGINT PRIMARY KEY,
dept_id BIGINT REFERENCES sys_dept(id),
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
real_name VARCHAR(50),
phone VARCHAR(20),
email VARCHAR(100),
role_type VARCHAR(30), -- admin/leader/manager/operator/tech
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
-- 角色表
CREATE TABLE sys_role (
id BIGINT PRIMARY KEY,
role_name VARCHAR(50) UNIQUE,
role_key VARCHAR(50) UNIQUE, -- admin/supervisor/biz_manager/field_ops/tech_maintain
data_scope VARCHAR(20), -- ALL/DEPT/SELF
description VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
-- 角色-用户关联
CREATE TABLE sys_user_role (
user_id BIGINT REFERENCES sys_user(id),
role_id BIGINT REFERENCES sys_role(id),
PRIMARY KEY (user_id, role_id)
);
```
### 4.2 设备管理(物联网)
```sql
-- 设备模型定义
CREATE TABLE iot_device_model (
id BIGINT PRIMARY KEY,
model_key VARCHAR(50) UNIQUE, -- 比如 water_meter_dn15
model_name VARCHAR(100), -- DN15远传水表
vendor VARCHAR(100), -- 厂商
protocol VARCHAR(20), -- MQTT/Modbus/CoAP
properties JSONB, -- 属性定义 [{key, name, unit, data_type, range}]
commands JSONB, -- 支持指令 [{key, name, params}]
created_at TIMESTAMP DEFAULT NOW()
);
-- 设备实例
CREATE TABLE iot_device (
id BIGINT PRIMARY KEY,
device_sn VARCHAR(100) UNIQUE, -- 设备序列号
device_name VARCHAR(200),
model_id BIGINT REFERENCES iot_device_model(id),
device_type VARCHAR(30), -- flow_meter/pressure/valve/water_quality/level
position_type VARCHAR(20), -- water_plant/pressure_station/pipe_network/village
loc_lng DOUBLE PRECISION, -- 经度
loc_lat DOUBLE PRECISION, -- 纬度
geom GEOMETRY(Point, 4326), -- PostGIS 空间坐标
area VARCHAR(50), -- 所属片区
station_id BIGINT, -- 所属站点
status VARCHAR(20), -- online/offline/maintenance/fault
last_report_time TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.3 时序数据(TDengine)
```sql
-- TDengine 超级表:设备遥测数据
CREATE STABLE iot_telemetry (
ts TIMESTAMP,
device_sn NCHAR(100),
metric_key NCHAR(50),
metric_value DOUBLE,
quality TINYINT
) TAGS (
device_type NCHAR(30),
area NCHAR(50)
);
-- 按设备类型创建子表
CREATE TABLE flow_data USING iot_telemetry TAGS('flow_meter', '精芒片区');
CREATE TABLE press_data USING iot_telemetry TAGS('pressure', '托里片区');
CREATE TABLE wq_data USING iot_telemetry TAGS('water_quality', '一体化水厂');
```
### 4.4 营业收费
```sql
-- 用水户
CREATE TABLE rev_customer (
id BIGINT PRIMARY KEY,
customer_no VARCHAR(30) UNIQUE, -- 户号
customer_name VARCHAR(100),
customer_type VARCHAR(20), -- residential/business/enterprise/institution
area VARCHAR(50),
address VARCHAR(300),
phone VARCHAR(20),
id_card VARCHAR(18),
contract_no VARCHAR(50),
status VARCHAR(20) DEFAULT 'active',
created_at TIMESTAMP DEFAULT NOW()
);
-- 水表档案
CREATE TABLE rev_meter (
id BIGINT PRIMARY KEY,
meter_no VARCHAR(50) UNIQUE,
customer_id BIGINT REFERENCES rev_customer(id),
device_id BIGINT REFERENCES iot_device(id), -- 关联IoT设备
caliber VARCHAR(10), -- DN15/DN20/DN40...
meter_type VARCHAR(20), -- mechanical/ultrasonic/electromagnetic
initial_reading DECIMAL(10,2),
install_date DATE,
status VARCHAR(20), -- active/dismantled/scrapped/repaired
created_at TIMESTAMP DEFAULT NOW()
);
-- 抄表记录
CREATE TABLE rev_reading (
id BIGINT PRIMARY KEY,
meter_id BIGINT REFERENCES rev_meter(id),
reading_date DATE,
prev_reading DECIMAL(10,2),
curr_reading DECIMAL(10,2),
consumption DECIMAL(10,2), -- 用水量
read_type VARCHAR(20), -- manual/remote/estimate
reader_id BIGINT,
photo_url VARCHAR(500),
verified TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
-- 水费账单
CREATE TABLE rev_bill (
id BIGINT PRIMARY KEY,
customer_id BIGINT REFERENCES rev_customer(id),
bill_period VARCHAR(10), -- 2026-06
consumption DECIMAL(10,2),
water_fee DECIMAL(10,2),
sewage_fee DECIMAL(10,2),
total_fee DECIMAL(10,2),
paid_fee DECIMAL(10,2) DEFAULT 0,
status VARCHAR(20), -- pending/partial/paid/overdue
due_date DATE,
paid_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.5 巡检系统
```sql
-- 巡检路线
CREATE TABLE patrol_route (
id BIGINT PRIMARY KEY,
route_name VARCHAR(100),
area VARCHAR(50),
route_points JSONB, -- [{seq, lng, lat, name, device_ids}]
estim_duration INT, -- 预计时长(分钟)
status TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
-- 巡检任务
CREATE TABLE patrol_task (
id BIGINT PRIMARY KEY,
route_id BIGINT REFERENCES patrol_route(id),
assignee_id BIGINT REFERENCES sys_user(id),
task_date DATE,
plan_start TIME,
plan_end TIME,
actual_start TIMESTAMP,
actual_end TIMESTAMP,
status VARCHAR(20), -- pending/in_progress/completed/expired
distance DECIMAL(8,2),
created_at TIMESTAMP DEFAULT NOW()
);
-- 巡检记录
CREATE TABLE patrol_record (
id BIGINT PRIMARY KEY,
task_id BIGINT REFERENCES patrol_task(id),
point_seq INT,
device_id BIGINT REFERENCES iot_device(id),
check_items JSONB, -- [{item, result, value, photo_url, remark}]
gps_lng DOUBLE PRECISION,
gps_lat DOUBLE PRECISION,
record_time TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
```
### 4.6 报警管理
```sql
CREATE TABLE alert_rule (
id BIGINT PRIMARY KEY,
rule_name VARCHAR(100),
device_type VARCHAR(30),
metric_key VARCHAR(50),
alert_level VARCHAR(10), -- info/warning/critical/emergency
condition_expr VARCHAR(200), -- "> 0.5" 或 "ABS_DELTA > 10"
threshold_value VARCHAR(50),
debounce_sec INT DEFAULT 300, -- 去重窗口(秒)
notify_scheme_id BIGINT,
enabled TINYINT DEFAULT 1,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE alert_event (
id BIGINT PRIMARY KEY,
rule_id BIGINT REFERENCES alert_rule(id),
device_id BIGINT REFERENCES iot_device(id),
device_sn VARCHAR(100),
metric_key VARCHAR(50),
metric_value DOUBLE,
threshold VARCHAR(50),
alert_level VARCHAR(10),
message VARCHAR(500),
confirmed_by BIGINT,
confirmed_at TIMESTAMP,
dispatched TINYINT DEFAULT 0, -- 是否已派单
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
```
---
## 五、接口设计规范
### 5.1 RESTful API 规范
```
GET /api/v1/{resource} # 列表查询
GET /api/v1/{resource}/{id} # 详情查询
POST /api/v1/{resource} # 创建
PUT /api/v1/{resource}/{id} # 全量更新
PATCH /api/v1/{resource}/{id} # 部分更新
DELETE /api/v1/{resource}/{id} # 删除
```
### 5.2 统一响应格式
```json
{
"code": 200,
"message": "success",
"data": {},
"timestamp": 1718340000000
}
```
### 5.3 错误码规范
| 范围 | 说明 |
|:----:|------|
| 200 | 成功 |
| 400-499 | 客户端错误(参数/权限/资源) |
| 500-599 | 服务端错误 |
| 10001-10099 | 物联网相关 |
| 20001-20099 | 营业收费相关 |
| 30001-30099 | 供水生产相关 |
---
## 六、部署方案
### 6.1 开发环境
```
Docker Compose 单机部署
├── PostgreSQL 16 + PostGIS
├── TDengine 3.x
├── Redis 7
├── Kafka (单节点)
├── Elasticsearch
├── MinIO
├── EMQX
├── Nacos
└── 各微服务 JAR
```
### 6.2 生产环境
```
K8s 集群 (3 master + 5 worker)
├── ingress-nginx (LB)
├── Spring Cloud Gateway (ClusterIP)
├── 微服务 Pods (HPA自动扩缩)
├── PostgreSQL (主从)
├── TDengine Cluster (3节点)
├── Redis Cluster (6节点)
├── Kafka Cluster (3节点)
├── Elasticsearch Cluster (3节点)
├── MinIO (分布式 4节点)
└── EMQX Cluster (3节点)
```
### 6.3 监控体系
| 组件 | 用途 |
|------|------|
| Prometheus + Grafana | 系统指标 + 业务指标 |
| SkyWalking | 分布式链路追踪 |
| ELK (ES + Logstash + Filebeat) | 日志收集分析 |
| AlertManager | 告警管理 |
---
## 七、安全设计
| 层面 | 措施 |
|------|------|
| 传输安全 | HTTPS/TLS 1.3,MQTT over TLS |
| 认证 | JWT + Sa-Token,设备端 X.509证书 |
| 授权 | RBAC + 数据权限(片区隔离) |
| 审计 | 全操作日志(登录/操作/异常),不可删除 |
| 数据安全 | 敏感字段 AES 加密,数据库连接 SSL |
| 等保 | 不低于等保二级,关键业务等保三级 |
| 防护 | SQL注入/ XSS/ CSRF/ DDoS限流 |
---
## 八、关键技术决策记录
| # | 决策 | 方案 | 理由 |
|---|------|------|------|
| 1 | GIS引擎 | GeoServer + Leaflet/Cesium | 开源可控,支持标准WMS/WMTS,可平滑迁移超图 |
| 2 | 时序库 | TDengine 3.x | 物联网写入性能优(亿级/天),SQL兼容,运维简单 |
| 3 | 消息队列 | Kafka | IoT 高吞吐实时流,Exactly-Once 语义 |
| 4 | 流程引擎 | Camunda 7 | BPMN 2.0 标准,Java 原生嵌入,中文社区 |
| 5 | 移动端 | Flutter | 一套代码双端,GIS 地图插件成熟 |
| 6 | 微服务框架 | Spring Cloud Alibaba | Nacos注册配置一体化,Sentinel限流 |
| 7 | API规范 | RESTful + WebSocket(实时) | 前后端分离标准,WS 用于实时监测和报警推送 |
| 8 | 视频AI | 预训练 YOLOv8 + 迁移学习 | 人员闯入场景单一,少量标注即可达到85%+精度 |
---
## 文档版本
| 版本 | 日期 | 修订 | 修订人 |
|------|------|------|--------|
| v1.0 | 2026-06-14 | 初始版本:技术选型/架构/数据库/部署 | bot_pm |
+1781
View File
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
# 智慧水务管理系统 — 工作量与报价评估
## 一、估算前提
| 维度 | 假设 |
|------|------|
| 技术栈 | Java/Spring Cloud + Vue3 + Flutter + IoT MQTT + GIS |
| 开发人均效能 | 中级以上全栈/前后端分离团队 |
## 二、模块逐项估算
### 基础平台层
| 模块 | 人月 | 备注 |
|------|:--:|------|
| 物联网平台 | 10 | 多协议适配、设备建模、感知层标准、已建+新建设备统一接入 |
| 业务流程引擎 | 5 | 基于Camunda/Flowable,整合各子系统流程 |
| 大数据分析系统 | 8 | 数仓、ETL、BI看板、决策支持 |
| 数据引擎 | 7 | 汇聚→治理→服务三阶段,实时流+批处理 |
| 营业收费系统 | 15 | 8个子系统 |
| **平台层小计** | **45** | |
### 应用功能层
| 系统 | 人月 | 关键模块 |
|------|:--:|------|
| 供水生产管理平台 | 19 | 总览、在线监测(GIS+视频+AI)、水质管控、调度工作台、报警中心、调度业务(应急推演)、数据中心、配置中心 |
| 巡检管理系统 | 7 | 巡检管理、任务分派、统计分析、巡查设置、巡检APP |
| 移动APP(三合一) | 5 | 供水管理+巡检+营业收费集成 |
| **应用层小计** | **31** | |
### 基础设施
| 项目 | 人月 |
|------|:--:|
| 架构设计 & 技术选型 | 2-3 |
| DevOps & 部署 | 2-3 |
| 测试(集成+性能+安全) | 4-6 |
| 项目管理 | 贯穿 |
| **基础设施小计** | **12** |
## 三、汇总
| 类别 | 人月 |
|------|:--:|
| 平台层 | 45 |
| 应用层 | 31 |
| 基础设施 | 12 |
| **小计** | **88** |
| **+20%缓冲** | **≈ 105 人月** |
## 四、报价
| 方案 | 人力(万) | 第三方(万) | 合计(万) | 周期 |
|------|:--:|:--:|:--:|:--:|
| 标准版 | 318 | 90 | **≈ 410** | 10-12个月 |
## 五、排期建议
| 方案 | 团队 | 周期 |
|------|------|:--:|
| 快速交付 | 15-20人 | 6-8个月 |
| 稳健交付 | 10-12人 | 10-12个月 |
| 分阶段 | 8-10人/期 | 三期各4个月 |
## 六、风险项
- 物联网设备接入:多厂商、多协议、已建设备反向适配
- GIS深度集成:超图/ArcGIS/开源选型影响大
- AI视频识别(人员闯入):从零训练需额外3-5人月
- 水利行业标准符合
- 6个片区历史数据迁移
+455
View File
@@ -0,0 +1,455 @@
# 智慧水务管理系统 — 需求规格说明书
## 项目背景
精河县供水工程综合管理平台,覆盖物联网接入 → 数据治理 → 生产管理 → 营业收费 → 巡检运维 → 移动端的全栈智慧水务解决方案。
**覆盖范围**:一体化水厂、精芒片区、八家户片区、托里片区、大镇阿合其片区、托托片区。
---
## 一、物联网平台
### 1.1 概述
需要将已建的智能设备接入到平台。设备类型不同、型号不同、数据传输协议不同,需要建立感知层标准,对已建设备和新建设备进行统一管理。未来,新建、更换其他感知设备时,按物联网平台标准,能快速与设备对接,实现无缝升级。
### 1.2 功能需求
| 编号 | 功能 | 描述 |
|------|------|------|
| IOT-01 | 多协议适配 | 支持 MQTT、CoAP、Modbus、HTTP、NB-IoT 等主流 IoT 协议 |
| IOT-02 | 设备建模 | 建立统一的感知层设备模型标准,支持不同厂商设备类型定义 |
| IOT-03 | 已建设备接入 | 反向适配现有已建智能设备,接入统一管理 |
| IOT-04 | 设备注册/发现 | 设备自动发现与手动注册双模式 |
| IOT-05 | 设备影子 | 设备状态缓存,支持离线指令缓存与重发 |
| IOT-06 | 数据解析/转换 | 统一数据格式转换,标准化上报数据 |
| IOT-07 | 远程配置 | 设备参数远程下发与固件 OTA 升级 |
| IOT-08 | 设备监控 | 设备在线状态、运行状态、异常告警 |
---
## 二、业务流程引擎
### 2.1 概述
整合各业务子系统相关业务流程,实现各业务子系统业务流互通,完成各子系统业务流统一。增进客户业务流程的融合性,提高业务流程执行效率,提升客户业务领域信息化水平。
### 2.2 功能需求
| 编号 | 功能 | 描述 |
|------|------|------|
| BPM-01 | 流程定义 | 可视化流程设计器,支持 BPMN 2.0 规范 |
| BPM-02 | 流程创建 | 基于模板快速创建流程实例 |
| BPM-03 | 流程处理 | 审批、会签、流转、驳回、转办等流程操作 |
| BPM-04 | 统计评估 | 流程执行效率分析、瓶颈识别、时效统计 |
| BPM-05 | 流程编排 | 跨系统流程组合编排,打通各子系统的流程壁垒 |
| BPM-06 | 待办/已办 | 统一待办任务中心和已办历史查询 |
---
## 三、大数据分析系统
### 3.1 概述
建立一个大数据分析平台整合各业务系统数据,消除各业务系统之间的"数据壁垒",挖掘、生成管理所需"有用数据",发挥各类数据整合效益。运用商务智能理念,梳理、过滤、整合各类数据,使各级管理者在一个平台上就能得到管理所需的数据、数据组合、企业运行状况监控和数据分析功能,为企业管理层的管理决策提供有力支持。
### 3.2 功能需求
| 编号 | 功能 | 描述 |
|------|------|------|
| BI-01 | 数据中心 | 数据 ETL 管道,多源数据汇聚存储 |
| BI-02 | 数据分析平台 | 自助式 BI 看板,多维数据分析 |
| BI-03 | 数据可视化 | 运营仪表盘、专题大屏 |
| BI-04 | 决策支持 | 供水调度决策模型、需水量预测模型 |
| BI-05 | 报告生成 | 自动生成运营报告、分析报告 |
| BI-06 | 数据监控 | 关键指标实时监控与异常预警 |
---
## 四、数据引擎
### 4.1 概述
通过数据采集、接入、存储、集成、治理、服务等功能,实现基础数据、监测数据、业务数据、共享数据、多媒体数据和地理空间数据等信息汇聚,帮助业务层应用便捷、高效的构建全生命周期的数据池。
### 4.2 功能需求
#### 4.2.1 数据汇聚
| 编号 | 功能 | 描述 |
|------|------|------|
| DE-01 | 数据采集 | 支持实时流数据采集(MQTT/Kafka),批量数据采集 |
| DE-02 | 数据接入 | RESTful API、WebSocket、数据库直连等多种接入方式 |
| DE-03 | 数据存储 | 时序数据库(TDengine/InfluxDB)、关系数据库、对象存储 |
| DE-04 | 数据集成 | 多源异构数据整合,消除孤岛 |
#### 4.2.2 数据治理
| 编号 | 功能 | 描述 |
|------|------|------|
| DE-05 | 数据标准化 | 依据水利数据对象标准,统一数据格式和命名规范 |
| DE-06 | 数据清洗 | 缺失值填充、异常值检测与处理、重复数据去重 |
| DE-07 | 数据质控 | 数据质量评估、质量报告、自动质量控制规则 |
| DE-08 | 数据转换 | 格式转换、量纲转换、编码映射 |
| DE-09 | 数据加载 | 批量加载与增量加载到数据仓库 |
| DE-10 | 数据关联 | 梳理数据对象间的逻辑关系,建立数据血缘 |
#### 4.2.3 数据服务
| 编号 | 功能 | 描述 |
|------|------|------|
| DE-11 | 数据产品加工 | 实时流处理(Flink/Spark Streaming)+ 批处理(Spark/Hive) |
| DE-12 | 产品服务 | API 网关,数据产品发布与订阅 |
| DE-13 | 数据查询服务 | 统一 SQL 查询、全文搜索、历史回溯 |
| DE-14 | 数据目录 | 数据资产目录,元数据管理 |
---
## 五、营业收费系统(8个子系统)
### 5.1 营收统一管理平台(含APP)
| 编号 | 功能 | 描述 |
|------|------|------|
| REV-01 | 平台运维审计管理 | 操作日志记录,安全审计 |
| REV-02 | 用户管理 | 用户注册、信息维护、实名认证 |
| REV-03 | 用户授权 | 基于角色和权限的授权管理 |
| REV-04 | 应用接入 | 第三方应用统一接入与授权 |
| REV-05 | 单点登录 | 统一身份认证(SSO),支持 OAuth2/OIDC |
### 5.2 报装管理系统
| 编号 | 功能 | 描述 |
|------|------|------|
| INS-01 | 首页 | 报装概览、关键指标展示 |
| INS-02 | 预受理申请 | 新建预申请、现场踏勘预约 |
| INS-03 | 工程申请 | 正式安装工程申请、材料清单、预算审批 |
| INS-04 | 任务管理 | 工程任务分派、进度跟踪 |
| INS-05 | 综合查询 | 按申请人、状态、时间等多维度查询 |
| INS-06 | 流程管理 | 流程配置、流程监控 |
| INS-07 | 报表分析 | 报装统计报表、周期分析 |
| INS-08 | 报装管理APP | 移动端报装申请、进度查看 |
### 5.3 营业收费系统
| 编号 | 功能 | 描述 |
|------|------|------|
| BILL-01 | 首页 | 营收总览、关键财务指标 |
| BILL-02 | 用户管理 | 用水户档案管理、分类(居民/企事业单位/商业) |
| BILL-03 | 抄表管理 | 人工抄表录入、远传抄表集成、异常核查 |
| BILL-04 | 收费管理 | 柜台收费、移动POS收费、第三方支付(支付宝/微信) |
| BILL-05 | 发票管理 | 电子发票开具、推送、打印 |
| BILL-06 | 账务管理 | 应收应付、冲销、对账、结转 |
| BILL-07 | 智能表平台 | 远传水表数据接入与匹配 |
| BILL-08 | 短信平台 | 欠费提醒、缴费通知、停水公告短信 |
| BILL-09 | 查询统计 | 收费统计、欠费统计、用水趋势分析 |
| BILL-10 | 业务参数设置 | 水价阶梯配置、污水处理费配置、优惠策略 |
| BILL-11 | 支付宝生活缴费 | 对接支付宝生活缴费平台 |
| BILL-12 | 手机抄表APP | 移动端抄表录入、拍照上传 |
### 5.4 表务管理系统
| 编号 | 功能 | 描述 |
|------|------|------|
| MET-01 | 水表入库 | 新表采购入库,记录批次、型号、数量 |
| MET-02 | 水表出库 | 安装出库、领用出库,记录去向 |
| MET-03 | 水表暂拆 | 临时拆除登记,记录拆除原因 |
| MET-04 | 水表复接 | 暂拆水表重新安装接入 |
| MET-05 | 故障换表 | 故障水表更换,新旧表底数记录 |
| MET-06 | 周期换表 | 到期水表批量更换管理 |
| MET-07 | 销户撤表 | 用户销户时水表拆除登记 |
| MET-08 | 新表交回 | 新表退回仓库管理 |
| MET-09 | 旧表交回 | 拆除旧表交回登记 |
| MET-10 | 水表报废 | 水表报废审批与处置 |
| MET-11 | 水表翻新 | 可修复水表翻新入库 |
| MET-12 | 水表校表 | 水表检定校准管理 |
### 5.5 客服热线管理系统
| 编号 | 功能 | 描述 |
|------|------|------|
| CSR-01 | APP 应用 | 移动端客服处理 |
| CSR-02 | 客户端应用 | Web 端客服工作台 |
| CSR-03 | 水费查询 | 语音/在线水费自助查询 |
| CSR-04 | TTS 语音自助服务 | 文字转语音,自动应答常见问题 |
| CSR-05 | 知识库 | 常见问题、政策法规、业务指南统一管理 |
| CSR-06 | 公告板 | 停水公告、水质公告、服务通知 |
| CSR-07 | KPI 指标分析 | 客服响应时长、满意度、处理量统计 |
### 5.6 微信网上营业厅
| 编号 | 功能 | 描述 |
|------|------|------|
| WX-01 | 首页 | 微信网厅首页,功能导航 |
| WX-02 | 在线缴费 | 微信支付在线缴纳水费 |
| WX-03 | 水费账单 | 月度账单查询、历史账单 |
| WX-04 | 电子发票 | 电子发票申请、下载 |
| WX-05 | 业务进度查询 | 报装、报修等业务办理进度 |
| WX-06 | 用户绑定 | 手机号/户号绑定与解绑 |
| WX-07 | 停水公告 | 停水信息推送与查看 |
| WX-08 | 水质公告 | 水质检测结果公开 |
| WX-09 | 办事指南 | 各类业务办理流程说明 |
| WX-10 | 用水百科 | 节水知识、用水常识 |
| WX-11 | 业务办理 | 在线报装、报修、变更申请 |
| WX-12 | 水管家服务 | 智能用水分析、节水建议 |
| WX-13 | AI 客服 | 智能问答客服机器人 |
| WX-14 | 后台:业务审核 | 业务申请的审核处理 |
| WX-15 | 后台:公告管理 | 水质/停水/新闻/百科/政策法规/业务指南发布 |
| WX-16 | 后台:消息推送 | 模板消息、群发通知 |
### 5.7 远传集抄系统
| 编号 | 功能 | 描述 |
|------|------|------|
| RMR-01 | 计量中心 | 远传设备计量数据汇总 |
| RMR-02 | 户表集抄 | 居民用户水表集中抄读 |
| RMR-03 | 大表监控 | 大口径水表(DN80+)实时监测 |
| RMR-04 | 小区 DMA | DMA(分区计量)社区漏损分析 |
| RMR-05 | 单点分析 | 单表用水趋势分析、异常检测 |
| RMR-06 | 智慧运维 | 远传设备状态监控、故障诊断 |
| RMR-07 | 系统设置 | 采集频率、通信参数配置 |
| RMR-08 | APP | 移动端远传抄表数据查看 |
### 5.8 工单管理系统
| 编号 | 功能 | 描述 |
|------|------|------|
| WO-01 | 流程定义 | 各类工单流程模板定义 |
| WO-02 | 流程创建 | 工单自动/手动创建 |
| WO-03 | 流程处理 | 工单分派、处理、反馈、审核、关闭 |
| WO-04 | 移动端 | 移动端工单处理(接收、处理、拍照上传、签字确认) |
| WO-05 | 跨模块集成 | 与设备管理、项目管理、客服系统联动 |
---
## 六、供水生产管理平台
### 6.1 总览(首页)
| 编号 | 功能 | 描述 |
|------|------|------|
| PROD-01 | 区域定位 | 用户进入平台,地图自动定位到所在供水区厂。一体化水厂负责人→一体化水厂;托里镇水厂负责人→托里镇水厂。系统管理员可查看所有水厂 |
| PROD-02 | 今日进出水量 | 显示今日进水水量、出水水量 |
| PROD-03 | 昨日总供水量 | 昨日供水总量汇总 |
| PROD-04 | 原水水质 | 显示原水水质关键指标(一体化水厂) |
| PROD-05 | 出厂水水质达标 | 显示出厂水水质达标情况(一体化水厂、分水厂) |
| PROD-06 | 末梢水水质 | 显示末梢水水质数据(分水厂) |
| PROD-07 | 实时报警 | 重要报警事件列表及详情 |
| PROD-08 | 设备运行概况 | 设备运行状态统计 |
| PROD-09 | 能耗信息 | 电耗、水耗实时数据展示 |
| PROD-10 | 药耗信息 | 药剂消耗实时数据展示 |
### 6.2 在线监测管理
#### 6.2.1 地图监测
| 编号 | 功能 | 描述 |
|------|------|------|
| MON-01 | GIS 点位展示 | 依托 GIS 地图可视化展示全域监测点位,支持流量、压力、液位、水质、阀门等监测控制设备实时状态监控 |
| MON-02 | 远程控制 | 支持对电动阀门下发远程控制指令,指令可同步下发至 SCADA 系统执行 |
| MON-03 | 实时数据 | 地图上各点位实时数据弹窗/浮动展示 |
#### 6.2.2 实时监测列表
| 编号 | 功能 | 描述 |
|------|------|------|
| MON-04 | 多维度筛选 | 按片区筛选(一体化水厂、精芒、八家户、托里、大镇阿合其、托托) |
| MON-05 | 位置类型区分 | 按位置类型区分:水厂、调压站、管网、村队点位 |
| MON-06 | 实时数据列表 | 各站点实时数据表格展示、刷新 |
#### 6.2.3 视频监控
| 编号 | 功能 | 描述 |
|------|------|------|
| MON-07 | 实时视频 | 汇聚厂区、站点、管网关键位置实时监控视频,支持画面实时预览 |
| MON-08 | AI 智能识别 | 接入模型库中的人员闯入智能识别模型,具备智能人员闯入识别功能 |
| MON-09 | 异常自动报警 | 触发闯入异常时自动报警 |
### 6.3 水质管控
#### 6.3.1 药剂投加管控
| 编号 | 功能 | 描述 |
|------|------|------|
| WQ-01 | 站点覆盖 | 覆盖一体化水厂、八家户水厂、查村调压站、沙山子调压站、大河沿子镇水厂所有生产站点 |
| WQ-02 | 全工艺监控 | 针对一体化水厂混凝、沉淀、过滤、消毒全工艺监测 |
| WQ-03 | 核心参数 | 进水浊度、絮凝剂投加速率、沉淀池液位与浊度、滤池液位、消毒剂投加速率、出厂水余氯、出厂水浊度实时监控 |
#### 6.3.2 水质监测管理
| 编号 | 功能 | 描述 |
|------|------|------|
| WQ-04 | 人工检测管理 | 规划、固化每月管网水、末梢水人工检测点位,记录人工检测数据 |
| WQ-05 | 自动监测接入 | 同步接入在线监测设备实时水质数据 |
| WQ-06 | 数据台账 | 形成水质数据台账,支持追溯与统计 |
### 6.4 调度工作台
| 编号 | 功能 | 描述 |
|------|------|------|
| DISP-01 | 今日值班 | 显示一体化水厂、各分水厂今日值班人员及联系方式 |
| DISP-02 | 我的值班 | 显示供水水厂实时监测信息、报警信息、下发指令;值班开始/结束操作 |
| DISP-03 | 指令台账 | 汇总所有已下发的指令,支持指令全生命周期管理(下发→接收→执行→完成→驳回),可追溯、可查询 |
| DISP-04 | 工单管理 | 展示所有待办/已处理的调度相关报警派单等工单流程;支持按条件查询、查看工单流程图和处理信息 |
| DISP-05 | 值班记录 | 按部门查看值班日志、交接班日志;支持导出交接班日志(Excel) |
### 6.5 报警管理中心
| 编号 | 功能 | 描述 |
|------|------|------|
| ALERT-01 | 报警总览 | 对报警记录进行统计分析,支持按条件筛选 |
| ALERT-02 | 实时报警 | ① 按报警等级/确认状态/站点类型/报警类型/区域/站点名称筛选查询;② 对未确认未派单的报警执行确认和派单操作 |
| ALERT-03 | 历史报警 | 记录已恢复的历史报警记录台账,支持回溯分析 |
### 6.6 调度业务管理
| 编号 | 功能 | 描述 |
|------|------|------|
| SCH-01 | 调度策略制定 | 支持常态化调度:配置、存储、调用典型日常调度预案;支持专项应急调度 |
| SCH-02 | 应急场景推演 | 爆管模拟、水质异常处置、风险管理、应急供水四大专项场景推演与策略制定 |
| SCH-03 | 调度指令执行跟踪 | 对所有下发调度指令全流程跟踪,记录下发/接收/执行/完成/驳回状态 |
| SCH-04 | 应急调度管理 | 针对突发爆管、水质异常、设备故障、停水等场景,应急调度方案调用、资源调配、流程管控 |
| SCH-05 | 调度效果评估 | 对日常调度、应急调度执行效果进行数据复盘、指标分析,形成评估报告 |
### 6.7 数据中心
| 编号 | 功能 | 描述 |
|------|------|------|
| DATA-01 | 数据查看 | 自动监测数据+人工填报数据统一查看,只读不可修改;图表/报表形式展示,支持历史回溯 |
| DATA-02 | 设备管理 | 供水工程设备列表,按设备名称/类型/运行时间/位置查询 |
| DATA-03 | 阈值管理 | 水压、流量等各类实时监测数据触发报警阈值设定与编辑 |
| DATA-04 | 信息发布 | 预报发布、预警发布,支持上级单位/上游水库信息发布,发布后在调度平台同步展示 |
| DATA-05 | 报表统计 | 水量报表、水压报表、流量报表、水质报表、液位报表 |
### 6.8 配置中心
| 编号 | 功能 | 描述 |
|------|------|------|
| CFG-01 | 指令配置 | 配置供水工程调度下发指令,定义常用调度指令内容,用于快速下发 |
| CFG-02 | 限值配置 | 配置管网压力、清水池液位等监测超限数值 |
| CFG-03 | 报警方案 | 新增报警方案(报警名称/通知方案/报警规则/报警级别),关联监测点 |
| CFG-04 | 值班安排 | 值班方案制定/值班班组配置/值班班次配置,按部门管理 |
### 6.9 文档管理
| 编号 | 功能 | 描述 |
|------|------|------|
| DOC-01 | 文档上传/下载 | 设备使用手册、维护记录、水质检测报告等上传下载 |
| DOC-02 | 版本控制 | 文档更新和修改有序管理,版本追溯 |
| DOC-03 | 分类管理 | 文档分类编目,快速定位 |
| DOC-04 | 搜索 | 全文检索 |
| DOC-05 | 权限管理 | 文档权限设置,确保信息安全 |
### 6.10 系统管理
| 编号 | 功能 | 描述 |
|------|------|------|
| SYS-01 | 角色管理 | 预设多级业务角色:系统管理员(最高权限)、水务分管领导、水务业务管理人员、现场运维操作人员、系统技术维护人员;支持角色权限自定义分配,适配精河县水利局、安阜清源水务公司等多单位用户 |
| SYS-02 | 用户管理 | 系统用户新增/编辑/停用管理 |
| SYS-03 | 菜单管理 | 平台菜单自定义配置 |
| SYS-04 | 部门管理 | 维护管理水利局、水务公司、运维单位等组织部门架构 |
| SYS-05 | 日志管理 | 自动记录系统登录日志、用户操作日志、系统异常日志;支持查询、溯源、导出 |
---
## 七、巡检管理系统
### 7.1 巡检管理
| 编号 | 功能 | 描述 |
|------|------|------|
| PAT-01 | 巡检总览 | 巡检任务执行概况、关键指标 |
| PAT-02 | 任务轨迹 | 巡检人员 GPS 轨迹记录与回放 |
| PAT-03 | 任务台账 | 巡检任务记录台账 |
| PAT-04 | 设备台账 | 巡检设备状态台账 |
| PAT-05 | 工单管理 | 巡检相关工单处理 |
| PAT-06 | 工单处理 | 巡检发现问题的处理闭环 |
### 7.2 巡查任务
| 编号 | 功能 | 描述 |
|------|------|------|
| PAT-07 | 任务分派 | 巡检任务按区域/人员自动或手动分派 |
| PAT-08 | 任务管理 | 任务创建、编辑、删除、状态跟踪 |
### 7.3 统计分析
| 编号 | 功能 | 描述 |
|------|------|------|
| PAT-09 | 任务执行率 | 任务完成率统计 |
| PAT-10 | 人员里程 | 巡检人员总里程统计 |
| PAT-11 | 考勤记录 | 巡检人员考勤统计 |
| PAT-12 | 巡检工作量 | 巡检工作量统计分析 |
| PAT-13 | 工作时长 | 巡检工作时长统计 |
| PAT-14 | 巡检统计 | 巡检结果分类统计 |
| PAT-15 | 任务统计 | 任务维度综合统计 |
### 7.4 巡查设置
| 编号 | 功能 | 描述 |
|------|------|------|
| PAT-16 | 区域设置 | 巡检区域划分与配置 |
| PAT-17 | 路线设置 | 巡检路线规划与配置 |
| PAT-18 | 自定义表单 | 巡检表单自定义(巡检项目/检查项) |
| PAT-19 | 表单挂接 | 表单与巡检点位/路线关联 |
| PAT-20 | 模版设置 | 巡检模版批量配置 |
### 7.5 巡查APP
| 编号 | 功能 | 描述 |
|------|------|------|
| PAT-21 | 巡检总览 | APP 端巡检概况 |
| PAT-22 | 今日任务 | 当日巡检任务列表 |
| PAT-23 | 待办工单 | 待处理巡检工单 |
| PAT-24 | 巡检统计 | 个人巡检数据统计 |
| PAT-25 | 任务列表 | 全部任务列表与详情 |
| PAT-26 | 工单列表 | 全部工单列表与详情 |
| PAT-27 | 问题上报 | 巡检现场问题即时上报(文字/照片/语音) |
| PAT-28 | 个人信息 | 个人中心/设置 |
| PAT-29 | 历史任务 | 历史巡检任务查询 |
---
## 八、移动APP(集成端)
| 编号 | 功能 | 描述 |
|------|------|------|
| APP-01 | 供水管理APP | 生产管理平台移动端(实时监测/报警/调度/水质) |
| APP-02 | 巡检APP | 巡检系统移动端(任务/轨迹/上报) |
| APP-03 | 营业收费APP | 营收系统移动端(抄表/账务/报装) |
| APP-04 | 统一入口 | 三合一集成入口,统一登录与权限 |
| APP-05 | 消息通知 | 报警推送、待办提醒、公告通知 |
---
## 九、用户角色体系
| 角色 | 权限范围 | 说明 |
|------|----------|------|
| 系统管理员 | 全部 | 最高权限,跨多单位管理 |
| 水务分管领导 | 查看全部 | BI看板、报表、总览 |
| 水务业务管理人员 | 业务模块 | 调度、营收、水质、报警管理 |
| 现场运维操作人员 | 操作层 | 值班、巡检、工单处理、监测查看 |
| 系统技术维护人员 | 技术层 | 配置、设备管理、日志、阈值维护 |
**多单位适配**:精河县水利局、安阜清源水务公司等。
---
## 十、技术约束
1. **物联网**:需兼容现有已建设备的传输协议,制定标准化接入方案
2. **GIS**:需支持超图/ArcGIS 或开源方案(Leaflet/OpenLayers),用于地图监测与空间分析
3. **视频**:需接入现有视频监控系统,集成 AI 人员闯入识别模型
4. **SCADA**:需与现有 SCADA 系统对接,指令同步下发
5. **水利标准**:遵循水利数据对象标准、计量检定标准
6. **安全等保**:不低于等保二级
---
## 文档版本
| 版本 | 日期 | 修订内容 | 修订人 |
|------|------|----------|--------|
| v1.0 | 2026-06-14 | 初始版本,基于 Excel 规格清单整理 | bot_pm |
-50
View File
@@ -1,50 +0,0 @@
# 水务管理系统 BI 前端
这是一个简单的HTML前端,用于展示水务管理系统的BI看板和数据可视化功能。
## 功能特点
- 自助BI看板浏览
- 图表数据可视化
- 实时数据展示
- 响应式设计
- 移动端兼容
## 文件结构
```
frontend/
├── index.html # 主页面
├── css/
│ └── styles.css # 样式文件
├── js/
│ ├── main.js # 主要JavaScript逻辑
│ ├── api.js # API调用封装
│ └── charts.js # 图表绘制逻辑
└── lib/
└── echarts.min.js # ECharts图表库
```
## 使用方法
1. 确保后端API服务器正在运行
2. 打开 `index.html` 文件
3. 选择要查看的看板
4. 查看实时数据和图表
## API接口
前端主要通过以下API接口与后端交互:
- `/bi-api/dashboards/overview` - 获取概览看板
- `/bi-api/dashboards/{id}/data` - 获取看板数据
- `/bi-api/charts/{id}/data` - 获取图表数据
- `/bi-api/search` - 搜索功能
## 技术栈
- HTML5
- CSS3
- JavaScript (ES6+)
- ECharts 图表库
- Fetch API
-412
View File
@@ -1,412 +0,0 @@
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
/* 头部样式 */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 0;
margin-bottom: 30px;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20px;
}
.header h1 {
font-size: 2.5rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 10px;
}
/* 按钮样式 */
.btn {
padding: 10px 20px;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background-color: #4CAF50;
color: white;
}
.btn-primary:hover {
background-color: #45a049;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn-secondary {
background-color: #2196F3;
color: white;
}
.btn-secondary:hover {
background-color: #1976D2;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.btn-back {
background-color: #ff7043;
color: white;
padding: 8px 16px;
font-size: 14px;
}
.btn-back:hover {
background-color: #f4511e;
}
/* 看板选择器样式 */
.dashboard-selector {
margin-bottom: 30px;
}
.dashboard-selector h2 {
margin-bottom: 20px;
color: #333;
font-size: 1.8rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.dashboard-card {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
}
.dashboard-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.dashboard-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.3rem;
}
.dashboard-card p {
color: #666;
margin-bottom: 15px;
font-size: 0.95rem;
}
.dashboard-card .tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-bottom: 15px;
}
.tag {
background-color: #e3f2fd;
color: #1976d2;
padding: 4px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
}
.dashboard-card .charts-count {
color: #667eea;
font-weight: 600;
font-size: 0.9rem;
}
/* 看板详情样式 */
.dashboard-detail {
background: white;
border-radius: 10px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.dashboard-header {
display: flex;
align-items: center;
margin-bottom: 30px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f0f0;
}
.dashboard-header h2 {
margin-left: 15px;
color: #333;
font-size: 1.8rem;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
}
.chart-container {
background: #fafafa;
border-radius: 8px;
padding: 20px;
border: 1px solid #e0e0e0;
}
.chart-container h3 {
color: #333;
margin-bottom: 15px;
font-size: 1.1rem;
}
.chart {
width: 100%;
height: 300px;
border-radius: 5px;
}
/* 模态框样式 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
animation: fadeIn 0.3s ease;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 10px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
animation: slideIn 0.3s ease;
}
.modal-header {
padding: 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
color: #333;
}
.modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.3s ease;
}
.modal-close:hover {
background-color: #f0f0f0;
}
.modal-body {
padding: 20px;
}
.search-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
margin-bottom: 20px;
outline: none;
}
.search-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.1);
}
.search-results {
max-height: 400px;
overflow-y: auto;
}
.search-result-item {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-item h4 {
color: #333;
margin-bottom: 5px;
}
.search-result-item p {
color: #666;
font-size: 0.9rem;
margin: 0;
}
.search-result-item .type {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
margin-top: 5px;
}
.search-result-item .type.chart {
background-color: #e3f2fd;
color: #1976d2;
}
.search-result-item .type.dashboard {
background-color: #f3e5f5;
color: #7b1fa2;
}
/* 动画效果 */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: translateY(-50px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 10px;
}
.header-content {
flex-direction: column;
gap: 15px;
}
.header h1 {
font-size: 2rem;
}
.dashboard-grid {
grid-template-columns: 1fr;
}
.charts-container {
grid-template-columns: 1fr;
}
.modal-content {
width: 95%;
margin: 10% auto;
}
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.loading::after {
content: '';
display: inline-block;
width: 20px;
height: 20px;
border: 2px solid #ddd;
border-top: 2px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-left: 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 错误状态 */
.error {
background-color: #ffebee;
color: #c62828;
padding: 12px;
border-radius: 5px;
margin: 10px 0;
border-left: 4px solid #c62828;
}
/* 空状态 */
.empty {
text-align: center;
padding: 40px;
color: #666;
}
+6 -61
View File
@@ -1,67 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>水务管理系统 - BI看板</title>
<link rel="stylesheet" href="css/styles.css">
<script src="lib/echarts.min.js"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智慧水务管理系统</title>
</head>
<body>
<div class="container">
<!-- 头部 -->
<header class="header">
<div class="header-content">
<h1>水务管理系统 BI 看板</h1>
<div class="header-actions">
<button id="refreshBtn" class="btn btn-primary">刷新数据</button>
<button id="searchBtn" class="btn btn-secondary">搜索</button>
</div>
</div>
</header>
<!-- 主内容区域 -->
<main class="main-content">
<!-- 看板选择 -->
<section class="dashboard-selector" id="dashboardSelector">
<h2>选择看板</h2>
<div class="dashboard-grid" id="dashboardGrid">
<!-- 看板卡片将通过JavaScript动态加载 -->
</div>
</section>
<!-- 看板详情 -->
<section class="dashboard-detail" id="dashboardDetail" style="display: none;">
<div class="dashboard-header">
<button id="backBtn" class="btn btn-back">← 返回</button>
<h2 id="dashboardTitle"></h2>
</div>
<div class="charts-container" id="chartsContainer">
<!-- 图表将通过JavaScript动态加载 -->
</div>
</section>
<!-- 搜索模态框 -->
<div id="searchModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>搜索看板和图表</h3>
<button id="closeSearchBtn" class="modal-close">&times;</button>
</div>
<div class="modal-body">
<input type="text" id="searchInput" placeholder="输入搜索关键词..." class="search-input">
<div id="searchResults" class="search-results">
<!-- 搜索结果将在这里显示 -->
</div>
</div>
</div>
</div>
</main>
</div>
<!-- JavaScript文件 -->
<script src="js/api.js"></script>
<script src="js/charts.js"></script>
<script src="js/main.js"></script>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>
-267
View File
@@ -1,267 +0,0 @@
/**
* API 封装模块
* 提供与后端API的交互功能
*/
class BIAPIClient {
constructor(baseURL = '') {
this.baseURL = baseURL || window.location.origin;
}
// 通用请求方法
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
...options,
};
try {
const response = await fetch(url, defaultOptions);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API请求失败:', error);
throw error;
}
}
// GET请求
async get(endpoint, params = {}) {
const url = new URL(`${this.baseURL}${endpoint}`, window.location.origin);
Object.keys(params).forEach(key => {
if (params[key] !== undefined && params[key] !== null) {
url.searchParams.append(key, params[key]);
}
});
return this.request(url.pathname + url.search);
}
// POST请求
async post(endpoint, data = {}) {
return this.request(endpoint, {
method: 'POST',
body: JSON.stringify(data),
});
}
// PUT请求
async put(endpoint, data = {}) {
return this.request(endpoint, {
method: 'PUT',
body: JSON.stringify(data),
});
}
// DELETE请求
async delete(endpoint) {
return this.request(endpoint, {
method: 'DELETE',
});
}
// BI相关API
async getOverviewDashboards() {
return this.get('/bi-api/dashboards/overview');
}
async getDashboardData(dashboardId) {
return this.get(`/bi-api/dashboards/${dashboardId}/data`);
}
async getChartData(chartId) {
return this.get(`/bi-api/charts/${chartId}/data`);
}
async searchBIObjects(keyword) {
return this.get('/bi-api/search', { keyword });
}
async getChartTypes() {
return this.get('/bi-api/charts/types');
}
async getDataSourceTypes() {
return this.get('/bi-api/data-sources/types');
}
async getPopularTags() {
return this.get('/bi-api/popular-tags');
}
async getQuickStats() {
return this.get('/bi-api/quick-stats');
}
async getChartSuggestions() {
return this.get('/bi-api/chart-suggestions');
}
}
// 创建全局API客户端实例
const apiClient = new BIAPIClient();
/**
* 数据获取函数
*/
// 获取概览看板列表
export async function getDashboards() {
try {
return await apiClient.getOverviewDashboards();
} catch (error) {
console.error('获取看板列表失败:', error);
throw error;
}
}
// 获取看板详细数据
export async function getDashboardDetail(dashboardId) {
try {
return await apiClient.getDashboardData(dashboardId);
} catch (error) {
console.error(`获取看板 ${dashboardId} 数据失败:`, error);
throw error;
}
}
// 获取图表数据
export async function getChartData(chartId) {
try {
return await apiClient.getChartData(chartId);
} catch (error) {
console.error(`获取图表 ${chartId} 数据失败:`, error);
throw error;
}
}
// 搜索BI对象
export async function searchBIObjects(keyword) {
try {
return await apiClient.searchBIObjects(keyword);
} catch (error) {
console.error(`搜索 ${keyword} 失败:`, error);
throw error;
}
}
// 获取支持的图表类型
export async function getChartTypes() {
try {
return await apiClient.getChartTypes();
} catch (error) {
console.error('获取图表类型失败:', error);
throw error;
}
}
// 获取支持的数据源类型
export async function getDataSourceTypes() {
try {
return await apiClient.getDataSourceTypes();
} catch (error) {
console.error('获取数据源类型失败:', error);
throw error;
}
}
// 获取热门标签
export async function getPopularTags() {
try {
return await apiClient.getPopularTags();
} catch (error) {
console.error('获取热门标签失败:', error);
throw error;
}
}
// 获取快速统计信息
export async function getQuickStats() {
try {
return await apiClient.getQuickStats();
} catch (error) {
console.error('获取快速统计失败:', error);
throw error;
}
}
// 获取图表建议
export async function getChartSuggestions() {
try {
return await apiClient.getChartSuggestions();
} catch (error) {
console.error('获取图表建议失败:', error);
throw error;
}
}
/**
* 工具函数
*/
// 格式化日期
export function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
// 格式化数字
export function formatNumber(num, decimals = 2) {
return parseFloat(num).toFixed(decimals);
}
// 显示加载状态
export function showLoading(element) {
element.innerHTML = '<div class="loading">加载中...</div>';
}
// 显示错误状态
export function showError(element, message) {
element.innerHTML = `<div class="error">${message}</div>`;
}
// 显示空状态
export function showEmpty(element, message = '暂无数据') {
element.innerHTML = `<div class="empty">${message}</div>`;
}
// 防抖函数
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// 节流函数
export function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
-510
View File
@@ -1,510 +0,0 @@
/**
* 图表绘制模块
* 使用ECharts绘制各种图表
*/
class ChartRenderer {
constructor() {
this.charts = new Map(); // 存储图表实例
}
// 创建图表容器
createChartContainer(chartId, title) {
const container = document.createElement('div');
container.className = 'chart-container';
container.innerHTML = `
<h3>${title}</h3>
<div class="chart" id="chart-${chartId}"></div>
`;
return container;
}
// 初始化图表
initChart(chartId, options = {}) {
const chartDom = document.getElementById(`chart-${chartId}`);
if (!chartDom) {
console.error(`图表容器 ${chartId} 不存在`);
return null;
}
// 清除之前的图表实例
if (this.charts.has(chartId)) {
this.charts.get(chartId).dispose();
}
const chart = echarts.init(chartDom);
this.charts.set(chartId, chart);
// 应用配置
chart.setOption(options);
// 响应式处理
const resizeHandler = () => {
chart.resize();
};
window.addEventListener('resize', resizeHandler);
// 保存resize处理函数以便后续清理
chartDom._resizeHandler = resizeHandler;
return chart;
}
// 销毁图表
destroyChart(chartId) {
const chart = this.charts.get(chartId);
if (chart) {
chart.dispose();
this.charts.delete(chartId);
// 移除resize监听器
const chartDom = document.getElementById(`chart-${chartId}`);
if (chartDom && chartDom._resizeHandler) {
window.removeEventListener('resize', chartDom._resizeHandler);
}
}
}
// 绘制折线图
renderLineChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '趋势图',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: options.legendData || ['数据'],
top: 'bottom'
},
xAxis: {
type: 'category',
data: data.map(item => item[options.xAxis || 'timestamp']),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: options.yAxisName || '数值'
},
series: options.seriesData || [{
name: '数据',
type: 'line',
data: data.map(item => item.value),
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: {
width: 2
},
areaStyle: {
opacity: 0.1
}
}],
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
}
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制柱状图
renderBarChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '柱状图',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
xAxis: {
type: 'category',
data: data.map(item => item[options.xAxis || 'category']),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: options.yAxisName || '数值'
},
series: [{
name: options.seriesName || '数据',
type: 'bar',
data: data.map(item => item.value),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 0.7, color: '#2378f7' },
{ offset: 1, color: '#83bff6' }
])
}
}
}],
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
}
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制饼图
renderPieChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '饼图',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
legend: {
orient: 'vertical',
left: 'left',
top: 'middle'
},
series: [{
name: options.seriesName || '数据',
type: 'pie',
radius: options.radius || ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '20',
fontWeight: 'bold'
}
},
labelLine: {
show: false
},
data: data.map(item => ({
name: item.name,
value: item.value
}))
}]
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制散点图
renderScatterChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '散点图',
left: 'center'
},
tooltip: {
trigger: 'item'
},
xAxis: {
type: 'value',
name: options.xAxisName || 'X轴'
},
yAxis: {
type: 'value',
name: options.yAxisName || 'Y轴'
},
series: [{
name: options.seriesName || '数据',
type: 'scatter',
data: data.map(item => [item.x, item.y]),
symbolSize: function (data) {
return Math.sqrt(data[2]) || 10;
},
itemStyle: {
color: new echarts.graphic.RadialGradient(0.5, 0.5, 1, [
{ offset: 0, color: 'rgba(58,77,233,0.8)' },
{ offset: 1, color: 'rgba(58,77,233,0.1)' }
])
}
}]
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制面积图
renderAreaChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '面积图',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: options.legendData || ['数据'],
top: 'bottom'
},
xAxis: {
type: 'category',
data: data.map(item => item[options.xAxis || 'timestamp']),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: options.yAxisName || '数值'
},
series: [{
name: options.seriesName || '数据',
type: 'line',
data: data.map(item => item.value),
smooth: true,
symbol: 'none',
areaStyle: {
opacity: 0.3,
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(128, 255, 165, 0.3)' },
{ offset: 1, color: 'rgba(1, 191, 236, 0.1)' }
])
}
}],
grid: {
left: '3%',
right: '4%',
bottom: '15%',
containLabel: true
}
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制仪表盘
renderGaugeChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '仪表盘',
left: 'center'
},
tooltip: {
formatter: '{b}: {c}%'
},
series: [{
name: options.seriesName || '数据',
type: 'gauge',
min: options.min || 0,
max: options.max || 100,
splitNumber: options.splitNumber || 10,
radius: options.radius || '80%',
axisLine: {
lineStyle: {
width: 10,
color: [
[0.3, '#ff6e76'],
[0.7, '#fddd60'],
[1, '#7cffb2']
]
}
},
pointer: {
itemStyle: {
color: 'auto'
}
},
axisTick: {
distance: -15,
length: 8,
lineStyle: {
color: '#999',
width: 2
}
},
splitLine: {
distance: -20,
length: 15,
lineStyle: {
color: '#999',
width: 3
}
},
axisLabel: {
color: '#999',
distance: 30,
fontSize: 12
},
detail: {
valueAnimation: true,
fontSize: 20,
offsetCenter: [0, '60%']
},
data: [{
value: data.value || 0,
name: options.name || '数据'
}]
}]
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, mergedOptions);
}
// 绘制表格
renderTableChart(chartId, data, options = {}) {
const defaultOptions = {
title: {
text: options.title || '表格',
left: 'center'
},
tooltip: {
show: false
},
grid: {
top: '10%',
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map(item => item[options.xAxis || 'name']),
axisLabel: {
interval: 0,
rotate: 45
}
},
yAxis: {
type: 'value'
},
series: [{
name: options.seriesName || '数据',
type: 'bar',
data: data.map(item => item.value),
itemStyle: {
color: '#5470c6'
}
}]
};
const mergedOptions = this.mergeOptions(defaultOptions, options);
this.initChart(chartId, defaultOptions); // 表格不需要复杂配置
}
// 合并配置选项
mergeOptions(defaultOptions, customOptions) {
return {
...defaultOptions,
...customOptions,
series: customOptions.series || defaultOptions.series
};
}
// 根据图表类型渲染图表
renderChartByType(chartId, chartData) {
const { chart_type: chartType, data, options = {} } = chartData;
switch (chartType) {
case 'line':
this.renderLineChart(chartId, data, options);
break;
case 'bar':
this.renderBarChart(chartId, data, options);
break;
case 'pie':
this.renderPieChart(chartId, data, options);
break;
case 'scatter':
this.renderScatterChart(chartId, data, options);
break;
case 'area':
this.renderAreaChart(chartId, data, options);
break;
case 'gauge':
this.renderGaugeChart(chartId, data, options);
break;
case 'table':
this.renderTableChart(chartId, data, options);
break;
default:
// 默认使用折线图
this.renderLineChart(chartId, data, options);
}
}
// 批量渲染图表
renderCharts(chartsData) {
chartsData.forEach(chartData => {
const { chart_id: chartId, chart_name: chartName, chart_type: chartType, data, options = {} } = chartData;
const chartContainer = this.createChartContainer(chartId, chartName);
document.getElementById('chartsContainer').appendChild(chartContainer);
this.renderChartByType(chartId, {
chart_type: chartType,
data,
options: {
...options,
title: chartName
}
});
});
}
// 清理所有图表
clearAllCharts() {
this.charts.forEach((chart, chartId) => {
this.destroyChart(chartId);
});
const chartsContainer = document.getElementById('chartsContainer');
if (chartsContainer) {
chartsContainer.innerHTML = '';
}
}
}
// 创建全局图表渲染器实例
const chartRenderer = new ChartRenderer();
export default chartRenderer;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "water-management-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"axios": "^1.7.0",
"element-plus": "^2.8.0",
"@element-plus/icons-vue": "^2.3.0",
"echarts": "^5.5.0",
"leaflet": "^1.9.0",
"cesium": "^1.120.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.4.0",
"vue-tsc": "^2.0.0",
"@types/leaflet": "^1.9.0",
"sass": "^1.77.0",
"unplugin-auto-import": "^0.17.0",
"unplugin-vue-components": "^0.27.0",
"vite-plugin-compression": "^0.5.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+13
View File
@@ -0,0 +1,13 @@
import request from './request'
export function login(username: string, password: string) {
return request.post('/base/auth/login', { username, password })
}
export function getUserInfo() {
return request.get('/base/auth/user-info')
}
export function logout() {
return request.post('/base/auth/logout')
}
+68
View File
@@ -0,0 +1,68 @@
import request from './request'
const BASE = '/api/production/dispatch-command'
// 创建指令
export function createCommand(data: any) {
return request.post(BASE, data)
}
// 下发指令
export function issueCommand(id: number, issuedBy: number, operatorName?: string) {
return request.post(`${BASE}/${id}/issue`, null, {
params: { issuedBy, operatorName: operatorName || 'system' }
})
}
// 指令台账
export function listCommands(params: {
page?: number; size?: number; status?: string;
commandType?: string; keyword?: string; startDate?: string; endDate?: string
}) {
return request.get(BASE, { params })
}
// 指令详情
export function getCommandDetail(id: number) {
return request.get(`${BASE}/${id}`)
}
// 状态统计
export function getCommandStats() {
return request.get(`${BASE}/stats`)
}
// 接收确认
export function receiveCommand(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/receive`, null, {
params: { userId, userName: userName || '' }
})
}
// 开始执行
export function startExecute(id: number, userId: number, userName?: string) {
return request.post(`${BASE}/${id}/start-execute`, null, {
params: { userId, userName: userName || '' }
})
}
// 完成执行
export function completeExecution(id: number, userId: number, data: {
userName?: string; feedback?: string; feedbackImages?: string
}) {
return request.post(`${BASE}/${id}/complete`, null, {
params: { userId, ...data }
})
}
// 驳回
export function rejectExecution(id: number, userId: number, reason: string, userName?: string) {
return request.post(`${BASE}/${id}/reject`, null, {
params: { userId, userName: userName || '', reason }
})
}
// 追踪日志
export function getTrackingLogs(id: number) {
return request.get(`${BASE}/${id}/tracking`)
}
+21
View File
@@ -0,0 +1,21 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({ baseURL: '/api', timeout: 15000 })
request.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) config.headers.Authorization = token
return config
})
request.interceptors.response.use(
res => {
const data = res.data
if (data.code !== 200) { ElMessage.error(data.message); return Promise.reject(data) }
return data
},
err => { ElMessage.error(err.message); return Promise.reject(err) }
)
export default request
+2
View File
@@ -0,0 +1,2 @@
body { margin:0; padding:0; font-family:"Microsoft YaHei","PingFang SC",sans-serif }
#app { height:100vh }
@@ -0,0 +1,42 @@
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const props = defineProps<{
center?: [number, number]
zoom?: number
markers?: Array<{ lat: number; lng: number; name: string; value?: string }>
}>()
const mapContainer = ref<HTMLElement>()
let map: any = null
onMounted(async () => {
const L = (await import('leaflet')).default
await import('leaflet/dist/leaflet.css')
map = L.map(mapContainer.value!).setView(props.center || [44.6000, 82.9000], props.zoom || 10)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap', maxZoom: 18
}).addTo(map)
if (props.markers) {
props.markers.forEach(m => {
L.marker([m.lat, m.lng])
.addTo(map)
.bindPopup(`<b>${m.name}</b><br/>${m.value || ''}`)
})
}
})
onUnmounted(() => { if (map) map.remove() })
</script>
<style scoped>
.map-container { width: 100%; height: 500px; border-radius: 8px }
</style>
@@ -0,0 +1,53 @@
<template>
<el-container style="height:100vh">
<el-aside width="220px" class="sidebar">
<div class="logo">💧 智慧水务</div>
<el-menu :default-active="route.path" router background-color="#304156" text-color="#bfcbd9" active-text-color="#409EFF">
<el-menu-item index="/dashboard"><el-icon><DataAnalysis /></el-icon>供水总览</el-menu-item>
<el-sub-menu index="system">
<template #title><el-icon><Setting /></el-icon>系统管理</template>
<el-menu-item index="/system/user">用户管理</el-menu-item>
<el-menu-item index="/system/role">角色管理</el-menu-item>
<el-menu-item index="/system/menu">菜单管理</el-menu-item>
<el-menu-item index="/system/dept">部门管理</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="topbar">
<span class="title">精河县供水工程综合管理平台</span>
<el-dropdown @command="handleCommand">
<span class="user">{{ userStore.realName || '管理员' }} <el-icon><ArrowDown /></el-icon></span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人中心</el-dropdown-item>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main><router-view /></el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
function handleCommand(cmd: string) {
if (cmd === 'logout') { userStore.logout(); router.push('/login') }
}
</script>
<style scoped>
.sidebar { background:#304156; overflow-y:auto }
.logo { color:#fff; text-align:center; padding:16px; font-size:18px; font-weight:bold; border-bottom:1px solid rgba(255,255,255,.1) }
.topbar { background:#fff; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #e6e6e6 }
.title { font-size:16px; color:#333 }
.user { cursor:pointer; color:#666 }
</style>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import './assets/style.scss'
const app = createApp(App)
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
app.component(key, comp)
}
app.use(ElementPlus).use(router).use(createPinia()).mount('#app')
+32
View File
@@ -0,0 +1,32 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'login', component: () => import('@/views/login/LoginView.vue') },
{
path: '/', component: () => import('@/components/layout/MainLayout.vue'),
redirect: '/dashboard',
children: [
{ path: 'dashboard', name: 'dashboard', component: () => import('@/views/dashboard/DashboardView.vue') },
{ path: 'system/user', name: 'user', component: () => import('@/views/system/user/UserList.vue') },
{ path: 'system/role', name: 'role', component: () => import('@/views/system/role/RoleList.vue') },
{ path: 'system/menu', name: 'menu', component: () => import('@/views/system/menu/MenuList.vue') },
{ path: 'system/dept', name: 'dept', component: () => import('@/views/system/dept/DeptList.vue') },
{ path: 'dispatch-command', name: 'dispatchCommandList', component: () => import('@/views/dispatch-command/CommandList.vue') },
{ path: 'dispatch-command/:id', name: 'dispatchCommandDetail', component: () => import('@/views/dispatch-command/CommandDetail.vue') },
{ path: 'cs/knowledge', name: 'csKnowledge', component: () => import('@/views/cs/KnowledgeBaseView.vue') },
{ path: 'cs/announcement', name: 'csAnnouncement', component: () => import('@/views/cs/AnnouncementView.vue') },
{ path: 'cs/kpi', name: 'csKpi', component: () => import('@/views/cs/KpiDashboardView.vue') },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' }
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach((to, _from, next) => {
const token = localStorage.getItem('token')
if (to.path !== '/login' && !token) { next('/login') }
else { next() }
})
export default router
+10
View File
@@ -0,0 +1,10 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref(localStorage.getItem('token') || '')
const realName = ref('')
const setToken = (t: string) => { token.value = t; localStorage.setItem('token', t) }
const logout = () => { token.value = ''; localStorage.removeItem('token') }
return { token, realName, setToken, logout }
})
+288
View File
@@ -0,0 +1,288 @@
<template>
<div class="announcement-mgmt">
<!-- 搜索区 -->
<el-card shadow="never">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="标题/内容/范围" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.type" placeholder="全部" clearable @change="handleSearch">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已撤回" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 统计卡片 -->
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="6" v-for="stat in typeStats" :key="stat.type">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ typeLabel(stat.type) }}</div>
</el-card>
</el-col>
</el-row>
<!-- 操作栏 -->
<div style="margin-top: 16px">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 发布公告</el-button>
</div>
<!-- 列表 -->
<el-table :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="typeTag(row.type)" size="small">{{ typeLabel(row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="affectedArea" label="影响范围" width="150" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="publishTime" label="发布时间" width="170" />
<el-table-column prop="publisherName" label="发布人" width="100" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" v-if="row.status === 0" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="success" v-if="row.status === 0" @click="handlePublish(row)">发布</el-button>
<el-button link type="warning" v-if="row.status === 1" @click="handleWithdraw(row)">撤回</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑公告' : '发布公告'" width="700px" destroy-on-close>
<el-form :model="form" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="公告标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="类型" required>
<el-select v-model="form.type" style="width: 100%">
<el-option label="停水" value="water_outage" />
<el-option label="水质" value="water_quality" />
<el-option label="维修" value="maintenance" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="优先级">
<el-select v-model="form.priority" style="width: 100%">
<el-option label="低" value="low" />
<el-option label="中" value="medium" />
<el-option label="高" value="high" />
<el-option label="紧急" value="urgent" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="影响范围">
<el-input v-model="form.affectedArea" placeholder="描述受影响的区域" />
</el-form-item>
<el-form-item label="区域编码">
<el-input v-model="form.areaCode" placeholder="用于定向推送(选填)" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="计划开始">
<el-date-picker v-model="form.plannedStart" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="开始时间" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="计划结束">
<el-date-picker v-model="form.plannedEnd" type="datetime" style="width: 100%"
value-format="YYYY-MM-DDTHH:mm:ss" placeholder="结束时间" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="6" placeholder="公告内容" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailItem?.title" width="600px">
<el-descriptions :column="2" border>
<el-descriptions-item label="类型">
<el-tag :type="typeTag(detailItem?.type)">{{ typeLabel(detailItem?.type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detailItem?.priority)">{{ priorityLabel(detailItem?.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detailItem?.status) }}</el-descriptions-item>
<el-descriptions-item label="影响范围">{{ detailItem?.affectedArea }}</el-descriptions-item>
<el-descriptions-item label="计划时间" :span="2">
{{ detailItem?.plannedStart }} ~ {{ detailItem?.plannedEnd }}
</el-descriptions-item>
<el-descriptions-item label="发布人">{{ detailItem?.publisherName }}</el-descriptions-item>
<el-descriptions-item label="发布时间">{{ detailItem?.publishTime }}</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="detail-content">{{ detailItem?.content }}</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/announcement'
const loading = ref(false)
const saving = ref(false)
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const typeStats = ref<any[]>([])
const detailItem = ref<any>(null)
const filterForm = reactive({ keyword: '', type: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium', publisherName: '当前用户'
})
onMounted(() => {
fetchData()
fetchStats()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchStats() {
try {
const res = await request.get(`${API}/stats`)
typeStats.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.type = ''; filterForm.status = undefined; handleSearch()
}
function openCreateDialog() {
isEdit.value = false; editId.value = null
Object.assign(form, { title: '', content: '', type: 'water_outage', affectedArea: '', areaCode: '',
plannedStart: '', plannedEnd: '', priority: 'medium' })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true; editId.value = row.id
Object.assign(form, row)
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData(); fetchStats()
} finally {
saving.value = false
}
}
function viewDetail(row: any) { detailItem.value = row; showDetail.value = true }
async function handlePublish(row: any) {
await ElMessageBox.confirm(`确定发布「${row.title}」?`, '确认发布')
await request.post(`${API}/${row.id}/publish`)
ElMessage.success('发布成功'); fetchData(); fetchStats()
}
async function handleWithdraw(row: any) {
await ElMessageBox.confirm(`确定撤回「${row.title}」?`, '确认撤回')
await request.post(`${API}/${row.id}/withdraw`)
ElMessage.success('撤回成功'); fetchData(); fetchStats()
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功'); fetchData(); fetchStats()
}
const typeMap: Record<string, string> = { water_outage: '停水', water_quality: '水质', maintenance: '维修', other: '其他' }
const typeTagMap: Record<string, string> = { water_outage: 'danger', water_quality: 'warning', maintenance: '', other: 'info' }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return (typeTagMap[t] || 'info') as any }
function priorityLabel(p: string) { return { low: '低', medium: '中', high: '高', urgent: '紧急' }[p] || p }
function priorityTag(p: string) { return ({ low: 'info', medium: '', high: 'warning', urgent: 'danger' }[p] || 'info') as any }
function statusLabel(s: number) { return ['草稿', '已发布', '已撤回'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
</script>
<style scoped>
.stat-card { text-align: center; cursor: pointer; }
.stat-value { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 13px; color: #666; margin-top: 4px; }
.detail-content { white-space: pre-wrap; line-height: 1.8; }
</style>
+298
View File
@@ -0,0 +1,298 @@
<template>
<div class="knowledge-base">
<!-- 搜索过滤区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="搜索标题/内容/标签" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="分类">
<el-select v-model="filterForm.category" placeholder="全部分类" clearable @change="handleSearch">
<el-option v-for="cat in categories" :key="cat.category" :label="cat.category" :value="cat.category">
<span>{{ cat.category }}</span>
<span style="float: right; color: #999; font-size: 12px">{{ cat.count }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
<el-option label="已归档" :value="2" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 操作栏 -->
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="openCreateDialog"><el-icon><Plus /></el-icon> 新建文章</el-button>
<el-radio-group v-model="viewMode" size="small">
<el-radio-button label="list">列表</el-radio-button>
<el-radio-button label="card">卡片</el-radio-button>
</el-radio-group>
</div>
<!-- 列表视图 -->
<el-table v-if="viewMode === 'list'" :data="tableData" border style="margin-top: 12px" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="category" label="分类" width="100">
<template #default="{ row }">
<el-tag size="small">{{ row.category }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="tags" label="标签" width="150" show-overflow-tooltip />
<el-table-column prop="viewCount" label="浏览" width="80" align="center" />
<el-table-column prop="likeCount" label="点赞" width="80" align="center" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="authorName" label="作者" width="100" />
<el-table-column prop="updatedAt" label="更新时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">查看</el-button>
<el-button link type="warning" @click="openEditDialog(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 卡片视图 -->
<el-row v-else :gutter="16" style="margin-top: 12px" v-loading="loading">
<el-col :span="6" v-for="item in tableData" :key="item.id">
<el-card shadow="hover" class="article-card" @click="viewDetail(item)">
<template #header>
<div class="card-header">
<span class="card-title">{{ item.title }}</span>
<el-tag :type="statusTag(item.status)" size="small">{{ statusLabel(item.status) }}</el-tag>
</div>
</template>
<p class="card-summary">{{ item.summary || '暂无摘要' }}</p>
<div class="card-meta">
<el-tag size="small">{{ item.category }}</el-tag>
<span class="meta-count">👁 {{ item.viewCount || 0 }} · 👍 {{ item.likeCount || 0 }}</span>
</div>
</el-card>
</el-col>
</el-row>
<!-- 分页 -->
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<!-- 编辑对话框 -->
<el-dialog v-model="showEditor" :title="isEdit ? '编辑文章' : '新建文章'" width="800px" destroy-on-close>
<el-form :model="form" label-width="80px">
<el-form-item label="标题" required>
<el-input v-model="form.title" placeholder="请输入文章标题" />
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="分类" required>
<el-select v-model="form.category" placeholder="选择分类" style="width: 100%">
<el-option label="FAQ" value="FAQ" />
<el-option label="政策法规" value="政策法规" />
<el-option label="操作指南" value="操作指南" />
<el-option label="常见问题" value="常见问题" />
<el-option label="通知公告" value="通知公告" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态">
<el-select v-model="form.status" style="width: 100%">
<el-option label="草稿" :value="0" />
<el-option label="已发布" :value="1" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="标签">
<el-input v-model="form.tags" placeholder="多个标签用逗号分隔" />
</el-form-item>
<el-form-item label="摘要">
<el-input v-model="form.summary" type="textarea" :rows="2" placeholder="文章摘要(选填)" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="12" placeholder="支持 Markdown 格式" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEditor = false">取消</el-button>
<el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
</template>
</el-dialog>
<!-- 详情对话框 -->
<el-dialog v-model="showDetail" :title="detailArticle?.title" width="700px">
<div class="detail-meta">
<el-tag>{{ detailArticle?.category }}</el-tag>
<span>作者: {{ detailArticle?.authorName }}</span>
<span>浏览: {{ detailArticle?.viewCount }}</span>
<span>点赞: {{ detailArticle?.likeCount }}</span>
</div>
<el-divider />
<div class="detail-content" v-html="renderMarkdown(detailArticle?.content || '')"></div>
<template #footer>
<el-button @click="handleLike" :icon="Star">点赞</el-button>
<el-button type="primary" @click="showDetail = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Star } from '@element-plus/icons-vue'
import request from '@/api/request'
const API = '/api/revenue/cs/kb'
const loading = ref(false)
const saving = ref(false)
const viewMode = ref('list')
const showEditor = ref(false)
const showDetail = ref(false)
const isEdit = ref(false)
const editId = ref<number | null>(null)
const tableData = ref<any[]>([])
const categories = ref<any[]>([])
const detailArticle = ref<any>(null)
const filterForm = reactive({ keyword: '', category: '', status: undefined as number | undefined })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const form = reactive({
title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0, authorName: '当前用户'
})
onMounted(() => {
fetchData()
fetchCategories()
})
async function fetchData() {
loading.value = true
try {
const res = await request.get(`${API}/list`, {
params: { page: pagination.page, size: pagination.size, ...filterForm }
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally {
loading.value = false
}
}
async function fetchCategories() {
try {
const res = await request.get(`${API}/categories`)
categories.value = res.data || []
} catch (e) { /* ignore */ }
}
function handleSearch() {
pagination.page = 1
fetchData()
}
function handleReset() {
filterForm.keyword = ''
filterForm.category = ''
filterForm.status = undefined
handleSearch()
}
function openCreateDialog() {
isEdit.value = false
editId.value = null
Object.assign(form, { title: '', content: '', summary: '', category: 'FAQ', tags: '', status: 0 })
showEditor.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editId.value = row.id
Object.assign(form, { title: row.title, content: row.content, summary: row.summary,
category: row.category, tags: row.tags, status: row.status, authorName: row.authorName })
showEditor.value = true
}
async function handleSave() {
if (!form.title) { ElMessage.warning('请输入标题'); return }
saving.value = true
try {
if (isEdit.value && editId.value) {
await request.put(`${API}/${editId.value}`, form)
} else {
await request.post(API, form)
}
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
showEditor.value = false
fetchData()
fetchCategories()
} finally {
saving.value = false
}
}
async function viewDetail(row: any) {
try {
const res = await request.get(`${API}/${row.id}`)
detailArticle.value = res.data
showDetail.value = true
fetchData() // refresh view count
} catch (e) {
detailArticle.value = row
showDetail.value = true
}
}
async function handleDelete(row: any) {
await ElMessageBox.confirm(`确定删除「${row.title}」?`, '确认删除', { type: 'warning' })
await request.delete(`${API}/${row.id}`)
ElMessage.success('删除成功')
fetchData()
}
async function handleLike() {
if (!detailArticle.value) return
await request.post(`${API}/${detailArticle.value.id}/like`)
ElMessage.success('点赞成功')
detailArticle.value.likeCount = (detailArticle.value.likeCount || 0) + 1
}
function statusLabel(s: number) { return ['草稿', '已发布', '已归档'][s] || '未知' }
function statusTag(s: number) { return ['info', 'success', 'warning'][s] as any || 'info' }
function renderMarkdown(md: string) {
// 简单 Markdown 渲染(生产环境可用 marked)
return md.replace(/### (.*)/g, '<h3>$1</h3>')
.replace(/## (.*)/g, '<h2>$1</h2>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/^- (.*)/gm, '<li>$1</li>')
.replace(/\n/g, '<br/>')
}
</script>
<style scoped>
.filter-card { margin-bottom: 8px; }
.article-card { margin-bottom: 16px; cursor: pointer; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.card-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.card-summary { color: #666; font-size: 13px; height: 40px; overflow: hidden; }
.card-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.meta-count { font-size: 12px; color: #999; }
.detail-meta { display: flex; gap: 16px; align-items: center; color: #666; }
.detail-content { line-height: 1.8; }
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div class="kpi-dashboard">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="4" v-for="card in statCards" :key="card.key">
<el-card shadow="hover" class="kpi-card" :class="'kpi-' + card.color">
<div class="kpi-icon">{{ card.icon }}</div>
<div class="kpi-value">{{ card.value }}</div>
<div class="kpi-label">{{ card.label }}</div>
<div class="kpi-sub" v-if="card.sub">{{ card.sub }}</div>
</el-card>
</el-col>
</el-row>
<!-- 图表区域 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="14">
<el-card shadow="never">
<template #header>
<span>📈 7日工单趋势</span>
</template>
<div ref="trendChartRef" style="height: 320px"></div>
</el-card>
</el-col>
<el-col :span="10">
<el-card shadow="never">
<template #header>
<span>📊 工单类型分布</span>
</template>
<div ref="pieChartRef" style="height: 320px"></div>
</el-card>
</el-col>
</el-row>
<!-- 排行榜 -->
<el-row :gutter="16" style="margin-top: 16px">
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>🏆 处理时效排行</span>
</template>
<el-table :data="efficiencyRank" size="small" :show-header="true">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="处理人/部门" />
<el-table-column prop="completed_count" label="完成数" width="80" align="center" />
<el-table-column prop="avg_hours" label="平均时效(h)" width="110" align="center">
<template #default="{ row }">
<el-tag :type="row.avg_hours < 4 ? 'success' : row.avg_hours < 8 ? '' : 'warning'" size="small">
{{ Number(row.avg_hours).toFixed(1) }}h
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header>
<span>📋 快捷操作</span>
</template>
<div class="quick-actions">
<el-button @click="$router.push('/cs/knowledge')" size="large">
📚 知识库管理
</el-button>
<el-button @click="$router.push('/cs/announcement')" size="large">
📢 公告管理
</el-button>
<el-button @click="refreshDashboard" size="large" :loading="loading">
🔄 刷新数据
</el-button>
</div>
<el-divider />
<div class="summary-info">
<p>📅 数据更新时间: {{ lastUpdate }}</p>
<p>📊 本月解决率: <el-progress :percentage="Number(kpiData.monthResolveRate || 0)" :stroke-width="16" /></p>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, shallowRef } from 'vue'
import request from '@/api/request'
import * as echarts from 'echarts'
const API = '/api/revenue/cs/kpi/dashboard'
const loading = ref(false)
const kpiData = reactive<any>({})
const efficiencyRank = ref<any[]>([])
const lastUpdate = ref('')
const trendChartRef = ref<HTMLElement>()
const pieChartRef = ref<HTMLElement>()
let trendChart: echarts.ECharts | null = null
let pieChart: echarts.ECharts | null = null
const statCards = computed(() => [
{ key: 'pending', icon: '⏳', label: '待处理工单', value: kpiData.pendingWorkOrders ?? '-', color: 'orange' },
{ key: 'today_new', icon: '📥', label: '今日新增', value: kpiData.todayNewWorkOrders ?? '-', color: 'blue' },
{ key: 'month_rate', icon: '✅', label: '本月解决率', value: (kpiData.monthResolveRate ?? 0) + '%', color: 'green',
sub: `${kpiData.monthResolvedCount ?? 0}/${kpiData.monthTotalCount ?? 0}` },
{ key: 'avg_hours', icon: '⏱️', label: '平均时效', value: (kpiData.avgProcessHours ?? 0) + 'h', color: 'purple' },
{ key: 'satisfaction', icon: '😊', label: '满意率', value: (kpiData.satisfactionRate ?? 0) + '%', color: 'green' },
{ key: 'complaints', icon: '📞', label: '今日投诉', value: kpiData.todayComplaints ?? '-', color: 'red' },
])
onMounted(() => {
refreshDashboard()
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
trendChart?.dispose()
pieChart?.dispose()
})
async function refreshDashboard() {
loading.value = true
try {
const res = await request.get(API)
Object.assign(kpiData, res.data)
efficiencyRank.value = res.data?.efficiencyRank || []
lastUpdate.value = new Date().toLocaleString()
await nextTick()
renderTrendChart()
renderPieChart()
} finally {
loading.value = false
}
}
function renderTrendChart() {
if (!trendChartRef.value) return
trendChart = trendChart || echarts.init(trendChartRef.value)
const data = kpiData.weeklyTrend || []
trendChart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', data: data.map((d: any) => d.date?.slice(5) || '') },
yAxis: { type: 'value', name: '工单数' },
series: [{
name: '工单数', type: 'line', smooth: true,
data: data.map((d: any) => d.count || 0),
areaStyle: { opacity: 0.15 },
itemStyle: { color: '#409eff' }
}]
})
}
function renderPieChart() {
if (!pieChartRef.value) return
pieChart = pieChart || echarts.init(pieChartRef.value)
const data = kpiData.typeDistribution || []
const nameMap: Record<string, string> = { pending: '待处理', in_progress: '处理中', completed: '已完成' }
pieChart.setOption({
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { bottom: 0 },
series: [{
type: 'pie', radius: ['40%', '65%'],
label: { show: true, formatter: '{b}\n{c}' },
data: data.map((d: any) => ({ name: nameMap[d.type] || d.type, value: d.count }))
}]
})
}
function handleResize() {
trendChart?.resize()
pieChart?.resize()
}
</script>
<style scoped>
.kpi-card { text-align: center; padding: 8px 0; }
.kpi-icon { font-size: 28px; }
.kpi-value { font-size: 32px; font-weight: 700; margin: 4px 0; }
.kpi-label { font-size: 13px; color: #666; }
.kpi-sub { font-size: 12px; color: #999; margin-top: 2px; }
.kpi-orange .kpi-value { color: #e6a23c; }
.kpi-blue .kpi-value { color: #409eff; }
.kpi-green .kpi-value { color: #67c23a; }
.kpi-purple .kpi-value { color: #9b59b6; }
.kpi-red .kpi-value { color: #f56c6c; }
.quick-actions { display: flex; gap: 12px; flex-wrap: wrap; }
.summary-info { margin-top: 16px; }
.summary-info p { margin: 8px 0; color: #666; }
</style>
@@ -0,0 +1,62 @@
<template>
<div class="dashboard">
<h2>供水总览</h2>
<el-row :gutter="16">
<el-col :span="6" v-for="card in cards" :key="card.title">
<el-card shadow="hover">
<div class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" style="margin-top:16px">
<el-col :span="12"><el-card><div ref="chart1" style="height:300px" /></el-card></el-col>
<el-col :span="12"><el-card><div ref="chart2" style="height:300px" /></el-card></el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
const cards = [
{ title: '今日进水(m³)', value: '12,580' },
{ title: '今日出水(m³)', value: '11,230' },
{ title: '在线设备', value: '156' },
{ title: '今日报警', value: '3' },
]
const chart1 = ref()
const chart2 = ref()
onMounted(() => {
const c1 = echarts.init(chart1.value)
c1.setOption({
title: { text: '24小时供水趋势' },
tooltip: { trigger: 'axis' },
xAxis: { data: ['00:00','04:00','08:00','12:00','16:00','20:00'] },
yAxis: { name: '流量(m³/h)' },
series: [{ data: [320,280,450,520,480,380], type: 'line', smooth: true, areaStyle: {} }]
})
const c2 = echarts.init(chart2.value)
c2.setOption({
title: { text: '片区用水分布' },
tooltip: {},
series: [{
type: 'pie', radius: ['45%','70%'],
data: [{ value: 3200, name: '一体化水厂' },{ value: 1800, name: '精芒片区' },
{ value: 1500, name: '八家户片区' },{ value: 1200, name: '托里片区' },
{ value: 900, name: '大镇片区' },{ value: 600, name: '托托片区' }]
}]
})
})
</script>
<style scoped>
.stat-card { text-align:center; padding:10px }
.stat-value { font-size:28px; font-weight:bold; color:#2a5298 }
.stat-title { color:#666; margin-top:8px }
</style>
@@ -0,0 +1,135 @@
<template>
<el-dialog
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
title="创建调度指令"
width="600"
:close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="指令标题" prop="commandTitle">
<el-input v-model="form.commandTitle" placeholder="请输入指令标题" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="指令类型" prop="commandType">
<el-select v-model="form.commandType" placeholder="请选择">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-radio-group v-model="form.priority">
<el-radio value="low">低</el-radio>
<el-radio value="normal">普通</el-radio>
<el-radio value="high">高</el-radio>
<el-radio value="urgent">紧急</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-input v-model="form.source" placeholder="手动/系统/报警联动" />
</el-form-item>
<el-form-item label="指令内容" prop="commandContent">
<el-input v-model="form.commandContent" type="textarea" :rows="5"
placeholder="请输入指令详细内容" maxlength="2000" show-word-limit />
</el-form-item>
<el-form-item label="目标类型" prop="targetType">
<el-select v-model="form.targetType" placeholder="请选择">
<el-option label="指定人员" value="user" />
<el-option label="部门" value="dept" />
<el-option label="角色" value="role" />
</el-select>
</el-form-item>
<el-form-item label="目标人员" prop="targetIds">
<el-input v-model="form.targetIds" placeholder="目标ID列表,多个用逗号分隔,如: 1,2,3" />
<div class="form-tip">输入用户ID,多个用逗号分隔</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="备注信息(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
创建指令
</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { createCommand } from '@/api/dispatchCommand'
const props = defineProps<{ visible: boolean }>()
const emit = defineEmits<{
'update:visible': [value: boolean]
'created': []
}>()
const formRef = ref<FormInstance>()
const submitting = ref(false)
const defaultForm = () => ({
commandTitle: '',
commandType: 'normal',
priority: 'normal',
source: '手动',
commandContent: '',
targetType: 'user',
targetIds: '',
remark: ''
})
const form = reactive(defaultForm())
const rules: FormRules = {
commandTitle: [{ required: true, message: '请输入指令标题', trigger: 'blur' }],
commandType: [{ required: true, message: '请选择指令类型', trigger: 'change' }],
priority: [{ required: true, message: '请选择优先级', trigger: 'change' }],
commandContent: [{ required: true, message: '请输入指令内容', trigger: 'blur' }],
targetType: [{ required: true, message: '请选择目标类型', trigger: 'change' }],
targetIds: [{ required: true, message: '请输入目标ID', trigger: 'blur' }]
}
watch(() => props.visible, (val) => {
if (val) {
Object.assign(form, defaultForm())
formRef.value?.resetFields()
}
})
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
submitting.value = true
try {
// 将 targetIds 转为 JSON 数组格式
const targetIdsArr = form.targetIds.split(',').map((s: string) => s.trim()).filter(Boolean)
await createCommand({
...form,
targetIds: JSON.stringify(targetIdsArr.map(Number))
})
ElMessage.success('指令创建成功')
emit('update:visible', false)
emit('created')
} catch (e: any) {
ElMessage.error(e.message || '创建失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
</style>
@@ -0,0 +1,304 @@
<template>
<div class="command-detail" v-loading="loading">
<!-- 返回按钮 -->
<el-page-header @back="router.back()" :title="'返回'" style="margin-bottom: 16px">
<template #content>
<span class="page-title">指令详情</span>
<el-tag :type="statusTag(detail.status)" style="margin-left: 12px">{{ statusLabel(detail.status) }}</el-tag>
</template>
</el-page-header>
<el-row :gutter="16">
<!-- 左侧:基本信息 + 状态流转图 -->
<el-col :span="14">
<el-card>
<template #header>
<span>{{ detail.command_title }}</span>
<el-tag size="small" style="margin-left: 8px">{{ detail.command_no }}</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="指令编号">{{ detail.command_no }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag size="small">{{ typeLabel(detail.command_type) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="优先级">
<el-tag :type="priorityTag(detail.priority)" size="small">{{ priorityLabel(detail.priority) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="来源">{{ detail.source || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ detail.created_at }}</el-descriptions-item>
<el-descriptions-item label="下发时间">{{ detail.issued_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="完成时间">{{ detail.completed_at || '-' }}</el-descriptions-item>
<el-descriptions-item label="目标类型">{{ detail.target_type || '-' }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 16px">
<h4>指令内容</h4>
<div class="content-block">{{ detail.command_content }}</div>
</div>
<!-- 状态流转图 -->
<div style="margin-top: 24px">
<h4>状态流转</h4>
<el-steps :active="statusStep(detail.status)" finish-status="success" align-center>
<el-step title="草稿" description="创建指令" />
<el-step title="已下发" description="下发给执行人" />
<el-step title="已接收" description="执行人确认" />
<el-step title="执行中" description="正在执行" />
<el-step title="完成/驳回" description="归档" />
</el-steps>
</div>
</el-card>
</el-col>
<!-- 右侧:执行记录列表 -->
<el-col :span="10">
<el-card>
<template #header>执行记录</template>
<el-timeline v-if="executions.length">
<el-timeline-item
v-for="exec in executions" :key="exec.id"
:type="executionTimelineType(exec.execute_status)"
:timestamp="exec.received_at || exec.created_at"
placement="top">
<div class="exec-card">
<div class="exec-header">
<span class="exec-user">{{ exec.user_name || `用户${exec.user_id}` }}</span>
<el-tag :type="executionStatusTag(exec.execute_status)" size="small">
{{ executionStatusLabel(exec.execute_status) }}
</el-tag>
</div>
<div v-if="exec.feedback" class="exec-feedback">
反馈: {{ exec.feedback }}
</div>
<div v-if="exec.rejected_reason" class="exec-reject">
驳回原因: {{ exec.rejected_reason }}
</div>
<div class="exec-actions" v-if="canOperate(exec)">
<el-button size="small" type="primary"
v-if="exec.execute_status === 'pending'"
@click="handleReceive(exec)">接收</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'received'"
@click="handleStartExecute(exec)">开始执行</el-button>
<el-button size="small" type="success"
v-if="exec.execute_status === 'executing'"
@click="showCompleteDialog(exec)">完成</el-button>
<el-button size="small" type="danger"
v-if="exec.execute_status !== 'completed' && exec.execute_status !== 'rejected'"
@click="handleReject(exec)">驳回</el-button>
</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无执行记录" />
</el-card>
</el-col>
</el-row>
<!-- 追踪日志 -->
<el-card style="margin-top: 16px">
<template #header>全过程追踪日志</template>
<el-timeline>
<el-timeline-item
v-for="log in trackingLogs" :key="log.id"
:timestamp="log.created_at" placement="top"
:type="trackingType(log.action)">
<div>
<el-tag size="small" :type="trackingType(log.action)">{{ trackingActionLabel(log.action) }}</el-tag>
<span style="margin-left: 8px">{{ log.operator_name || '' }}</span>
<span v-if="log.from_status" style="margin-left: 8px; color: #909399">
{{ log.from_status }} → {{ log.to_status }}
</span>
<div v-if="log.remark" style="color: #606266; margin-top: 4px">{{ log.remark }}</div>
</div>
</el-timeline-item>
</el-timeline>
<el-empty v-if="!trackingLogs.length" description="暂无追踪日志" />
</el-card>
<!-- 完成弹窗 -->
<el-dialog v-model="completeVisible" title="完成执行" width="500">
<el-form label-width="80px">
<el-form-item label="反馈说明">
<el-input v-model="completeForm.feedback" type="textarea" :rows="3" placeholder="请输入执行反馈" />
</el-form-item>
<el-form-item label="反馈图片">
<el-input v-model="completeForm.feedbackImages" placeholder="图片URL,多个用逗号分隔" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="completeVisible = false">取消</el-button>
<el-button type="primary" @click="handleComplete">确认完成</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCommandDetail, receiveCommand, startExecute, completeExecution, rejectExecution } from '@/api/dispatchCommand'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const detail = ref<any>({})
const executions = ref<any[]>([])
const trackingLogs = ref<any[]>([])
const completeVisible = ref(false)
const currentExec = ref<any>(null)
const completeForm = reactive({ feedback: '', feedbackImages: '' })
const commandId = Number(route.params.id)
// 模拟当前用户ID(实际应从登录态获取)
const currentUserId = 1
const currentUserName = 'admin'
const statusMap: Record<string, { label: string; type: string; step: number }> = {
draft: { label: '草稿', type: 'info', step: 0 },
issued: { label: '已下发', type: 'warning', step: 1 },
received: { label: '已接收', type: '', step: 2 },
executing: { label: '执行中', type: 'primary', step: 3 },
completed: { label: '已完成', type: 'success', step: 4 },
rejected: { label: '已驳回', type: 'danger', step: 4 }
}
const statusLabel = (s: string) => statusMap[s]?.label || s
const statusTag = (s: string) => (statusMap[s]?.type || 'info') as any
const statusStep = (s: string) => statusMap[s]?.step || 0
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const typeLabel = (t: string) => typeMap[t] || t
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
const priorityLabel = (p: string) => priorityMap[p]?.label || p
const priorityTag = (p: string) => (priorityMap[p]?.type || 'info') as any
const executionStatusLabel = (s: string) => {
const map: Record<string, string> = {
pending: '待接收', received: '已接收', executing: '执行中', completed: '已完成', rejected: '已驳回'
}
return map[s] || s
}
const executionStatusTag = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: '', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const executionTimelineType = (s: string) => {
const map: Record<string, string> = {
pending: 'info', received: 'primary', executing: 'primary', completed: 'success', rejected: 'danger'
}
return (map[s] || 'info') as any
}
const trackingActionLabel = (a: string) => {
const map: Record<string, string> = {
create: '创建', issue: '下发', receive: '接收', start_execute: '开始执行',
complete: '完成', reject: '驳回', cancel: '取消'
}
return map[a] || a
}
const trackingType = (a: string) => {
const map: Record<string, string> = {
create: 'info', issue: 'warning', receive: 'primary', start_execute: 'primary',
complete: 'success', reject: 'danger', cancel: 'danger'
}
return (map[a] || 'info') as any
}
function canOperate(exec: any) {
return exec.user_id === currentUserId || true // 简化:所有人可操作
}
async function fetchDetail() {
loading.value = true
try {
const res = await getCommandDetail(commandId)
detail.value = res.data || {}
executions.value = res.data?.executions || []
trackingLogs.value = res.data?.tracking_logs || res.data?.trackingLogs || []
} finally {
loading.value = false
}
}
async function handleReceive(exec: any) {
try {
await receiveCommand(commandId, exec.user_id, exec.user_name)
ElMessage.success('接收成功')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleStartExecute(exec: any) {
try {
await startExecute(commandId, exec.user_id, exec.user_name)
ElMessage.success('已开始执行')
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
function showCompleteDialog(exec: any) {
currentExec.value = exec
completeForm.feedback = ''
completeForm.feedbackImages = ''
completeVisible.value = true
}
async function handleComplete() {
try {
await completeExecution(commandId, currentExec.value.user_id, {
userName: currentExec.value.user_name,
feedback: completeForm.feedback,
feedbackImages: completeForm.feedbackImages
})
ElMessage.success('执行完成')
completeVisible.value = false
fetchDetail()
} catch (e: any) {
ElMessage.error(e.message || '操作失败')
}
}
async function handleReject(exec: any) {
try {
const { value } = await ElMessageBox.prompt('请输入驳回原因', '驳回', {
confirmButtonText: '确认驳回',
cancelButtonText: '取消',
inputPattern: /.+/,
inputErrorMessage: '驳回原因不能为空'
})
await rejectExecution(commandId, exec.user_id, value, exec.user_name)
ElMessage.success('已驳回')
fetchDetail()
} catch { /* cancel */ }
}
onMounted(fetchDetail)
</script>
<style scoped>
.page-title { font-size: 16px; font-weight: 600; }
.content-block {
padding: 12px; background: #f5f7fa; border-radius: 4px;
white-space: pre-wrap; line-height: 1.6;
}
.exec-card { padding: 4px 0; }
.exec-header { display: flex; justify-content: space-between; align-items: center; }
.exec-user { font-weight: 600; }
.exec-feedback { margin-top: 6px; color: #606266; font-size: 13px; }
.exec-reject { margin-top: 6px; color: #f56c6c; font-size: 13px; }
.exec-actions { margin-top: 8px; }
</style>
@@ -0,0 +1,193 @@
<template>
<div class="command-list">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filterForm">
<el-form-item label="关键词">
<el-input v-model="filterForm.keyword" placeholder="编号/标题" clearable @clear="handleSearch" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filterForm.status" placeholder="全部" clearable @change="handleSearch">
<el-option label="草稿" value="draft" />
<el-option label="已下发" value="issued" />
<el-option label="已接收" value="received" />
<el-option label="执行中" value="executing" />
<el-option label="已完成" value="completed" />
<el-option label="已驳回" value="rejected" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filterForm.commandType" placeholder="全部" clearable @change="handleSearch">
<el-option label="常规" value="normal" />
<el-option label="应急" value="emergency" />
<el-option label="维护" value="maintenance" />
<el-option label="巡检" value="inspection" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD"
@change="handleSearch" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch"><el-icon><Search /></el-icon> 查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-row :gutter="12" style="margin-top: 12px">
<el-col :span="4" v-for="stat in stats" :key="stat.status">
<el-card shadow="hover" class="stat-card" @click="filterByStatus(stat.status)">
<div class="stat-value">{{ stat.count }}</div>
<div class="stat-label">{{ statusLabel(stat.status) }}</div>
</el-card>
</el-col>
</el-row>
<div style="margin-top: 16px; display: flex; justify-content: space-between; align-items: center">
<el-button type="primary" @click="showCreateDialog = true"><el-icon><Plus /></el-icon> 创建指令</el-button>
<el-button @click="handleBatchIssue" :disabled="!selectedIds.length">批量下发</el-button>
</div>
<el-table :data="tableData" border style="margin-top: 10px"
@selection-change="handleSelectionChange" v-loading="loading">
<el-table-column type="selection" width="50" />
<el-table-column prop="command_no" label="指令编号" width="220" />
<el-table-column prop="command_title" label="标题" min-width="200" show-overflow-tooltip />
<el-table-column prop="command_type" label="类型" width="80">
<template #default="{ row }">
<el-tag :type="typeTag(row.command_type)" size="small">{{ typeLabel(row.command_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80">
<template #default="{ row }">
<el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="执行进度" width="120">
<template #default="{ row }">
<span>{{ row.completed_count || 0 }}/{{ row.total_executions || 0 }}</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="viewDetail(row)">详情</el-button>
<el-button link type="success" v-if="row.status === 'draft'" @click="handleIssue(row)">下发</el-button>
<el-button link type="danger" v-if="row.status === 'draft'" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination style="margin-top: 16px; justify-content: flex-end"
v-model:current-page="pagination.page" v-model:page-size="pagination.size"
:total="pagination.total" :page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next" @change="fetchData" />
<CommandCreate v-model:visible="showCreateDialog" @created="handleCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus } from '@element-plus/icons-vue'
import { listCommands, issueCommand, getCommandStats } from '@/api/dispatchCommand'
import CommandCreate from './CommandCreate.vue'
const router = useRouter()
const loading = ref(false)
const tableData = ref<any[]>([])
const stats = ref<any[]>([])
const selectedIds = ref<number[]>([])
const showCreateDialog = ref(false)
const dateRange = ref<[string, string] | null>(null)
const filterForm = reactive({ keyword: '', status: '', commandType: '' })
const pagination = reactive({ page: 1, size: 10, total: 0 })
const statusMap: Record<string, { label: string; type: string }> = {
draft: { label: '草稿', type: 'info' },
issued: { label: '已下发', type: 'warning' },
received: { label: '已接收', type: '' },
executing: { label: '执行中', type: 'primary' },
completed: { label: '已完成', type: 'success' },
rejected: { label: '已驳回', type: 'danger' }
}
const typeMap: Record<string, string> = { normal: '常规', emergency: '应急', maintenance: '维护', inspection: '巡检' }
const priorityMap: Record<string, { label: string; type: string }> = {
low: { label: '低', type: 'info' }, normal: { label: '普通', type: '' },
high: { label: '高', type: 'warning' }, urgent: { label: '紧急', type: 'danger' }
}
function statusLabel(s: string) { return statusMap[s]?.label || s }
function statusTag(s: string) { return (statusMap[s]?.type || 'info') as any }
function typeLabel(t: string) { return typeMap[t] || t }
function typeTag(t: string) { return t === 'emergency' ? 'danger' : t === 'maintenance' ? 'warning' : '' }
function priorityLabel(p: string) { return priorityMap[p]?.label || p }
function priorityTag(p: string) { return (priorityMap[p]?.type || 'info') as any }
async function fetchData() {
loading.value = true
try {
const res = await listCommands({
page: pagination.page, size: pagination.size,
status: filterForm.status || undefined,
commandType: filterForm.commandType || undefined,
keyword: filterForm.keyword || undefined,
startDate: dateRange.value?.[0], endDate: dateRange.value?.[1]
})
tableData.value = res.data?.records || []
pagination.total = res.data?.total || 0
} finally { loading.value = false }
}
async function fetchStats() {
try { const res = await getCommandStats(); stats.value = res.data || [] } catch { /* ignore */ }
}
function handleSearch() { pagination.page = 1; fetchData() }
function handleReset() {
filterForm.keyword = ''; filterForm.status = ''; filterForm.commandType = ''; dateRange.value = null; handleSearch()
}
function filterByStatus(status: string) { filterForm.status = status; handleSearch() }
function handleSelectionChange(rows: any[]) { selectedIds.value = rows.map((r: any) => r.id) }
function viewDetail(row: any) { router.push({ path: `/dispatch-command/${row.id}` }) }
async function handleIssue(row: any) {
try {
await ElMessageBox.confirm(`确认下发指令 "${row.command_title}" ?`, '下发确认')
await issueCommand(row.id, 1, 'admin'); ElMessage.success('指令已下发'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
async function handleBatchIssue() {
try {
await ElMessageBox.confirm(`确认批量下发 ${selectedIds.value.length} 条指令?`, '批量下发')
for (const id of selectedIds.value) { await issueCommand(id, 1, 'admin') }
ElMessage.success('批量下发完成'); fetchData(); fetchStats()
} catch { /* cancel */ }
}
function handleDelete(row: any) {
ElMessageBox.confirm(`确认删除指令 "${row.command_title}" ?`, '删除确认', { type: 'warning' })
.then(() => { ElMessage.info('删除功能待实现(逻辑删除)') }).catch(() => { /* cancel */ })
}
function handleCreated() { showCreateDialog.value = false; fetchData(); fetchStats() }
onMounted(() => { fetchData(); fetchStats() })
</script>
<style scoped>
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
.stat-card { cursor: pointer; text-align: center; }
.stat-value { font-size: 28px; font-weight: bold; color: #409eff; }
.stat-label { font-size: 13px; color: #909399; margin-top: 4px; }
</style>
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="login-container">
<div class="login-box">
<h1>智慧水务管理系统</h1>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" size="large" @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" style="width:100%" :loading="loading" @click="handleLogin">登 录</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
import { login } from '@/api/auth'
import { ElMessage } from 'element-plus'
const router = useRouter()
const userStore = useUserStore()
const formRef = ref()
const loading = ref(false)
const form = reactive({ username: 'admin', password: 'admin123' })
const rules = { username: [{ required: true, message: '请输入用户名' }], password: [{ required: true, message: '请输入密码' }] }
async function handleLogin() {
await formRef.value.validate()
loading.value = true
try {
const res = await login(form.username, form.password)
userStore.setToken(res.data)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
console.error(e)
} finally { loading.value = false }
}
</script>
<style scoped>
.login-container { display:flex; justify-content:center; align-items:center; height:100vh; background:linear-gradient(135deg,#1e3c72 0%,#2a5298 100%) }
.login-box { width:400px; padding:40px; background:#fff; border-radius:8px; box-shadow:0 2px 12px rgba(0,0,0,.3) }
h1 { text-align:center; margin-bottom:30px; color:#1e3c72 }
</style>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增部门</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增部门"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增菜单</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增菜单"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增角色</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增角色"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
@@ -0,0 +1,22 @@
<template>
<div>
<el-button type="primary" @click="dialogVisible=true">新增用户</el-button>
<el-table :data="tableData" style="margin-top:10px" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" />
<el-table-column label="操作" width="180">
<template #default>
<el-button link type="primary">编辑</el-button>
<el-button link type="danger">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" title="新增用户"><span>表单内容待实现</span></el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const dialogVisible = ref(false)
const tableData = ref<Array<{id:number,name:string}>>([])
</script>
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.vue"
]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
server: {
port: 3000,
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }
}
}
})
-323
View File
@@ -1,323 +0,0 @@
"""
水务管理系统主程序
集成REST API、WebSocket和批量导入功能
"""
import asyncio
import logging
import signal
import sys
from pathlib import Path
import argparse
from datetime import datetime
# 导入各个模块
from src.api.rest_api import app as rest_api_app
from src.websocket.websocket_server import websocket_server
from src.batch.batch_import import batch_manager
from src.utils.data_utils import data_converter, data_formatter, quality_checker
from src.models.models import validator
from src.iot.app import create_iot_app
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('water_management.log'),
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger(__name__)
class WaterManagementSystem:
"""水务管理系统主类"""
def __init__(self, config_file: str = "config.json"):
self.config_file = config_file
self.running = False
self.tasks = []
self.config = self.load_config()
def load_config(self) -> dict:
"""加载配置文件"""
config_path = Path(self.config_file)
if config_path.exists():
import json
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
# 默认配置
return {
"api": {
"host": "0.0.0.0",
"port": 8000
},
"websocket": {
"host": "0.0.0.0",
"port": 8765
},
"batch": {
"max_file_size_mb": 100,
"supported_formats": ["csv", "excel", "json"]
},
"logging": {
"level": "INFO",
"file": "water_management.log"
}
}
async def start_api_server(self):
"""启动API服务器"""
import uvicorn
logger.info("启动REST API服务器...")
# 创建组合应用(包含所有模块)
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from werkzeug.serving import WSGIRequestHandler
# IoT应用
iot_app = create_iot_app()
# 组合所有应用
def combined_app(environ, start_response):
path = environ.get('PATH_INFO', '')
if path.startswith('/api/iot/'):
return iot_app(environ, start_response)
else:
return rest_api_app(environ, start_response)
# 配置uvicorn
api_config = uvicorn.Config(
app=combined_app,
host=self.config["api"]["host"],
port=self.config["api"]["port"],
log_level="info"
)
api_server = uvicorn.Server(api_config)
# 在后台任务中运行
await api_server.serve()
async def start_websocket_server(self):
"""启动WebSocket服务器"""
logger.info("启动WebSocket服务器...")
# 启动WebSocket服务器
server = await websocket_server.start_server()
# 添加服务器关闭处理
def cleanup():
logger.info("关闭WebSocket服务器...")
server.close()
asyncio.create_task(server.wait_closed())
return server, cleanup
async def start_data_generator(self):
"""启动数据生成器(用于演示)"""
logger.info("启动数据生成器...")
while self.running:
# 生成模拟数据
import random
sensor_types = ["LL", "YL", "SW", "ZD"]
sensor_type = random.choice(sensor_types)
# 根据传感器类型生成合理的数值范围
if sensor_type == "LL": # 流量
value = random.uniform(10, 100)
elif sensor_type == "YL": # 压力
value = random.uniform(0.1, 1.0)
elif sensor_type == "SW": # 水位
value = random.uniform(0, 10)
else: # ZD 浊度
value = random.uniform(0, 50)
sensor_data = {
"data_type": sensor_type,
"device_id": f"device_{random.randint(1, 10)}",
"value": round(value, 2),
"location": random.choice(["A区", "B区", "C区", "D区"])
}
# 通过WebSocket发送数据
await websocket_server.send_sensor_data(sensor_data)
# 等待5秒
await asyncio.sleep(5)
async def handle_batch_import(self, file_path: str, batch_id: str, data_source: str):
"""处理批量导入请求"""
try:
logger.info(f"开始批量导入: {file_path}")
# 验证文件
validation_result = await batch_manager.validate_file(file_path)
if not validation_result["valid"]:
raise Exception(f"文件验证失败: {validation_result['error']}")
# 导入文件
result = await batch_manager.import_file(
file_path=file_path,
batch_id=batch_id,
data_source=data_source,
file_type="auto"
)
logger.info(f"批量导入完成: {result}")
return result
except Exception as e:
logger.error(f"批量导入失败: {str(e)}")
raise
async def start_system(self):
"""启动系统"""
logger.info("启动水务管理系统...")
# 标记系统为运行状态
self.running = True
try:
# 启动WebSocket服务器
ws_server, ws_cleanup = await self.start_websocket_server()
self.tasks.append(ws_server)
# 启动数据生成器(如果启用)
if self.config.get("demo_mode", False):
generator_task = asyncio.create_task(self.start_data_generator())
self.tasks.append(generator_task)
# 启动API服务器
await self.start_api_server()
except Exception as e:
logger.error(f"系统启动失败: {str(e)}")
await self.stop_system()
raise
async def stop_system(self):
"""停止系统"""
logger.info("停止水务管理系统...")
self.running = False
# 取消所有任务
for task in self.tasks:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
# 清理WebSocket服务器
if hasattr(websocket_server, 'server') and websocket_server.server:
websocket_server.server.close()
await websocket_server.server.wait_closed()
logger.info("水务管理系统已停止")
async def run(self):
"""运行系统"""
# 设置信号处理
def signal_handler():
logger.info("收到停止信号...")
asyncio.create_task(self.stop_system())
for sig in [signal.SIGINT, signal.SIGTERM]:
signal.signal(sig, signal_handler)
try:
await self.start_system()
# 保持运行直到收到停止信号
while self.running:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("收到键盘中断信号")
except Exception as e:
logger.error(f"系统运行时出错: {str(e)}")
finally:
await self.stop_system()
def create_sample_config():
"""创建示例配置文件"""
sample_config = {
"api": {
"host": "0.0.0.0",
"port": 8000
},
"websocket": {
"host": "0.0.0.0",
"port": 8765
},
"batch": {
"max_file_size_mb": 100,
"supported_formats": ["csv", "excel", "json"]
},
"demo_mode": True,
"logging": {
"level": "INFO",
"file": "water_management.log"
}
}
import json
with open("config.json", 'w', encoding='utf-8') as f:
json.dump(sample_config, f, indent=2, ensure_ascii=False)
logger.info("示例配置文件已创建: config.json")
def create_requirements():
"""创建requirements.txt文件"""
requirements = [
"fastapi==0.104.1",
"uvicorn[standard]==0.24.0",
"websockets==12.0",
"pandas==2.1.3",
"openpyxl==3.1.2",
"aiofiles==23.2.1",
"python-multipart==0.0.6",
"jinja2==3.1.2",
"paho-mqtt==1.6.1",
"flask==2.3.3"
]
with open("requirements.txt", 'w') as f:
f.write('\n'.join(requirements))
logger.info("依赖文件已创建: requirements.txt")
def main():
"""主函数"""
parser = argparse.ArgumentParser(description="水务管理系统")
parser.add_argument("--config", "-c", default="config.json", help="配置文件路径")
parser.add_argument("--init", action="store_true", help="初始化项目(创建配置文件和依赖)")
parser.add_argument("--demo", action="store_true", help="启动演示模式")
args = parser.parse_args()
if args.init:
create_sample_config()
create_requirements()
logger.info("项目初始化完成")
return
# 创建系统实例
system = WaterManagementSystem(args.config)
# 如果启用演示模式
if args.demo:
system.config["demo_mode"] = True
logger.info("启用演示模式")
# 运行系统
try:
asyncio.run(system.run())
except KeyboardInterrupt:
logger.info("程序已退出")
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'services/auth_service.dart';
import 'pages/login/login_page.dart';
import 'pages/home/home_page.dart';
void main() => runApp(const WaterApp());
class WaterApp extends StatelessWidget {
const WaterApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [ChangeNotifierProvider(create: (_) => AuthService())],
child: MaterialApp(
title: '智慧水务',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: Consumer<AuthService>(
builder: (_, auth, __) => auth.isLoggedIn ? const HomePage() : const LoginPage(),
),
),
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _tabIndex = 0;
@override
Widget build(BuildContext context) {
final auth = context.read<AuthService>();
return Scaffold(
appBar: AppBar(title: const Text('智慧水务'), actions: [
IconButton(icon: const Icon(Icons.logout), onPressed: () async { await auth.logout(); Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomePage())); })
]),
body: IndexedStack(index: _tabIndex, children: const [
Center(child: Text('💧 供水管理')),
Center(child: Text('🔍 巡检管理')),
Center(child: Text('💰 营业收费')),
]),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _tabIndex, onTap: (i) => setState(() => _tabIndex = i),
items: const [
BottomNavigationBarItem(icon: Icon(Icons.water), label: '供水'),
BottomNavigationBarItem(icon: Icon(Icons.search), label: '巡检'),
BottomNavigationBarItem(icon: Icon(Icons.receipt_long), label: '营收'),
],
),
);
}
}
+44
View File
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/auth_service.dart';
import '../home/home_page.dart';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@override State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
final _userCtrl = TextEditingController(text: 'admin');
final _passCtrl = TextEditingController(text: 'admin123');
bool _loading = false;
Future<void> _login() async {
setState(() => _loading = true);
final ok = await context.read<AuthService>().login(_userCtrl.text, _passCtrl.text);
if (!mounted) return;
setState(() => _loading = false);
if (ok) {
Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomePage()));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登录失败')));
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [
const Text('智慧水务', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.blue)),
const SizedBox(height: 32),
TextField(controller: _userCtrl, decoration: const InputDecoration(labelText: '用户名', prefixIcon: Icon(Icons.person))),
const SizedBox(height: 16),
TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: '密码', prefixIcon: Icon(Icons.lock))),
const SizedBox(height: 24),
SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _loading ? null : _login, child: Text(_loading ? '登录中...' : '登录'))),
])),
),
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AuthService extends ChangeNotifier {
String _token = '';
bool get isLoggedIn => _token.isNotEmpty;
final Dio _dio = Dio(BaseOptions(baseUrl: 'http://10.0.2.2:8080/api/base'));
Future<bool> login(String username, String password) async {
try {
final res = await _dio.post('/auth/login', data: {'username': username, 'password': password});
if (res.data['code'] == 200) {
_token = res.data['data'];
final prefs = await SharedPreferences.getInstance();
await prefs.setString('token', _token);
notifyListeners();
return true;
}
} catch (e) {
debugPrint('Login failed: $e');
}
return false;
}
Future<void> logout() async {
_token = '';
final prefs = await SharedPreferences.getInstance();
await prefs.remove('token');
notifyListeners();
}
}
+22
View File
@@ -0,0 +1,22 @@
name: water_management
description: 智慧水务管理系统 - 移动端
version: 1.0.0
environment:
sdk: '>=3.2.0 <4.0.0'
dependencies:
flutter: { sdk: flutter }
dio: ^5.4.0
provider: ^6.1.0
shared_preferences: ^2.2.0
flutter_map: ^6.1.0
latlong2: ^0.9.0
geolocator: ^11.0.0
image_picker: ^1.0.0
permission_handler: ^11.0.0
flutter_local_notifications: ^17.0.0
dev_dependencies:
flutter_test: { sdk: flutter }
flutter_lints: ^3.0.0
+127
View File
@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.water</groupId>
<artifactId>wm-parent</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>智慧水务管理系统</name>
<description>精河县供水工程综合管理平台</description>
<properties>
<java.version>17</java.version>
<spring-cloud.version>2023.0.3</spring-cloud.version>
<spring-cloud-alibaba.version>2023.0.1.0</spring-cloud-alibaba.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<sa-token.version>1.39.0</sa-token.version>
<hutool.version>5.8.29</hutool.version>
<knife4j.version>4.5.0</knife4j.version>
<easyexcel.version>3.3.4</easyexcel.version>
<postgis.version>2023.1.0</postgis.version>
<minio.version>8.5.10</minio.version>
</properties>
<modules>
<module>wm-common</module>
<module>wm-gateway</module>
<module>wm-base</module>
<module>wm-iot</module>
<module>wm-data-engine</module>
<module>wm-bpm</module>
<module>wm-bpm-engine</module>
<module>wm-production</module>
<module>wm-revenue</module>
<module>wm-patrol</module>
<module>wm-bi</module>
<module>wm-notify</module>
<module>wm-job</module>
<module>wm-dispatch</module>
<module>wm-system</module>
<module>wm-mobile-app</module>
<module>wm-config</module>
<module>wm-dma</module>
</modules>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>${hutool.version}</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>${knife4j.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>${easyexcel.version}</version>
</dependency>
<dependency>
<groupId>net.postgis</groupId>
<artifactId>postgis-jdbc</artifactId>
<version>${postgis.version}</version>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>${minio.version}</version>
</dependency>
<dependency>
<groupId>com.water</groupId>
<artifactId>wm-common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
-10
View File
@@ -1,10 +0,0 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
websockets==12.0
pandas==2.1.3
openpyxl==3.1.2
aiofiles==23.2.1
python-multipart==0.0.6
jinja2==3.1.2
requests==2.31.0
python-dateutil==2.8.2
-215
View File
@@ -1,215 +0,0 @@
"""
BI前端API模块
为前端提供BI相关的API接口,简化前端调用
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional, Dict, Any
import json
# 创建BI路由器
router = APIRouter(prefix="/bi-api", tags=["BI API"])
@router.get("/dashboards/overview")
async def get_overview_dashboards():
"""获取概览看板数据"""
return {
"dashboards": [
{
"id": "operation_overview",
"name": "水务运营总览",
"description": "水务系统整体运营情况综合看板",
"charts_count": 4,
"is_public": True,
"tags": ["运营", "总览", "综合"]
},
{
"id": "device_management",
"name": "设备管理看板",
"description": "设备状态监控和维护管理",
"charts_count": 1,
"is_public": False,
"tags": ["设备", "管理", "监控"]
},
{
"id": "security_monitoring",
"name": "安全监控看板",
"description": "系统安全和警报监控",
"charts_count": 1,
"is_public": False,
"tags": ["安全", "监控", "警报"]
}
]
}
@router.get("/charts/{chart_id}/data")
async def get_chart_data_frontend(chart_id: str):
"""获取图表数据(前端友好格式)"""
# 这里调用BI服务获取数据,简化前端调用
try:
from ..bi.services import BIService
bi_service = BIService()
data = bi_service.get_chart_data_api(chart_id)
if "error" in data:
raise HTTPException(status_code=404, detail=data["error"])
# 格式化为前端友好的数据格式
formatted_data = {
"chartId": chart_id,
"chartName": data.get("chart_name", ""),
"chartType": data.get("chart_type", ""),
"data": data.get("data", []),
"options": data.get("options", {}),
"columns": data.get("columns", [])
}
return formatted_data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboards/{dashboard_id}/data")
async def get_dashboard_data_frontend(dashboard_id: str):
"""获取看板数据(前端友好格式)"""
try:
from ..bi.services import BIService
bi_service = BIService()
data = bi_service.get_dashboard_data_api(dashboard_id)
if "error" in data:
raise HTTPException(status_code=404, detail=data["error"])
return data
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/charts/types")
async def get_chart_types():
"""获取支持的图表类型"""
return {
"line": {"name": "折线图", "description": "适合展示趋势数据"},
"bar": {"name": "柱状图", "description": "适合展示分类数据"},
"pie": {"name": "饼图", "description": "适合展示比例数据"},
"scatter": {"name": "散点图", "description": "适合展示关系数据"},
"area": {"name": "面积图", "description": "适合展示累计数据"},
"gauge": {"name": "仪表盘", "description": "适合展示进度或状态"},
"table": {"name": "表格", "description": "适合展示详细数据"},
"heatmap": {"name": "热力图", "description": "适合展示密度数据"}
}
@router.get("/data-sources/types")
async def get_data_source_types():
"""获取支持的数据源类型"""
return {
"sensor_data": {"name": "传感器数据", "description": "IoT传感器实时和历史数据"},
"device_data": {"name": "设备数据", "description": "设备状态和配置信息"},
"alert_data": {"name": "警报数据", "description": "系统警报和通知记录"},
"system_stats": {"name": "系统统计", "description": "系统运行性能统计"},
"batch_data": {"name": "批量数据", "description": "批量导入的数据"}
}
@router.get("/search")
async def search_bi_objects(keyword: str = Query(..., description="搜索关键词")):
"""搜索BI对象(图表和看板)"""
try:
from ..bi.services import BIService
bi_service = BIService()
# 搜索图表
charts = bi_service.search_charts(keyword)
charts_data = [chart.to_dict() for chart in charts]
# 搜索看板
dashboards = bi_service.search_dashboards(keyword)
dashboards_data = [dashboard.to_dict() for dashboard in dashboards]
return {
"charts": charts_data,
"dashboards": dashboards_data,
"total": len(charts_data) + len(dashboards_data)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/popular-tags")
async def get_popular_tags():
"""获取热门标签"""
try:
from ..bi.services import BIService
bi_service = BIService()
# 收集所有标签
all_tags = set()
for chart in bi_service.get_all_charts():
all_tags.update(chart.tags)
for dashboard in bi_service.get_all_dashboards():
all_tags.update(dashboard.tags)
# 返回热门标签(按字母排序)
return {"tags": sorted(list(all_tags))}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/quick-stats")
async def get_quick_stats():
"""获取快速统计信息"""
try:
from ..bi.services import BIService
bi_service = BIService()
charts = bi_service.get_all_charts()
dashboards = bi_service.get_all_dashboards()
public_dashboards = bi_service.get_public_dashboards()
# 统计图表类型分布
chart_type_stats = {}
for chart in charts:
chart_type = chart.chart_type.value
chart_type_stats[chart_type] = chart_type_stats.get(chart_type, 0) + 1
# 统计标签分布
tag_stats = {}
for chart in charts:
for tag in chart.tags:
tag_stats[tag] = tag_stats.get(tag, 0) + 1
for dashboard in dashboards:
for tag in dashboard.tags:
tag_stats[tag] = tag_stats.get(tag, 0) + 1
return {
"total_charts": len(charts),
"total_dashboards": len(dashboards),
"public_dashboards": len(public_dashboards),
"chart_types": chart_type_stats,
"popular_tags": dict(sorted(tag_stats.items(), key=lambda x: x[1], reverse=True)[:10])
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/chart-suggestions")
async def get_chart_suggestions():
"""获取图表建议"""
return {
"suggestions": [
{
"id": "flow_analysis",
"name": "流量分析建议",
"description": "基于历史流量数据,分析流量趋势和异常",
"charts": ["flow_trend", "flow_comparison"],
"tags": ["流量", "分析", "趋势"]
},
{
"id": "device_performance",
"name": "设备性能分析",
"description": "分析设备运行状态和性能指标",
"charts": ["device_status_distribution", "device_uptime"],
"tags": ["设备", "性能", "分析"]
},
{
"id": "security_dashboard",
"name": "安全监控看板",
"description": "集中监控系统安全和警报信息",
"charts": ["alert_level_stats", "alert_trend"],
"tags": ["安全", "监控", "警报"]
}
]
}
-189
View File
@@ -1,189 +0,0 @@
"""
REST API 数据接入模块
支持 IoT 设备数据、手动录入和 API 批量导入
"""
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
import uvicorn
import asyncio
import json
from datetime import datetime
# 导入BI模块
from ..bi.controllers import router as bi_router
from .bi_api import router as bi_api_router
# 将BI路由添加到主应用
app.include_router(bi_router)
app.include_router(bi_api_router)
# 创建FastAPI应用
app = FastAPI(title="Water Management System Data API", version="1.0.0")
# CORS配置
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 数据模型
class IoTData(BaseModel):
device_id: str
data_type: str # "LL", "YL", "SW", "ZD" 等
value: float
timestamp: datetime
location: str
class ManualInputData(BaseModel):
source: str
data_type: str
value: float
timestamp: datetime
operator: str
notes: Optional[str] = None
class BatchImportRequest(BaseModel):
batch_id: str
data_source: str
records: List[Dict[str, Any]]
# 数据存储(示例,实际应该用数据库)
data_store = []
@app.get("/")
async def root():
"""API根路径"""
return {"message": "Water Management System API", "version": "1.0.0"}
@app.post("/api/iot/data")
async def receive_iot_data(data: IoTData):
"""接收IoT设备数据"""
try:
data_dict = data.dict()
data_store.append({
**data_dict,
"id": len(data_store) + 1,
"type": "iot"
})
return {"status": "success", "id": len(data_store), "message": "IoT data received"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/manual/data")
async def receive_manual_data(data: ManualInputData):
"""接收手动录入数据"""
try:
data_dict = data.dict()
data_store.append({
**data_dict,
"id": len(data_store) + 1,
"type": "manual"
})
return {"status": "success", "id": len(data_store), "message": "Manual data received"}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.post("/api/batch/import")
async def batch_import(request: BatchImportRequest):
"""批量导入数据"""
try:
imported_count = 0
failed_count = 0
for record in request.records:
# 验证记录
if not all(k in record for k in ['device_id', 'data_type', 'value']):
failed_count += 1
continue
# 创建数据对象
data_record = {
"device_id": record['device_id'],
"data_type": record['data_type'],
"value": float(record['value']),
"timestamp": record.get('timestamp', datetime.now()),
"location": record.get('location', 'unknown'),
"batch_id": request.batch_id,
"source": request.data_source,
"id": len(data_store) + 1,
"type": "batch"
}
data_store.append(data_record)
imported_count += 1
return {
"status": "success",
"imported_count": imported_count,
"failed_count": failed_count,
"message": f"Batch import completed: {imported_count} records imported, {failed_count} failed"
}
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/data/{data_type}")
async def get_data_by_type(data_type: str, limit: int = 100, offset: int = 0):
"""根据数据类型获取数据"""
filtered_data = [
item for item in data_store
if item.get('data_type') == data_type
]
return {
"data": filtered_data[offset:offset+limit],
"total": len(filtered_data),
"limit": limit,
"offset": offset
}
@app.get("/api/data/recent")
async def get_recent_data(hours: int = 24, limit: int = 100):
"""获取最近的数据"""
from datetime import timedelta
cutoff_time = datetime.now() - timedelta(hours=hours)
recent_data = [
item for item in data_store
if item.get('timestamp', datetime.now()) > cutoff_time
]
return {
"data": recent_data[-limit:],
"total": len(recent_data),
"hours": hours,
"limit": limit
}
@app.get("/api/stats")
async def get_statistics():
"""获取数据统计信息"""
stats = {
"total_records": len(data_store),
"by_type": {},
"by_device": {},
"by_hour": {}
}
for item in data_store:
data_type = item.get('data_type', 'unknown')
device_id = item.get('device_id', 'unknown')
hour = item.get('timestamp', datetime.now()).strftime('%Y-%m-%d %H:00:00')
stats['by_type'][data_type] = stats['by_type'].get(data_type, 0) + 1
stats['by_device'][device_id] = stats['by_device'].get(device_id, 0) + 1
stats['by_hour'][hour] = stats['by_hour'].get(hour, 0) + 1
return stats
@app.get("/health")
async def health_check():
"""健康检查"""
return {"status": "healthy", "timestamp": datetime.now()}
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=8000)
-336
View File
@@ -1,336 +0,0 @@
"""
批量数据导入模块
支持CSV、Excel、JSON等多种格式的批量数据导入
"""
import pandas as pd
import json
import csv
import asyncio
import aiofiles
from typing import List, Dict, Any, Optional, Union
from pathlib import Path
from datetime import datetime
import logging
from concurrent.futures import ThreadPoolExecutor
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class BatchImportError(Exception):
"""批量导入异常"""
pass
class DataValidator:
"""数据验证器"""
# 水利行业标准字段映射
STANDARD_FIELDS = {
"LL": "流量",
"YL": "压力",
"SW": "水位",
"ZD": "浊度",
"PH": "pH值",
"WD": "温度",
"DD": "电导率",
"YD": "硬度"
}
# 单位映射
UNIT_MAP = {
"LL": "m³/h",
"YL": "MPa",
"SW": "m",
"ZD": "NTU",
"PH": "",
"WD": "°C",
"DD": "μS/cm",
"YD": "mg/L"
}
@classmethod
def validate_data_type(cls, data_type: str) -> bool:
"""验证数据类型是否有效"""
return data_type in cls.STANDARD_FIELDS
@classmethod
def get_field_description(cls, data_type: str) -> str:
"""获取字段描述"""
return cls.STANDARD_FIELDS.get(data_type, "未知类型")
@classmethod
def get_unit(cls, data_type: str) -> str:
"""获取单位"""
return cls.UNIT_MAP.get(data_type, "")
@classmethod
def validate_record(cls, record: Dict[str, Any]) -> Dict[str, Any]:
"""验证单条记录"""
errors = []
validated_record = {}
# 必需字段检查
required_fields = ["device_id", "data_type", "value"]
for field in required_fields:
if field not in record:
errors.append(f"缺少必需字段: {field}")
else:
validated_record[field] = record[field]
# 数据类型验证
if "data_type" in validated_record:
if not cls.validate_data_type(validated_record["data_type"]):
errors.append(f"无效的数据类型: {validated_record['data_type']}")
# 数值验证
if "value" in validated_record:
try:
validated_record["value"] = float(validated_record["value"])
except (ValueError, TypeError):
errors.append(f"无效的数值: {validated_record['value']}")
# 时间戳处理
if "timestamp" in record:
try:
if isinstance(record["timestamp"], str):
validated_record["timestamp"] = datetime.fromisoformat(record["timestamp"])
else:
validated_record["timestamp"] = record["timestamp"]
except (ValueError, TypeError):
# 如果时间戳无效,使用当前时间
validated_record["timestamp"] = datetime.now()
else:
validated_record["timestamp"] = datetime.now()
# 地点字段处理
validated_record["location"] = record.get("location", "未知")
return {
"validated": len(errors) == 0,
"record": validated_record,
"errors": errors
}
class BatchImporter:
"""批量导入器"""
def __init__(self):
self.validator = DataValidator()
async def import_csv(self, file_path: str, batch_id: str, data_source: str) -> Dict[str, Any]:
"""导入CSV文件"""
try:
# 使用线程池执行文件读取
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
df = await loop.run_in_executor(
executor,
lambda: pd.read_csv(file_path, encoding='utf-8')
)
return await self._process_dataframe(df, batch_id, data_source)
except Exception as e:
raise BatchImportError(f"CSV文件导入失败: {str(e)}")
async def import_excel(self, file_path: str, batch_id: str, data_source: str, sheet_name: str = 0) -> Dict[str, Any]:
"""导入Excel文件"""
try:
# 使用线程池执行文件读取
loop = asyncio.get_event_loop()
with ThreadPoolExecutor() as executor:
df = await loop.run_in_executor(
executor,
lambda: pd.read_excel(file_path, sheet_name=sheet_name)
)
return await self._process_dataframe(df, batch_id, data_source)
except Exception as e:
raise BatchImportError(f"Excel文件导入失败: {str(e)}")
async def import_json(self, file_path: str, batch_id: str, data_source: str) -> Dict[str, Any]:
"""导入JSON文件"""
try:
async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
content = await f.read()
data = json.loads(content)
# 处理不同的JSON格式
if isinstance(data, list):
return await self._process_records(data, batch_id, data_source)
elif isinstance(data, dict):
if "records" in data:
return await self._process_records(data["records"], batch_id, data_source)
else:
return await self._process_records([data], batch_id, data_source)
else:
raise BatchImportError("不支持的JSON格式")
except Exception as e:
raise BatchImportError(f"JSON文件导入失败: {str(e)}")
async def _process_dataframe(self, df: pd.DataFrame, batch_id: str, data_source: str) -> Dict[str, Any]:
"""处理DataFrame数据"""
# 转换为字典列表
records = df.to_dict('records')
return await self._process_records(records, batch_id, data_source)
async def _process_records(self, records: List[Dict[str, Any]], batch_id: str, data_source: str) -> Dict[str, Any]:
"""处理记录列表"""
import_count = 0
error_count = 0
errors = []
imported_records = []
for i, record in enumerate(records):
validation_result = self.validator.validate_record(record)
if validation_result["validated"]:
# 添加批次信息
import_record = {
**validation_result["record"],
"batch_id": batch_id,
"data_source": data_source,
"import_time": datetime.now(),
"type": "batch"
}
imported_records.append(import_record)
import_count += 1
else:
error_count += 1
error_msg = f"记录 {i+1}: {', '.join(validation_result['errors'])}"
errors.append(error_msg)
logger.warning(error_msg)
# 保存导入的记录到文件(实际项目中应该保存到数据库)
await self._save_imported_records(imported_records)
return {
"status": "completed" if error_count == 0 else "completed_with_errors",
"imported_count": import_count,
"error_count": error_count,
"total_count": len(records),
"success_rate": import_count / len(records) if records else 0,
"batch_id": batch_id,
"data_source": data_source,
"errors": errors[:10], # 只返回前10个错误
"imported_records": imported_records[:5] # 返回前5条记录作为示例
}
async def _save_imported_records(self, records: List[Dict[str, Any]]):
"""保存导入的记录"""
# 这里可以将记录保存到数据库或文件
# 为了示例,我们只保存到日志
for record in records:
logger.info(f"导入记录: {record}")
async def get_import_summary(self, batch_id: str) -> Dict[str, Any]:
"""获取导入摘要"""
# 这里应该从数据库查询批次信息
# 为了示例,返回一个空摘要
return {
"batch_id": batch_id,
"status": "not_found",
"message": "批次信息未找到(示例实现)"
}
class BatchImportManager:
"""批量导入管理器"""
def __init__(self):
self.importer = BatchImporter()
async def import_file(self, file_path: str, batch_id: str, data_source: str,
file_type: str = "auto", **kwargs) -> Dict[str, Any]:
"""导入文件"""
file_path_obj = Path(file_path)
if not file_path_obj.exists():
raise BatchImportError(f"文件不存在: {file_path}")
# 自动检测文件类型
if file_type == "auto":
if file_path_obj.suffix.lower() == '.csv':
file_type = "csv"
elif file_path_obj.suffix.lower() in ['.xlsx', '.xls']:
file_type = "excel"
elif file_path_obj.suffix.lower() == '.json':
file_type = "json"
else:
raise BatchImportError(f"不支持的文件类型: {file_path_obj.suffix}")
logger.info(f"开始导入{file_type}文件: {file_path}")
if file_type == "csv":
result = await self.importer.import_csv(file_path, batch_id, data_source)
elif file_type == "excel":
sheet_name = kwargs.get("sheet_name", 0)
result = await self.importer.import_excel(file_path, batch_id, data_source, sheet_name)
elif file_type == "json":
result = await self.importer.import_json(file_path, batch_id, data_source)
else:
raise BatchImportError(f"不支持的文件类型: {file_type}")
logger.info(f"文件导入完成: {result}")
return result
async def validate_file(self, file_path: str) -> Dict[str, Any]:
"""验证文件格式"""
file_path_obj = Path(file_path)
if not file_path_obj.exists():
return {"valid": False, "error": "文件不存在"}
file_size = file_path_obj.stat().st_size
if file_size > 100 * 1024 * 1024: # 100MB限制
return {"valid": False, "error": "文件过大,最大支持100MB"}
# 尝试读取文件前几行进行验证
try:
with open(file_path, 'r', encoding='utf-8') as f:
first_line = f.readline()
if not first_line:
return {"valid": False, "error": "文件为空"}
except Exception as e:
return {"valid": False, "error": f"无法读取文件: {str(e)}"}
return {"valid": True, "size": file_size, "format": file_path_obj.suffix.lower()}
# 全局导入管理器实例
batch_manager = BatchImportManager()
# 示例用法
async def example_usage():
"""示例用法"""
import os
# 创建示例CSV文件
sample_data = [
{"device_id": "device_001", "data_type": "LL", "value": 25.5, "location": "A区"},
{"device_id": "device_002", "data_type": "YL", "value": 0.8, "location": "B区"},
{"device_id": "device_003", "data_type": "SW", "value": 5.2, "location": "C区"}
]
sample_file = "/tmp/sample_data.csv"
with open(sample_file, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=["device_id", "data_type", "value", "location"])
writer.writeheader()
writer.writerows(sample_data)
# 导入文件
try:
result = await batch_manager.import_file(
file_path=sample_file,
batch_id="batch_" + datetime.now().strftime("%Y%m%d_%H%M%S"),
data_source="manual_test",
file_type="csv"
)
print("导入结果:", result)
except Exception as e:
print(f"导入失败: {str(e)}")
if __name__ == "__main__":
asyncio.run(example_usage())
-4
View File
@@ -1,4 +0,0 @@
"""
BI模块 - 自助BI看板和数据可视化
集成Superset/Metabase功能
"""
-273
View File
@@ -1,273 +0,0 @@
"""
BI控制器模块
提供REST API接口,支持自助BI看板和数据可视化
"""
from fastapi import APIRouter, HTTPException, Depends, Query, Path
from fastapi.responses import JSONResponse
from typing import List, Optional, Dict, Any
from datetime import datetime
from .services import BIService
from .models import ChartType, DataSourceType
# 创建路由器
router = APIRouter(prefix="/bi", tags=["BI"])
# 创建BI服务实例
bi_service = BIService()
@router.get("/")
async def get_bi_info():
"""获取BI系统信息"""
return {
"message": "Water Management System BI API",
"version": "1.0.0",
"endpoints": {
"charts": "/bi/charts",
"dashboards": "/bi/dashboards",
"data_sources": "/bi/data-sources",
"datasets": "/bi/datasets"
}
}
# 图表相关接口
@router.get("/charts", response_model=List[Dict[str, Any]])
async def get_all_charts():
"""获取所有图表"""
charts = bi_service.get_all_charts()
return [chart.to_dict() for chart in charts]
@router.get("/charts/{chart_id}", response_model=Dict[str, Any])
async def get_chart(chart_id: str = Path(..., description="图表ID")):
"""获取单个图表"""
chart = bi_service.get_chart(chart_id)
if not chart:
raise HTTPException(status_code=404, detail="Chart not found")
return chart.to_dict()
@router.post("/charts", response_model=Dict[str, Any])
async def create_chart(chart_data: Dict[str, Any]):
"""创建图表"""
try:
chart = bi_service.create_chart(chart_data)
return chart.to_dict()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/charts/{chart_id}", response_model=Dict[str, Any])
async def update_chart(chart_id: str = Path(..., description="图表ID"), chart_data: Dict[str, Any] = None):
"""更新图表"""
if not chart_data:
raise HTTPException(status_code=400, detail="Chart data is required")
chart = bi_service.update_chart(chart_id, chart_data)
if not chart:
raise HTTPException(status_code=404, detail="Chart not found")
return chart.to_dict()
@router.delete("/charts/{chart_id}")
async def delete_chart(chart_id: str = Path(..., description="图表ID")):
"""删除图表"""
success = bi_service.delete_chart(chart_id)
if not success:
raise HTTPException(status_code=404, detail="Chart not found")
return {"message": "Chart deleted successfully"}
@router.get("/charts/{chart_id}/data", response_model=Dict[str, Any])
async def get_chart_data(chart_id: str = Path(..., description="图表ID")):
"""获取图表数据"""
data = bi_service.get_chart_data_api(chart_id)
if "error" in data:
raise HTTPException(status_code=404, detail=data["error"])
return data
# 看板相关接口
@router.get("/dashboards", response_model=List[Dict[str, Any]])
async def get_all_dashboards():
"""获取所有看板"""
dashboards = bi_service.get_all_dashboards()
return [dashboard.to_dict() for dashboard in dashboards]
@router.get("/dashboards/{dashboard_id}", response_model=Dict[str, Any])
async def get_dashboard(dashboard_id: str = Path(..., description="看板ID")):
"""获取单个看板"""
dashboard = bi_service.get_dashboard(dashboard_id)
if not dashboard:
raise HTTPException(status_code=404, detail="Dashboard not found")
return dashboard.to_dict()
@router.post("/dashboards", response_model=Dict[str, Any])
async def create_dashboard(dashboard_data: Dict[str, Any]):
"""创建看板"""
try:
dashboard = bi_service.create_dashboard(dashboard_data)
return dashboard.to_dict()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/dashboards/{dashboard_id}", response_model=Dict[str, Any])
async def update_dashboard(dashboard_id: str = Path(..., description="看板ID"), dashboard_data: Dict[str, Any] = None):
"""更新看板"""
if not dashboard_data:
raise HTTPException(status_code=400, detail="Dashboard data is required")
dashboard = bi_service.update_dashboard(dashboard_id, dashboard_data)
if not dashboard:
raise HTTPException(status_code=404, detail="Dashboard not found")
return dashboard.to_dict()
@router.delete("/dashboards/{dashboard_id}")
async def delete_dashboard(dashboard_id: str = Path(..., description="看板ID")):
"""删除看板"""
success = bi_service.delete_dashboard(dashboard_id)
if not success:
raise HTTPException(status_code=404, detail="Dashboard not found")
return {"message": "Dashboard deleted successfully"}
@router.get("/dashboards/{dashboard_id}/data", response_model=Dict[str, Any])
async def get_dashboard_data(dashboard_id: str = Path(..., description="看板ID")):
"""获取看板数据"""
data = bi_service.get_dashboard_data_api(dashboard_id)
if "error" in data:
raise HTTPException(status_code=404, detail=data["error"])
return data
# 数据源相关接口
@router.get("/data-sources", response_model=List[Dict[str, Any]])
async def get_all_data_sources():
"""获取所有数据源"""
data_sources = bi_service.get_all_data_sources()
return [source.to_dict() for source in data_sources]
@router.get("/data-sources/{source_id}", response_model=Dict[str, Any])
async def get_data_source(source_id: str = Path(..., description="数据源ID")):
"""获取单个数据源"""
source = bi_service.get_data_source(source_id)
if not source:
raise HTTPException(status_code=404, detail="Data source not found")
return source.to_dict()
# 数据集相关接口
@router.get("/datasets", response_model=List[Dict[str, Any]])
async def get_all_datasets():
"""获取所有数据集"""
datasets = bi_service.get_all_datasets()
return [dataset.to_dict() for dataset in datasets]
@router.get("/datasets/{dataset_id}", response_model=Dict[str, Any])
async def get_dataset(dataset_id: str = Path(..., description="数据集ID")):
"""获取单个数据集"""
dataset = bi_service.get_dataset(dataset_id)
if not dataset:
raise HTTPException(status_code=404, detail="Dataset not found")
return dataset.to_dict()
# 搜索相关接口
@router.get("/search/charts")
async def search_charts(keyword: str = Query(..., description="搜索关键词")):
"""搜索图表"""
charts = bi_service.search_charts(keyword)
return [chart.to_dict() for chart in charts]
@router.get("/search/dashboards")
async def search_dashboards(keyword: str = Query(..., description="搜索关键词")):
"""搜索看板"""
dashboards = bi_service.search_dashboards(keyword)
return [dashboard.to_dict() for dashboard in dashboards]
# 标签相关接口
@router.get("/charts/tag/{tag}")
async def get_charts_by_tag(tag: str = Path(..., description="标签")):
"""根据标签获取图表"""
charts = bi_service.get_charts_by_tag(tag)
return [chart.to_dict() for chart in charts]
@router.get("/dashboards/tag/{tag}")
async def get_dashboards_by_tag(tag: str = Path(..., description="标签")):
"""根据标签获取看板"""
dashboards = bi_service.get_dashboards_by_tag(tag)
return [dashboard.to_dict() for dashboard in dashboards]
# 公开看板接口
@router.get("/public/dashboards")
async def get_public_dashboards():
"""获取公开看板"""
dashboards = bi_service.get_public_dashboards()
return [dashboard.to_dict() for dashboard in dashboards]
# 图表类型和枚举
@router.get("/types/chart-types")
async def get_chart_types():
"""获取支持的图表类型"""
return [{"value": ct.value, "label": ct.name} for ct in ChartType]
@router.get("/types/data-source-types")
async def get_data_source_types():
"""获取支持的数据源类型"""
return [{"value": dst.value, "label": dst.name} for dst in DataSourceType]
# 默认数据接口
@router.get("/default-charts")
async def get_default_charts():
"""获取默认图表"""
# 运营总览相关的图表
default_chart_ids = ["flow_trend", "device_status_distribution", "alert_level_stats", "system_performance"]
charts = [bi_service.get_chart(chart_id).to_dict() for chart_id in default_chart_ids if bi_service.get_chart(chart_id)]
return charts
@router.get("/default-dashboards")
async def get_default_dashboards():
"""获取默认看板"""
# 运营总览看板
default_dashboard_ids = ["operation_overview", "device_management", "security_monitoring"]
dashboards = [bi_service.get_dashboard(db_id).to_dict() for db_id in default_dashboard_ids if bi_service.get_dashboard(db_id)]
return dashboards
# Superset集成接口
@router.post("/integrations/superset")
async def setup_superset_integration(integration_data: Dict[str, Any]):
"""设置Superset集成"""
try:
integration = bi_service.setup_superset_integration(integration_data)
return integration.to_dict()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/integrations/superset")
async def get_superset_integration():
"""获取Superset集成配置"""
if not bi_service.superset_integration:
raise HTTPException(status_code=404, detail="Superset integration not configured")
return bi_service.superset_integration.to_dict()
# Metabase集成接口
@router.post("/integrations/metabase")
async def setup_metabase_integration(integration_data: Dict[str, Any]):
"""设置Metabase集成"""
try:
integration = bi_service.setup_metabase_integration(integration_data)
return integration.to_dict()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/integrations/metabase")
async def get_metabase_integration():
"""获取Metabase集成配置"""
if not bi_service.metabase_integration:
raise HTTPException(status_code=404, detail="Metabase integration not configured")
return bi_service.metabase_integration.to_dict()
# 统计信息接口
@router.get("/stats")
async def get_bi_stats():
"""获取BI系统统计信息"""
return {
"total_charts": len(bi_service.charts),
"total_dashboards": len(bi_service.dashboards),
"total_data_sources": len(bi_service.data_sources),
"total_datasets": len(bi_service.datasets),
"public_dashboards": len(bi_service.get_public_dashboards()),
"integrations": {
"superset_configured": bi_service.superset_integration is not None,
"metabase_configured": bi_service.metabase_integration is not None
}
}
-321
View File
@@ -1,321 +0,0 @@
"""
BI数据模型定义
定义自助BI看板和可视化相关数据结构
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from datetime import datetime
from enum import Enum
class ChartType(Enum):
"""图表类型枚举"""
LINE = "line" # 折线图
BAR = "bar" # 柱状图
PIE = "pie" # 饼图
SCATTER = "scatter" # 散点图
AREA = "area" # 面积图
GAUGE = "gauge" # 仪表盘
TABLE = "table" # 表格
HEATMAP = "heatmap" # 热力图
class DataSourceType(Enum):
"""数据源类型枚举"""
SENSOR_DATA = "sensor_data" # 传感器数据
DEVICE_DATA = "device_data" # 设备数据
ALERT_DATA = "alert_data" # 警报数据
BATCH_DATA = "batch_data" # 批量导入数据
SYSTEM_STATS = "system_stats" # 系统统计数据
@dataclass
class Chart:
"""图表模型"""
id: str
name: str
description: str
chart_type: ChartType
data_source: DataSourceType
x_axis: str # X轴字段
y_axis: List[str] # Y轴字段列表
filters: Dict[str, Any] = field(default_factory=dict)
group_by: List[str] = field(default_factory=list)
aggregation: str = "sum" # sum, avg, max, min, count
time_range: Optional[Dict[str, datetime]] = None
options: Dict[str, Any] = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
created_by: str = "system"
is_public: bool = False
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"chart_type": self.chart_type.value,
"data_source": self.data_source.value,
"x_axis": self.x_axis,
"y_axis": self.y_axis,
"filters": self.filters,
"group_by": self.group_by,
"aggregation": self.aggregation,
"time_range": {
"start": self.time_range["start"].isoformat() if self.time_range and "start" in self.time_range else None,
"end": self.time_range["end"].isoformat() if self.time_range and "end" in self.time_range else None
} if self.time_range else None,
"options": self.options,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_by": self.created_by,
"is_public": self.is_public,
"tags": self.tags
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Chart':
"""从字典创建对象"""
time_range = None
if data.get("time_range"):
tr = data["time_range"]
time_range = {
"start": datetime.fromisoformat(tr["start"]) if tr.get("start") else None,
"end": datetime.fromisoformat(tr["end"]) if tr.get("end") else None
}
return cls(
id=data["id"],
name=data["name"],
description=data["description"],
chart_type=ChartType(data["chart_type"]),
data_source=DataSourceType(data["data_source"]),
x_axis=data["x_axis"],
y_axis=data["y_axis"],
filters=data.get("filters", {}),
group_by=data.get("group_by", []),
aggregation=data.get("aggregation", "sum"),
time_range=time_range,
options=data.get("options", {}),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
created_by=data.get("created_by", "system"),
is_public=data.get("is_public", False),
tags=data.get("tags", [])
)
@dataclass
class Dashboard:
"""看板模型"""
id: str
name: str
description: str
charts: List[str] # 图表ID列表
layout: List[Dict[str, Any]] = field(default_factory=list) # 布局配置
filters: Dict[str, Any] = field(default_factory=dict)
shared_users: List[str] = field(default_factory=list)
shared_groups: List[str] = field(default_factory=list)
is_public: bool = False
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
created_by: str = "system"
tags: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"charts": self.charts,
"layout": self.layout,
"filters": self.filters,
"shared_users": self.shared_users,
"shared_groups": self.shared_groups,
"is_public": self.is_public,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_by": self.created_by,
"tags": self.tags
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Dashboard':
"""从字典创建对象"""
return cls(
id=data["id"],
name=data["name"],
description=data["description"],
charts=data.get("charts", []),
layout=data.get("layout", []),
filters=data.get("filters", {}),
shared_users=data.get("shared_users", []),
shared_groups=data.get("shared_groups", []),
is_public=data.get("is_public", False),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
created_by=data.get("created_by", "system"),
tags=data.get("tags", [])
)
@dataclass
class DataSource:
"""数据源模型"""
id: str
name: str
type: DataSourceType
description: str
config: Dict[str, Any] = field(default_factory=dict)
query_template: str = ""
columns: List[str] = field(default_factory=list)
refresh_interval_minutes: int = 60
is_active: bool = True
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
created_by: str = "system"
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"type": self.type.value,
"description": self.description,
"config": self.config,
"query_template": self.query_template,
"columns": self.columns,
"refresh_interval_minutes": self.refresh_interval_minutes,
"is_active": self.is_active,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_by": self.created_by
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'DataSource':
"""从字典创建对象"""
return cls(
id=data["id"],
name=data["name"],
type=DataSourceType(data["type"]),
description=data["description"],
config=data.get("config", {}),
query_template=data.get("query_template", ""),
columns=data.get("columns", []),
refresh_interval_minutes=data.get("refresh_interval_minutes", 60),
is_active=data.get("is_active", True),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
created_by=data.get("created_by", "system")
)
@dataclass
class Dataset:
"""数据集模型"""
id: str
name: str
description: str
data_source_id: str
query: str
columns: List[Dict[str, Any]] = field(default_factory=list) # 字段定义
transformations: List[str] = field(default_factory=list) # 数据转换规则
cache_enabled: bool = True
cache_timeout_minutes: int = 30
is_active: bool = True
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
created_by: str = "system"
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"data_source_id": self.data_source_id,
"query": self.query,
"columns": self.columns,
"transformations": self.transformations,
"cache_enabled": self.cache_enabled,
"cache_timeout_minutes": self.cache_timeout_minutes,
"is_active": self.is_active,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
"created_by": self.created_by
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Dataset':
"""从字典创建对象"""
return cls(
id=data["id"],
name=data["name"],
description=data["description"],
data_source_id=data["data_source_id"],
query=data["query"],
columns=data.get("columns", []),
transformations=data.get("transformations", []),
cache_enabled=data.get("cache_enabled", True),
cache_timeout_minutes=data.get("cache_timeout_minutes", 30),
is_active=data.get("is_active", True),
created_at=datetime.fromisoformat(data["created_at"]),
updated_at=datetime.fromisoformat(data["updated_at"]),
created_by=data.get("created_by", "system")
)
@dataclass
class SupersetIntegration:
"""Superset集成配置"""
superset_url: str
superset_api_key: str
superset_username: str
dashboard_mapping: Dict[str, str] = field(default_factory=dict) # 本地dashboard -> Superset dashboard
chart_mapping: Dict[str, str] = field(default_factory=dict) # 本地chart -> Superset chart
sync_enabled: bool = True
sync_interval_minutes: int = 60
last_sync_at: Optional[datetime] = None
sync_status: str = "idle" # idle, syncing, error
error_message: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"superset_url": self.superset_url,
"superset_api_key": self.superset_api_key,
"superset_username": self.superset_username,
"dashboard_mapping": self.dashboard_mapping,
"chart_mapping": self.chart_mapping,
"sync_enabled": self.sync_enabled,
"sync_interval_minutes": self.sync_interval_minutes,
"last_sync_at": self.last_sync_at.isoformat() if self.last_sync_at else None,
"sync_status": self.sync_status,
"error_message": self.error_message
}
@dataclass
class MetabaseIntegration:
"""Metabase集成配置"""
metabase_url: str
metabase_secret_key: str
collection_name: str = "水务管理系统"
dashboard_mapping: Dict[str, str] = field(default_factory=dict)
question_mapping: Dict[str, str] = field(default_factory=dict)
sync_enabled: bool = True
sync_interval_minutes: int = 60
last_sync_at: Optional[datetime] = None
sync_status: str = "idle"
error_message: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"metabase_url": self.metabase_url,
"metabase_secret_key": self.metabase_secret_key,
"collection_name": self.collection_name,
"dashboard_mapping": self.dashboard_mapping,
"question_mapping": self.question_mapping,
"sync_enabled": self.sync_enabled,
"sync_interval_minutes": self.sync_interval_minutes,
"last_sync_at": self.last_sync_at.isoformat() if self.last_sync_at else None,
"sync_status": self.sync_status,
"error_message": self.error_message
}
-562
View File
@@ -1,562 +0,0 @@
"""
BI服务模块
提供自助BI看板和数据可视化服务
包括数据集管理、图表创建、看板配置等功能
"""
from typing import Dict, List, Optional, Any, Tuple
from datetime import datetime, timedelta
import json
import pandas as pd
import numpy as np
from .models import (
Chart, ChartType, DataSourceType, Dashboard,
DataSource, Dataset, SupersetIntegration, MetabaseIntegration
)
class BIService:
"""BI服务主类"""
def __init__(self):
# 初始化数据存储
self.charts: Dict[str, Chart] = {}
self.dashboards: Dict[str, Dashboard] = {}
self.data_sources: Dict[str, DataSource] = {}
self.datasets: Dict[str, Dataset] = {}
self.superset_integration: Optional[SupersetIntegration] = None
self.metabase_integration: Optional[MetabaseIntegration] = None
# 初始化默认数据源
self._init_default_data_sources()
self._init_default_charts()
self._init_default_dashboards()
def _init_default_data_sources(self):
"""初始化默认数据源"""
# 传感器数据源
sensor_source = DataSource(
id="sensor_data",
name="传感器数据",
type=DataSourceType.SENSOR_DATA,
description="所有IoT传感器实时和历史数据",
config={
"time_field": "timestamp",
"value_field": "value",
"device_field": "device_id",
"location_field": "location",
"type_field": "data_type"
},
query_template="SELECT * FROM sensor_data WHERE {filters}",
columns=[
{"name": "id", "type": "integer", "description": "数据记录ID"},
{"name": "device_id", "type": "string", "description": "设备ID"},
{"name": "data_type", "type": "string", "description": "数据类型"},
{"name": "value", "type": "float", "description": "数值"},
{"name": "unit", "type": "string", "description": "单位"},
{"name": "timestamp", "type": "datetime", "description": "时间戳"},
{"name": "location", "type": "string", "description": "位置"},
{"name": "quality_score", "type": "float", "description": "质量评分"}
]
)
self.data_sources[sensor_source.id] = sensor_source
# 设备状态数据源
device_source = DataSource(
id="device_data",
name="设备状态",
type=DataSourceType.DEVICE_DATA,
description="所有设备的运行状态和配置信息",
config={
"status_field": "status",
"type_field": "device_type",
"location_field": "location"
},
query_template="SELECT * FROM device WHERE {filters}",
columns=[
{"name": "id", "type": "string", "description": "设备ID"},
{"name": "name", "type": "string", "description": "设备名称"},
{"name": "device_type", "type": "string", "description": "设备类型"},
{"name": "location", "type": "string", "description": "位置"},
{"name": "status", "type": "string", "description": "状态"},
{"name": "install_date", "type": "datetime", "description": "安装日期"},
{"name": "metadata", "type": "json", "description": "元数据"}
]
)
self.data_sources[device_source.id] = device_source
# 警报数据源
alert_source = DataSource(
id="alert_data",
name="警报数据",
type=DataSourceType.ALERT_DATA,
description="系统警报和通知记录",
config={
"level_field": "level",
"type_field": "alert_type",
"resolved_field": "resolved"
},
query_template="SELECT * FROM alert WHERE {filters}",
columns=[
{"name": "id", "type": "string", "description": "警报ID"},
{"name": "device_id", "type": "string", "description": "设备ID"},
{"name": "alert_type", "type": "string", "description": "警报类型"},
{"name": "level", "type": "string", "description": "警报级别"},
{"name": "message", "type": "string", "description": "警报信息"},
{"name": "timestamp", "type": "datetime", "description": "发生时间"},
{"name": "resolved", "type": "boolean", "description": "是否已解决"},
{"name": "resolved_at", "type": "datetime", "description": "解决时间"}
]
)
self.data_sources[alert_source.id] = alert_source
# 系统统计数据源
stats_source = DataSource(
id="system_stats",
name="系统统计",
type=DataSourceType.SYSTEM_STATS,
description="系统运行性能和使用统计",
config={
"cpu_field": "cpu_usage_percent",
"memory_field": "memory_usage_mb",
"records_field": "total_records"
},
query_template="SELECT * FROM system_stats WHERE {filters}",
columns=[
{"name": "timestamp", "type": "datetime", "description": "统计时间"},
{"name": "total_records", "type": "integer", "description": "总记录数"},
{"name": "total_devices", "type": "integer", "description": "设备总数"},
{"name": "active_connections", "type": "integer", "description": "活跃连接数"},
{"name": "api_requests_count", "type": "integer", "description": "API请求数"},
{"name": "alerts_count", "type": "integer", "description": "警报数量"},
{"name": "data_quality_score", "type": "float", "description": "数据质量评分"},
{"name": "memory_usage_mb", "type": "float", "description": "内存使用量(MB)"},
{"name": "cpu_usage_percent", "type": "float", "description": "CPU使用率(%)"}
]
)
self.data_sources[stats_source.id] = stats_source
def _init_default_charts(self):
"""初始化默认图表"""
# 流量趋势图
flow_trend = Chart(
id="flow_trend",
name="流量趋势分析",
description="显示各区域流量随时间的变化趋势",
chart_type=ChartType.LINE,
data_source=DataSourceType.SENSOR_DATA,
x_axis="timestamp",
y_axis=["value"],
group_by=["location"],
aggregation="avg",
filters={"data_type": "LL"},
options={
"title": "各区域流量趋势",
"yAxis": {"title": "流量 (m³/h)"},
"xAxis": {"title": "时间"},
"legend": {"show": True},
"tooltip": {"trigger": "axis"}
},
tags=["流量", "趋势", "区域"]
)
self.charts[flow_trend.id] = flow_trend
# 设备状态分布图
device_status = Chart(
id="device_status_distribution",
name="设备状态分布",
description="显示不同状态设备的数量分布",
chart_type=ChartType.PIE,
data_source=DataSourceType.DEVICE_DATA,
x_axis="status",
y_axis=["count"],
aggregation="count",
options={
"title": "设备状态分布",
"legend": {"show": True},
"tooltip": {"trigger": "item"}
},
tags=["设备", "状态", "分布"]
)
self.charts[device_status.id] = device_status
# 警报级别统计图
alert_stats = Chart(
id="alert_level_stats",
name="警报级别统计",
description="按级别统计警报数量",
chart_type=ChartType.BAR,
data_source=DataSourceType.ALERT_DATA,
x_axis="level",
y_axis=["count"],
aggregation="count",
filters={"resolved": False},
options={
"title": "未解决警报按级别统计",
"yAxis": {"title": "数量"},
"xAxis": {"title": "警报级别"},
"legend": {"show": False}
},
tags=["警报", "级别", "统计"]
)
self.charts[alert_stats.id] = alert_stats
# 系统性能监控图
system_performance = Chart(
id="system_performance",
name="系统性能监控",
description="显示系统CPU和内存使用率趋势",
chart_type=ChartType.LINE,
data_source=DataSourceType.SYSTEM_STATS,
x_axis="timestamp",
y_axis=["cpu_usage_percent", "memory_usage_mb"],
options={
"title": "系统性能监控",
"yAxis": [{"title": "CPU使用率(%)"}, {"title": "内存使用量(MB)"}],
"xAxis": {"title": "时间"},
"legend": {"show": True},
"tooltip": {"trigger": "axis"}
},
tags=["系统", "性能", "监控"]
)
self.charts[system_performance.id] = system_performance
def _init_default_dashboards(self):
"""初始化默认看板"""
# 水务运营总览看板
overview_dashboard = Dashboard(
id="operation_overview",
name="水务运营总览",
description="水务系统整体运营情况综合看板",
charts=["flow_trend", "device_status_distribution", "alert_level_stats", "system_performance"],
layout=[
{"i": "flow_trend", "x": 0, "y": 0, "w": 12, "h": 8},
{"i": "device_status_distribution", "x": 12, "y": 0, "w": 6, "h": 6},
{"i": "alert_level_stats", "x": 18, "y": 0, "w": 6, "h": 6},
{"i": "system_performance", "x": 0, "y": 8, "w": 24, "h": 8}
],
is_public=True,
tags=["运营", "总览", "综合"]
)
self.dashboards[overview_dashboard.id] = overview_dashboard
# 设备管理看板
device_dashboard = Dashboard(
id="device_management",
name="设备管理看板",
description="设备状态监控和维护管理",
charts=["device_status_distribution"],
layout=[
{"i": "device_status_distribution", "x": 0, "y": 0, "w": 12, "h": 8}
],
tags=["设备", "管理", "监控"]
)
self.dashboards[device_dashboard.id] = device_dashboard
# 安全监控看板
security_dashboard = Dashboard(
id="security_monitoring",
name="安全监控看板",
description="系统安全和警报监控",
charts=["alert_level_stats"],
layout=[
{"i": "alert_level_stats", "x": 0, "y": 0, "w": 12, "h": 8}
],
tags=["安全", "监控", "警报"]
)
self.dashboards[security_dashboard.id] = security_dashboard
def get_chart(self, chart_id: str) -> Optional[Chart]:
"""获取图表"""
return self.charts.get(chart_id)
def get_all_charts(self) -> List[Chart]:
"""获取所有图表"""
return list(self.charts.values())
def create_chart(self, chart_data: Dict[str, Any]) -> Chart:
"""创建图表"""
chart = Chart.from_dict(chart_data)
self.charts[chart.id] = chart
return chart
def update_chart(self, chart_id: str, chart_data: Dict[str, Any]) -> Optional[Chart]:
"""更新图表"""
if chart_id in self.charts:
chart = Chart.from_dict(chart_data)
chart.id = chart_id # 保持ID不变
self.charts[chart_id] = chart
return chart
return None
def delete_chart(self, chart_id: str) -> bool:
"""删除图表"""
if chart_id in self.charts:
del self.charts[chart_id]
# 从所有看板中移除该图表
for dashboard in self.dashboards.values():
if chart_id in dashboard.charts:
dashboard.charts.remove(chart_id)
return True
return False
def get_dashboard(self, dashboard_id: str) -> Optional[Dashboard]:
"""获取看板"""
return self.dashboards.get(dashboard_id)
def get_all_dashboards(self) -> List[Dashboard]:
"""获取所有看板"""
return list(self.dashboards.values())
def create_dashboard(self, dashboard_data: Dict[str, Any]) -> Dashboard:
"""创建看板"""
dashboard = Dashboard.from_dict(dashboard_data)
self.dashboards[dashboard.id] = dashboard
return dashboard
def update_dashboard(self, dashboard_id: str, dashboard_data: Dict[str, Any]) -> Optional[Dashboard]:
"""更新看板"""
if dashboard_id in self.dashboards:
dashboard = Dashboard.from_dict(dashboard_data)
dashboard.id = dashboard_id # 保持ID不变
self.dashboards[dashboard_id] = dashboard
return dashboard
return None
def delete_dashboard(self, dashboard_id: str) -> bool:
"""删除看板"""
if dashboard_id in self.dashboards:
del self.dashboards[dashboard_id]
return True
return False
def get_data_source(self, source_id: str) -> Optional[DataSource]:
"""获取数据源"""
return self.data_sources.get(source_id)
def get_all_data_sources(self) -> List[DataSource]:
"""获取所有数据源"""
return list(self.data_sources.values())
def get_dataset(self, dataset_id: str) -> Optional[Dataset]:
"""获取数据集"""
return self.datasets.get(dataset_id)
def get_all_datasets(self) -> List[Dataset]:
"""获取所有数据集"""
return list(self.datasets.values())
def execute_chart_data(self, chart_id: str) -> Dict[str, Any]:
"""执行图表数据查询"""
chart = self.get_chart(chart_id)
if not chart:
return {"error": "Chart not found"}
# 这里模拟数据查询,实际应该连接到数据库或数据源
data = self._generate_chart_data(chart)
return {
"chart_id": chart_id,
"chart_name": chart.name,
"data": data,
"columns": chart.options.get("columns", []),
"chart_type": chart.chart_type.value,
"options": chart.options
}
def _generate_chart_data(self, chart: Chart) -> List[Dict[str, Any]]:
"""生成图表数据(模拟)"""
# 根据图表类型和数据源生成模拟数据
if chart.data_source == DataSourceType.SENSOR_DATA:
return self._generate_sensor_data(chart)
elif chart.data_source == DataSourceType.DEVICE_DATA:
return self._generate_device_data(chart)
elif chart.data_source == DataSourceType.ALERT_DATA:
return self._generate_alert_data(chart)
elif chart.data_source == DataSourceType.SYSTEM_STATS:
return self._generate_stats_data(chart)
else:
return []
def _generate_sensor_data(self, chart: Chart) -> List[Dict[str, Any]]:
"""生成传感器数据"""
data = []
# 生成时间序列数据
base_time = datetime.now() - timedelta(days=7)
locations = ["A区", "B区", "C区", "D区"]
for i in range(24 * 7): # 7天,每小时一个点
timestamp = base_time + timedelta(hours=i)
for location in locations:
# 添加一些随机波动
base_value = 50 if chart.filters.get("data_type") == "LL" else 1.0
value = base_value + np.random.normal(0, 10)
data.append({
"timestamp": timestamp.isoformat(),
"location": location,
"value": round(value, 2),
"device_id": f"device_{hash(location) % 10 + 1}",
"data_type": chart.filters.get("data_type", "LL"),
"quality_score": round(np.random.uniform(0.8, 1.0), 2)
})
# 应用过滤和聚合
if chart.group_by:
# 简单的分组聚合
grouped_data = {}
for item in data:
key = tuple(item.get(field) for field in chart.group_by)
if key not in grouped_data:
grouped_data[key] = []
grouped_data[key].append(item)
result = []
for key, items in grouped_data.items():
group_data = {}
for i, field in enumerate(chart.group_by):
group_data[field] = key[i]
# 聚合计算
values = [item["value"] for item in items]
if chart.aggregation == "avg":
group_data["value"] = sum(values) / len(values)
elif chart.aggregation == "sum":
group_data["value"] = sum(values)
elif chart.aggregation == "max":
group_data["value"] = max(values)
elif chart.aggregation == "min":
group_data["value"] = min(values)
else:
group_data["value"] = sum(values) / len(values)
result.append(group_data)
return result
return data
def _generate_device_data(self, chart: Chart) -> List[Dict[str, Any]]:
"""生成设备数据"""
devices = [
{"id": "device_1", "name": "流量计-001", "device_type": "流量计", "location": "A区", "status": "active"},
{"id": "device_2", "name": "压力计-001", "device_type": "压力计", "location": "A区", "status": "active"},
{"id": "device_3", "name": "水位计-001", "device_type": "水位计", "location": "B区", "status": "maintenance"},
{"id": "device_4", "name": "浊度计-001", "device_type": "浊度计", "location": "B区", "status": "active"},
{"id": "device_5", "name": "pH计-001", "device_type": "pH计", "location": "C区", "status": "inactive"},
]
# 按状态分组
status_groups = {}
for device in devices:
status = device["status"]
if status not in status_groups:
status_groups[status] = []
status_groups[status].append(device)
# 生成统计数据
result = []
for status, devices_in_status in status_groups.items():
result.append({
"status": status,
"count": len(devices_in_status),
"devices": [d["name"] for d in devices_in_status]
})
return result
def _generate_alert_data(self, chart: Chart) -> List[Dict[str, Any]]:
"""生成警报数据"""
alerts = [
{"level": "info", "count": 5, "description": "信息级别警报"},
{"level": "warning", "count": 3, "description": "警告级别警报"},
{"level": "error", "count": 1, "description": "错误级别警报"},
{"level": "critical", "count": 0, "description": "严重级别警报"},
]
return alerts
def _generate_stats_data(self, chart: Chart) -> List[Dict[str, Any]]:
"""生成系统统计数据"""
data = []
base_time = datetime.now() - timedelta(days=1)
for i in range(24): # 24小时数据
timestamp = base_time + timedelta(hours=i)
data.append({
"timestamp": timestamp.isoformat(),
"total_records": 1000 + np.random.randint(-100, 100),
"total_devices": 25 + np.random.randint(-5, 5),
"active_connections": 5 + np.random.randint(-2, 3),
"api_requests_count": 150 + np.random.randint(-30, 30),
"alerts_count": np.random.randint(0, 5),
"data_quality_score": round(np.random.uniform(0.9, 1.0), 2),
"memory_usage_mb": 100 + np.random.randint(-20, 20),
"cpu_usage_percent": 30 + np.random.randint(-10, 10)
})
return data
def setup_superset_integration(self, integration_data: Dict[str, Any]) -> SupersetIntegration:
"""设置Superset集成"""
integration = SupersetIntegration.from_dict(integration_data)
self.superset_integration = integration
return integration
def setup_metabase_integration(self, integration_data: Dict[str, Any]) -> MetabaseIntegration:
"""设置Metabase集成"""
integration = MetabaseIntegration.from_dict(integration_data)
self.metabase_integration = integration
return integration
def get_chart_data_api(self, chart_id: str) -> Dict[str, Any]:
"""获取图表数据API接口"""
return self.execute_chart_data(chart_id)
def get_dashboard_data_api(self, dashboard_id: str) -> Dict[str, Any]:
"""获取看板数据API接口"""
dashboard = self.get_dashboard(dashboard_id)
if not dashboard:
return {"error": "Dashboard not found"}
charts_data = {}
for chart_id in dashboard.charts:
charts_data[chart_id] = self.get_chart_data_api(chart_id)
return {
"dashboard_id": dashboard_id,
"dashboard_name": dashboard.name,
"charts": charts_data,
"layout": dashboard.layout
}
def get_public_dashboards(self) -> List[Dashboard]:
"""获取公开看板"""
return [db for db in self.dashboards.values() if db.is_public]
def get_charts_by_tag(self, tag: str) -> List[Chart]:
"""根据标签获取图表"""
return [chart for chart in self.charts.values() if tag in chart.tags]
def get_dashboards_by_tag(self, tag: str) -> List[Dashboard]:
"""根据标签获取看板"""
return [dashboard for dashboard in self.dashboards.values() if tag in dashboard.tags]
def search_charts(self, keyword: str) -> List[Chart]:
"""搜索图表"""
keyword = keyword.lower()
return [
chart for chart in self.charts.values()
if keyword in chart.name.lower() or keyword in chart.description.lower()
or any(keyword in tag.lower() for tag in chart.tags)
]
def search_dashboards(self, keyword: str) -> List[Dashboard]:
"""搜索看板"""
keyword = keyword.lower()
return [
dashboard for dashboard in self.dashboards.values()
if keyword in dashboard.name.lower() or keyword in dashboard.description.lower()
or any(keyword in tag.lower() for tag in dashboard.tags)
]
-9
View File
@@ -1,9 +0,0 @@
"""
IoT Module - 物联网平台核心模块
包含MQTT协议适配器、设备注册/发现API、统一设备模型等
"""
from .device_manager import DeviceManager
from .device_controller import DeviceController
__all__ = ['DeviceManager', 'DeviceController']
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-177
View File
@@ -1,177 +0,0 @@
"""
IoT模块Flask应用
集成MQTT适配器、设备管理器、OTA管理等核心组件
"""
import logging
import os
from flask import Flask
from .device_manager import DeviceManager
from .mqtt_adapter import MqttAdapter
from .device_controller import DeviceController
from .ota_manager import OtaManager
from .ota_controller import OtaController
from .config import MqttConfig, DatabaseConfig
def create_iot_app(config=None):
"""
创建IoT模块Flask应用
Args:
config: 配置字典
Returns:
Flask: Flask应用实例
"""
app = Flask(__name__)
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# 加载配置
if config is None:
config = {
'mqtt': MqttConfig.from_env(),
'database': DatabaseConfig.from_env()
}
# 初始化组件
device_manager = DeviceManager()
mqtt_adapter = MqttAdapter(
broker_host=config['mqtt'].broker_host,
broker_port=config['mqtt'].broker_port,
username=config['mqtt'].username,
password=config['mqtt'].password,
client_id=config['mqtt'].client_id
)
ota_manager = OtaManager()
# 创建控制器
device_controller = DeviceController(device_manager, mqtt_adapter)
ota_controller = OtaController(ota_manager)
# 注册蓝图
app.register_blueprint(device_controller.get_blueprint())
app.register_blueprint(ota_controller.get_blueprint())
@app.route('/api/iot/status')
def get_status():
"""获取IoT模块状态"""
return {
"mqtt": mqtt_adapter.get_connection_status(),
"device_statistics": device_manager.get_device_statistics(),
"ota_statistics": ota_manager.get_update_statistics(),
"devices_count": len(device_manager.devices),
"shadows_count": len(device_manager.shadows)
}
@app.route('/api/iot/mqtt/connect', methods=['POST'])
def connect_mqtt():
"""连接MQTT"""
try:
success = mqtt_adapter.connect()
return {
"success": success,
"message": "MQTT connected" if success else "MQTT connection failed"
}
except Exception as e:
logger.error(f"MQTT connection error: {e}")
return {"success": False, "error": str(e)}
@app.route('/api/iot/mqtt/disconnect', methods=['POST'])
def disconnect_mqtt():
"""断开MQTT连接"""
try:
mqtt_adapter.disconnect()
return {"success": True, "message": "MQTT disconnected"}
except Exception as e:
logger.error(f"MQTT disconnection error: {e}")
return {"success": False, "error": str(e)}
@app.route('/api/iot/mqtt/reconnect', methods=['POST'])
def reconnect_mqtt():
"""重连MQTT"""
try:
mqtt_adapter.disconnect()
success = mqtt_adapter.connect()
return {
"success": success,
"message": "MQTT reconnected" if success else "MQTT reconnection failed"
}
except Exception as e:
logger.error(f"MQTT reconnection error: {e}")
return {"success": False, "error": str(e)}
@app.route('/api/iot/mqtt/publish', methods=['POST'])
def publish_message():
"""发布MQTT消息"""
try:
data = request.get_json()
topic = data.get('topic')
payload = data.get('payload', {})
qos = data.get('qos', 0)
retain = data.get('retain', False)
success = mqtt_adapter.publish(topic, payload, qos, retain)
return {
"success": success,
"message": "Message published" if success else "Message publish failed"
}
except Exception as e:
logger.error(f"MQTT publish error: {e}")
return {"success": False, "error": str(e)}
@app.route('/api/iot/initialize', methods=['POST'])
def initialize_iot():
"""初始化IoT模块"""
try:
# 连接MQTT
mqtt_success = mqtt_adapter.connect()
# 订阅设备主题
mqtt_adapter.subscribe_device_topics(device_manager)
# 初始化一些示例设备
if len(device_manager.devices) == 0:
sample_devices = [
{
'device_sn': 'LL-001',
'device_type': 'flow_meter',
'name': '流量计-001',
'description': 'A区入口流量计',
'area': 'A区',
'position': '入口处',
'manufacturer': '华为',
'model': 'LL-100'
},
{
'device_sn': 'YL-001',
'device_type': 'pressure_meter',
'name': '压力表-001',
'description': 'B区主压力表',
'area': 'B区',
'position': '主管道',
'manufacturer': '西门子',
'model': 'YL-200'
}
]
for device_data in sample_devices:
device_manager.register_device(device_data)
return {
"success": True,
"mqtt_connected": mqtt_success,
"devices_count": len(device_manager.devices),
"message": "IoT module initialized successfully"
}
except Exception as e:
logger.error(f"IoT initialization error: {e}")
return {"success": False, "error": str(e)}
return app
-189
View File
@@ -1,189 +0,0 @@
"""
IoT模块配置
包含MQTT配置、数据库配置、设备类型映射等
"""
import os
from dataclasses import dataclass
@dataclass
class MqttConfig:
"""MQTT配置"""
broker_host: str = "localhost"
broker_port: int = 1883
username: str = ""
password: str = ""
client_id: str = "water-management-system"
keep_alive: int = 60
clean_session: bool = True
@classmethod
def from_env(cls):
"""从环境变量加载配置"""
return cls(
broker_host=os.getenv('MQTT_BROKER_HOST', 'localhost'),
broker_port=int(os.getenv('MQTT_BROKER_PORT', 1883)),
username=os.getenv('MQTT_USERNAME', ''),
password=os.getenv('MQTT_PASSWORD', ''),
client_id=os.getenv('MQTT_CLIENT_ID', 'water-management-system'),
keep_alive=int(os.getenv('MQTT_KEEP_ALIVE', 60)),
clean_session=os.getenv('MQTT_CLEAN_SESSION', 'true').lower() == 'true'
)
@dataclass
class DatabaseConfig:
"""数据库配置"""
host: str = "localhost"
port: int = 3306
database: str = "water_management"
username: str = "root"
password: str = ""
@classmethod
def from_env(cls):
"""从环境变量加载配置"""
return cls(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', 3306)),
database=os.getenv('DB_NAME', 'water_management'),
username=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', '')
)
@dataclass
class DeviceTypeMapping:
"""设备类型映射"""
# 水利行业标准设备类型映射
MAPPINGS = {
# 流量计
'LL': 'flow_meter', # 流量计
'Q': 'flow_meter', # 流量
'QL': 'flow_meter', # 瞬时流量
# 压力表
'YL': 'pressure_meter', # 压力表
'P': 'pressure_meter', # 压力
'PL': 'pressure_meter', # 瞬时压力
# 水位计
'SW': 'level_meter', # 水位计
'L': 'level_meter', # 水位
'WL': 'level_meter', # 瞬时水位
# 水质仪
'ZD': 'quality_meter', # 浊度
'PH': 'quality_meter', # pH值
'DO': 'quality_meter', # 溶解氧
'COD': 'quality_meter', # 化学需氧量
'NH3': 'quality_meter', # 氨氮
'TEMP': 'quality_meter', # 温度
# 阀门
'FV': 'valve', # 阀门
'BV': 'valve', # 球阀
'GV': 'valve', # 闸阀
# 水泵
'PUMP': 'pump', # 水泵
'CP': 'pump', # 循环泵
'FP': 'pump', # 给水泵
# 传感器
'SENSOR': 'sensor', # 传感器
'TS': 'sensor', # 温度传感器
'HS': 'sensor', # 湿度传感器
# 摄像头
'CAM': 'camera', # 摄像头
'IPC': 'camera', # 网络摄像头
# 其他
'OTHER': 'other' # 其他设备
}
@classmethod
def map_device_type(cls, standard_type: str) -> str:
"""
将标准设备类型映射为内部类型
Args:
standard_type: 标准设备类型
Returns:
str: 内部设备类型
"""
return cls.MAPPINGS.get(standard_type.upper(), 'other')
@classmethod
def get_all_standard_types(cls) -> list:
"""获取所有标准设备类型"""
return list(cls.MAPPINGS.keys())
@dataclass
class UnitConversion:
"""单位转换配置"""
# 水利行业常用单位转换
CONVERSIONS = {
# 流量单位
'm³/h': {'m³/s': 1/3600, 'L/s': 1000/3600},
'm³/s': {'m³/h': 3600, 'L/s': 1000},
'L/s': {'m³/h': 3600/1000, 'm³/s': 1/1000},
# 压力单位
'MPa': {'kPa': 1000, 'Pa': 1000000},
'kPa': {'MPa': 1/1000, 'Pa': 1000},
'Pa': {'MPa': 1/1000000, 'kPa': 1/1000},
# 水位单位
'm': {'cm': 100, 'mm': 1000},
'cm': {'m': 1/100, 'mm': 10},
'mm': {'m': 1/1000, 'cm': 1/10},
# 水质单位
'NTU': {'': 1}, # 浊度无标准转换
'pH': {'': 1}, # pH值无标准转换
'mg/L': {'ppm': 1}, # mg/L 和 ppm 等价
'ppm': {'mg/L': 1}
}
@classmethod
def convert_unit(cls, value: float, from_unit: str, to_unit: str) -> float:
"""
单位转换
Args:
value: 数值
from_unit: 源单位
to_unit: 目标单位
Returns:
float: 转换后的数值
"""
if from_unit == to_unit:
return value
if from_unit in cls.CONVERSIONS and to_unit in cls.CONVERSIONS[from_unit]:
factor = cls.CONVERSIONS[from_unit][to_unit]
return value * factor
# 如果没有找到转换关系,尝试反向查找
for unit, conversions in cls.CONVERSIONS.items():
if to_unit in conversions and from_unit in conversions:
# 相同基准单位的转换
factor = conversions[from_unit] / conversions[to_unit]
return value * factor
# 无法转换,返回原值
return value
@classmethod
def get_supported_units(cls) -> list:
"""获取支持的所有单位"""
units = set()
for conversions in cls.CONVERSIONS.values():
units.update(conversions.keys())
return list(units)
-260
View File
@@ -1,260 +0,0 @@
"""
设备控制器
提供设备注册/发现的REST API接口
"""
import json
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from flask import Blueprint, request, jsonify
from .device_manager import DeviceManager
from .models import DeviceType, DeviceStatus
class DeviceController:
"""设备控制器"""
def __init__(self, device_manager: DeviceManager):
"""
初始化设备控制器
Args:
device_manager: 设备管理器
"""
self.device_manager = device_manager
self.logger = logging.getLogger(__name__)
# 创建Blueprint
self.blueprint = Blueprint('device', __name__, url_prefix='/api/iot/device')
# 注册路由
self._register_routes()
def _register_routes(self):
"""注册API路由"""
@self.blueprint.route('/', methods=['GET'])
def list_devices():
"""获取设备列表"""
try:
# 获取查询参数
device_type_str = request.args.get('type')
status_str = request.args.get('status')
area = request.args.get('area')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 10))
# 过滤条件
device_type = DeviceType(device_type_str) if device_type_str else None
status = DeviceStatus(status_str) if status_str else None
# 获取设备列表
devices = self.device_manager.list_devices(device_type, status, area)
# 分页
total = len(devices)
start = (page - 1) * per_page
end = start + per_page
paginated_devices = devices[start:end]
# 转换为字典格式
device_list = [device.to_dict() for device in paginated_devices]
return jsonify({
"success": True,
"data": device_list,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"pages": (total + per_page - 1) // per_page
}
})
except Exception as e:
self.logger.error(f"Error listing devices: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>', methods=['GET'])
def get_device(device_sn):
"""获取设备详情"""
try:
device = self.device_manager.get_device(device_sn)
if not device:
return jsonify({"success": False, "error": "Device not found"}), 404
# 获取设备影子
shadow = self.device_manager.get_device_shadow(device_sn)
device_data = device.to_dict()
if shadow:
device_data['shadow'] = shadow.to_dict()
return jsonify({
"success": True,
"data": device_data
})
except Exception as e:
self.logger.error(f"Error getting device {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/', methods=['POST'])
def register_device():
"""注册新设备"""
try:
device_data = request.get_json()
# 验证必要字段
required_fields = ['device_sn', 'device_type', 'name']
for field in required_fields:
if field not in device_data:
return jsonify({"success": False, "error": f"Missing required field: {field}"}), 400
# 检查设备是否已存在
existing_device = self.device_manager.get_device(device_data['device_sn'])
if existing_device:
return jsonify({"success": False, "error": "Device already exists"}), 409
# 注册设备
device = self.device_manager.register_device(device_data)
return jsonify({
"success": True,
"data": device.to_dict(),
"message": "Device registered successfully"
}), 201
except Exception as e:
self.logger.error(f"Error registering device: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>', methods=['PUT'])
def update_device(device_sn):
"""更新设备信息"""
try:
device = self.device_manager.get_device(device_sn)
if not device:
return jsonify({"success": False, "error": "Device not found"}), 404
updates = request.get_json()
# 更新设备
updated_device = self.device_manager.update_device(device_sn, updates)
if not updated_device:
return jsonify({"success": False, "error": "Failed to update device"}), 400
return jsonify({
"success": True,
"data": updated_device.to_dict(),
"message": "Device updated successfully"
})
except Exception as e:
self.logger.error(f"Error updating device {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>', methods=['DELETE'])
def delete_device(device_sn):
"""删除设备"""
try:
success = self.device_manager.delete_device(device_sn)
if not success:
return jsonify({"success": False, "error": "Device not found"}), 404
return jsonify({
"success": True,
"message": "Device deleted successfully"
})
except Exception as e:
self.logger.error(f"Error deleting device {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>/shadow', methods=['GET'])
def get_device_shadow(device_sn):
"""获取设备影子"""
try:
shadow = self.device_manager.get_device_shadow(device_sn)
if not shadow:
return jsonify({"success": False, "error": "Device shadow not found"}), 404
return jsonify({
"success": True,
"data": shadow.to_dict()
})
except Exception as e:
self.logger.error(f"Error getting device shadow {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>/shadow', methods=['PUT'])
def update_device_shadow(device_sn):
"""更新设备影子"""
try:
state = request.get_json()
success = self.device_manager.update_device_shadow(device_sn, state)
if not success:
return jsonify({"success": False, "error": "Device not found"}), 404
return jsonify({
"success": True,
"message": "Device shadow updated successfully"
})
except Exception as e:
self.logger.error(f"Error updating device shadow {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/discover', methods=['POST'])
def discover_devices():
"""设备发现"""
try:
discovered = self.device_manager.discover_devices()
return jsonify({
"success": True,
"data": discovered,
"message": f"Discovered {len(discovered)} devices"
})
except Exception as e:
self.logger.error(f"Error discovering devices: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/<device_sn>/command', methods=['POST'])
def send_device_command(device_sn):
"""发送设备控制命令"""
try:
command = request.get_json()
# 验证设备存在
device = self.device_manager.get_device(device_sn)
if not device:
return jsonify({"success": False, "error": "Device not found"}), 404
# 发送命令
success = self.mqtt_adapter.send_command(device_sn, command)
if not success:
return jsonify({"success": False, "error": "Failed to send command"}), 500
return jsonify({
"success": True,
"message": "Command sent successfully",
"device_sn": device_sn,
"command": command
})
except Exception as e:
self.logger.error(f"Error sending command to device {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/statistics', methods=['GET'])
def get_device_statistics():
"""获取设备统计信息"""
try:
statistics = self.device_manager.get_device_statistics()
return jsonify({
"success": True,
"data": statistics
})
except Exception as e:
self.logger.error(f"Error getting device statistics: {e}")
return jsonify({"success": False, "error": str(e)}), 500
def get_blueprint(self):
"""获取Blueprint"""
return self.blueprint
-219
View File
@@ -1,219 +0,0 @@
"""
设备管理服务
负责设备的CRUD操作、设备影子管理、设备发现等功能
"""
import json
import logging
from datetime import datetime
from typing import List, Optional, Dict, Any
from .models import Device, DeviceShadow, DeviceStatus, DeviceType
class DeviceManager:
"""设备管理器"""
def __init__(self):
self.devices: Dict[str, Device] = {} # device_sn -> Device
self.shadows: Dict[str, DeviceShadow] = {} # device_sn -> DeviceShadow
self.logger = logging.getLogger(__name__)
def register_device(self, device_data: Dict[str, Any]) -> Device:
"""
注册设备
Args:
device_data: 设备数据字典
Returns:
Device: 注册的设备对象
"""
device = Device(
device_sn=device_data['device_sn'],
device_type=DeviceType(device_data.get('device_type', 'other')),
name=device_data.get('name', ''),
description=device_data.get('description', ''),
area=device_data.get('area', ''),
position=device_data.get('position', ''),
geom=device_data.get('geom'),
manufacturer=device_data.get('manufacturer', ''),
model=device_data.get('model', ''),
firmware_version=device_data.get('firmware_version', ''),
hardware_version=device_data.get('hardware_version', ''),
metadata=device_data.get('metadata', {})
)
self.devices[device.device_sn] = device
# 创建设备影子
shadow = DeviceShadow(device_sn=device.device_sn)
self.shadows[device.device_sn] = shadow
self.logger.info(f"Device registered: {device.device_sn}")
return device
def get_device(self, device_sn: str) -> Optional[Device]:
"""
获取设备信息
Args:
device_sn: 设备序列号
Returns:
Device: 设备对象,如果不存在返回None
"""
return self.devices.get(device_sn)
def update_device(self, device_sn: str, updates: Dict[str, Any]) -> Optional[Device]:
"""
更新设备信息
Args:
device_sn: 设备序列号
updates: 更新的字段
Returns:
Device: 更新后的设备对象,如果不存在返回None
"""
device = self.devices.get(device_sn)
if not device:
return None
# 更新设备属性
for key, value in updates.items():
if hasattr(device, key):
setattr(device, key, value)
device.updated_at = datetime.now()
self.logger.info(f"Device updated: {device_sn}")
return device
def delete_device(self, device_sn: str) -> bool:
"""
删除设备
Args:
device_sn: 设备序列号
Returns:
bool: 是否删除成功
"""
if device_sn in self.devices:
del self.devices[device_sn]
if device_sn in self.shadows:
del self.shadows[device_sn]
self.logger.info(f"Device deleted: {device_sn}")
return True
return False
def list_devices(self,
device_type: Optional[DeviceType] = None,
status: Optional[DeviceStatus] = None,
area: Optional[str] = None) -> List[Device]:
"""
列出设备
Args:
device_type: 设备类型过滤
status: 设备状态过滤
area: 区域过滤
Returns:
List[Device]: 设备列表
"""
devices = list(self.devices.values())
if device_type:
devices = [d for d in devices if d.device_type == device_type]
if status:
devices = [d for d in devices if d.status == status]
if area:
devices = [d for d in devices if d.area == area]
return devices
def update_device_shadow(self, device_sn: str, state: Dict[str, Any]) -> bool:
"""
更新设备影子
Args:
device_sn: 设备序列号
state: 设备状态
Returns:
bool: 是否更新成功
"""
if device_sn not in self.shadows:
return False
shadow = self.shadows[device_sn]
shadow.state.update(state)
shadow.timestamp = datetime.now()
self.logger.debug(f"Device shadow updated: {device_sn}")
return True
def get_device_shadow(self, device_sn: str) -> Optional[DeviceShadow]:
"""
获取设备影子
Args:
device_sn: 设备序列号
Returns:
DeviceShadow: 设备影子对象
"""
return self.shadows.get(device_sn)
def discover_devices(self) -> List[Dict[str, Any]]:
"""
设备发现 - 扫描网络中的设备
Returns:
List[Dict[str, Any]]: 发现的设备列表
"""
discovered = []
# 模拟设备发现过程
# 在实际实现中,这里可以包含网络扫描、协议握手等逻辑
for device_sn, device in self.devices.items():
if device.status == DeviceStatus.OFFLINE:
# 模拟设备上线
device.status = DeviceStatus.ONLINE
device.last_seen = datetime.now()
device.ip_address = f"192.168.1.{hash(device_sn) % 255 + 1}"
discovered.append({
"device_sn": device_sn,
"name": device.name,
"type": device.device_type.value,
"ip_address": device.ip_address,
"status": device.status.value
})
self.logger.info(f"Discovered {len(discovered)} devices")
return discovered
def get_device_statistics(self) -> Dict[str, Any]:
"""
获取设备统计信息
Returns:
Dict[str, Any]: 统计信息
"""
total = len(self.devices)
online = sum(1 for d in self.devices.values() if d.status == DeviceStatus.ONLINE)
offline = total - online
by_type = {}
for device in self.devices.values():
device_type = device.device_type.value
by_type[device_type] = by_type.get(device_type, 0) + 1
return {
"total_devices": total,
"online_devices": online,
"offline_devices": offline,
"devices_by_type": by_type
}
-163
View File
@@ -1,163 +0,0 @@
"""
IoT 设备模型定义
包含设备实体、设备影子、OTA升级等核心数据模型
"""
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Dict, List, Optional, Any
import uuid
class DeviceStatus(Enum):
"""设备状态枚举"""
ONLINE = "online"
OFFLINE = "offline"
MAINTENANCE = "maintenance"
FAULT = "fault"
class DeviceType(Enum):
"""设备类型枚举"""
FLOW_METER = "flow_meter" # 流量计
PRESSURE_METER = "pressure_meter" # 压力表
LEVEL_METER = "level_meter" # 水位计
QUALITY_METER = "quality_meter" # 水质仪
VALVE = "valve" # 阀门
PUMP = "pump" # 水泵
SENSOR = "sensor" # 传感器
CAMERA = "camera" # 摄像头
OTHER = "other" # 其他
@dataclass
class Device:
"""设备实体模型"""
# 必需字段(无默认值)
device_sn: str # 设备序列号(唯一标识)
device_type: DeviceType # 设备类型
name: str # 设备名称
# 可选字段(有默认值)
description: str = "" # 设备描述
area: str = "" # 区域
position: str = "" # 位置
geom: Optional[str] = None # 地理坐标(GeoJSON格式)
manufacturer: str = "" # 厂商
model: str = "" # 型号
firmware_version: str = "" # 固件版本
hardware_version: str = "" # 硬件版本
status: DeviceStatus = DeviceStatus.OFFLINE
last_seen: Optional[datetime] = None
ip_address: Optional[str] = None
port: Optional[int] = None
metadata: Dict[str, Any] = field(default_factory=dict)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
id: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"device_sn": self.device_sn,
"device_type": self.device_type.value,
"name": self.name,
"description": self.description,
"area": self.area,
"position": self.position,
"geom": self.geom,
"manufacturer": self.manufacturer,
"model": self.model,
"firmware_version": self.firmware_version,
"hardware_version": self.hardware_version,
"status": self.status.value,
"last_seen": self.last_seen.isoformat() if self.last_seen else None,
"ip_address": self.ip_address,
"port": self.port,
"metadata": self.metadata,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat()
}
@dataclass
class DeviceShadow:
"""设备影子模型"""
# 必需字段
device_sn: str # 设备序列号
# 可选字段
state: Dict[str, Any] = field(default_factory=dict) # 设备状态
desired_state: Dict[str, Any] = field(default_factory=dict) # 期望状态
reported_state: Dict[str, Any] = field(default_factory=dict) # 报告状态
timestamp: datetime = field(default_factory=datetime.now) # 时间戳
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"device_sn": self.device_sn,
"state": self.state,
"desired_state": self.desired_state,
"reported_state": self.reported_state,
"timestamp": self.timestamp.isoformat()
}
@dataclass
class OtaUpdate:
"""OTA升级记录"""
# 必需字段
device_sn: str # 设备序列号
version: str # 目标版本
file_url: str # 固件文件URL
file_size: int # 文件大小
checksum: str # 文件校验和
# 可选字段
id: str = field(default_factory=lambda: str(uuid.uuid4()))
status: str = "pending" # 状态:pending/downloading/installed/failed
progress: int = 0 # 进度百分比
error_message: Optional[str] = None # 错误信息
started_at: Optional[datetime] = None
completed_at: Optional[datetime] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"device_sn": self.device_sn,
"version": self.version,
"file_url": self.file_url,
"file_size": self.file_size,
"checksum": self.checksum,
"status": self.status,
"progress": self.progress,
"error_message": self.error_message,
"started_at": self.started_at.isoformat() if self.started_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None
}
@dataclass
class MqttMessage:
"""MQTT消息模型"""
# 必需字段
topic: str # 主题
payload: Dict[str, Any] # 消息内容
# 可选字段
qos: int = 0 # QoS等级
retain: bool = False # 是否保留消息
timestamp: datetime = field(default_factory=datetime.now) # 时间戳
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"topic": self.topic,
"payload": self.payload,
"qos": self.qos,
"retain": self.retain,
"timestamp": self.timestamp.isoformat()
}
-352
View File
@@ -1,352 +0,0 @@
"""
MQTT 协议适配器
负责MQTT连接管理、消息订阅/发布、消息解析等功能
"""
import json
import logging
import paho.mqtt.client as mqtt
from datetime import datetime
from typing import Dict, Any, Optional, Callable, List
from .models import MqttMessage
from threading import Lock
class MqttAdapter:
"""MQTT适配器"""
def __init__(self,
broker_host: str = "localhost",
broker_port: int = 1883,
username: Optional[str] = None,
password: Optional[str] = None,
client_id: str = "water-management-system"):
"""
初始化MQTT适配器
Args:
broker_host: MQTT broker地址
broker_port: MQTT broker端口
username: 用户名
password: 密码
client_id: 客户端ID
"""
self.broker_host = broker_host
self.broker_port = broker_port
self.username = username
self.password = password
self.client_id = client_id
self.client = mqtt.Client(client_id=client_id)
self.message_handlers: Dict[str, Callable] = {}
self.connected = False
self.lock = Lock()
# 配置MQTT客户端
if username and password:
self.client.username_pw_set(username, password)
# 设置回调函数
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.on_publish = self._on_publish
self.client.on_subscribe = self._on_subscribe
self.logger = logging.getLogger(__name__)
def _on_connect(self, client, userdata, flags, rc):
"""连接回调"""
if rc == 0:
self.connected = True
self.logger.info(f"Connected to MQTT broker at {self.broker_host}:{self.broker_port}")
else:
self.logger.error(f"Failed to connect to MQTT broker, return code {rc}")
def _on_disconnect(self, client, userdata, rc):
"""断开连接回调"""
self.connected = False
self.logger.warning(f"Disconnected from MQTT broker, return code {rc}")
def _on_message(self, client, userdata, msg):
"""消息接收回调"""
try:
# 解析消息
payload = json.loads(msg.payload.decode('utf-8')) if msg.payload else {}
message = MqttMessage(
topic=msg.topic,
payload=payload,
qos=msg.qos,
retain=msg.retain
)
self.logger.debug(f"Received message: {message.topic} - {message.payload}")
# 查找对应的消息处理器
for topic_pattern, handler in self.message_handlers.items():
if self._topic_matches(msg.topic, topic_pattern):
try:
handler(message)
except Exception as e:
self.logger.error(f"Error in message handler for {msg.topic}: {e}")
except json.JSONDecodeError as e:
self.logger.error(f"Failed to parse JSON message from {msg.topic}: {e}")
except Exception as e:
self.logger.error(f"Error processing message from {msg.topic}: {e}")
def _on_publish(self, client, userdata, mid):
"""发布消息回调"""
self.logger.debug(f"Message published with mid: {mid}")
def _on_subscribe(self, client, userdata, mid, granted_qos):
"""订阅回调"""
self.logger.debug(f"Subscribed with mid: {mid}, granted_qos: {granted_qos}")
def _topic_matches(self, topic: str, pattern: str) -> bool:
"""检查主题是否匹配模式"""
# 简单的通配符匹配实现
# 支持单层通配符 + 和多层通配符 #
pattern_parts = pattern.split('/')
topic_parts = topic.split('/')
if len(pattern_parts) != len(topic_parts):
return False
for p_part, t_part in zip(pattern_parts, topic_parts):
if p_part == '+' or p_part == '#':
continue
if p_part != t_part:
return False
return True
def connect(self) -> bool:
"""
连接到MQTT broker
Returns:
bool: 是否连接成功
"""
try:
self.client.connect(self.broker_host, self.broker_port, 60)
self.client.loop_start()
return True
except Exception as e:
self.logger.error(f"Failed to connect to MQTT broker: {e}")
return False
def disconnect(self):
"""断开MQTT连接"""
if self.connected:
self.client.loop_stop()
self.client.disconnect()
def is_connected(self) -> bool:
"""
检查是否已连接
Returns:
bool: 是否已连接
"""
return self.connected
def subscribe(self, topic: str, qos: int = 0) -> bool:
"""
订阅主题
Args:
topic: 主题
qos: QoS等级
Returns:
bool: 是否订阅成功
"""
try:
result = self.client.subscribe(topic, qos)
if result[0] == mqtt.MQTT_ERR_SUCCESS:
self.logger.info(f"Subscribed to topic: {topic}")
return True
else:
self.logger.error(f"Failed to subscribe to topic: {topic}")
return False
except Exception as e:
self.logger.error(f"Error subscribing to topic {topic}: {e}")
return False
def unsubscribe(self, topic: str) -> bool:
"""
取消订阅主题
Args:
topic: 主题
Returns:
bool: 是否取消订阅成功
"""
try:
result = self.client.unsubscribe(topic)
if result[0] == mqtt.MQTT_ERR_SUCCESS:
self.logger.info(f"Unsubscribed from topic: {topic}")
return True
else:
self.logger.error(f"Failed to unsubscribe from topic: {topic}")
return False
except Exception as e:
self.logger.error(f"Error unsubscribing from topic {topic}: {e}")
return False
def publish(self, topic: str, payload: Any, qos: int = 0, retain: bool = False) -> bool:
"""
发布消息
Args:
topic: 主题
payload: 消息内容
qos: QoS等级
retain: 是否保留消息
Returns:
bool: 是否发布成功
"""
try:
if isinstance(payload, dict):
payload = json.dumps(payload)
elif not isinstance(payload, str):
payload = str(payload)
result = self.client.publish(topic, payload, qos, retain)
if result[0] == mqtt.MQTT_ERR_SUCCESS:
self.logger.debug(f"Published to topic: {topic}")
return True
else:
self.logger.error(f"Failed to publish to topic: {topic}")
return False
except Exception as e:
self.logger.error(f"Error publishing to topic {topic}: {e}")
return False
def add_message_handler(self, topic_pattern: str, handler: Callable[[MqttMessage], None]):
"""
添加消息处理器
Args:
topic_pattern: 主题模式(支持通配符)
handler: 消息处理函数
"""
with self.lock:
self.message_handlers[topic_pattern] = handler
self.logger.info(f"Added message handler for pattern: {topic_pattern}")
def remove_message_handler(self, topic_pattern: str):
"""
移除消息处理器
Args:
topic_pattern: 主题模式
"""
with self.lock:
if topic_pattern in self.message_handlers:
del self.message_handlers[topic_pattern]
self.logger.info(f"Removed message handler for pattern: {topic_pattern}")
def subscribe_device_topics(self, device_manager):
"""
订阅设备相关主题
Args:
device_manager: 设备管理器实例
"""
# 设备状态上报
self.add_message_handler("devices/+/status", self._handle_device_status)
# 设备数据上报
self.add_message_handler("devices/+/data", self._handle_device_data)
# 设备控制命令响应
self.add_message_handler("devices/+/command/response", self._handle_command_response)
# 设备OTA状态
self.add_message_handler("devices/+/ota/status", self._handle_ota_status)
def _handle_device_status(self, message: MqttMessage):
"""处理设备状态消息"""
topic_parts = message.topic.split('/')
if len(topic_parts) >= 2:
device_sn = topic_parts[1]
status = message.payload.get('status', 'unknown')
# 更新设备状态
device = device_manager.get_device(device_sn)
if device:
from .models import DeviceStatus
try:
device.status = DeviceStatus(status)
device.last_seen = datetime.now()
device_manager.logger.info(f"Device {device_sn} status updated to {status}")
except ValueError:
device_manager.logger.warning(f"Unknown status: {status}")
def _handle_device_data(self, message: MqttMessage):
"""处理设备数据消息"""
topic_parts = message.topic.split('/')
if len(topic_parts) >= 2:
device_sn = topic_parts[1]
data = message.payload
# 更新设备影子
device_manager.update_device_shadow(device_sn, data)
device_manager.logger.debug(f"Device {device_sn} data updated")
def _handle_command_response(self, message: MqttMessage):
"""处理命令响应消息"""
topic_parts = message.topic.split('/')
if len(topic_parts) >= 2:
device_sn = topic_parts[1]
command_id = message.payload.get('command_id')
result = message.payload.get('result')
device_manager.logger.info(f"Device {device_sn} command response: {command_id} -> {result}")
def _handle_ota_status(self, message: MqttMessage):
"""处理OTA状态消息"""
topic_parts = message.topic.split('/')
if len(topic_parts) >= 2:
device_sn = topic_parts[1]
status = message.payload.get('status')
progress = message.payload.get('progress', 0)
device_manager.logger.info(f"Device {device_sn} OTA status: {status}, progress: {progress}%")
def send_command(self, device_sn: str, command: Dict[str, Any]) -> bool:
"""
发送设备控制命令
Args:
device_sn: 设备序列号
command: 命令内容
Returns:
bool: 是否发送成功
"""
topic = f"devices/{device_sn}/command"
command['command_id'] = f"cmd_{datetime.now().timestamp()}"
command['timestamp'] = datetime.now().isoformat()
return self.publish(topic, command, qos=1)
def get_connection_status(self) -> Dict[str, Any]:
"""
获取连接状态
Returns:
Dict[str, Any]: 连接状态信息
"""
return {
"connected": self.connected,
"broker_host": self.broker_host,
"broker_port": self.broker_port,
"client_id": self.client_id,
"message_handlers_count": len(self.message_handlers)
}
-214
View File
@@ -1,214 +0,0 @@
"""
OTA固件升级控制器
提供OTA升级相关的REST API接口
"""
import json
import logging
from datetime import datetime
from typing import Dict, Any, List, Optional
from flask import Blueprint, request, jsonify
from .ota_manager import OtaManager
from .models import OtaUpdate
class OtaController:
"""OTA控制器"""
def __init__(self, ota_manager: OtaManager):
"""
初始化OTA控制器
Args:
ota_manager: OTA管理器
"""
self.ota_manager = ota_manager
self.logger = logging.getLogger(__name__)
# 创建Blueprint
self.blueprint = Blueprint('ota', __name__, url_prefix='/api/iot/ota')
# 注册路由
self._register_routes()
def _register_routes(self):
"""注册API路由"""
@self.blueprint.route('/updates', methods=['GET'])
def list_updates():
"""获取OTA更新列表"""
try:
# 获取查询参数
device_sn = request.args.get('device_sn')
status = request.args.get('status')
page = int(request.args.get('page', 1))
per_page = int(request.args.get('per_page', 10))
# 过滤条件
updates = list(self.ota_manager.updates.values())
if device_sn:
updates = [u for u in updates if u.device_sn == device_sn]
if status:
updates = [u for u in updates if u.status == status]
# 分页
total = len(updates)
start = (page - 1) * per_page
end = start + per_page
paginated_updates = updates[start:end]
# 转换为字典格式
update_list = [update.to_dict() for update in paginated_updates]
return jsonify({
"success": True,
"data": update_list,
"pagination": {
"page": page,
"per_page": per_page,
"total": total,
"pages": (total + per_page - 1) // per_page
}
})
except Exception as e:
self.logger.error(f"Error listing OTA updates: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates/<update_id>', methods=['GET'])
def get_update(update_id):
"""获取OTA更新详情"""
try:
update = self.ota_manager.get_update(update_id)
if not update:
return jsonify({"success": False, "error": "Update not found"}), 404
return jsonify({
"success": True,
"data": update.to_dict()
})
except Exception as e:
self.logger.error(f"Error getting OTA update {update_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates', methods=['POST'])
def create_update():
"""创建OTA更新任务"""
try:
update_data = request.get_json()
# 验证必要字段
required_fields = ['device_sn', 'version', 'file_url', 'file_size', 'checksum']
for field in required_fields:
if field not in update_data:
return jsonify({"success": False, "error": f"Missing required field: {field}"}), 400
# 创建更新
update = self.ota_manager.create_update(
device_sn=update_data['device_sn'],
version=update_data['version'],
file_url=update_data['file_url'],
file_size=update_data['file_size'],
checksum=update_data['checksum']
)
return jsonify({
"success": True,
"data": update.to_dict(),
"message": "OTA update created successfully"
}), 201
except Exception as e:
self.logger.error(f"Error creating OTA update: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates/<update_id>/start', methods=['POST'])
def start_update(update_id):
"""开始OTA更新"""
try:
success = self.ota_manager.start_update(update_id)
if not success:
return jsonify({"success": False, "error": "Failed to start update"}), 400
return jsonify({
"success": True,
"message": "OTA update started successfully",
"update_id": update_id
})
except Exception as e:
self.logger.error(f"Error starting OTA update {update_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates/<update_id>/progress', methods=['PUT'])
def update_progress(update_id):
"""更新OTA进度"""
try:
progress_data = request.get_json()
progress = progress_data.get('progress')
error_message = progress_data.get('error_message')
if progress is None:
return jsonify({"success": False, "error": "Progress is required"}), 400
success = self.ota_manager.update_progress(update_id, progress, error_message)
if not success:
return jsonify({"success": False, "error": "Update not found"}), 404
return jsonify({
"success": True,
"message": "OTA progress updated successfully",
"update_id": update_id,
"progress": progress
})
except Exception as e:
self.logger.error(f"Error updating OTA progress {update_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates/<update_id>/cancel', methods=['POST'])
def cancel_update(update_id):
"""取消OTA更新"""
try:
success = self.ota_manager.cancel_update(update_id)
if not success:
return jsonify({"success": False, "error": "Failed to cancel update"}), 400
return jsonify({
"success": True,
"message": "OTA update cancelled successfully",
"update_id": update_id
})
except Exception as e:
self.logger.error(f"Error cancelling OTA update {update_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/updates/device/<device_sn>', methods=['GET'])
def get_device_updates(device_sn):
"""获取设备的OTA更新记录"""
try:
updates = self.ota_manager.get_updates_by_device(device_sn)
return jsonify({
"success": True,
"data": [update.to_dict() for update in updates],
"device_sn": device_sn
})
except Exception as e:
self.logger.error(f"Error getting updates for device {device_sn}: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@self.blueprint.route('/statistics', methods=['GET'])
def get_statistics():
"""获取OTA统计信息"""
try:
statistics = self.ota_manager.get_update_statistics()
return jsonify({
"success": True,
"data": statistics
})
except Exception as e:
self.logger.error(f"Error getting OTA statistics: {e}")
return jsonify({"success": False, "error": str(e)}), 500
def get_blueprint(self):
"""获取Blueprint"""
return self.blueprint
-173
View File
@@ -1,173 +0,0 @@
"""
OTA固件升级管理器
负责设备OTA升级流程、版本管理、升级状态跟踪等功能
"""
import json
import logging
import hashlib
from datetime import datetime
from typing import Dict, Any, List, Optional
from .models import OtaUpdate
class OtaManager:
"""OTA管理器"""
def __init__(self):
self.updates: Dict[str, OtaUpdate] = {} # update_id -> OtaUpdate
self.logger = logging.getLogger(__name__)
def create_update(self, device_sn: str, version: str, file_url: str,
file_size: int, checksum: str) -> OtaUpdate:
"""
创建OTA升级任务
Args:
device_sn: 设备序列号
version: 目标版本
file_url: 固件文件URL
file_size: 文件大小
checksum: 文件校验和
Returns:
OtaUpdate: OTA升级对象
"""
update = OtaUpdate(
device_sn=device_sn,
version=version,
file_url=file_url,
file_size=file_size,
checksum=checksum
)
self.updates[update.id] = update
self.logger.info(f"Created OTA update: {update.id} for device {device_sn}")
return update
def get_update(self, update_id: str) -> Optional[OtaUpdate]:
"""
获取OTA升级信息
Args:
update_id: 更新ID
Returns:
OtaUpdate: OTA升级对象
"""
return self.updates.get(update_id)
def get_updates_by_device(self, device_sn: str) -> List[OtaUpdate]:
"""
获取设备的OTA升级记录
Args:
device_sn: 设备序列号
Returns:
List[OtaUpdate]: OTA升级列表
"""
return [update for update in self.updates.values() if update.device_sn == device_sn]
def start_update(self, update_id: str) -> bool:
"""
开始OTA升级
Args:
update_id: 更新ID
Returns:
bool: 是否开始成功
"""
update = self.updates.get(update_id)
if not update:
return False
if update.status != "pending":
self.logger.warning(f"Update {update_id} is not in pending state")
return False
update.status = "downloading"
update.started_at = datetime.now()
self.logger.info(f"Started OTA update: {update_id}")
return True
def update_progress(self, update_id: str, progress: int, error_message: Optional[str] = None) -> bool:
"""
更新OTA升级进度
Args:
update_id: 更新ID
progress: 进度百分比
error_message: 错误信息
Returns:
bool: 是否更新成功
"""
update = self.updates.get(update_id)
if not update:
return False
update.progress = progress
if error_message:
update.error_message = error_message
update.status = "failed"
self.logger.error(f"OTA update {update_id} failed: {error_message}")
elif progress >= 100:
update.status = "installed"
update.completed_at = datetime.now()
self.logger.info(f"OTA update {update_id} completed successfully")
else:
# 保持下载中状态
pass
return True
def cancel_update(self, update_id: str) -> bool:
"""
取消OTA升级
Args:
update_id: 更新ID
Returns:
bool: 是否取消成功
"""
update = self.updates.get(update_id)
if not update:
return False
if update.status in ["installed", "failed"]:
self.logger.warning(f"Cannot cancel completed update {update_id}")
return False
update.status = "failed"
update.error_message = "Cancelled by user"
update.completed_at = datetime.now()
self.logger.info(f"Cancelled OTA update: {update_id}")
return True
def get_update_statistics(self) -> Dict[str, Any]:
"""
获取OTA统计信息
Returns:
Dict[str, Any]: 统计信息
"""
total = len(self.updates)
pending = sum(1 for u in self.updates.values() if u.status == "pending")
downloading = sum(1 for u in self.updates.values() if u.status == "downloading")
installed = sum(1 for u in self.updates.values() if u.status == "installed")
failed = sum(1 for u in self.updates.values() if u.status == "failed")
return {
"total_updates": total,
"pending": pending,
"downloading": downloading,
"installed": installed,
"failed": failed
}
-317
View File
@@ -1,317 +0,0 @@
"""
数据模型定义
定义水务管理系统的各种数据结构
"""
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any
from datetime import datetime
from enum import Enum
class DataType(Enum):
"""数据类型枚举"""
LL = "LL" # 流量
YL = "YL" # 压力
SW = "SW" # 水位
ZD = "ZD" # 浊度
PH = "PH" # pH值
WD = "WD" # 温度
DD = "DD" # 电导率
YD = "YD" # 硬度
class AlertLevel(Enum):
"""警报级别枚举"""
INFO = "info"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
@dataclass
class Device:
"""设备模型"""
id: str
name: str
device_type: str
location: str
description: Optional[str] = None
install_date: Optional[datetime] = None
status: str = "active" # active, inactive, maintenance
metadata: Dict[str, Any] = field(default_factory=dict)
@dataclass
class SensorData:
"""传感器数据模型"""
id: str
device_id: str
data_type: DataType
value: float
unit: str
timestamp: datetime
location: str
quality_score: float = 1.0
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"device_id": self.device_id,
"data_type": self.data_type.value,
"value": self.value,
"unit": self.unit,
"timestamp": self.timestamp.isoformat(),
"location": self.location,
"quality_score": self.quality_score,
"metadata": self.metadata
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'SensorData':
"""从字典创建对象"""
return cls(
id=data["id"],
device_id=data["device_id"],
data_type=DataType(data["data_type"]),
value=float(data["value"]),
unit=data.get("unit", ""),
timestamp=datetime.fromisoformat(data["timestamp"]),
location=data.get("location", ""),
quality_score=float(data.get("quality_score", 1.0)),
metadata=data.get("metadata", {})
)
@dataclass
class Alert:
"""警报模型"""
id: str
device_id: str
alert_type: str
level: AlertLevel
message: str
timestamp: datetime
resolved: bool = False
resolved_by: Optional[str] = None
resolved_at: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"device_id": self.device_id,
"alert_type": self.alert_type,
"level": self.level.value,
"message": self.message,
"timestamp": self.timestamp.isoformat(),
"resolved": self.resolved,
"resolved_by": self.resolved_by,
"resolved_at": self.resolved_at.isoformat() if self.resolved_at else None,
"metadata": self.metadata
}
@dataclass
class BatchImport:
"""批量导入记录模型"""
id: str
batch_id: str
data_source: str
total_records: int
successful_records: int
failed_records: int
status: str # pending, processing, completed, failed
file_name: Optional[str] = None
import_time: Optional[datetime] = None
completed_time: Optional[datetime] = None
error_messages: List[str] = field(default_factory=list)
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"batch_id": self.batch_id,
"data_source": self.data_source,
"total_records": self.total_records,
"successful_records": self.successful_records,
"failed_records": self.failed_records,
"status": self.status,
"file_name": self.file_name,
"import_time": self.import_time.isoformat() if self.import_time else None,
"completed_time": self.completed_time.isoformat() if self.completed_time else None,
"error_messages": self.error_messages,
"metadata": self.metadata
}
@dataclass
class APIRequest:
"""API请求模型"""
id: str
method: str
endpoint: str
params: Dict[str, Any]
headers: Dict[str, Any]
body: Optional[Any] = None
timestamp: datetime = field(default_factory=datetime.now)
response_code: Optional[int] = None
response_time_ms: Optional[float] = None
response_body: Optional[Any] = None
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"method": self.method,
"endpoint": self.endpoint,
"params": self.params,
"headers": self.headers,
"body": self.body,
"timestamp": self.timestamp.isoformat(),
"response_code": self.response_code,
"response_time_ms": self.response_time_ms,
"response_body": self.response_body
}
@dataclass
class WebSocketConnection:
"""WebSocket连接模型"""
id: str
client_ip: str
connected_at: datetime
disconnected_at: Optional[datetime] = None
subscriptions: List[str] = field(default_factory=list)
message_count: int = 0
last_message_at: Optional[datetime] = None
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"id": self.id,
"client_ip": self.client_ip,
"connected_at": self.connected_at.isoformat(),
"disconnected_at": self.disconnected_at.isoformat() if self.disconnected_at else None,
"subscriptions": self.subscriptions,
"message_count": self.message_count,
"last_message_at": self.last_message_at.isoformat() if self.last_message_at else None,
"metadata": self.metadata
}
@dataclass
class SystemStats:
"""系统统计模型"""
timestamp: datetime
total_records: int
total_devices: int
active_connections: int
api_requests_count: int
alerts_count: int
data_quality_score: float
memory_usage_mb: float
cpu_usage_percent: float
def to_dict(self) -> Dict[str, Any]:
"""转换为字典"""
return {
"timestamp": self.timestamp.isoformat(),
"total_records": self.total_records,
"total_devices": self.total_devices,
"active_connections": self.active_connections,
"api_requests_count": self.api_requests_count,
"alerts_count": self.alerts_count,
"data_quality_score": self.data_quality_score,
"memory_usage_mb": self.memory_usage_mb,
"cpu_usage_percent": self.cpu_usage_percent
}
class DataValidator:
"""数据验证器"""
@staticmethod
def validate_sensor_data(data: Dict[str, Any]) -> List[str]:
"""验证传感器数据"""
errors = []
# 必需字段检查
required_fields = ["device_id", "data_type", "value", "location"]
for field in required_fields:
if field not in data:
errors.append(f"缺少必需字段: {field}")
# 数据类型验证
if "data_type" in data:
try:
DataType(data["data_type"])
except ValueError:
errors.append(f"无效的数据类型: {data['data_type']}")
# 数值验证
if "value" in data:
try:
value = float(data["value"])
# 根据数据类型进行数值范围检查
data_type = data.get("data_type")
if data_type == "LL" and value < 0:
errors.append("流量不能为负数")
elif data_type == "YL" and value < 0:
errors.append("压力不能为负数")
elif data_type == "SW" and value < 0:
errors.append("水位不能为负数")
except (ValueError, TypeError):
errors.append(f"无效的数值: {data['value']}")
# 时间戳验证
if "timestamp" in data:
try:
if isinstance(data["timestamp"], str):
datetime.fromisoformat(data["timestamp"])
except (ValueError, TypeError):
errors.append(f"无效的时间戳格式: {data['timestamp']}")
return errors
@staticmethod
def validate_device_data(data: Dict[str, Any]) -> List[str]:
"""验证设备数据"""
errors = []
# 必需字段检查
required_fields = ["id", "name", "device_type", "location"]
for field in required_fields:
if field not in data:
errors.append(f"缺少必需字段: {field}")
# 设备ID格式验证
if "id" in data:
device_id = data["id"]
if not isinstance(device_id, str) or not device_id.strip():
errors.append("设备ID不能为空")
elif len(device_id) > 50:
errors.append("设备ID长度不能超过50个字符")
# 状态验证
if "status" in data and data["status"] not in ["active", "inactive", "maintenance"]:
errors.append("设备状态必须是: active, inactive, maintenance")
return errors
@staticmethod
def validate_alert_data(data: Dict[str, Any]) -> List[str]:
"""验证警报数据"""
errors = []
# 必需字段检查
required_fields = ["device_id", "alert_type", "level", "message"]
for field in required_fields:
if field not in data:
errors.append(f"缺少必需字段: {field}")
# 警报级别验证
if "level" in data:
try:
AlertLevel(data["level"])
except ValueError:
errors.append(f"无效的警报级别: {data['level']}")
return errors
# 全局验证器实例
validator = DataValidator()
-409
View File
@@ -1,409 +0,0 @@
"""
数据处理工具模块
提供数据验证、转换、格式化等工具函数
"""
import json
import csv
import pandas as pd
from typing import Dict, List, Any, Optional, Union
from datetime import datetime, timedelta
import hashlib
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class DataConverter:
"""数据转换器"""
# 水利行业标准字段映射
FIELD_MAPPING = {
"流量": "LL",
"压力": "YL",
"水位": "SW",
"浊度": "ZD",
"pH值": "PH",
"温度": "WD",
"电导率": "DD",
"硬度": "YD",
# 支持常见的中文字段名
"流量计": "LL",
"压力表": "YL",
"水位计": "SW",
"浊度仪": "ZD",
"pH计": "PH",
"温度计": "WD",
"电导率仪": "DD",
"硬度计": "YD"
}
# 单位转换
UNIT_CONVERSIONS = {
# 流量单位转换 (m³/h)
"m³/h": 1.0,
"L/s": 3.6, # L/s = m³/h / 1000 * 3600
"m³/d": 1/24, # m³/d = m³/h / 24
"L/min": 1/60, # L/min = m³/h / 1000 * 60
# 压力单位转换 (MPa)
"MPa": 1.0,
"kPa": 0.001, # kPa = MPa / 1000
"bar": 0.1, # bar = MPa * 10
"kgf/cm²": 0.0980665, # kgf/cm² = MPa / 0.0980665
# 水位单位转换 (m)
"m": 1.0,
"cm": 0.01, # cm = m / 100
"mm": 0.001, # mm = m / 1000
# 浊度单位转换 (NTU)
"NTU": 1.0,
"FNU": 1.0, # FNU ≈ NTU
# pH值单位转换
"pH": 1.0,
# 温度单位转换 (°C)
"°C": 1.0,
"K": 1.0, # 相对差值
"°F": lambda x: (x - 32) / 1.8, # °F to °C
# 电导率单位转换 (μS/cm)
"μS/cm": 1.0,
"mS/cm": 1000, # mS/cm = μS/cm * 1000
"S/m": 10000 # S/m = μS/cm * 100
}
@classmethod
def normalize_field_name(cls, field_name: str) -> str:
"""标准化字段名"""
if not field_name:
return ""
field_name = field_name.strip().upper()
# 如果已经是标准格式,直接返回
if field_name in cls.FIELD_MAPPING.values():
return field_name
# 查映射表
if field_name in cls.FIELD_MAPPING:
return cls.FIELD_MAPPING[field_name]
# 英文映射
english_mapping = {
"flow": "LL",
"pressure": "YL",
"level": "SW",
"turbidity": "ZD",
"ph": "PH",
"temperature": "WD",
"conductivity": "DD",
"hardness": "YD"
}
if field_name.lower() in english_mapping:
return english_mapping[field_name.lower()]
return field_name
@classmethod
def convert_unit(cls, value: float, from_unit: str, to_unit: str) -> float:
"""单位转换"""
if from_unit == to_unit:
return value
if from_unit not in cls.UNIT_CONVERSIONS:
raise ValueError(f"不支持的单位: {from_unit}")
if to_unit not in cls.UNIT_CONVERSIONS:
raise ValueError(f"不支持的目标单位: {to_unit}")
from_conv = cls.UNIT_CONVERSIONS[from_unit]
to_conv = cls.UNIT_CONVERSIONS[to_unit]
if callable(from_conv):
value = from_conv(value)
if callable(to_conv):
return value / to_conv
else:
return value * (to_conv / from_conv)
@classmethod
def validate_sensor_data(cls, data: Dict[str, Any]) -> Dict[str, Any]:
"""验证传感器数据"""
errors = []
validated_data = {}
# 必需字段验证
required_fields = ["device_id", "data_type", "value"]
for field in required_fields:
if field not in data:
errors.append(f"缺少必需字段: {field}")
else:
validated_data[field] = data[field]
# 数据类型验证和标准化
if "data_type" in validated_data:
original_type = validated_data["data_type"]
validated_data["data_type"] = cls.normalize_field_name(original_type)
if validated_data["data_type"] != original_type:
logger.info(f"字段名标准化: {original_type} -> {validated_data['data_type']}")
# 数值验证
if "value" in validated_data:
try:
validated_data["value"] = float(validated_data["value"])
# 检查数值范围
data_type = validated_data.get("data_type", "")
if data_type == "LL" and validated_data["value"] < 0:
errors.append("流量不能为负数")
elif data_type == "YL" and validated_data["value"] < 0:
errors.append("压力不能为负数")
elif data_type == "SW" and validated_data["value"] < 0:
errors.append("水位不能为负数")
except (ValueError, TypeError):
errors.append(f"无效的数值: {validated_data['value']}")
# 地点验证
if "location" not in validated_data or not validated_data["location"]:
validated_data["location"] = "未知"
# 时间戳处理
if "timestamp" in data:
try:
if isinstance(data["timestamp"], str):
validated_data["timestamp"] = datetime.fromisoformat(data["timestamp"])
else:
validated_data["timestamp"] = data["timestamp"]
except (ValueError, TypeError):
validated_data["timestamp"] = datetime.now()
else:
validated_data["timestamp"] = datetime.now()
return {
"valid": len(errors) == 0,
"data": validated_data,
"errors": errors
}
class DataFormatter:
"""数据格式化器"""
@staticmethod
def format_sensor_data(data: Dict[str, Any], format_type: str = "json") -> str:
"""格式化传感器数据"""
if format_type == "json":
return json.dumps(data, ensure_ascii=False, indent=2)
elif format_type == "csv":
# CSV格式只包含关键字段
csv_fields = ["device_id", "data_type", "value", "location", "timestamp"]
csv_data = {k: data.get(k, "") for k in csv_fields}
import io
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=csv_fields)
writer.writeheader()
writer.writerow(csv_data)
return output.getvalue()
else:
raise ValueError(f"不支持的格式类型: {format_type}")
@staticmethod
def format_statistics(stats: Dict[str, Any], format_type: str = "text") -> str:
"""格式化统计数据"""
if format_type == "json":
return json.dumps(stats, ensure_ascii=False, indent=2)
elif format_type == "text":
lines = ["数据统计报告", "=" * 20]
lines.append(f"总记录数: {stats.get('total_records', 0)}")
if "by_type" in stats:
lines.append("\n按数据类型统计:")
for data_type, count in stats["by_type"].items():
lines.append(f" {data_type}: {count} 条")
if "by_device" in stats:
lines.append("\n按设备统计:")
for device_id, count in list(stats["by_device"].items())[:10]: # 只显示前10个
lines.append(f" {device_id}: {count} 条")
return "\n".join(lines)
else:
raise ValueError(f"不支持的格式类型: {format_type}")
class DataHasher:
"""数据哈希工具"""
@staticmethod
def calculate_data_hash(data: Dict[str, Any]) -> str:
"""计算数据哈希值"""
# 将数据转换为字符串
data_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
# 计算MD5哈希
hash_md5 = hashlib.md5(data_str.encode())
return hash_md5.hexdigest()
@staticmethod
def generate_data_id(device_id: str, data_type: str, timestamp: datetime) -> str:
"""生成数据ID"""
# 使用设备ID、数据类型和时间戳生成唯一ID
time_str = timestamp.strftime("%Y%m%d_%H%M%S")
hash_input = f"{device_id}_{data_type}_{time_str}"
hash_md5 = hashlib.md5(hash_input.encode())
return f"{data_type}_{device_id}_{hash_md5.hexdigest()[:8]}"
class DataQualityChecker:
"""数据质量检查器"""
@staticmethod
def check_data_quality(records: List[Dict[str, Any]]) -> Dict[str, Any]:
"""检查数据质量"""
quality_report = {
"total_records": len(records),
"valid_records": 0,
"invalid_records": 0,
"quality_score": 0,
"issues": [],
"statistics": {}
}
if not records:
quality_report["quality_score"] = 0
return quality_report
valid_records = []
for record in records:
issues = []
# 检查必需字段
required_fields = ["device_id", "data_type", "value"]
for field in required_fields:
if field not in record or not record[field]:
issues.append(f"缺少必需字段: {field}")
# 检查数据类型
if "data_type" in record and record["data_type"]:
valid_types = ["LL", "YL", "SW", "ZD", "PH", "WD", "DD", "YD"]
if record["data_type"] not in valid_types:
issues.append(f"无效的数据类型: {record['data_type']}")
# 检查数值范围
if "value" in record and record["value"]:
try:
value = float(record["value"])
data_type = record.get("data_type", "")
if data_type == "LL" and value < 0:
issues.append("流量不能为负数")
elif data_type == "YL" and value < 0:
issues.append("压力不能为负数")
elif data_type == "SW" and value < 0:
issues.append("水位不能为负数")
# 检查异常值
if data_type == "LL" and value > 10000:
issues.append("流量值异常大")
elif data_type == "YL" and value > 10:
issues.append("压力值异常大")
except (ValueError, TypeError):
issues.append("无效的数值格式")
if not issues:
valid_records.append(record)
quality_report["valid_records"] += 1
else:
quality_report["invalid_records"] += 1
quality_report["issues"].extend(issues)
# 计算质量分数
quality_report["quality_score"] = quality_report["valid_records"] / len(records)
# 统计信息
if records:
quality_report["statistics"] = {
"completeness": quality_report["valid_records"] / len(records),
"uniqueness": len(set(r.get("device_id", "") for r in valid_records)) / len(valid_records) if valid_records else 0,
"timeliness": quality_report.calculate_timeliness(records)
}
return quality_report
@staticmethod
def calculate_timeliness(records: List[Dict[str, Any]]) -> float:
"""计算数据及时性(24小时内的数据比例)"""
if not records:
return 0
now = datetime.now()
recent_count = 0
for record in records:
timestamp = record.get("timestamp")
if timestamp:
try:
if isinstance(timestamp, str):
timestamp = datetime.fromisoformat(timestamp)
time_diff = now - timestamp
if time_diff <= timedelta(hours=24):
recent_count += 1
except:
pass
return recent_count / len(records)
class DataExporter:
"""数据导出工具"""
@staticmethod
def export_to_csv(records: List[Dict[str, Any]], file_path: str) -> bool:
"""导出为CSV文件"""
try:
if not records:
return False
# 获取所有字段
all_fields = set()
for record in records:
all_fields.update(record.keys())
# 排序字段
field_order = ["device_id", "data_type", "value", "location", "timestamp"]
for field in all_fields:
if field not in field_order:
field_order.append(field)
with open(file_path, 'w', newline='', encoding='utf-8') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=field_order)
writer.writeheader()
writer.writerows(records)
return True
except Exception as e:
logger.error(f"导出CSV失败: {str(e)}")
return False
@staticmethod
def export_to_json(records: List[Dict[str, Any]], file_path: str) -> bool:
"""导出为JSON文件"""
try:
with open(file_path, 'w', encoding='utf-8') as jsonfile:
json.dump(records, jsonfile, ensure_ascii=False, indent=2, default=str)
return True
except Exception as e:
logger.error(f"导出JSON失败: {str(e)}")
return False
# 全局工具实例
data_converter = DataConverter()
data_formatter = DataFormatter()
data_hasher = DataHasher()
quality_checker = DataQualityChecker()
data_exporter = DataExporter()
-214
View File
@@ -1,214 +0,0 @@
"""
WebSocket 实时数据推送服务器
支持实时数据推送、连接管理和数据广播
"""
import asyncio
import json
import websockets
from datetime import datetime
from typing import Set, Dict, Any
import logging
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WebSocketServer:
"""WebSocket服务器类"""
def __init__(self, host: str = "0.0.0.0", port: int = 8765):
self.host = host
self.port = port
self.clients: Set[websockets.WebSocketServerProtocol] = set()
self.data_history: list = [] # 存储最近的数据用于新连接
async def register_client(self, websocket: websockets.WebSocketServerProtocol):
"""注册新客户端"""
self.clients.add(websocket)
client_ip = websocket.remote_address[0]
logger.info(f"新客户端连接: {client_ip}")
# 发送历史数据给新连接的客户端
if self.data_history:
await websocket.send(json.dumps({
"type": "history",
"data": self.data_history[-50:] # 发送最近50条数据
}))
# 发送欢迎消息
await websocket.send(json.dumps({
"type": "welcome",
"message": "已连接到水务管理系统实时数据服务器",
"timestamp": datetime.now().isoformat()
}))
async def unregister_client(self, websocket: websockets.WebSocketServerProtocol):
"""注销客户端"""
if websocket in self.clients:
self.clients.remove(websocket)
client_ip = websocket.remote_address[0]
logger.info(f"客户端断开连接: {client_ip}")
async def broadcast_data(self, data: Dict[str, Any]):
"""广播数据到所有连接的客户端"""
if not self.clients:
return
# 添加时间戳
data["timestamp"] = datetime.now().isoformat()
# 保存历史数据
self.data_history.append(data)
if len(self.data_history) > 1000: # 只保留最近1000条记录
self.data_history.pop(0)
# 广播数据
message = json.dumps(data)
disconnected_clients = []
for client in self.clients:
try:
await client.send(message)
except websockets.exceptions.ConnectionClosed:
disconnected_clients.append(client)
# 清理已断开的连接
for client in disconnected_clients:
await self.unregister_client(client)
async def handle_client_message(self, websocket: websockets.WebSocketServerProtocol, message: str):
"""处理客户端消息"""
try:
data = json.loads(message)
if data.get("type") == "subscribe":
# 处理订阅请求
subscription_type = data.get("subscription", "all")
response = {
"type": "subscription_ack",
"subscription": subscription_type,
"message": f"已订阅 {subscription_type} 类型数据"
}
await websocket.send(json.dumps(response))
logger.info(f"客户端订阅了 {subscription_type} 类型数据")
elif data.get("type") == "ping":
# 响应心跳检测
response = {
"type": "pong",
"timestamp": datetime.now().isoformat()
}
await websocket.send(json.dumps(response))
else:
logger.warning(f"未知的消息类型: {data.get('type', 'unknown')}")
except json.JSONDecodeError:
logger.error("无效的JSON消息")
except Exception as e:
logger.error(f"处理客户端消息时出错: {str(e)}")
async def client_handler(self, websocket: websockets.WebSocketServerProtocol, path: str):
"""处理客户端连接"""
await self.register_client(websocket)
try:
async for message in websocket:
await self.handle_client_message(websocket, message)
except websockets.exceptions.ConnectionClosed:
pass
finally:
await self.unregister_client(websocket)
async def start_server(self):
"""启动WebSocket服务器"""
logger.info(f"启动WebSocket服务器: {self.host}:{self.port}")
# 创建并启动服务器
self.server = await websockets.serve(
self.client_handler,
self.host,
self.port
)
logger.info("WebSocket服务器已启动")
return self.server
async def send_sensor_data(self, sensor_data: Dict[str, Any]):
"""发送传感器数据"""
data = {
"type": "sensor_data",
"data_type": sensor_data.get("data_type"),
"device_id": sensor_data.get("device_id"),
"value": sensor_data.get("value"),
"location": sensor_data.get("location"),
"timestamp": datetime.now().isoformat()
}
await self.broadcast_data(data)
async def send_alert(self, alert_data: Dict[str, Any]):
"""发送警报信息"""
data = {
"type": "alert",
"level": alert_data.get("level", "warning"),
"message": alert_data.get("message"),
"device_id": alert_data.get("device_id"),
"timestamp": datetime.now().isoformat()
}
await self.broadcast_data(data)
# 全局WebSocket服务器实例
websocket_server = WebSocketServer()
# 示例数据生成器
async def data_generator():
"""模拟数据生成器"""
import random
while True:
await asyncio.sleep(5) # 每5秒发送一次数据
# 模拟不同的传感器数据
sensor_types = ["LL", "YL", "SW", "ZD"]
sensor_type = random.choice(sensor_types)
# 根据传感器类型生成合理的数值范围
if sensor_type == "LL": # 流量
value = random.uniform(10, 100)
elif sensor_type == "YL": # 压力
value = random.uniform(0.1, 1.0)
elif sensor_type == "SW": # 水位
value = random.uniform(0, 10)
else: # ZD 浊度
value = random.uniform(0, 50)
sensor_data = {
"data_type": sensor_type,
"device_id": f"device_{random.randint(1, 10)}",
"value": round(value, 2),
"location": random.choice(["A区", "B区", "C区", "D区"])
}
await websocket_server.send_sensor_data(sensor_data)
# 启动服务器和生成器
async def main():
"""主函数"""
# 启动WebSocket服务器
server = await websocket_server.start_server()
# 启动数据生成器
generator_task = asyncio.create_task(data_generator())
# 保持服务器运行
try:
await asyncio.Future() # 永远等待
except KeyboardInterrupt:
logger.info("收到中断信号,正在关闭服务器...")
server.close()
await server.wait_closed()
generator_task.cancel()
await generator_task
if __name__ == "__main__":
asyncio.run(main())
-148
View File
@@ -1,148 +0,0 @@
"""
IoT模块测试脚本
用于验证MQTT适配器、设备管理器和API功能
"""
import asyncio
import json
import sys
import os
# 添加项目根目录到Python路径
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from src.iot.device_manager import DeviceManager
from src.iot.mqtt_adapter import MqttAdapter
from src.iot.device_controller import DeviceController
from src.iot.models import DeviceType, DeviceStatus
async def test_device_manager():
"""测试设备管理器"""
print("=== 测试设备管理器 ===")
device_manager = DeviceManager()
# 注册设备
device_data = {
'device_sn': 'LL-001',
'device_type': 'flow_meter',
'name': '流量计-001',
'description': 'A区入口流量计',
'area': 'A区',
'position': '入口处',
'manufacturer': '华为',
'model': 'LL-100'
}
device = device_manager.register_device(device_data)
print(f"注册设备: {device.device_sn} - {device.name}")
# 获取设备
retrieved_device = device_manager.get_device('LL-001')
print(f"获取设备: {retrieved_device.name}")
# 更新设备
updated_device = device_manager.update_device('LL-001', {'status': DeviceStatus.ONLINE})
print(f"更新设备状态: {updated_device.status}")
# 列出设备
devices = device_manager.list_devices()
print(f"设备列表: {len(devices)}个设备")
# 更新设备影子
device_manager.update_device_shadow('LL-001', {'temperature': 25.5, 'pressure': 0.8})
shadow = device_manager.get_device_shadow('LL-001')
print(f"设备影子: {shadow.state}")
# 获取统计信息
stats = device_manager.get_device_statistics()
print(f"设备统计: {stats}")
print("设备管理器测试完成\n")
async def test_mqtt_adapter():
"""测试MQTT适配器"""
print("=== 测试MQTT适配器 ===")
# 创建MQTT适配器(不实际连接)
mqtt_adapter = MqttAdapter(
broker_host="localhost",
broker_port=1883,
client_id="test-client"
)
# 测试消息发布
test_payload = {"message": "Hello IoT", "timestamp": "2024-01-01T00:00:00"}
success = mqtt_adapter.publish("test/topic", test_payload)
print(f"消息发布测试: {'成功' if success else '失败'}")
# 测试连接状态
status = mqtt_adapter.get_connection_status()
print(f"MQTT状态: {status}")
print("MQTT适配器测试完成\n")
async def test_device_controller():
"""测试设备控制器"""
print("=== 测试设备控制器 ===")
# 创建组件
device_manager = DeviceManager()
mqtt_adapter = MqttAdapter()
device_controller = DeviceController(device_manager, mqtt_adapter)
# 注册一些测试设备
test_devices = [
{
'device_sn': 'LL-001',
'device_type': 'flow_meter',
'name': '流量计-001',
'area': 'A区',
'manufacturer': '华为'
},
{
'device_sn': 'YL-001',
'device_type': 'pressure_meter',
'name': '压力表-001',
'area': 'B区',
'manufacturer': '西门子'
}
]
for device_data in test_devices:
device_manager.register_device(device_data)
# 模拟API请求
print("测试设备注册:")
print(f"已注册设备数量: {len(device_manager.devices)}")
# 模拟设备发现
discovered = device_manager.discover_devices()
print(f"发现设备数量: {len(discovered)}")
print("设备控制器测试完成\n")
async def main():
"""主测试函数"""
print("开始 IoT 模块测试...\n")
try:
# 测试各个组件
await test_device_manager()
await test_mqtt_adapter()
await test_device_controller()
print("✅ 所有测试完成!")
except Exception as e:
print(f"❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
-149
View File
@@ -1,149 +0,0 @@
"""
IoT模块简化测试脚本
仅测试设备管理器功能,不依赖MQTT
"""
import asyncio
import json
import sys
import os
# 添加项目根目录到Python路径
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from src.iot.device_manager import DeviceManager
from src.iot.models import DeviceType, DeviceStatus
async def test_device_manager():
"""测试设备管理器"""
print("=== 测试设备管理器 ===")
device_manager = DeviceManager()
# 注册设备
device_data = {
'device_sn': 'LL-001',
'device_type': 'flow_meter',
'name': '流量计-001',
'description': 'A区入口流量计',
'area': 'A区',
'position': '入口处',
'manufacturer': '华为',
'model': 'LL-100'
}
device = device_manager.register_device(device_data)
print(f"注册设备: {device.device_sn} - {device.name}")
# 获取设备
retrieved_device = device_manager.get_device('LL-001')
print(f"获取设备: {retrieved_device.name}")
# 更新设备
updated_device = device_manager.update_device('LL-001', {'status': DeviceStatus.ONLINE})
print(f"更新设备状态: {updated_device.status}")
# 列出设备
devices = device_manager.list_devices()
print(f"设备列表: {len(devices)}个设备")
# 更新设备影子
device_manager.update_device_shadow('LL-001', {'temperature': 25.5, 'pressure': 0.8})
shadow = device_manager.get_device_shadow('LL-001')
print(f"设备影子: {shadow.state}")
# 获取统计信息
stats = device_manager.get_device_statistics()
print(f"设备统计: {stats}")
# 测试设备发现
discovered = device_manager.discover_devices()
print(f"发现设备: {len(discovered)}个设备")
print("设备管理器测试完成\n")
async def test_device_filtering():
"""测试设备过滤功能"""
print("=== 测试设备过滤 ===")
device_manager = DeviceManager()
# 注册多个设备
test_devices = [
{'device_sn': 'LL-001', 'device_type': 'flow_meter', 'name': '流量计-001', 'area': 'A区'},
{'device_sn': 'YL-001', 'device_type': 'pressure_meter', 'name': '压力表-001', 'area': 'A区'},
{'device_sn': 'SW-001', 'device_type': 'level_meter', 'name': '水位计-001', 'area': 'B区'},
{'device_sn': 'LL-002', 'device_type': 'flow_meter', 'name': '流量计-002', 'area': 'B区'},
]
for device_data in test_devices:
device_manager.register_device(device_data)
# 测试按类型过滤
flow_meters = device_manager.list_devices(device_type=DeviceType.FLOW_METER)
print(f"流量计数量: {len(flow_meters)}")
# 测试按区域过滤
a_zone_devices = device_manager.list_devices(area='A区')
print(f"A区设备数量: {len(a_zone_devices)}")
# 测试按状态过滤
online_devices = device_manager.list_devices(status=DeviceStatus.ONLINE)
print(f"在线设备数量: {len(online_devices)}")
print("设备过滤测试完成\n")
async def test_device_shadow():
"""测试设备影子功能"""
print("=== 测试设备影子 ===")
device_manager = DeviceManager()
# 注册设备
device_data = {
'device_sn': 'LL-001',
'device_type': 'flow_meter',
'name': '流量计-001'
}
device_manager.register_device(device_data)
# 更新设备影子
shadow_data = {
'temperature': 25.5,
'pressure': 0.8,
'flow_rate': 100.5
}
success = device_manager.update_device_shadow('LL-001', shadow_data)
print(f"影子更新成功: {success}")
# 获取设备影子
shadow = device_manager.get_device_shadow('LL-001')
print(f"设备影子状态: {shadow.state}")
print("设备影子测试完成\n")
async def main():
"""主测试函数"""
print("开始 IoT 模块简化测试...\n")
try:
# 测试各个组件
await test_device_manager()
await test_device_filtering()
await test_device_shadow()
print("✅ 所有测试完成!")
except Exception as e:
print(f"❌ 测试失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
asyncio.run(main())
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.water</groupId><artifactId>wm-parent</artifactId><version>1.0.0-SNAPSHOT</version></parent>
<artifactId>wm-base</artifactId>
<dependencies>
<dependency><groupId>com.water</groupId><artifactId>wm-common</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>cn.dev33</groupId><artifactId>sa-token-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId></dependency>
</dependencies>
</project>
@@ -0,0 +1,11 @@
package com.water.base;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BaseApplication {
public static void main(String[] args) {
SpringApplication.run(BaseApplication.class, args);
}
}
@@ -0,0 +1,18 @@
package com.water.base.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyBatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
return interceptor;
}
}
@@ -0,0 +1,25 @@
package com.water.base.config;
import cn.dev33.satoken.interceptor.SaInterceptor;
import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SaInterceptor(handle -> {
// 放行认证接口
SaRouter.match("/auth/**").stop();
// 放行 Swagger
SaRouter.match("/swagger-ui/**").stop();
SaRouter.match("/v3/api-docs/**").stop();
// 其他需要登录
SaRouter.match("/**").check(r -> StpUtil.checkLogin());
})).addPathPatterns("/**");
}
}

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